【Flutter】フラグメントシェーダーを使ってみる

この記事は約12分で読めます。

フラグメントシェーダーとは

フラグメントシェーダーは主にGPUで画像を描画するときに使われる画素単位での色の計算を行うものです。3DCGなどでは頂点の位置を計算するためのバーテックスシェーダーの後にフラグメントシェーダーによる画素単位での画素値の計算を行うことで画像を生成しています。

Flutterでは今のところ3Dの描画機能は備わっていないのでバーテックスシェーダーは存在していませんが、フラグメントシェーダーは使用することができます。ただし、Web版では使用することが出来ないのでこの記事の例はすべてLinuxビルドのスクリーンショットとなります。もちろんAndroidやiOSでも使用可能です。

ベースとなるコード

まずは本記事でのベースとなるコードを示します。このコードの時点ではフラグメントシェーダーを使用していません。単にcanvas.drawRect関数により矩形を描画しているだけです。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MainView());
  }
}

class MainView extends StatefulWidget {
  const MainView({super.key});

  @override
  MainViewState createState() => MainViewState();
}

class MainViewState extends State<MainView> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: SizedBox(
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height,
            child: CustomPaint(painter: ShaderPainter())));
  }
}

class ShaderPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Offset.zero & size, Paint());
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

Linuxのアプリケーションとして実行した結果は下記のようになります。

フラグメントシェーダーによる描画

ここからフラグメントシェーダーを使用した描画について見ていきます。まず、最初に示したコードの中のShaderPainterをフラグメントシェーダーを使用して描画するように書き換えます。

class ShaderPainter extends CustomPainter {
  final FragmentShader shader;

  ShaderPainter(this.shader);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

ここではメンバ変数にFragmentShader型のshaderを追加し、コンストラクタで受け取るようにしました。また、paint関数内のcanvas.drawRectでshaderを使用するようにしました。Paint()..shader = shaderとしている部分がshaderを使用して描画するように指定しているところです。

ShaderPainterがコンストラクタにFragmentShader型の引数を取るようになったため、MainViewStateクラスの方でFragmentShaderを用意する必要があります。後述しますが、Flutterではアセットとしてフラグメントシェーダーをあらかじめ用意しておき、それを読み込んで使用します。その処理を行うために、MainViewStateクラスにFragmentShader型のメンバ変数とinitState関数を追加します。また、build関数の中でshaderShaderPainterに渡すようにします。

class MainViewState extends State<MainView> {
  FragmentShader? shader;

  @override
  void initState() {
    super.initState();
    FragmentProgram.fromAsset('shaders/shader1.frag').then((program) {
      setState(() {
        shader = program.fragmentShader();
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    if (shader == null) {
      return Container();
    } else {
      return Scaffold(
          body: SizedBox(
              width: MediaQuery.of(context).size.width,
              height: MediaQuery.of(context).size.height,
              child: CustomPaint(painter: ShaderPainter(shader!))));
    }
  }
}

最後にフラグメントシェーダーのコードを用意します。まずは非常にシンプルな例として、画素値を単に赤色にするだけのものを用意しました。

out vec4 color;

void main() {
  color = vec4(1.0, 0.0, 0.0, 1.0);
}

このコードはFlutterのプロジェクトルートからの相対パスでshaders/shader1.fragに置いてあるものとします。このコードをアセットに追加するため、pubspec.yamlのflutter:と書かれているところに下記の記述を追加します。

flutter:
  # 下2行を追加
  shaders:
    - shaders/shader1.frag

これでコードの修正は完了です。実行してみると下記のようになります。

座標に応じた計算を行う

FlutterのフラグメントシェーダーはGLSLで記述できるので、gl_FragCoordを使って座標に応じた計算をすることが出来ます。例えば、さきほどの例で用意したshaders/shader1.fragを次のように変更してみます。

out vec4 color;

void main() {
  float n = mod(floor(gl_FragCoord.x / 100.0) + floor(gl_FragCoord.y / 100.0), 2.0);
  float c = step(float(n), 0.5);
  
  color = vec4(c, c, c, 1.0);
}

これで実行すると下記のように市松模様を描くことが出来ます。

作ってみてから思いましたが、このような規則的な図形を描く場合はdrawRectなどの用意された描画関数を使うよりもフラグメントシェーダーを使う方がコード量は少なくて済むかもしれませんね。

画像を使う

画像をテクスチャとして使用して描画することも出来ます。ここでは別記事で生成した以下の画像を使います。

この画像をぼかしつつ引き伸ばして描画することにします。

まず、この画像をShaderPainterが受け取る必要があるので、ShaderPainterを以下のように変更します。なお、dart:uiに定義されているImageがウィジェットのImageと重複してしまうので、dart:uiをインポートしているところをimport 'dart:ui' as ui;と書き換えています。

class ShaderPainter extends CustomPainter {
  final ui.FragmentShader shader;
  final ui.Image image;

  ShaderPainter(this.shader, this.image);

  @override
  void paint(Canvas canvas, Size size) {
    shader.setImageSampler(0, image);
    shader.setFloat(0, size.width);
    shader.setFloat(1, size.height);
    shader.setFloat(2, image.width.toDouble());
    shader.setFloat(3, image.height.toDouble());

    canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

新たにimageがメンバ変数として追加されていますが、これをshader.setImageSampler(0, image)とすることで、画像をテクスチャとしてフラグメントシェーダーに渡すことができます。また、shader.setFloat(0, size.width)とすることで描画領域の幅をフラグメントシェーダーに渡しています。その下の行で同様に高さも渡しています。また、画像の幅と高さも渡しています。これらの数値をフラグメントシェーダーの中で使用します。

次にShaderPainterに画像を渡すためにMainViewState側で画像を用意します。MainViewStateクラスは以下のように書き換えました。

class MainViewState extends State<MainView> {
  ui.FragmentShader? shader;
  ui.Image? image;

  @override
  void initState() {
    super.initState();

    ui.FragmentProgram.fromAsset('shaders/shader1.frag').then((program) {
      setState(() {
        shader = program.fragmentShader();
      });
    });

    rootBundle.load('assets/image.png').then((data) {
      ui.decodeImageFromList(data.buffer.asUint8List(), (image) {
        setState(() {
          this.image = image;
        });
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    if (shader == null || image == null) {
      return Container();
    } else {
      return Scaffold(
          body: SizedBox(
              width: MediaQuery.of(context).size.width,
              height: MediaQuery.of(context).size.height,
              child: CustomPaint(painter: ShaderPainter(shader!, image!))));
    }
  }
}

フラグメントシェーダーと同様にアセットから画像を読み込んでいて、フラグメントシェーダーと画像の両方を読み込み終わったらShaderPainterの描画を行うようになっています。アセットから画像を読み込むため、pubspec.yamlも以下のように記述を追加します。

flutter:
  # 下2行を追加
  shaders:
    - shaders/shader1.frag
  # さらに追加
  assets:
    - assets/image.png

もちろんFlutterのプロジェクトルートからの相対パスassets/image.pngに画像は置いておく必要があります。

最後にフラグメントシェーダーを以下のように書き換えます。

out vec4 color;

uniform sampler2D imageSampler;
uniform vec2 viewSize, samplerSize;

void main() {
  vec3 rgb = vec3(0.0, 0.0, 0.0);
  const int N = 10;

  for(int i = -N; i <= N; i++) {
    for(int j = -N; j <= N; j++) {
      rgb += texture(imageSampler, gl_FragCoord.xy / viewSize + vec2(j, i) / samplerSize).xyz;
    }
  }
  rgb /= (2.0 * N + 1.0) * (2.0 * N + 1.0);
  
  color = vec4(rgb, 1.0);
}

ここでは、forループを使用して入力画像の41×41ピクセルの画素値を平均して画素値を計算しています。gl_FragCoordは描画領域基準で座標が格納されていますが、texture関数は画像に対して0.0~1.0の正規化された座標でアクセスします。そのため、gl_FragCoordを描画領域の幅と高さで割ったものでアクセスしています。

以上のコードを実行してみると下記のようになります。

以上、ここまでで画像を使用した描画を行うことが出来ました。

まとめ

本記事では、Flutterでフラグメントシェーダーを使用した描画の方法について確認しました。フラグメントシェーダーを使うことで座標に応じて色を付ける処理や入力画像に対して画像処理した結果を描画することなどが出来ました。

コメント

タイトルとURLをコピーしました