万華鏡の描画を「毎タイル描き直し」から「1枚焼いて貼る」に変えた話

| 開発記録 | 万華鏡

タグ: #Flutter #Canvas #パフォーマンス #描画最適化

万華鏡アプリの描画が、敷き詰めたタイルの数だけ中身を描き直していました。三角形の中身を一度だけ画像に焼いて、各コピーに貼る方式へ変更。コストがタイル数×オブジェクト数の掛け算から足し算に落ちました。副産物で奥行き感まで付いた記録です。

万華鏡は「同じ絵を何十回も描く」アプリだった

きらきら万華鏡カメラは、三角形のセルの中で物理エンジンが図形を動かしています。それを鏡映で画面いっぱいに敷き詰めます。万華鏡らしい模様は、この「敷き詰め(テッセレーション)」で作られています。

問題は描画コストの構造でした。初期の描画ループはこうです。

for (final transform in transforms) {
  canvas.save();
  canvas.transform(transform.storage);
  canvas.clipPath(clipPath);
  drawTriangleContent(canvas); // 三角形の中身を「タイルの数だけ」描き直している
  canvas.restore();
}

drawTriangleContent は、三角形の中にいる物理オブジェクトを全部描く処理です。タイル数を C、オブジェクト数を N とします。すると 1 フレームあたりの描画命令は C × N に比例します。

これは効きます。画面が広いほどタイル数は増えます。遊んでいるうちにオブジェクトも増えます。コストは掛け算で膨らみます。しかも万華鏡は常に動いている画面です。この掛け算が毎フレーム発生します。

気付き: コピーはすべて「同じ絵」

落ち着いて考えました。敷き詰められたタイルはどれも、中心の三角形とまったく同じ絵です。違うのは、どの向きに反射しているかという座標変換だけ。

それなら、中身を一度だけ描いて画像に焼けばいい。あとは各タイルにその画像を貼るだけです。描画命令は「中身を 1 回描く N」+「画像を C 回貼る」で O(N + C) に落ちます。掛け算が足し算になる、という変更です。

PictureRecordertoImageSync() で焼く

Flutter でこれをやるには、PictureRecorder に描画を記録して Picture を作ります。それを Picture.toImageSync() でラスタライズします。

まず、三角形の bounding box を求めます。焼いた画像のサイズと、貼り付け先の矩形を決めるためです。

double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final v in triangleVertices) {
  minX = min(minX, v.x);
  minY = min(minY, v.y);
  maxX = max(maxX, v.x);
  maxY = max(maxY, v.y);
}
final bboxW = (maxX - minX).ceilToDouble();
final bboxH = (maxY - minY).ceilToDouble();
if (bboxW <= 0 || bboxH <= 0) {
  canvas.restore();
  return; // レイアウト確定前などの退避
}

次に、その bounding box の左上が原点に来るように平行移動してから記録します。三角形の頂点は画面中心を原点とした座標で持っています。そのまま記録すると画像の外に出てしまうからです。

final recorder = ui.PictureRecorder();
final recCanvas = Canvas(recorder);
recCanvas.translate(-minX, -minY); // bbox の左上を画像の原点に合わせる
recCanvas.clipPath(clipPath);      // クリップも画像側に焼き込む
drawTriangleContent(recCanvas);
final picture = recorder.endRecording();

// 一度だけラスタライズする
final image = picture.toImageSync(bboxW.toInt(), bboxH.toInt());
picture.dispose();

大事なのは、クリップも画像側に含めることです。三角形の外側を切り落とした状態で焼いてしまう。すると貼る側は、何も考えずに矩形として貼るだけで済みます。以前は各タイルで clipPath を呼んでいました。その分の負荷も消えます。

貼る側はこうなりました。

final srcRect = Rect.fromLTWH(0, 0, bboxW, bboxH);
final dstRect = Rect.fromLTWH(minX, minY, bboxW, bboxH);

for (final tile in tiles) {
  canvas.save();
  canvas.transform(tile.transform.storage);
  canvas.drawImageRect(image, srcRect, dstRect, paint);
  canvas.restore();
}

image.dispose();

dstRect の原点を (minX, minY) に戻しているのがポイントです。記録するときに -minX, -minY ずらした分を、貼るときに戻しています。これで、元の座標系のまま反射行列を掛けられます。

toImageSync() の性質を確認しておく

名前が Sync なので「その場で全部やる」ように見えます。公式 API ドキュメントの説明はもう少し正確です。

  • イメージオブジェクトは同期的に作られて返るが、ラスタライズ自体は非同期に行われる
  • GPU コンテキストが利用できる場合、この画像は GPU 常駐として作られ、ホスト側にコピーバックされない
  • ラスタライズに失敗した場合は、その画像を Canvas に描画したときに例外が投げられる

「GPU に置いたまま、CPU 側に持ってこない」。これがこの用途で効いてくる性質です。焼いた画像は、そのフレームのうちに何十回も貼るだけ。ピクセルを CPU 側で読む必要はまったくありません。逆に、画像の中身をバイト列として取り出したいなら Image.toByteData() を呼ぶことになります。GPU 常駐の画像から読み戻すので、ここで得られる利点は薄れます。

picture.dispose()image.dispose() を忘れないのも重要です。毎フレーム焼き直す構造なので、解放が漏れると素直にリークします。

副産物: 反射の深さで奥行きを付けられるようになった

構造が「1 枚の画像をタイルごとに貼る」に変わりました。そうしたら思わぬ副産物がありました。タイル単位の不透明度制御が安く付けられるようになったのです。

敷き詰めは、中心の三角形から辺ごとに鏡映して隣を見つける BFS(幅優先探索)で作っています。BFS なら、探索の段数がそのまま「中心から何回反射したか」になります。そこで、変換行列だけを持っていたタイルを、深さも持つ型に変えました。

/// 敷き詰めタイル: 変換行列と、中心から何回反射したかの深さ
class TessellationTile {
  final Matrix4 transform;
  final int depth;

  const TessellationTile(this.transform, this.depth);
}

BFS の中では、隣を見つけるたびに深さを 1 つ増やして積むだけです。

final queue = Queue<TessellationTile>();
queue.add(TessellationTile(Matrix4.identity(), 0));

while (queue.isNotEmpty) {
  final current = queue.removeFirst();
  final currentVerts = transformVertices(current.transform);

  for (var i = 0; i < 3; i++) {
    final edgeReflection =
        reflectionMatrix(currentVerts[i], currentVerts[(i + 1) % 3]);
    final combined = edgeReflection.multiplied(current.transform);

    final newVerts = transformVertices(combined);
    final key = centroidKey(newVerts);
    if (placedCentroids.contains(key)) continue; // 重心を量子化したキーで重複を弾く
    if (!isVisible(newVerts)) continue;          // 画面外まで来たら打ち切る

    final tile = TessellationTile(combined, current.depth + 1);
    placedCentroids.add(key);
    result.add(tile);
    queue.add(tile);
  }
}

あとは、深いタイルほど薄く貼ります。実装では減衰の強さと下限を式の中に直接書いています。ここでは意味が分かるように定数名を付けて示します。

/// 最も深いタイルまでに落とす不透明度の割合
const kDepthFadeStrength = 0.6;

/// 落としきっても残す下限(奥が沈み込みすぎないように)
const kMinTileOpacity = 0.4;

double tileOpacity(int depth, int maxDepth) {
  if (maxDepth == 0) return 1.0;
  final fade = depth / (maxDepth + 1) * kDepthFadeStrength;
  return (1.0 - fade).clamp(kMinTileOpacity, 1.0);
}

これを Paint に反映して貼ります。中心の三角形は完全に不透明のまま、外側に向かって少しずつ薄くなります。

final paint = Paint()
  ..filterQuality = FilterQuality.low
  ..color = Color.fromRGBO(255, 255, 255, tileOpacity(tile.depth, maxDepth));

見た目は、中心から遠いほど霞んでいく奥行き感です。実物の万華鏡でも、鏡の反射を重ねた奥のほうは光量が落ちて暗く見えます。そちらに近づいた形です。

最適化のつもりで構造を変えたら、表現の幅も広がりました。順番が逆です。もし今も各タイルで中身を描き直していたら、タイルごとに不透明度を変えるには全描画命令に alpha を伝播させる必要があります。ここまで安くは実装できなかったはずです。

ハマったこと: 回転で図形が三角形からはみ出す

同じ時期に、別のバグが出ました。2 本指で万華鏡全体を回転させると、物理オブジェクトが三角形の枠からはみ出して見えます。

原因は、二重に回転を掛けていたことでした。物理エンジン側のオブジェクト座標は、回転後の境界を反映したワールド座標としてすでに更新されています。なのに描画側でも回転角を掛け直していました(自分で二度回しておいて、はみ出すと騒いでいました)。

// 修正前: ワールド座標に対して、さらに回転を掛けていた
final rx = pos.x * cosR - pos.y * sinR;
final ry = pos.x * sinR + pos.y * cosR;
canvas.translate(rx * renderScale, ry * renderScale);
canvas.rotate(child.body.angle + worldRotation);

// 修正後: 位置はすでに回転済みなので、そのまま使う
canvas.translate(pos.x * renderScale, pos.y * renderScale);
canvas.rotate(child.body.angle);

削除した行のほうが多い修正でした。座標変換が絡む描画では、「どの段階で、どの座標系に入っているか」を取り違えると、こうなります。「なんとなく合っているが微妙にずれる」不具合です。回転が小さいうちは気付きにくく、大きく回したときにだけ破綻する。やっかいでした。

教訓

  • 掛け算になっているループを探す。「コピーの数 × 中身の数」の形を見つけたら、共通部分を一度だけ焼いて貼る形に潰せないか考える
  • クリップも焼き込む。切り抜き済みの画像にしておけば、貼る側は矩形を貼るだけで済む
  • toImageSync() は「同期に返るが、ラスタライズは非同期」。GPU 常駐で作られてホストにコピーバックされないので、そのフレーム内で貼るだけの用途に向く。ピクセルを読み出す用途(Image.toByteData())では、その読み戻しコストが乗る
  • 幾何計算の副産物を捨てない。BFS の段数のような値は、探索のついでにタダで手に入る。それが表現に直接使えることがある
  • 座標系の責務は一箇所に寄せる。物理側で回転済みなら描画側は回さない。二重適用のバグは、足すのではなく削って直る

万華鏡は見た目が派手です。でもやっていることは「同じ絵を正しい向きで並べる」だけ。だからこそ、その「並べる」部分の構造が、そのまま性能と表現に効いてきます。地味な足し算のほうが、ちゃんときれいに見えました。