AI生成の3Dシーンをゲームで動かすための最適化

生成されたロケーションは手作りのものとは違う壊れ方をします。まず計測し、次にマテリアル、インスタンシング、LOD、テクスチャ、カリングの順で直します。

Cuberta でエージェントが構築した都心。高層ビルを囲むガラスの塔と広い大通り

エージェントが十一分で組み上げた街区は、製品版のオープンワールドが90fpsで動くマシンでも20fps程度しか出ないことがよくあります。まず三角形を削りたくなりますが、それはたいてい最初の一手として間違っています。生成されたシーンが破綻するのは三角形の数ではなく、レンダラーが手を止めて状態を切り替える回数のほうだからです。

生成シーンには固有の壊れ方がある

ライブラリ型の都市ジェネレーターでも、プロシージャルツールでも、Cuberta のようにエージェントが実際のエディタを動かしてテクスチャ込みのGLBやFBXを書き出すものでも、インポートしたデータはだいたい同じ顔つきをしています。

  • ユニークマテリアルが数百個。レベルアーティストなら十二個で済ませるところです。
  • すべてのオブジェクトが個別メッシュ。同一形状の街灯240本が、一つのメッシュの240インスタンスではなく240個のアセットとして届きます。
  • LODチェーンがどこにもない。5mでも300mでも同じメッシュを描いています。
  • テクスチャメモリが均等に配られている。40m以内に近づくことのない棟の稜線に2048のアルベドが載っている。解像度が画面上のサイズではなくオブジェクト単位で割り振られたせいです。
  • 重なりと同一平面のジオメトリ。路面標示が路面とまったく同じ平面にあり、縁石が歩道にめり込み、壁が隣家と面を共有しています。
  • オクルージョンの構造がない。スタティック指定なし、オクルーダー指定なし、屋内には部屋のボリュームもありません。

どれもバグではありません。ソフトウェアが「配置が正しいこと」を最適化した結果であって、「GPUにどう投げられるか」を最適化した結果ではない、というだけです。それぞれに固有の対処があり、順序が効きます。最初の修正が、二番目の修正を正当化するはずだった計測値そのものを変えてしまうからです。

手をつける前に計測する

fpsではなくミリ秒で考えてください。60fpsは16.7ms、120fpsは8.3msで、ミリ秒は引き算できますがフレームレートはできません。そのうえで次のカウンターを読みます。

カウンター測っているもの生成シーンでの症状
Batches / draw callsCPUが1フレームに組み立てる提出の回数レンダラー数とほぼ同じ。まったくバッチングしていない
SetPass callsステート、テクスチャ、シェーダーを結び直す回数バッチ数と数パーセント差で、マテリアル数に連動している
三角形ジオメトリのスループットデスクトップではたいてい問題なし、モバイルではたいていこれが問題
テクスチャメモリマップが占有する常駐VRAMGB単位。平均フレーム時間よりヒッチやストリーミングの遅れとして出る
スレッドとGPUの時間どちら側が待たされているかメインスレッドが張り付き、GPUは遊んでいる

どちらが待っているのか

いちばん速い診断は設定を一つ変えるだけです。レンダー解像度を半分にします。フレーム時間が大きく下がればGPUバウンドです。ピクセルを減らしたのですから当然です。ほとんど変わらなければCPUバウンドで、生成コンテンツの場合それはほぼ確実にdraw callの発行コストを意味します。プロファイラはもっと正確に同じことを教えます。Unityではメインスレッドが Gfx.WaitForPresentOnGfxThread に留まっていればGPU待ち、レンダースレッドが Gfx.WaitForCommands に留まっていればメインスレッド待ちです。Unrealでは stat unit がGame、Draw、GPUを並べて出すので、いちばん大きい値があなたの予算です。

なぜ順序が効くのか

マテリアルをまとめるとバッチ数は一桁減ることがあります。CPUバウンドに見えていた街区は、いまやシャドウ深度でGPUバウンドになっていて、これからやろうとしていたLOD作業は急に正解になるか、急に不要になるかのどちらかです。一つ直し、測り直し、新しい数字から次を選びます。

マテリアル、アトラス、インスタンシング

バッチングが共有マテリアルに依存する理由

draw callが高いのは描くからではなく、その手前で変えなければならないものが高いからです。新しいテクスチャのバインド、新しい定数バッファのアップロード、場合によっては別のシェーダーをパイプラインステートに入れること。マテリアルを共有する二つのオブジェクトは一回の提出にまとめられますが、マテリアルが違えばメッシュがどれだけ同一でもまとめられません。どのエンジンでも、あらゆるバッチング経路の前提条件に「同じマテリアル」と書いてあるのはそのためです。

知っておく価値のある但し書きがあります。UnityのSRP Batcherはマテリアル単位ではなくシェーダーバリアント単位でまとめ、各マテリアルのプロパティを事前に定数バッファへ載せます。ですから同じlitシェーダーを使う百個のマテリアルはバッチされます。省けるのはマテリアルデータを組み直すCPUコストであって、テクスチャのバインドでもテクスチャメモリでもありません。一桁に抑えるべき対象がシェーダーバリアント数に移るということです。Unrealはバッチャーではなくinstanced static meshとNaniteのクラスタに寄せています。エンジン固有の話はそれぞれの記事に譲ります。Unity側はこちらの記事を参照してください。

生成された町のマテリアルをまとめる

マテリアルは数ではなく被覆面積で並べ替えます。パレットはすぐ収束します。アスファルト、縁石、歩道、レンガ、左官仕上げ、コンクリート、ガラス、瓦、金属、塗装木材、植生。住宅地なら十から十四セットで足ります。そのうえでグループごとに方法を選びます。アトラスは異なるアルベドを一枚にベイクしてUVを張り直すもので、画面上で小さいままのユニークプロップ向き。タイリングとトリムはユニーク展開を共有の繰り返しマテリアルとトリムシートに置き換えるもので、ファサード、道路、歩道向きです。

プロップはアトラス、建築はタイリング。面が四メートルを超えたあたりから、ユニークなテクセルは割に合わなくなります。12mのファサードを1メートルあたり512ピクセルで持とうとすれば幅6144ピクセルのマップが要る計算で、誰もそこには払いません。

いちばん安い修正は上流にあります。名前の付いた十個のマテリアルからなる明示的なパレットで街区を作り直すほうが、あとから300個を統合するより安上がりです。ビルドの様子をその場で見ていられて、ビューポートで何でも選択でき、どの手順も取り消せるエディタなら、この修正は数秒で済みます。

インスタンシングと、その条件

インスタンシングは一つのメッシュの多数のコピーを、インスタンスごとのトランスフォーム付きで一回の提出で描きます。条件は厳格です。同じメッシュ、同じマテリアル、そのために書かれたシェーダー。色だけ違う場合は、その差分が二つ目のマテリアルではなくインスタンスごとのデータに移っていて初めて条件を満たします。Unityの1バッチあたりの上限は1023インスタンス、シェーダーがインスタンスごとの逆行列を必要とする場合は511です。等倍スケール前提のプラグマで外せますし、インダイレクト描画なら上限そのものが消えます。

生成された街区は良い候補です。ジェネレーターはもともと、車止めもベンチも樹木も一つのソースオブジェクトを使い回していたからです。これを壊すのは、配置ごとに独立したメッシュを書きトランスフォームを頂点に焼き込む書き出し工程です。240本の街灯が240個のアセットとして来たのか240インスタンスとして来たのかを確認することは、書き出しについて投げられる最も費用対効果の高い質問であることが多いです。

LOD、インポスター、そして街区単位の方針

400本のチェーンを手で書きたくはありません。すべてのメッシュを「三角形数 × インスタンス数 × 平均的な画面被覆」で並べ、その順位に手間を決めさせます。上位二十件は人が確認したチェーン、それ以外は自動の二段階減面か、何もしないかです。出発点として使える方針は、LOD0がフル、LOD1が三角形数およそ半分、LOD2がおよそ五分の一、それより遠くはビルボードか描画なし。切り替えはエンジンが許すかぎりメートルではなく画面上の相対サイズで指定します。UnityのLOD GroupもUnrealのLOD screen sizeもそう作られていますし、1080pで画角60度に合わせた距離は、どちらかが変わった瞬間に間違いになるからです。

街の外周こそインポスターが元を取る場所です。焼き込んだビルボード一枚が街区まるごとの代わりをします。UnrealのWorld Partitionはこれを階層HLODとして構築し、最も遠い階層は統合プロキシとインポスターの板になります。UE5ではNaniteのおかげで不透明な剛体ジオメトリのメッシュ単位LODはほぼ問題でなくなりますが、万能の傘ではありません。マスクドマテリアルは不透明時の面積に近いコストがかかり、極端に小さなメッシュはピクセル閾値を下回り、微小なNaniteメッシュが数千個あるシーンはインスタンスごとのオーバーヘッドを実際に抱えます。マテリアル数を代わりに減らしてもくれません。詳しくはUnrealの記事にあります。

テクスチャ予算は好みではなく算術

プレイヤーが最も近づく地点で画面上に占めるピクセル数を超えるテクセルは、テクスチャには必要ありません。それを超えた分は、あなたが金を払い、ミップマップが捨てているメモリです。

BC7はテクセルあたり8ビットなので、2048四方のマップは4MB、ミップマップの33%を足して約5.3MB。BC1ならその半分です。仮にインポートで180個のユニークマテリアルが来て、それぞれにアルベド、ノーマル、ラフネス・メタル・オクルージョンのパックマップが2048で付いているとすると、540枚でおよそ2.8GB。十四セットにまとめれば42枚、約220MBになり、そのうち半分は屋根や上層階なので誰にも気づかれずに1024へ落とせます。

街区に対してテクセル密度を一つ決め、解像度は面積から導きます。1メートルあたり512ピクセル前後が環境アセットではよくある基準で、カメラが至近まで寄る面には1024を取っておきます。そのうえで画面と突き合わせます。40m先で画面高さ60ピクセルを占めるオブジェクトは、2048のマップのミップ5あたりをサンプリングしています。つまり上位四段のミップは、スキップされるためだけに存在しています。ストリーミングは罪を軽くしますが、ビルドサイズ、ベイク時間、初回訪問時のヒッチはどれも現実に残ります。

カリングと、生成された屋内にセルが要る理由

視錐台と距離

視錐台カリングは自動で走り十分に安価で、そしてまっすぐな大通りの端に立って街区全体が正面にあるときには何の役にも立ちません。生成された都市のスクリーンショットに使われるのは、まさにその画角です。距離カリングは手に入る中で最も安い実利です。UnrealにはオブジェクトサイズをカリングDistanceに対応づけるCull Distance Volumeがあり、Godotにはgeometry instanceごとのvisibility rangeがあり、Unityにはカメラのレイヤー別カリング距離があります。ゴミ箱、看板、車止めは40mから80mの間で消えて構いません。誰も気づかず、それが数千回の提出になります。

オクルージョン

オクルージョンは「何が何の陰にあるか」というより難しい問いを扱います。Unrealは既定でGPUオクルージョン、すなわちハードウェアオクルージョンクエリと、シーン深度のミップ連鎖をサンプリングする階層Zバッファ経路を使います。後者は意図的に保守的で、テストを安く済ませる代わりにカリングする対象を減らします。非力なプラットフォーム向けにはprecomputed visibilityもあります。UnityはUmbraでベイクし、シーンをオフラインでボクセル化してセルとポータルに構造化し、実行時は低解像度の深度階層に問い合わせます。Godot 4はオクルージョンカリングが既定で無効なので、レンダリング設定で有効にしてからベイクするか手でオクルーダーを置きます。三つとも、どのジオメトリがスタティックでどれが正当なオクルーダーかを知る必要があり、インポートしたてのデータにはそのどちらのフラグも立っていません。建物、地形、道路をスタティックにし、小物をオクルーダー集合から外す。この数分の作業がシステム全体を起動させます。

屋内にはセルかポータルが要る

生成された屋内は、はっきり決まったかたちで壊れます。壁は独立した箱で、隣り合う壁はしばしばぴったり合っていません。プレイヤーには一生見えない2cmの隙間も、ベイクされたオクルーダーにとっては穴です。部屋は何も遮らなくなり、同じ街区にいるかぎりその建物の家具が毎フレーム提出され続けます。俯瞰用に作られた部屋には天井がないことも多く、これは同じ問題の垂直版です。外殻を密閉するか、見えている壁は放っておいて部屋よりわざと少し大きい箱オクルーダーを置くか。そのうえで各出入口にポータルを置き、閉じた扉がその向こうをカリングするようにします。中からしか見えない屋内は、別にストリーミングするサブレベルにするほうが正直な答えであることが多いです。

ジオメトリの衛生:狩る価値のある三つの欠陥

誰にも見られない面

箱ベースの生成は閉じた箱を作ります。八戸の連棟住宅は十四枚の壁面を隣家の中に埋め、地面の平面はすべての建物の下を通り抜け、家具には底面があります。これらの三角形は、深度テストが生成するピクセルをすべて捨てたとしてもGPU上で頂点変換されますし、ライトマップUVのパッキングを膨らませ、他のジオメトリの内側にあるジオメトリを渡すことでオクルーダーのベイクを壊します。

同一平面の面

路面標示が路面とまったく同じ平面、ラグが床とまったく同じ高さ、ポスターが壁とまったく同じ深さ。同じ深度にある二面はカメラが動くとちらつき、その破綻は遠方から先に現れます。深度精度がそこで最も薄いからです。優先順に、上側の面を1〜5mmずらす。本当にデカールであるものにはエンジンのデカールシステムを使う。depth biasは最後にします。浅い角度で面が壁を突き抜けることがあるからです。ついでにニアクリップ面も見てください。2kmの街に0.01mのニア面は、遠方の精度を飢えさせます。

誰も頼んでいないディテール

画面上12ピクセルの車止めに64分割の円柱。ジェネレーターがグリッドで作業したせいでグリッド分割された平らな壁。ランプシェードに1000三角形の球。三角形はシルエットか陰影のグラデーションのどちらかを買うべきで、どちらも買わないなら純粋なオーバーヘッドです。車止めは8〜12面で足り、壁は変形も頂点ライティングもしないなら三角形二枚で足ります。

この順番で回す

  1. プロファイルして数字を書き留める。解像度を半分にしてどちらが待っているかを確かめます。
  2. 見えないものを消す。埋まった面、建物の下の地面、プレイ範囲外のジオメトリ。
  3. マテリアルを名前付きのパレットに統合し、シェーダーバリアントは一桁に保つ。
  4. プロップはアトラス、建築はタイリングにして書き出し直す。
  5. もう一度測る。ボトルネックはたぶん移動していて、この先の項目はその移動先を中心に並べ替えるべきです。
  6. 繰り返し要素をすべてインスタンス化し、書き出しがインスタンシングを保っているか確認する。
  7. 上位二十のメッシュにLODを付け、外周にはインポスターかHLODを入れる。
  8. テクスチャ解像度を一巡させる。基準は重要度ではなく実際の画面被覆です。
  9. スタティックフラグ、オクルーダー、距離カリング、屋内のセルかポータルを設定する。
  10. ライティングをベイクして最後にもう一度測る。サポート予定の中で最も非力なハードウェアの上で。

モバイルとVRのより厳しい数字

スタンドアロンVRはあらゆる予算を圧縮します。MetaのQuest向けガイダンスはdraw callでおよそ50から150、下限72Hz、つまり13.9msの中でシーンを両眼ぶん二回描くことになります。三角形の予算も百万単位ではなく数十万単位なので、ジオメトリの衛生が選択肢ではなくなります。モバイルGPUはタイルベースで、それが問題のかたちを変えます。半透明とオーバードローが不釣り合いに高くつくので、アルファブレンドの植生板とガラスファサードだらけのシーンは、三角形数が問題になるずっと手前で潰れます。可能なものはアルファテストか不透明に変え、ガラスにはリフレクションキューブマップ付きの不透明シェーダーを与え、ASTCを使い、どれが本当に必要かを議論し始める前にすべてのテクスチャ解像度を半分にしてください。

ここに華やかなものは何もなく、AI固有のものも何一つありません。固有なのは破綻のかたちです。生成されたロケーションはその時々に支配的な欠陥を一つ抱えていて、たいていはマテリアル数です。上の順序で進めるということは、いちばん目につく欠陥ではなく、実際にフレームを食っている欠陥に手間を注ぐということです。