🕵️‍♀️

Flutterで直接RenderObjectを描画する回

2022/12/20に公開

皆様いかがお過ごしでしょうか。冬なので肌の乾燥がきびしい Kurogoma4D です。
アドベントカレンダー用の記事を書こうと思って色々と準備してましたが、既出の記事とハチャメチャにネタ被りしてたので諦めました。Scrapboxで供養してます

というわけで(?)、今回は Flutter でWidget を使わずに画面を描画していこうと思います。何を言っているかわからないかもしれないですが、まぁこの記事を読めばわかるようになりますって。

FlutterでWidgetを使わない?

Flutter は通常、Widget を駆使して画面を作っていきます。Widget を使うことで、Widget Tree やRender Tree 、Element Tree といった構造を内部に生成しつつ、うまいこと差分更新をしながら画面を更新していくことが可能となっています。
通常の画面の描画の仕組みは、Flutterレンダリングパイプライン入門Inside Flutter など素晴らしい文献があるのでそちらを読んでいただければ雰囲気が掴めると思います。

しかし、今回はそんな Widget を使いません。ではどういう方法で画面を描画していくかというと、RenderObject を直接画面に描き出すようにプログラムしていきます!!
……失礼、タイトルに書いてありましたね🤷

というわけで、実際に RenderObject を直接描画するコードを見ていきましょう。

描画する画面を生成する方法

実は、そういったテーマの example が Flutter 本体のリポジトリにあります。

https://github.com/flutter/flutter/blob/master/examples/layers/rendering/spinning_square.dart

丁寧にコメントがついているので、一つずつ追っていきましょう。

  // We first create a render object that represents a green box.
  final RenderBox green = RenderDecoratedBox(
    decoration: const BoxDecoration(color: Color(0xFF00FF00)),
  );

最初に、今回描画したい RenderObject をインスタンス化しています。
今回は緑色の箱を描画したいようですね。

RenderObject、もとい RenderBox は多くのWidgetで描画の基礎となる矩形の抽象表現であり、RenderRecoratedBoxDecoration を属性として持つ RenderBox です。この人は単体で「矩形にどんな装飾がついているか(BoxDecoration を使うのであれば色や縁取り等)」を表現してくれます。
Widget の世界ではあらゆる RenderObject の集合体とも言える Container がありますが、逆にここでは必要な要素を一つずつ自分で用意していく必要があります。

  // Second, we wrap that green box in a render object that forces the green box
  // to have a specific size.
  final RenderBox square = RenderConstrainedBox(
    additionalConstraints: const BoxConstraints.tightFor(width: 200.0, height: 200.0),
    child: green,
  );

次に、さっき作った greenRenderConstrainedBox で包んでいます。
これは Widget の世界でもわりとよく見る ConstrainedBox の RenderObject で、今回は 200x200 の大きさを与えています。
これで、緑色の箱が箱らしく色と大きさを手に入れました。

  // Third, we wrap the sized green square in a render object that applies rotation
  // transform before painting its child. Each frame of the animation, we'll
  // update the transform of this render object to cause the green square to
  // spin.
  final RenderTransform spin = RenderTransform(
    transform: Matrix4.identity(),
    alignment: Alignment.center,
    child: square,
  );

どうやらこのコードでは緑色の箱を回転させたいようなので、RenderTransform を用意してさっき作った square を包んでいます。
このとき与えている変形行列は 4x4 の単位行列、つまり何も変わりません🤷
このまま描画しても何も変化がないですが、後でこの spin は使います。

  // Finally, we center the spinning green square...
  final RenderBox root = RenderPositionedBox(
    child: spin,
  );
  // and attach it to the window.
  RenderingFlutterBinding(root: root);

回る準備ができた緑色の箱を RenderPositionedBox で包むことで、センタリングをしています。
RenderPositionedBox はデフォルトで Alignment.center = Alignment(0, 0) が適用されるので、何も引数を指定しなければセンタリングされる挙動になっています。

いよいよこの時がやってきました。
描画ウィンドウに RenderObject をバインドしてくれる魔法のクラス、RenderingFlutterBinding を呼び出して緑色の箱を描画できました!👏

dancing crabs
via GIPHY

…ちょっと待って、まだ踊りだすには早いです。
この example では Render tree に従って簡潔にアニメーションをする方法を示す、と最初の方のコメントに書いてあります。

// This example shows how to perform a simple animation using the underlying
// render tree.

というわけで、続きでありいちばん重要なコードを見てみましょう。

  // To make the square spin, we use an animation that repeats every 1800
  // milliseconds.
  final AnimationController animation = AnimationController(
    duration: const Duration(milliseconds: 1800),
    vsync: const NonStopVSync(),
  )..repeat();
  // The animation will produce a value between 0.0 and 1.0 each frame, but we
  // want to rotate the square using a value between 0.0 and math.pi. To change
  // the range of the animation, we use a Tween.
  final Tween<double> tween = Tween<double>(begin: 0.0, end: math.pi);
  // We add a listener to the animation, which will be called every time the
  // animation ticks.
  animation.addListener(() {
    // This code runs every tick of the animation and sets a new transform on
    // the "spin" render object by evaluating the tween on the current value
    // of the animation. Setting this value will mark a number of dirty bits
    // inside the render tree, which cause the render tree to repaint with the
    // new transform value this frame.
    spin.transform = Matrix4.rotationZ(tween.evaluate(animation));
  });

まず Flutter のアニメーションではお約束の AnimationController を用意しています。
本来、Widgetの世界の上では StatefulWidgetTickerProviderStateMixin を組み合わせることで、Flutter が内部的に用意してくれる TickerProvider を召喚しています。vsync というやつです。
ここではWidgetに頼ることはできないため、自前で NonStopVSync という形で TickerProvider を実装しています。
中身は至極単純、createTicker が呼ばれたら素直に Ticker を返すだけ、です🧐

class NonStopVSync implements TickerProvider {
  const NonStopVSync();
  
  Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
}

そもそも TickerProviderStateMixinTickerProvider でもあるので、ドキュメントとソースコードを読むと何をしているかがわかりますが、ここでは深入りしません。まぁなんやかんやしてWidgetの世界に馴染むようになってます。
その点 NonStopVSync の方は、ただ AnimationController が毎フレーム動いてくれればいいだけなので、特別な処理が必要というわけでもないわけですね。

AnimationController を用意した上で、箱の回転をアニメーションとして表現するには AnimationController から提供される値を回転量に対応させてあげる必要があります。
普段 Flutter でアニメーションするとき、みなさんも Tween を使うと思いますが、ここでも同じように Tween を使うことができます。
内容は Tween<double>(begin: 0.0, end: math.pi) なので、時間 t0→1 に変化する時に 0→\pi に変化させる、というものです。数式でいうと r = \pi t 的な感じですね。\pi rad = 180\degree なので、このアニメーションは 1800ms の間に箱が半回転する、という内容になってます(なぜ半回転なのか?というと、この example で扱うのはただ一色の正方形=点対称のオブジェクトなので、半回転を繰り返すだけで1回転に見えるからです)。

かくして、アニメーションを生成させる元とアニメーションに適用する値の用意ができました。
animation#addListener を呼び出すことで、Flutter が描画する時の毎フレームで呼ばれる関数を登録することができます。
ここではただ一行のみが書いてあり、さっき箱の回転用に作った spintransform プロパティを直に変更しています。
変更する値は、直前に作っていた tween を適用した AnimationController の値を回転行列に変換したものです。

普段 Tween を使ったアニメーションをする際は Tween#animate メソッドを呼び出して Animation<T> として扱うので意識することはないですが、Tween#evaluate を使うことで、その時の AnimationController の値を Tween を適用させた状態で取り出すことができます。

アニメーションの値を適用する際にただ transform に代入しているだけで、それでアニメーションが成立するんか?デタラメ言ってない?🤔と思う方がいるかも知れませんが、その答えがきっちりとコメントに書いてあります。

    // This code runs every tick of the animation and sets a new transform on
    // the "spin" render object by evaluating the tween on the current value
    // of the animation. Setting this value will mark a number of dirty bits
    // inside the render tree, which cause the render tree to repaint with the
    // new transform value this frame.
    spin.transform = Matrix4.rotationZ(tween.evaluate(animation));

このコードはアニメーションの毎チックで実行され、"spin" render object に Tween を使って評価した transform の値をセットしています。この値をセットすると render tree の中で dirty bits(更新が必要であることを示すフラグ)が立ち、新しい transform の値で再描画を引き起こします。

Flutter のレンダリング方式といえば先に挙げた3つの木構造を使った差分更新ですが、その差分更新アルゴリズムの中で dirty bits は仕組みとして取り入れられています。ここでは、render tree に使われている更新の仕組みを Widget を介さずに直接利用して描画を実現している、というわけです。

おまけ:FBMノイズを実装してみる

これまでに追ってきたコードで、RenderObject を直接描画して、しかもアニメーションする方法がわかりました。わかりましたよね?
3年前のツイートでは、これを使ってノイズの描画をやってみよう、ということをやっていました。
その際に実装したところをちょろっとだけ解説します。

ソースコード:
https://github.com/Kurogoma4D/flutter_noise_rendering

commit message
コミットメッセージがめっちゃ陽気

ここで実装しているノイズが FBM (Fractal Brownian Motion) で、これは2次元の連続な値が現れるものです。
そして、ここでは The Book of Shaders で実装されているものをほとんどそのまま Dart に移植しました。詳しく説明するとそれだけで1つやそれ以上記事が書けてしまうので、気になった方は移植元をご覧ください。結構詳しく解説してあります。

元のコードは GLSL であり、簡潔に言えば 1px ずつの処理をGPUを使って並列で特定のピクセルの色を計算するようになっていますが、Dartではそういったことはできないため、forループを使って 1px ずつの計算をすることになります。

更に、1px ずつ計算した結果を RenderObject として表示するために、ここでは dart:uiImage 型にピクセル情報を盛り込んで RenderImage に突っ込む、という方法を取りました。

Image を作り出す関数がこれで、

Future<ui.Image> makeImage({double time = 0}) {
  final c = Completer<ui.Image>();
  final pixels = Int32List(kImageDimension * kImageDimension);
  int x = 0;
  int y = 0;
  for (int i = 0; i < pixels.length; i++) {
    y = (i / kImageDimension).floor();
    x = i % kImageDimension;
    pixels[i] = makeColor(time, x, y);
  }
  ui.decodeImageFromPixels(
    pixels.buffer.asUint8List(),
    kImageDimension,
    kImageDimension,
    ui.PixelFormat.rgba8888,
    c.complete,
  );
  return c.future;
}

特定のピクセルの色を計算する関数がこんな感じです。コメントにも書いてありますが、この関数は GLSL の main 関数に相当します。

int makeColor(double time, int x, int y) {
  // main function of GLSL.
  int red = 0;
  int green = 0;
  int blue = 0;
  int alpha = 255;
  int resultColor = 0;
  Vector2 p = Vector2(
    (x.toDouble() * 2 - kImageDimension) / kImageDimension,
    (y.toDouble() * 2 - kImageDimension) / kImageDimension,
  );

  // color processing here
  double primary = fbm(p * 2.0);
  Vector2 secondary = Vector2(
    p.x + primary + time,
    p.y + primary + time,
  );
  red = (fbm(secondary) * 255).toInt();
  green = red;
  blue = red;

  // convert 8bit integers to 32bit integers
  resultColor += (alpha << alphaOffset);
  resultColor += (red << redOffset);
  resultColor += (green << greenOffset);
  resultColor += (blue << blueOffset);

  return resultColor;
}

一連のコードを CodePen にまとめてみました。
ただ、1px ずつ愚直に2重 for ループを回しているので、流石に解像度が低くないと重いです😇 ローカルで macOS とかのプラットフォームで動かせばもうちょっと解像度は上げられます。

まとめ

  • Flutter で RenderObject だけを扱うことはできる
    • 自分で RenderObject を重ねる
    • rootの RenderObject を直接 Flutter engine にアタッチする
  • Flutter で1pxずつの描画もできる
    • 計算した色を Image オブジェクトにしてから描画する
    • ただし凝ったことをやると勿論それなりに重い

恐らくこの記事の内容が Flutter アプリの実装に役に立つことはほとんど無いかと思いますが(いつもの)、楽しんでいただけていたら嬉しいです。

Discussion