Flutterで直接RenderObjectを描画する回
皆様いかがお過ごしでしょうか。冬なので肌の乾燥がきびしい Kurogoma4D です。
アドベントカレンダー用の記事を書こうと思って色々と準備してましたが、既出の記事とハチャメチャにネタ被りしてたので諦めました。Scrapboxで供養してます。
というわけで(?)、今回は Flutter でWidget を使わずに画面を描画していこうと思います。何を言っているかわからないかもしれないですが、まぁこの記事を読めばわかるようになりますって。
FlutterでWidgetを使わない?
Flutter は通常、Widget を駆使して画面を作っていきます。Widget を使うことで、Widget Tree やRender Tree 、Element Tree といった構造を内部に生成しつつ、うまいこと差分更新をしながら画面を更新していくことが可能となっています。
通常の画面の描画の仕組みは、Flutterレンダリングパイプライン入門や Inside Flutter など素晴らしい文献があるのでそちらを読んでいただければ雰囲気が掴めると思います。
しかし、今回はそんな Widget を使いません。ではどういう方法で画面を描画していくかというと、RenderObject
を直接画面に描き出すようにプログラムしていきます!!
……失礼、タイトルに書いてありましたね🤷
というわけで、実際に RenderObject を直接描画するコードを見ていきましょう。
描画する画面を生成する方法
実は、そういったテーマの example が Flutter 本体のリポジトリにあります。
丁寧にコメントがついているので、一つずつ追っていきましょう。
// 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で描画の基礎となる矩形の抽象表現であり、RenderRecoratedBox
は Decoration
を属性として持つ 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,
);
次に、さっき作った green
を RenderConstrainedBox
で包んでいます。
これは 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
を呼び出して緑色の箱を描画できました!👏
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の世界の上では StatefulWidget
と TickerProviderStateMixin
を組み合わせることで、Flutter が内部的に用意してくれる TickerProvider
を召喚しています。vsync
というやつです。
ここではWidgetに頼ることはできないため、自前で NonStopVSync
という形で TickerProvider
を実装しています。
中身は至極単純、createTicker
が呼ばれたら素直に Ticker
を返すだけ、です🧐
class NonStopVSync implements TickerProvider {
const NonStopVSync();
Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
}
そもそも TickerProviderStateMixin
が TickerProvider
でもあるので、ドキュメントとソースコードを読むと何をしているかがわかりますが、ここでは深入りしません。まぁなんやかんやしてWidgetの世界に馴染むようになってます。
その点 NonStopVSync
の方は、ただ AnimationController
が毎フレーム動いてくれればいいだけなので、特別な処理が必要というわけでもないわけですね。
AnimationController
を用意した上で、箱の回転をアニメーションとして表現するには AnimationController
から提供される値を回転量に対応させてあげる必要があります。
普段 Flutter でアニメーションするとき、みなさんも Tween
を使うと思いますが、ここでも同じように Tween
を使うことができます。
内容は Tween<double>(begin: 0.0, end: math.pi)
なので、時間
かくして、アニメーションを生成させる元とアニメーションに適用する値の用意ができました。
animation#addListener
を呼び出すことで、Flutter が描画する時の毎フレームで呼ばれる関数を登録することができます。
ここではただ一行のみが書いてあり、さっき箱の回転用に作った spin
の transform
プロパティを直に変更しています。
変更する値は、直前に作っていた 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年前のツイートでは、これを使ってノイズの描画をやってみよう、ということをやっていました。
その際に実装したところをちょろっとだけ解説します。
ソースコード:
コミットメッセージがめっちゃ陽気
ここで実装しているノイズが FBM (Fractal Brownian Motion) で、これは2次元の連続な値が現れるものです。
そして、ここでは The Book of Shaders で実装されているものをほとんどそのまま Dart に移植しました。詳しく説明するとそれだけで1つやそれ以上記事が書けてしまうので、気になった方は移植元をご覧ください。結構詳しく解説してあります。
元のコードは GLSL であり、簡潔に言えば 1px ずつの処理をGPUを使って並列で特定のピクセルの色を計算するようになっていますが、Dartではそういったことはできないため、forループを使って 1px ずつの計算をすることになります。
更に、1px ずつ計算した結果を RenderObject として表示するために、ここでは dart:ui
の Image
型にピクセル情報を盛り込んで 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