🌗

Flutter × Shader 入門

2023/12/24に公開

CPUとGPU

 Shaderを理解するためには、CPUとGPUの違いについて知る必要があります。なぜならShaderはGPUに直接命令する言語だからです。普段皆さんが使っているDartやJavaScriptといった一般的な言語は、CPUに命令をします。
 GPUは主に画面の描画に必要な演算処理を行うために使われます。GPUで全てのピクセルの色を何色にするかを決定することで、今見ている画面が描画されています。CPUは計算が非常に速いですが、数が少ないです。それに対してGPUは計算が遅いですが、沢山あります。
 もしCPUで画面を描画しようとすると、全てのピクセルの色を1つのCPUで計算することになり、膨大な数の計算が必要になります。スマホやPCの画面を滑らかに描写することは不可能です。GPUの場合沢山あるため、それらが同時に計算を行なうことで、1つのGPUで行う処理を非常に少なくすることができます。それにより、スマホやPCの画面であろうと滑らかに描画することが出来ます。CPUとGPUでの描画のイメージは下の動画が非常に分かりやすいのでぜひご覧ください。
https://www.youtube.com/watch?v=-P28LKWTzrI

Shaderとは?

 Shaderで行うことは1つのピクセルの色を何色にするかということだけです。下記のコードを見てください。

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

 こちらは世界的に有名なShaderの本「The Book of Shaders」で書かれているHelloWorldです。実行するとピンク色の四角形が表示されます。

Shaderで文字を表示しようとすると大変なので、代わりに色がついた四角形を表示しています。コードを見ると、gl_FragColorという変数にvec4型を代入しています。このgl_FragColorがピクセルの色を何色で描くかを決定するものになります。vec4は色のRGBAを表します。色は16進数や0~255の値で指定することが多いですが、Shaderの場合は0~255の値を255で割って0~1に正規化した値を使います。つまりvec4(1.0,0.0,1.0,1.0)はピンク色を表します。
 以上からこのコードは全てのピクセルに対して、ピンク色を指定しているため、ピンク色の四角形が表示されたのです。

FlutterにおけるShader

 ShaderにはVertexShaderとFragmentShaderがあります。VertexShaderを実行したのち、FragmentShaderを実行するという順番で行われます。VertexShaderでは描画するものの頂点を決めます。これにより描画するキャンバスの形が決定します。FragmentShaderではVertexShaderで決定したキャンバスの内側のピクセルの色を何色にするかを決定します。
 しかしFlutterではVertexShaderはサポートされていません。FragmentShaderのみ指定することができます。このことについてはこちらのFlutter公式ドキュメントに記述されています。
 FragmentShaderだけでも綺麗なグラフィックを描くことは可能です。こちらの画像をご覧ください。

Shaderの投稿サイトShadertoyに投稿された作品「Seascape」です。ShadertoyではVertexShaderはありません。決まった長方形のキャンバスの中にFragmentShaderで描きます。FragmentShaderだけでもこれだけのグラフィックが描けるのです。
 しかし「Seascape」のコードを見て頂けると分かると思いますが、コードがとても難しいです。綺麗なグラフィックを描くためには数学の知識が必要になってきます。そしてその難易度は、VertexShaderを使った場合より、FragmentShaderのみを使う場合の方が難しいです。なのでVertexShaderをサポートしていないFlutterでShaderを使うのは難しいです。
 しかし抜け道はあります。画像です。画像とFragmentShaderとの相性は抜群にいいです。画像は四角形でできています。なので四角形のキャンバスしかなくても、画像であれば自然に描くことができます。またFragmentShaderでは1ピクセルごとの色を指定します。このことから画像の1ピクセルごとの色を使って、簡単に効果の掛かった画像を描画することができます。

Shaderを使って画像を表示する

 ではまず、単純に画像を表示することから始めます。shaders/show_image.fragファイルとassets/dash.jpgを用意し、pubspec.yamlに下記のように記述します。

flutter:
  assets:
    - assets/
  shaders:
    - shaders/show_image.frag

shaders/show_image.frag

#include <flutter/runtime_effect.glsl>

out vec4 fragColor;
uniform vec2 uSize;
uniform sampler2D uTexture;

void main() {
    vec2 uv = FlutterFragCoord().xy / uSize;
    fragColor = texture(uTexture, uv.xy).rgba;
}

outは出力する変数を表すキーワードです。ピクセルの色を何色にするかを決定するため、vec4型を出力します。

uniformはCPUから渡される変数を表すキーワードです。後でDartでShaderに渡す処理を書きます。uSizeのuはuniformを表します。Shaderではuniformをuから始まる変数で宣言することが多いです。Flutterの場合あまり関係がありませんが、VertexShaderを使った場合、VertexShaderから渡される変数はvから始まるものにして、どこから渡されたものか区別しやすくしたりします。

FlutterFragCoord()は描画するピクセルの(x,y)座標を取得します。0からキャンバスの縦横の幅までの値が入ります。flutter/runtime_effect.glslライブラリで宣言されているメソッドの為、#include <flutter/runtime_effect.glsl>でライブラリをインポートする必要があります。

uSizeはキャンバスのサイズを表します。FlutterFragCoord().xyを割ることで(x,y)の座標の値を0~1の正規化した値に変換することができます。

uTextureは画像を表します。texture(uTexture, uv.xy).rgbaで画像の指定した座標の位置の色を取得します。この時座標は0~1の値のものでないといけないため、uvを使っています。ここで取得した色をfragColorに代入することで、色を出力します。この処理を全てのピクセルで同時に行われることで画像が描画されます。

 FlutterでShaderはCustomPainterを使って描画します。shader_painter.dartを用意します。

shader_painter.dart

import 'dart:ui';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

class ShaderPainter extends CustomPainter {
  ShaderPainter({required this.shader, required this.image});

  final FragmentShader shader;
  final ui.Image image;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    shader.setFloat(0, size.width);
    shader.setFloat(1, size.height);
    shader.setImageSampler(0, image);
    paint.shader = shader;
    canvas.drawRect(Offset.zero & size, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

 Shaderに対してsetFloatsetImageSamplerでuniform変数を渡します。
setFloatの引数はindexとdouble型の値です。indexはShaderに渡す順番になります。今回だとuniformにはvec2型が一つとsampler2D型が一つあります。vec2型は2つのfloat型を引数になります。なのでsetFloat(0, size.width)でve2型の一つ目の引数に値を入れて、setFloat(1, size.height)でve2型の2つ目の引数を入れます。このindexの順番を逆にしてしまうと、縦横の幅が逆になったサイズになるのでご注意ください。

 sampler2D型はsetFloatではなくsetImageSamplerを使って指定します。setImageSamplerの引数はindexとImage型です。ここで指定するindexはsampler2D型のindexになります。sampler2D型以外のuniform変数はindexに含まれません。setFloatは逆にsampler2D型以外のuniform変数をindexとして指定します。sampler2D型はuTexture一つなので、setImageSampler(0, image)とします。

 残りはこのCustompainterを組み込むだけです。show_image_page.dartを用意します。

show_image_page.dart

import 'dart:ui';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

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

  @override
  State<ShowImagePage> createState() => _BShowImagePageState();
}

class _ShowImagePageState extends State<BasePage> {
  FragmentShader? shader;
  ui.Image? image;

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

  Future<void> setup() async {
    final imageData = await rootBundle.load('assets/dash.jpg');
    image = await decodeImageFromList(imageData.buffer.asUint8List());
    final program = await FragmentProgram.fromAsset('shaders/show_image.frag');
    shader = program.fragmentShader();
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ShowImage'),
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (shader == null || image == null) {
      return const Center(child: CircularProgressIndicator());
    } else {
      return AspectRatio(
        aspectRatio: 1,
        child: CustomPaint(
          painter: ShaderPainter(
            shader: shader!,
            uniforms: [...widget.uniforms, ...images],
          ),
          child: Container(),
        ),
      );
    }
  }
}

setupで画像とShaderを読み込み、それを先ほどのShaderPainter渡しています。実行すると画像のように表示されます。

ネガポジ反転

 Shaderを使って画像を表示することができました。次はShaderならではの画像の効果を作成します。shaders/nega_posi_reverse.fragを用意します。

shaders/nega_posi_reverse.frag

#include <flutter/runtime_effect.glsl>

out vec4 fragColor;
uniform vec2 uSize;
uniform sampler2D uTexture;

void main() {
    vec2 uv = FlutterFragCoord().xy / uSize;
    vec4 color = texture(uTexture, uv.xy);
    fragColor = vec4(1 - color.rgb, color.a);
}

実行すると、ショックを受けたようなダッシュ君が表示されます。

fragColor = vec4(1 - color.rgb, color.a);で、1から画像のRGBの値を引いた値を代入しています。これにより画像の濃淡が逆になります。
 下は画像のRGBの出現頻度を表したヒストグラムです。これを見ると通常の画像のグラフと、ネガポジ反転の画像のグラフが凹凸が丁度逆になっていることが読み取れます。このようにShaderを使うと、画像の1ピクセルごとの色の情報を使って簡単に面白い効果が作れるのです。


通常の画像

ネガポジ反転の画像

クロスフェード

 2枚の画像を使ってクロスフェードを作成します。flutter_log.pngcross_fading.fragを用意します。

cross_fading.frag

#include <flutter/runtime_effect.glsl>

out vec4 fragColor;
uniform vec2 uSize;
uniform float uTime;
uniform sampler2D uTexture1;
uniform sampler2D uTexture2;

void main() {
    vec2 uv = FlutterFragCoord().xy / uSize;
    vec4 color1 = texture(uTexture1, uv.xy).rgba;
    vec4 color2 = texture(uTexture2, uv.xy).rgba;
    fragColor = mix(color1, color2, abs(sin(uTime)));
}

 画像を2枚使うため、sampler2D型のuniform変数を2つ宣言しています。また時間を使うため、uTimeを宣言しています。uTimeはTimer.periodicを使って渡しています。

cross_fading_page.dart

double time = 0;
late Timer timer;

  @override
  void initState() {
    super.initState();
    setup();
    timer = Timer.periodic(const Duration(milliseconds: 1), (timer) {
      setState(() {
        time += 1/1000;
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

 実行すると時間経過で画像が移り変わる様子が確認できます。

texture(uTexture1, uv.xy).rgbaとttexture(uTexture2, uv.xy).rgbaで2枚の画像の色を取得します。その2枚の画像とuTimeを使って、mix(color1, color2, abs(sin(uTime)))で計算した色を出力することで実現しています。
mixabssinと突然出てきた関数はなんでしょうか?こういう時は「The Book of Shaders」こちらのShaderで使える関数の一覧を紹介しているページを見るといいです。
mixは以下のように説明されています。

Parameters
x: Specify the start of the range in which to interpolate.
y: Specify the end of the range in which to interpolate.
a: Specify the value to use to interpolate between x and y.

Description
mix() performs a linear interpolation between x and y using a to weight between them. The return value is computed as x×(1−a)+y×a.

DeepLを使って日本語訳をすると以下のようになります。

パラメータ
x: 補間する範囲の開始点を指定する。
y: 補間する範囲の終点を指定する。
a: x と y の間を補間するために使用する値を指定する。

説明
mix()は、x と y の間の重み付けに a を使用して線形補間を行う。戻り値は x×(1-a)+y×a として計算される。

 x,y,aを引数に取り、x×(1-a)+y×aを返します。ようはaでxとyをそれぞれどれくらいの割合取るかを表します。左端がx、右端をyとする数直線で表すと下のような感じです。a=0の時xになり、aの値が増えてくるとyの割合が増えて、a=1の時完全にyになることが分かります。

 今回の場合だと、xがtexture(uTexture1, uv.xy).rgba、yがtexture(uTexture2, uv.xy).rgba、aがabs(sin(uTime))です。xとyが2枚画像のそれぞれ同じ座標の位置の色情報を取ってきます。それをabs(sin(uTime))という割合でどちらの色をどれくらい使うかを決めています。
 GIF画像を見ると、Dash君が完全に表示される時と、Flutterのロゴが完全に表示される時があります。それはaが0もしくは1になったということを表しているのです。

 次はabs(sin(uTime))sin(uTime)について見ていきます。sinは以下のように説明されています。

Parameters
angle specify the quantity, in radians, of which to return the sine.

Description
sin() returns the trigonometric sine of angle.

DeepLを使って日本語訳をすると以下のようになります。

パラメータ
angle 正弦を返す量をラジアン単位で指定する。

説明
sin()は角度の三角正弦を返す。

最後に

https://github.com/YukiMaitani/flutter_shader

参考

https://thebookofshaders.com/
https://docs.flutter.dev/ui/design/graphics/fragment-shaders
https://codelabs.developers.google.com/codelabs/flutter-next-gen-uis?hl=ja#5
https://medium.com/flutter-community/image-manipulation-with-shaders-flutter-aa11027b4a4d
https://www.amazon.co.jp/ディジタル画像処理-改訂第二版-ディジタル画像処理編集委員会-ebook/dp/B085L18YXF/ref=tmm_kin_swatch_0?_encoding=UTF8&qid=&sr=

GitHubで編集を提案

Discussion