回転する万華鏡でカメラ映像が伸び縮みする — AABB をやめて外接円で作る回転不変の描画矩形

| 開発記録 | 万華鏡

タグ: #Flutter #Dart #Canvas #幾何 #テスト

万華鏡アプリのカメラ背景が、三角形の回転に合わせてびよんびよん伸び縮みしました。原因は回転する三角形の AABB へ画像を引き伸ばしていたことです。外接円に外接する正方形という回転不変な矩形と、BoxFit.cover 相当の中央クロップで解決し、純粋関数化と回帰テストで固定するまでの記録です。

「回転中にカメラ映像が伸び縮みして見える」

きらきら万華鏡カメラには、万華鏡の背景としてカメラのライブ映像を映すモードがあります。三角形のセルの中で、物理エンジンが動かす図形の背後にカメラ映像が流れます。それが鏡映で画面いっぱいに敷き詰められて、現実の風景が万華鏡の模様になります。

このモードで「三角形が回転している間、背景のカメラ映像の縦横比が変わって見える」という報告がありました。実機で回してみると、確かに映像が回転に合わせて周期的にびよんびよんと伸び縮みします。静止させると止まる。回すとまた脈打つ。明らかに回転角と連動した何かです。

カメラ背景はどう描かれていたか

問題の描画コードは、三角形の頂点からバウンディングボックスを作っていました。そこへカメラ画像を drawImageRect で引き伸ばす実装です。

// 修正前: 回転済みの三角形頂点から AABB(軸並行の外接矩形)を作る
double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final v in pixelVertices) {
  minX = min(minX, v.x);
  minY = min(minY, v.y);
  maxX = max(maxX, v.x);
  maxY = max(maxY, v.y);
}

final src = Rect.fromLTWH(
    0, 0, image.width.toDouble(), image.height.toDouble());
final dst = Rect.fromLTRB(minX, minY, maxX, maxY);
canvas.drawImageRect(image, src, dst, paint);

Canvas はこの時点で既に三角形の形にクリップされています。だから矩形に描いても、実際に見えるのは三角形の内側だけです。「三角形を囲む矩形にカメラ画像を敷けば、三角形の中はカメラ映像で埋まる」。この発想自体は自然に見えます。

AABB は回転不変ではない

原因はこの AABB(軸並行バウンディングボックス)にありました。

AABB は「座標軸に平行な辺で対象を囲む最小の矩形」です。三角形が回転すると、同じ三角形でも囲む矩形の幅と高さが回転角に応じて変わります。頂点が真上を向いているときと、辺が真上を向いているときで、縦横の比率が違うのです。

つまり dst の縦横比が毎フレーム変わります。一方 src はカメラ画像全域で固定。固定の画像を、縦横比が脈打つ矩形へ引き伸ばし続ける。映像はその脈に合わせて伸び縮みします。回転角と連動していた「何か」の正体は、囲み方そのものでした。

さらにこのコードには、副次的な問題が2つ隠れています。

  • src(カメラ画像全域)と dst の縦横比を合わせる cover/crop 計算がないので、実は静止中も歪んでいる
  • カメラフレームはセンサーの向き(横長)のまま届くことがあり、端末を縦に持ったときの補正もない

回転で症状が目立って発覚しました。でも縦横比の管理はもともと存在しなかった、というのが正確なところです(回転のバグだと思って調べたら、静止画も歪んでいました)。

回転しても変わらない図形はなにか — 外接円

修正方針はこうです。「回転角の関数になってしまう矩形をやめて、回転不変な矩形を使う」。

この三角形は Canvas の原点を中心に回転します。回転で変わらないのは「中心からの距離」、すなわち円です。回転中心から最も遠い頂点までの距離を半径とする円。これは三角形がどの角度を向いていても、同じ位置・同じ半径のまま動きません。

万華鏡のセルは原点を外心とする正三角形なので、この円はちょうど三角形の外接円に一致します(一般の三角形では外心が回転中心と一致するとは限らないので、正確には「回転中心を中心とする包含円」です)。この円に外接する正方形を dst にすれば、縦横比は常に 1:1 で固定されます。

// 修正後: 外接円に外接する正方形 = 回転不変な dst
var circumradius = 0.0;
for (final v in pixelVertices) {
  circumradius = max(circumradius, v.length); // 原点からの距離の最大値
}
if (circumradius <= 0) return;
final dst = Rect.fromCircle(center: Offset.zero, radius: circumradius);

正方形は三角形よりずっと大きく、盛大にはみ出します。それでよいのです。Canvas は三角形にクリップ済みなので、はみ出した部分は描かれずに捨てられます。クリップが効いている描画では、「対象をぴったり囲む」ことより「回転しても変わらない形で囲む」ことを優先できる。これがこの修正の要点です。

回転する正三角形を囲む矩形の比較。AABB は回転 0 度で 104×90、回転 30 度で 90×104 と縦横比が変わるが、外接円に外接する正方形は常に 120×120
図: 半径 60 の正三角形を 0° と 30° で囲んだ例。AABB(赤)は縦横比が回転角で変わり、外接円に外接する正方形(緑)は変わらない

src 側は BoxFit.cover 相当の中央正方形クロップ

dst が正方形に固定されました。なら src も正方形にすれば、縦横比のズレは構造的に起きません。カメラ画像の短辺に合わせて、中央の正方形を切り出します。

// BoxFit.cover 相当: 中央の正方形クロップ(センサー向きに依存しない)
final cropSize = min(image.width, image.height).toDouble();
final src = Rect.fromCenter(
  center: Offset(image.width / 2, image.height / 2),
  width: cropSize,
  height: cropSize,
);

1920×1080 の横長フレームでも、1080×1920 の縦長フレームでも、切り出されるのは中央の正方形です。センサーの向きがどちらでも歪みません。BoxFit.coverdrawImageRect の世界で手書きした形です。静止時の歪みと向きの問題も、ここで一緒に解消しました。

レビュー指摘「その計算、テストできますか」

修正をコミットして AI コードレビュー(Codex)にかけました。返ってきたのはこんな趣旨の指摘です。外接円の計算もクロップの計算も Canvas を触る描画メソッドの中に埋まっていて、単体テストが書けない。将来誰かが AABB 方式へ戻すリファクタリングをしても、何も検知できない。

もっともです。幾何計算だけを純粋関数に切り出しました。

class CameraBackgroundMath {
  /// 三角形頂点(原点中心のピクセル座標)の外接円に外接する、回転不変な正方形
  static Rect? destinationRect(List<Vector2> vertices) {
    if (vertices.isEmpty) return null;
    var circumradius = 0.0;
    for (final v in vertices) {
      circumradius = max(circumradius, v.length);
    }
    if (circumradius <= 0) return null;
    return Rect.fromCircle(center: Offset.zero, radius: circumradius);
  }

  /// カメラフレーム中央の正方形クロップ(BoxFit.cover 相当)
  static Rect? sourceRect(int imageWidth, int imageHeight) {
    if (imageWidth <= 0 || imageHeight <= 0) return null;
    final cropSize = min(imageWidth, imageHeight).toDouble();
    return Rect.fromCenter(
      center: Offset(imageWidth / 2, imageHeight / 2),
      width: cropSize,
      height: cropSize,
    );
  }
}

こうなると「回転不変」という仕様そのものをテストにできます。三角形を 12 方向に回転させて、返る矩形が全く同じであることを主張する回帰テストです。

test('is invariant under rotation of the triangle', () {
  final base = scaledVertices(50.0);
  final reference = CameraBackgroundMath.destinationRect(base)!;

  const angleCount = 12;
  for (var i = 1; i <= angleCount; i++) {
    final angle = 2 * pi * i / angleCount;
    final rotated = TriangleTransform.rotateVertices(base, angle);
    final dst = CameraBackgroundMath.destinationRect(rotated)!;
    expect(dst.left, closeTo(reference.left, 1e-9));
    expect(dst.top, closeTo(reference.top, 1e-9));
    expect(dst.width, closeTo(reference.width, 1e-9));
    expect(dst.height, closeTo(reference.height, 1e-9));
  }
});

ほかにも同じ形で固定しました。「ピンチズームで頂点座標が 2 倍になれば矩形の幅も 2 倍」「横長・縦長どちらのフレームでも中央正方形が切り出される」「空の頂点リストや全頂点が原点の退化ケースでは null」。バグの本質が「dst の縦横比が変動すること」だったので、テストも「変動しないこと」を直接主張できます。描画コードに埋まったままでは、この主張はどこにも書けませんでした。

学び

  • 回転するものを AABB で囲んではいけない。軸並行の矩形は「囲む側の形」が回転角の関数になる。回転が絡む幾何は、中心からの距離=円という不変量で考える
  • クリップが効いている描画では「ぴったり囲む」より「不変に囲む」を優先してよい。はみ出しはクリップが捨ててくれる。正確な最小包含矩形は、ここでは必要のない精度だった
  • 縦横比バグの原因は dst 側と src 側の両方に潜む。今回も「AABB の脈動(dst)」と「cover 計算の不在(src)」の合わせ技。回転はそれを目立たせた引き金にすぎない
  • 描画コードに埋まった幾何計算は純粋関数に切り出す。仕様(回転不変)がそのままテスト名とアサーションになる。方式ごと巻き戻すリファクタリングへの回帰テストにもなる

一度「外接円」と言葉にしてしまえば、数行の修正でした。でもそこへたどり着くには「何が回転で変わり、何が変わらないか」を切り分ける必要があります。画面のバグを座標系の言葉に翻訳できたとき、修正はもうほとんど終わっています。翻訳がいちばん時間を食うのですが。