万華鏡アプリでテッセレーション沼にハマった話

| 開発記録 | 万華鏡

タグ: #Flutter #Canvas #数学

「画面いっぱいに敷き詰める」だけのはずが、四隅に隙間ができる。RDPアルゴリズムの epsilon を間違える。テッセレーション(敷き詰め)周りで何度もやらかした記録です。

そもそもテッセレーションとは

万華鏡アプリでは、ユーザーが描いた図形を「画面いっぱいに鏡映・回転で敷き詰める」描画をします。これがテッセレーション(tessellation)です。三角形やひし形のセルを、隙間なく敷き詰めて万華鏡パターンを作る、というやつです。

四隅に出る謎の隙間

最初のリリース版で、画面の四隅にうっすら「敷き詰めきれない隙間」が出る問題がありました。

原因は単純で、テッセレーションの可視判定が画面矩形ぴったりで切られていて、画面端のセルが「中心から遠い=描画スキップ」と判定されていたのです。

対策として、テッセレーションの margin を画面サイズより少し外側まで拡張しました。これだけで四隅の隙間が消えました。

final margin = max(screenWidth, screenHeight) * 0.2;
final renderBounds = Rect.fromLTWH(
  -margin, -margin,
  screenWidth + margin * 2,
  screenHeight + margin * 2,
);

RDP アルゴリズムの epsilon 問題

万華鏡アプリの中で、ユーザーが描いた線を「滑らかな曲線」に変換するために RDP(Ramer-Douglas-Peucker)アルゴリズムを使っています。これは「曲線をできるだけ少ない点で近似する」古典的なアルゴリズムです。

RDP には epsilon という閾値があり、これが大きいと粗い近似、小さいと細かい近似になります。当初 epsilon を 2.0 にしていたら、ユーザーの繊細な描画がガクガクの直線になり、「滑らかさ」が壊れていました。

0.5 に下げたら、今度は計算量が膨らんでパフォーマンスが落ちました。

落としどころとして 1.0 に固定し、描画密度(ユーザーがゆっくり描いたか、速く描いたか)に応じて動的に epsilon を変える、という設計に着地しました。

参考までに、RDP の再帰実装は次のような形です。最大距離が epsilon を超える点を分割点として残し、残らなかった区間は両端だけに圧縮します。

List<Offset> simplifyRdp(List<Offset> points, double epsilon) {
  if (points.length < 3) return List.of(points);
  return _rdp(points, 0, points.length - 1, epsilon);
}

List<Offset> _rdp(List<Offset> pts, int start, int end, double eps) {
  double dmax = 0;
  int index = start;
  for (var i = start + 1; i < end; i++) {
    final d = _perpendicularDistance(pts[i], pts[start], pts[end]);
    if (d > dmax) {
      dmax = d;
      index = i;
    }
  }
  if (dmax <= eps) {
    return [pts[start], pts[end]];
  }
  final left = _rdp(pts, start, index, eps);
  final right = _rdp(pts, index, end, eps);
  return [...left.sublist(0, left.length - 1), ...right];
}

形状プレビューが表示されない問題

shape プレビュー(描いた形のサムネイル)が、追加メニューのグリッド上で表示されない不具合もありました。Canvas のスナップショット取得タイミングが Widget ツリーの構築前で、レンダーオブジェクトがまだ無い状態でスナップショットを撮ろうとしていたのが原因です。

addPostFrameCallback でひと呼吸置いてから撮るようにしたら、安定しました。Flutter の Canvas 周りは、こういう「タイミング問題」が定期的に出ます。

2048px エクスポート

ユーザーから「壁紙にしたいので高解像度で書き出したい」要望があり、2048px サイズでのエクスポートを追加しました。Canvas を仮想的に高解像度で生成して、PNG として書き出す処理です。

メモリのピークが上がるので、書き出し中は他の描画を停止し、完了後にリソースを解放するようにしました。

教訓

  • 描画系の不具合は「画面外まで描く」で大体解決する
  • RDP の epsilon は固定値より動的化したほうが良い
  • スナップショットは Frame 構築後に撮る

万華鏡を作るのに、こんなに数学とタイミング問題に向き合うことになるとは思っていませんでした。