3,200個の破片を事前分割して破綻し、「1打目の殻+実行時スライス」に行き着くまで
ワイングラスを割るために約3,200ノードの事前破断ツリーを用意したら実機で生成ラグと7mm網目の継ぎ目が露出。1打目の粗破断と実行時平面カットのハイブリッドへ転換した記録。
#Unity#3Dメッシュ#パフォーマンス
スマホの画面をタップして、目の前のワイングラスを木っ端微塵に叩き割る「gashaan(がしゃーん)」というiOSアプリを Unity 6(URP)で作っています。
このアプリのコアは、タップした瞬間に映像・物理演算・破壊音・そして Core Haptics の鋭い振動が1フレームのズレもなく同期して発火する「本物の破壊の手触り」です。開発の初期に決めた重要な設計原則(D10)として、**「人工的な終了ロックを持たない(99%崩壊していても、残った破片をさらに叩けば再帰的に割れ続ける)」**というルールがありました。
1打目でグラスが大きく砕け散ったあとも、床に転がったガラス片をタップすればパリンとさらに細かく割れ、「これ以上細かくしても砂を叩くようで楽しくない」という最小サイズ(ボウルなら10mm、塊のステムなら16mm)に達するまで何度でも叩き続けられる体験です。
これを本物の3Dメッシュ(Blenderで作った厚みのあるワイングラス)で実現しようとしたとき、ゲーム開発の教科書的な定石である**「事前破断(Pre-fracture)ツリー方式」をそのまま採用した結果、約3,200個のノードを抱えて実機で破綻し、最終的に「1打目の殻(事前破断)+2打目以降の実行時メッシュスライス」**というハイブリッド構成(D15)に行き着きました。その試行錯誤の記録です。
なぜ「事前破断の入れ子ツリー」を作ろうとしたのか
最初に作ったプロトタイプ(ステップA)では、ワイングラスを数学的な回転体としてコードで生成し、実行時にパラメータ空間(角度と高さ)でポリゴンを切り分けていました。これなら実行時にいくらでも再分割できましたが、見た目がどうしても「厚みのないペラペラの板」になってしまいます。
「本物のガラスが割れた感じ」を出すには、ボウル部分は1.5mmの薄殻、持ち手(ステム)と台座は中まで詰まったソリッドな塊としてモデリングし、割れた断面にも厚みと光の反射を持たせた実アセット(FBX)が必要です。そこでステップBでは、Blender の Cell Fracture(ボロノイ分割)を使って事前に割っておいたFBXを出力し、Unity 側で叩かれた瞬間に破片群へ差し替える定石アプローチを取りました。
問題は、「床に落ちた破片をさらに叩いて、10mm以下になるまで何段でも割る」という再帰分割をどうやって事前破断のFBXに仕込むかでした。
最初に試したのは、1段目の破片(約30個)のうち大きいものに対して、Blender 内で再帰的に Cell Fracture をかけ、親破片の子オブジェクトとして2段目、3段目のサブ破片をFBXの階層(ツリー)として埋め込む方法でした。しかし、このやり方はすぐに2つの壁にぶつかりました。
- ボウルの湾曲した薄殻で、ボロノイの種点が空振りする。 破片の外接バウンディングボックスの中にランダムに種点を打って Cell Fracture をかけると、湾曲した薄いガラス板はボックスの体積の9割が空洞であるため、種点がガラスの実体を外れて空振りし、「20〜35mmもある大きなボウル片なのに、叩いてもそれ以上割れない」という不良破片が多発しました。
- 再帰的に Cell Fracture オペレータを呼ぶと、Blender の生成に4時間かかってクラッシュする。 種点を三角形の表面サンプリング(面積重み+Blue Noise近似)に直して空振りを消したところ、今度は深さ制限なしのツリー全体で数百〜数千回のブーリアン演算が走り、1バリアントの生成に1時間以上かかった挙句、ネイティブクラッシュするようになりました。
3,200ノードの一発破断と「7mmの網目」のジレンマ
生成時間を劇的に縮めるため、パイプラインを根底から書き換えました。「親から子へ順番に割る」のではなく、**「①最初に2,500個の種点で一気に10mm以下の最小破片(葉ノード)まで割り切る → ②破片同士の隣接マップをKDTreeで計算する → ③葉ノードを空間クラスタリングして、塊の内部の断面だけを削除しながら親ノード(中間層〜第1層)へとボトムアップに溶接していく」**というアルゴリズムです。
これで1バリアントの生成時間は1時間超から約5分に短縮され、深さ4〜5段・合計約3,100〜3,250ノードの完全な破壊ツリーを持つFBXが生成できるようになりました。
ところが、これを iPhone 実機にビルドしてグラスを割った瞬間、致命的な見た目の欠陥が露呈しました。割れた瞬間の大きな破片の表面全体に、7mm間隔の細かい「網目(最小破片の分割線)」がびっしり浮き出てしまったのです。
原因は、Blender のブーリアン演算の性質にありました。2,500個の微細な葉ノードを作った時点で、元の滑らかなガラス表面は7mmごとの不揃いな三角形に再分割されています。あとからそれらを結合して頂点をマージ(溶接)し、無傷グラスの表面へスナップさせても、ブーリアンが刻んだ不規則な三角ポリゴンの並び自体は消えず、屈折と強いスペキュラを持つガラスシェーダがその微小な法線の揺らぎを「全面に走る細かいヒビの網目」としてくっきり拾ってしまったのです。
L1(1打目)だけを「本物の粗破断」にしてみた結果
この網目を消すために、さらなる改良を入れました。「1打目で飛び散る第1層(L1)の約34個の破片だけは、葉ノードの溶接ではなく、無傷のグラスから直接34個に粗く割った本物(genuine)の破片メッシュを使い、その子階層(L2以降)に溶接ノードをぶら下げる」という方式です。
これで1打目の見た目は劇的にきれいになり、FBXのサイズも1ファイル24MBから15MBまで縮まりました。しかし、実機でテストしたユーザーから返ってきたのは、設計の根幹を揺るがす2つのNG判定でした。
| 実機で起きた問題 | 根本原因 |
|---|---|
| 問題A:割った瞬間のラグが強く、スマホで耐えられない | Instantiate(variantPrefab) を呼んだ1フレームの間に、非アクティブな子孫を含む約3,200個の GameObject と MeshFilter を一括生成し、15MBのプレハブを展開していたため |
| 問題B:1打目はきれいだが、2打目以降にまた7mmの網目が出る | L1(1打目の殻)だけを genuine 化したため、落ちた破片をもう一度叩いて L2(溶接ノード)を露出させた瞬間に、結局7mmの継ぎ目が現れてしまうため |
ここで完全に詰みました。問題Aと問題Bは真っ向から相反していたからです。もし問題Bを解決しようとして、L2やL3の中間ノードまですべて「溶接ではない本物の破断メッシュ」として重複保持すれば、FBXの頂点数とノード数はさらに膨れ上がり、問題Aの一括生成ラグとメモリ消費が致命的に悪化します。
「深い事前破断ツリーを丸ごと出荷して一括生成する」というアーキテクチャ自体が、モバイル実機の制約とガラスのシェーディング要件の前では破綻していたのです。
転換:「1打目の殻」+「2打目以降の実行時スライス」(D15)
立ち止まって考え直したとき、1つの事実に気づきました。
プレイヤーは、1打目で飛び散った30個の破片のすべてを、3,200個の粉になるまで全部叩くわけではありません。実際に2打目、3打目で追い打ちをかけるのは、目の前に転がってきたせいぜい数個〜十数個の破片だけです。それなのに、叩かれない9割以上の破片の「未来の割れ方」まで事前に3,200個すべてメモリ上に実体化させていたから、1打目が重くなっていたのです。
そこで、事前破断と実行時処理の役割をきれいに二分する**ハイブリッド方式(D15)**へ舵を切りました。
| フェーズ | 採用した方式 | なぜこれが最適か |
|---|---|---|
| 1打目(無傷 → 全壊) | 1段だけの事前破断(Genuine L1) | 衝撃点の高さ(5ゾーン)に応じたサイズ勾配と、滑らかなガラス外面、わずか22〜34個の破片数。FBXは 0.97MB(旧15〜24MBの15分の1以下)で、Instantiate のラグが完全にゼロになる |
| 2打目以降(破片の追い打ち) | 叩かれたその破片だけを実行時に平面スライス(MeshSlicer) | 叩かれた1個の破片(数百〜数千ポリゴン)だけを、そのフレームで2〜4個に平面カットする。処理時間はコンマ数ミリ秒で、溶接の継ぎ目(7mm網目)も原理的に存在しない |
この方式なら、「一度に大量のオブジェクトを生成する瞬間」がどこにもありません。2打目以降に新しく作った実行時メッシュは、Unity が自動では破棄しないため _ownsMesh フラグを持たせ、OnDestroy 時に明示的に Destroy してメモリリークを防ぎました。
実メッシュを切り口つきで割る MeshSlicer の実装
実行時スライスを実現するため、厚みのある3Dガラスメッシュを任意の平面で2つに切断する MeshSlicer.cs をスクラッチで実装しました。
やるべきことは大きく2つです。1つ目は、Sutherland-Hodgman 法の要領で、平面をまたぐ三角形を両側にクリップし、巻き順(表裏)を保ったまま交点の位置と法線を線形補間すること。2つ目は、切っただけでは断面が空洞(開いたメッシュ)になってしまうため、切断エッジの交点セグメントを繋いで閉ループを作り、その重心から扇状に三角形を張って「切り口のフタ(キャップ)」を塞ぐことです。
このフタのポリゴンには、断面用のサブメッシュ(マテリアルスロット1)を割り当てます。これで、実行時にスパッと切った破片にもちゃんとガラスの厚みの断面が生まれ、フル解像度のスクリーンスペース屈折(_CameraOpaqueTexture)やプリマルチプライドブレンドの輝きが1打目の破片とまったく同じ品質で乗るようになりました。
さらに、実機へビルドする前にバッチモードで自動検証する BuildScript.TestSlice を書き、33個の破片に対して再帰的に 1,105回の連続スライス を走らせました。すべての切断でNaNや退化ポリゴン、クラッシュがゼロで、全破片に正しく断面キャップが付与されることを確認してから iPhone 実機へ送り出しました。
結果は劇的でした。Blender のパイプライン生成時間は1バリアント250秒からわずか3秒に、FBXサイズは15MBから0.97MBになり、実機での1打目のラグは完全に消滅しました。もちろん2打目以降に何度叩いても7mmの網目は一切出ず、10mmの最小サイズに達するまで小気味よくパリン、パリンと割れ続けます。
「脳死で即破壊」を壊さない2モードカメラと安全網(D8)
破片を細かく追い打ちできるようになると、「近くで破片を眺めたい」「いろんな角度から回して見たい」という欲求が自然に出てきます。そこで二本指ピンチでのズームと、二本指スライドでの視点回転を入れることになりました。
しかし、gashaan には**「起動したら説明ゼロで、目の前の1個を脳死で即破壊できる」**という最上位のオンボーディング原則があります。最初から自由視点カメラにしてしまうと、叩こうとしてカメラが回ってしまったり、二本指で動かしているうちにグラスが画面外へ消えて見失ったりして、直感的な破壊のテンポが死んでしまいます。
そこでカメラを**2モード制(D8)**に分けました。
- アイテムフォーカス固定モード(起動時デフォルト): カメラが常にアイテムを追いかけ、ユーザーは視点操作を一切気にせず画面を叩くことだけに集中できるモード
- 自由視点モード: 画面の「フォーカスボタン」をタップして切り替えたときだけ、二本指でのズームと縦横の回転が解禁されるモード
そして一番大事なのが、自由視点でどこを向いているか分からなくなったときに、フォーカスボタンをワンタップすればアイテムが正面に再センタリングされ、ダブルタップすれば即座に「フォーカス固定モード」へ戻れる安全網です。
「3,200個の事前破断ツリー」から「1段の殻+実行時スライス」への転換も、このカメラの2モード制も、根っこにある考え方は同じです。最初から全部を1つの重い仕組みで抱え込むのではなく、「最初の1タップ目(1打目・起動直後)の体験を最も軽く、最も美しく見せる仕組み」と、「そこから先へ踏み込んだときの自由度をオンデマンドで支える仕組み」を分けること。それが、モバイル実機の厳しい制約の中で気持ちよさを守り抜く一番の近道でした。
