5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterでShaderを使ってみる

Posted at

FlutterではGLSLで記述したシェーダーを使って描画することもできます。
標準のウィジェットで大抵のことはできるので、滅多に使う機会がありませんがデザイン次第で出番があるかもしれません。

シェーダーを使うための手順

Flutterでは以下のような手順でシェーダーを使った描画を行います。

1. pubspec.yaml にシェーダーファイルを追加する

一般的にGLSLのファイル拡張子はglsl、vert、fragなどが使われます。

flutter:
  shaders:
    - shaders/my_fragment_shader.frag

2. シェーダーを読み込む

Flutterのコード内ではFragmentProgramクラスを介してシェーダーを扱います。

final program = await FragmentProgram.fromAsset('shaders/my_fragment_shader.frag');

3. CustomPainterを使って描画する

CustomPaintのpainterにシェーダーを使ったCustomPainterを渡すことで描画します。

// 何かのウィジェットのbuildメソッド
Widget build(BuildContext context) {
  return CustomPaint(
    painter: MyCustomPainter(program: program);
  );
}
class MyCustomPainter extends CustomPainter {
  const MyCustomPainter({
    required this.program,
  });

  final FragmentProgram program;

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

  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

Flutter特有のクセ

基本的にGLSLですが、FlutterではGLSLで一般的と思われるresolutionやu_timeなどのuniformパラメータが使えません。必要であればアプリケーションコードでセットアップする必要があります。
アプリケーションコードでuniformパラメータをセットアップするには以下のようにします。

final shader = fragmentProgram.fragmentShader()
  ..setFloat(0, size.width)
  ..setFloat(1, size.height)
  ..setImageSampler(0, texture);

backbufferも使えないので、BackdropFilter経由でImage化して次のフレームに持ち越すなどアプリケーション側で対処します。どうしても使いたいのであれば、shader_buffersパッケージを使うのが一番簡単そうです。

何か作ってみる

ここからは実際に何か作って動かしてみたいと思います。シェーダーを使う意義のあるものと考えるとなかなか難しいですが、とりあえず以下のようにします。

  • グリッドビューを使ったリストを表示する
  • 各セルの背景は大きな画像の一部を切り取ったものにする
  • リストをスクロールさせても画像は固定されて見えるようにする

日本語にするのが難しいので、できたものを動画にしました。こういうものを作ってみます。

shader.gif

シェーダーでやっていることは以下の通りです。

  • 描画範囲がテクスチャのどの部分かを計算する
  • 該当ピクセルを出力する

ソースコード

実行可能なソースコードです。画像は適当なものをお使いください。

pubspec.yaml(の一部)

  assets:
    - assets/images/background1.png
    - assets/images/background2.png
  shaders:
    - shaders/list_cell.frag

shaders/list_cell.frag

#include <flutter/runtime_effect.glsl>

// フラグメントシェーダーの出力カラー
out vec4 frag_color;
// シェーダーが適用されたウィジェットのサイズ
uniform vec2 u_size;
// 画面全体のサイズ
uniform vec2 u_screen_size;
// シェーダーが適用されたウィジェットのオフセット位置
uniform vec2 u_offset;
// 使用するテクスチャ
uniform sampler2D u_texture;

/// メイン関数
/// 画面幅いっぱいにテクスチャを表示しようとしたときに、
/// このシェーダーが適用された矩形領域に対応するテクスチャの部分を描画する。
void main() {
    // 描画矩形を正規化する
    vec2 uv = FlutterFragCoord().xy / u_size;
    // ウィジェット左上に相当するテクスチャ座標を計算する
    vec2 top_left = u_offset / u_screen_size;
    // ウィジェット右下に相当するテクスチャ座標を計算する
    vec2 bottom_right = (u_offset + u_size) / u_screen_size;

    // ウィジェット内の正規化座標からテクスチャ座標を計算する
    float x = mix(top_left.x, bottom_right.x, uv.x);
    // maxを使って0以上に切り上げるのは、テクスチャの反転を避けるため
    // そもそも0未満になる場合、ピクセルは画面外にあるので影響はない
    float y = max(0, mix(top_left.y, bottom_right.y, uv.y));

    // テクスチャから色を取得して出力する
    frag_color = texture(u_texture, vec2(x, y)).rgba;
}

lib/main.dart

dart:uiのImageとflutter/materialのImageが衝突するため、dart:uiはプレフィクスをつけてimportしています。

import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// アプリケーションのタイトル
const _applicationTitle = 'Grid with Shader';

/// アプリケーションの背景色
const _backgroundColor = Color(0xFF203040);

const _numColumns = 3;
const _numRows = 12;
const _numCells = _numColumns * _numRows;

/// アプリケーションのエントリーポイント
void main() {
  runApp(const MyApp());
}

/// ルートWidget
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _applicationTitle,
      theme: ThemeData(colorScheme: .fromSeed(seedColor: Colors.deepPurple)),
      home: const _Content(),
    );
  }
}

/// コンテンツ
class _Content extends StatefulWidget {
  const _Content();

  @override
  State<_Content> createState() => _ContentState();
}

/// ホームページの状態
class _ContentState extends State<_Content> {
  /// 初期化済みフラグ
  bool _initialized = false;

  /// テクスチャ
  late final List<ui.Image> _textures;

  /// シェーダー
  late final ui.FragmentProgram _fragmentProgram;

  /// GlobalKeyマップ。グリッドの画面座標を取得するために使用する。
  final Map<int, GlobalKey> _painterKeys = {};

  /// スクロールコントローラー。スクロールするたびに再描画するために使用する。
  final ScrollController _scrollController = ScrollController();

  /// 初期化処理
  @override
  void initState() {
    super.initState();

    _setupShader();
  }

  /// シェーダーとテクスチャのセットアップ
  Future<void> _setupShader() async {
    final results = await Future.wait([
      _loadTexture(1),
      _loadTexture(2),
      _loadShader(),
    ]);

    _textures = [results[0] as ui.Image, results[1] as ui.Image];
    _fragmentProgram = results[2] as ui.FragmentProgram;

    setState(() {
      _initialized = true;
    });
  }

  /// テクスチャ読み込み
  Future<ui.Image> _loadTexture(int index) async {
    final img = await rootBundle.load('assets/images/background$index.png');
    final codec = await ui.instantiateImageCodec(img.buffer.asUint8List());
    final frame = await codec.getNextFrame();
    return frame.image;
  }

  /// シェーダー読み込み
  Future<ui.FragmentProgram> _loadShader() {
    return ui.FragmentProgram.fromAsset('shaders/list_cell.frag');
  }

  /// 破棄処理
  @override
  void dispose() {
    for (final t in _textures) {
      t.dispose();
    }

    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 初期化が完了していない場合はローディング表示
    if (!_initialized) {
      // 初期化中の表示
      return Scaffold(
        appBar: AppBar(title: const Text(_applicationTitle)),
        backgroundColor: _backgroundColor,
        body: const ColoredBox(color: Colors.amber),
      );
    }

    // 初期化済みの表示。 グリッドを表示する。
    return Scaffold(
      appBar: AppBar(title: const Text(_applicationTitle)),
      backgroundColor: _backgroundColor,
      body: CustomScrollView(
        controller: _scrollController,
        slivers: [
          SliverPadding(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
            sliver: SliverGrid.builder(
              addRepaintBoundaries: true,
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: _numColumns,
                childAspectRatio: 1,
                mainAxisSpacing: 8,
                crossAxisSpacing: 8,
              ),
              // 各グリッドセルのWidgetを生成
              itemBuilder: (context, index) {
                final painterKey = _painterKeys[index] ??= GlobalKey();
                final texture = _textures[index ~/ (_numCells ~/ 2)];

                return _Cell(
                  painterKey: painterKey,
                  index: index,
                  fragmentProgram: _fragmentProgram,
                  texture: texture,
                  listenable: _scrollController.position,
                );
              },
              itemCount: _numCells,
            ),
          ),
        ],
      ),
    );
  }
}

/// グリッドセル1つ分のWidget
class _Cell extends StatelessWidget {
  const _Cell({
    required this.painterKey,
    required this.index,
    required this.fragmentProgram,
    required this.texture,
    required this.listenable,
  });

  final GlobalKey painterKey;
  final int index;
  final ui.FragmentProgram fragmentProgram;
  final ui.Image texture;
  final Listenable listenable;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        // セルの背景(シェーダー描画)
        SizedBox.expand(
          child: ClipRRect(
            borderRadius: BorderRadius.circular(12),
            child: _CellBackground(
              key: painterKey,
              fragmentProgram: fragmentProgram,
              texture: texture,
              listenable: listenable,
            ),
          ),
        ),
        // セルの前景(タップ可能なラベル)
        Positioned.fill(
          child: Material(
            color: Colors.transparent,
            child: InkWell(
              onTap: () {
                print('Tapped on item $index');
              },
              child: Center(
                child: Text(
                  'Item $index',
                  style: const TextStyle(
                    fontSize: 24,
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

/// グリッドセルの背景を描画するウィジェット
class _CellBackground extends StatefulWidget {
  const _CellBackground({
    required super.key,
    required this.fragmentProgram,
    required this.texture,
    required this.listenable,
  });

  final ui.FragmentProgram fragmentProgram;
  final ui.Image texture;
  final Listenable listenable;

  @override
  State<_CellBackground> createState() => _CellBackgroundState();
}

class _CellBackgroundState extends State<_CellBackground> {

  late final ui.FragmentShader _shader;

  @override
  void initState() {
    super.initState();
    _shader = widget.fragmentProgram.fragmentShader();
  }


  @override
  void dispose() {
    _shader.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: _CellBackgroundPainter(
        key: widget.key as GlobalKey,
        fragmentShader: _shader,
        texture: widget.texture,
        screenSize: MediaQuery.of(context).size,
        listenable: widget.listenable,
      ),
    );
  }
}

class _CellBackgroundPainter extends CustomPainter {
  const _CellBackgroundPainter({
    required this.key,
    required this.fragmentShader,
    required this.texture,
    required this.screenSize,
    required Listenable listenable,
  }) : super(repaint: listenable);

  final GlobalKey key;
  final ui.FragmentShader fragmentShader;
  final ui.Image texture;
  final Size screenSize;


  @override
  void paint(Canvas canvas, Size size) {
    final offset = _getScreenPosition();

    fragmentShader
      // set u_size
      ..setFloat(0, size.width)
      ..setFloat(1, size.height)
      // set u_screen_size
      ..setFloat(2, screenSize.width)
      ..setFloat(3, screenSize.height)
      // set u_offset
      ..setFloat(4, offset.dx)
      ..setFloat(5, offset.dy)
      // set u_texture
      ..setImageSampler(0, texture);

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

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

  // このウィジェットインスタンスの画面上の位置を取得する。
  Offset _getScreenPosition() {
    final box = key.currentContext?.findRenderObject() as RenderBox?;
    if (box == null) {
      return Offset.zero;
    }

    final topLeft = box.localToGlobal(Offset.zero);
    return Offset(topLeft.dx, topLeft.dy);
  }
}

最後に

株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?