【Flutter】HtmlElementViewを使用してWebGLを使う

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

Flutter WebでWebGLを使いたい

Flutterはマルチプラットフォームで使用することのできるとてもよいライブラリなのですが、3Dグラフィックスの機能が組み込まれていないのが欠点といえば欠点です。ですが、HtmlElementViewを介してCanvasElementを表示することができるので、そこからWebGLを使うことができます。

本記事では、package:webでWebGL2を使用する前提で進めていきます。それでは早速コードを見ていきたいと思います。

最低限のコード

まず、OpenGLやOpenGL ESでいうところのglClearを行うところまでのコードを示します。FlutterとしてのUI定義を行っているlib/main.dartとWebGLの初期化や描画処理を行うlib/renderer.dartの2ファイル構成としています。まずは、lib/main.dartです。

import 'package:flutter/material.dart';
import 'package:web/web.dart';

import 'dart:ui_web';

import 'renderer.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(theme: ThemeData.dark(), home: MainView());
  }
}

class MainView extends StatelessWidget {
  // WebGLによる描画を行うCanvasElement
  final canvasElement = HTMLCanvasElement();

  // レンダラーはcanvasElementから取得できるコンテクストにより後で初期化
  late final Renderer renderer;

  MainView({super.key}) {
    // WebGL2のコンテクストでレンダラーを初期化
    renderer =
        Renderer(canvasElement.getContext('webgl2')! as WebGL2RenderingContext);

    // CanvasElementを'canvas'として登録
    platformViewRegistry.registerViewFactory('canvas', (_) {
      return canvasElement;
    });

    // レンダラーで描画
    renderer.render();
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
        // 'canvas'として登録されたCanvasElementを使用
        body: HtmlElementView(viewType: 'canvas'));
  }
}

46行目のように、HtmlElementViewではviewTypeに指定する文字列によって表示するHTML要素を決定します。そのため、表示するHTML要素を予め登録しておく必要があります。それを行っているのが34行目で、platformViewRegistry.registerViewFactory関数で登録を行っています。

また、CanvasElementの描画自体はFlutterと関係がないため、build関数とは別のタイミングで行っています。

次にlib/renderer.dartです。

import 'package:web/web.dart';

class Renderer {
  final WebGL2RenderingContext context;

  Renderer(this.context);

  void render() {
    context.clearColor(0.0, 0.0, 0.0, 1.0);
    context.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT);
  }
}

CanvasElementから取得したWebGL2のコンテクストをコンストラクタで受け取っています。描画を行うrender関数はカラーバッファを黒で初期化しているだけです。

黒く塗りつぶしているだけなので面白みはないですが、実行すると下のようになります。

三角形を描く

カラーバッファの初期化が動いているのでWebGLも使えているはず・・・とはいえ、さすがにこれだけでは例として微妙なので、三角形を描いて回転させるところまでやってみます。まず、lib/renderer.dartを下記のように修正しました。

import 'dart:typed_data' as td;
import 'dart:js_interop';
import 'package:flutter/services.dart';
import 'package:web/web.dart';
import 'package:vector_math/vector_math.dart' as vm;

class Renderer {
  final WebGL2RenderingContext context;
  late final WebGLVertexArrayObject? vertexArray;
  late final WebGLBuffer? vertexArrayBuffer;

  var _rotation = 0.0;

  // WebGLのプログラム
  WebGLShader? vertexShader, fragmentShader;
  WebGLProgram? program;

  double get rotation => _rotation;

  Renderer(this.context);

  Future<void> initialize() async {
    vertexArray = context.createVertexArray();

    // 頂点データ
    final vertices = td.Float32List.fromList([-0.5, 0.0, 0.5, 0.0, 0.0, 0.5]);
    vertexArrayBuffer = context.createBuffer();
    context.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, vertexArrayBuffer);
    context.bufferData(WebGL2RenderingContext.ARRAY_BUFFER, vertices.toJS,
        WebGL2RenderingContext.STATIC_DRAW, 0, vertices.length);
    context.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, null);

    // シェーダー
    final vertexShaderSource = await rootBundle.loadString('assets/vertex.glsl'),
        fragmentShaderSource = await rootBundle.loadString('assets/fragment.glsl');

    vertexShader =
        createShader(WebGL2RenderingContext.VERTEX_SHADER, vertexShaderSource);
    fragmentShader = createShader(
        WebGL2RenderingContext.FRAGMENT_SHADER, fragmentShaderSource);

    if (vertexShader == null || fragmentShader == null) {
      return;
    }

    program = createProgram(vertexShader!, fragmentShader!);
    if (program == null) {
      return;
    }
  }

  void setRotation(double radian) {
    _rotation = radian;
  }

  WebGLShader? createShader(int type, String source) {
    final shader = context.createShader(type);
    if (shader == null) {
      return null;
    }

    context.shaderSource(shader, source);
    context.compileShader(shader);

    final log = context.getShaderInfoLog(shader);
    if (log != null) {
      print(log);
    }
    return shader;
  }

  WebGLProgram? createProgram(WebGLShader vs, WebGLShader fs) {
    final program = context.createProgram();
    if (program == null) {
      return null;
    }

    context.attachShader(program, vs);
    context.attachShader(program, fs);
    context.linkProgram(program);
    return program;
  }

  void render() {
    if (program == null) {
      return;
    }

    // ビューポートの設定
    context.viewport(
        0, 0, context.drawingBufferWidth, context.drawingBufferHeight);

      // カラーバッファのクリア
    context.clearColor(0.0, 0.0, 0.0, 1.0);
    context.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT);

    // プログラムの指定とUniform変数の設定
    context.useProgram(program);
    context.uniform1f(context.getUniformLocation(program!, 'aspectRatio'),
        context.drawingBufferWidth / context.drawingBufferHeight);
    context.uniformMatrix2fv(context.getUniformLocation(program!, 'rotation'),
        true, vm.Matrix2.rotation(_rotation).storage.toJS);

      // 頂点データの設定
    context.bindVertexArray(vertexArray);
    context.enableVertexAttribArray(0);
    context.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, vertexArrayBuffer);
    context.vertexAttribPointer(
        0, 2, WebGL2RenderingContext.FLOAT, false, 0, 0);

      // 描画
    context.drawArrays(WebGL2RenderingContext.TRIANGLES, 0, 3);

    // 後片付け
    context.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, null);
    context.bindVertexArray(null);
    context.disableVertexAttribArray(0);
    context.bindVertexArray(null);
  }
}

少し長くなってしまいましたが、WebGLの記述を追加したのが大部分です。WebGL自体の解説記事ではないため、この説明については省略します。初期化のためのinitialize関数は非同期関数として用意しています。これは、シェーダーのコードをアセットからロードするようにしたためです。次に示すlib/main.dartではinitialize関数の完了をトリガーとして描画を開始していきます。

次にlib/main.dartを下記のように修正します。

import 'package:flutter/material.dart';
import 'package:web/web.dart';

import 'dart:ui_web';
import 'dart:async';
import 'dart:math';

import 'renderer.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(theme: ThemeData.dark(), home: MainView());
  }
}

class MainView extends StatelessWidget {
  // WebGLによる描画を行うCanvasElement
  final canvasElement = HTMLCanvasElement();

  // レンダラーはcanvasElementから取得できるコンテクストにより後で初期化
  late final Renderer renderer;

  MainView({super.key}) {
    // WebGL2のコンテクストでレンダラーを初期化
    renderer =
        Renderer(canvasElement.getContext('webgl2')! as WebGL2RenderingContext);
    renderer.initialize().then((_) {
      Timer.periodic(const Duration(milliseconds: 1000 ~/ 60), (timer) {
        renderer.setRotation(renderer.rotation + pi / 180.0);
        renderer.render();
      });
    });

    // CanvasElementを'canvas'として登録
    platformViewRegistry.registerViewFactory('canvas', (_) {
      return canvasElement;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: LayoutBuilder(builder: (context, constraints) {
          // リサイズをcanvasElementに反映
          canvasElement.width = constraints.maxWidth.round();
          canvasElement.height = constraints.maxHeight.round();

          return const HtmlElementView(viewType: 'canvas');
        }));
  }
}

アニメーションのための処理等を追加しています。そのほか、build関数内でLayoutBuilderを使用するようにしています。画面のリサイズなどがあったときにHTML要素が自身の表示されるサイズを把握出来ないので、LayoutBuilderによってリサイズがあったときにcanvasElementに反映させるための対応です。今回の例に限らずですが、MediaQueryから取得することのできるdevicePixelRatioを考慮する必要があります。

また、さきほど述べたようにRendererinitialize関数が非同期関数のため、その完了をトリガーとして描画を開始するようにしています。

シェーダーも必要なので追加していきます。まずはバーテックスシェーダーです。今回はassets/vertex.glslとして作成しています。

#version 300 es

uniform float aspectRatio;
uniform mat2 rotation;

in vec2 pos;

void main() {
  vec2 v = pos * rotation;
  gl_Position = vec4(v.x / aspectRatio, v.y, 0.0, 1.0);
}

次にフラグメントシェーダーです。こちらはassets/fragment.glslとして作成しています。

#version 300 es

precision mediump float;

out vec4 color;

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

シェーダーのコードをアセットとして用意したため、pubspec.yamlも修正しています。こちらは修正箇所だけ示します。

# ~中略~
flutter:
# ~中略~
# 以下を追加
  assets:
    - assets/

これを実行すれば下のように回転する三角形をWebGLで描画することが出来ます。

まとめ

本記事ではFlutter Webの中でWebGLの描画を行う方法について説明しました。今回は結局2次元の描画しか行っていませんが、WebGLによる3Dグラフィックの描画を行うことができるようになればFlutterの使いどころが増えてくるのでないかと思います。

コメント

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