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

| 開発記録 | 万華鏡

タグ: #Flutter #Canvas #数学

「画面いっぱいに敷き詰めるだけ」のはずが、四隅に隙間ができました。RDP アルゴリズムの epsilon は 2.0 でガクガク、0.5 で重くなる。万華鏡アプリのテッセレーション(敷き詰め)まわりで、何度もやらかした記録です。

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

万華鏡アプリでは、ユーザーが描いた図形を鏡映と回転で画面いっぱいに敷き詰めます。これがテッセレーション(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 構築後に撮る

万華鏡を作るのに、数学とタイミング問題にここまで向き合うとは思っていませんでした。きれいに敷き詰まった画面は、その分だけ気持ちいいです。