Meshの最適化によるレンダリングコストの軽減
[2-0] はじめに
本記事は、前回の記事
の続きになります。
前回の記事で述べたように、スタンドアロンのVR機器で数千万といった高ポリゴンモデルを使用したリッチな背景を実現するには、様々な方法でモデルのリダクション(ポリゴン数、頂点数の軽減)、バッチ化によるドローコール軽減などが不可欠です。Meta Quest2では90fpsが標準的なフレームレートになるため、1フレームを11ミリ秒以下で左右両方の画像をレンダリングする必要があり、そのための技術の紹介を前回に引き続き行いたいと思います。
今回は、3Dモデルレンダリングの古典的なテクニックで、少しでもレンダリングコストを下げるために、メッシュの結合と描画が不要な面のカリングを行うUnityEditor上で動作するツール"MeshCombiner"を作成しました。
メッシュの結合(バッチ化)やカリングは、何も設定しなくてもUnityの標準機能でそれなりにやってくれます。なので、パフォーマンスやシーンのロード時間が気にならないのならば、何もせずとも問題ありません。でも、元データを加工することで、Unityのバッチ化やカリングの効率が向上するのも事実です。そのため、事前の処理としてメッシュのデータを加工するのは有効な手段なのです。
上のタイトル画像にある、金閣寺、鳥居、大仏、三重塔、橋、草花などは、元のメッシュデータを加工して最適化することで、レンダリングコストを大幅に削減できました。本記事では、Unityの標準的な最適化と比較して、その効果の度合もできる限り説明できればとおもいます。
※ 大仏は鎌倉大仏、三重塔は和歌山県の青岸渡寺がモデルとなっていますが、ゲーム中では京都のステージということになっています。
[2-1] Meshの結合
MeshCombinerを使うと、同一マテリアルのメッシュをまとめて一つのメッシュを生成できます。
例えば、下の左の図1のように、球や直方体のプリミティブモデルを使って人の形のモデルを作ったとします。
図1. プリミティブで構成された人形 | 図2. 同一マテリアルのメッシュ結合 |
赤い部分にあたる両目と鼻と口は、それぞれ球体や直方体のプリミティブメッシュでできていています。つまり球や直方体といった4つのメッシュを同じ色で4回レンダリングしています。
両目と鼻と口を球などのプリミティブではなく、一つのメッシュに結合できると、一度の描画(ドローコール)で両目と鼻と口をレンダリングできるようになります。右側の図2は、MeshCombinerを使用して、両目と鼻と口のメッシュを結合し、1560ポリゴンの一つのメッシュにしたものです。
GPUレンダリングのドローコールとバッチ化について
通常、3Dモデルのデータは、多くのメッシュから構成されています。メッシュにはマテリアルがアタッチされていて、レンダリングするテクスチャや色、表現方法などがマテリアルによって指定されています。マテリアルにはシェーダーがアタッチされていて、GPUはシェーダープログラムによって画像をレンダリングします。
バラバラのメッシュをそのままGPUがレンダリングすると、メッシュの個数の回数ぶん「描画処理」が行われます。これを「ドローコール」と呼んでいます。GPUのレンダリングは、マテリアル(とマテリアルにアタッチされたシェーダー)によって行われるので、同じマテリアルのメッシュが複数ある場合、メッシュを一つに結合してしまえばGPUの描画処理=ドローコールの回数を減らすことができます。
このように、本来は複数回行われる処理をひとまとめにすることを「バッチ処理」といいます。
Meta QuestのようなMobile系のGPUの場合、ドローコールのオーバーヘッドが大きいため、レンダリングの高速化を行うにはドローコールを減らすことが重要なテクニックとなります。QuestのSDKドキュメントには、ドローコールは200回以下が推奨と記載があります。デスクトップPCなら数千回のドローコールもヘッチャラですが、モバイル機器はドローコールの制約が厳しいのです。
メッシュの結合は、Blenderのようなモデリングツールでもできますが、UnityEditor上でシーンの編集時にできると便利です。そこで、メッシュ結合などの最適化を行うためのツール(スクリプト)を制作することにしました。
まず、何かを作ろうと思ったときは、既存の記事を検索して調べます。"Unity メッシュ結合" とかでググると、良い記事が見つかりました。
少し古い記事ですが、私はこの記事を参考にさせていただきました。
この記事のプログラムを参考に、以下の点を拡張して書き直しました。
- プログラムコードをUnity 2021.3の仕様に合わせました。
- SubMeshを正しく分離して結合できるようにしました。
- 指定エリアから常に裏面となるポリゴンの削除機能を追加しました。
- エリアによるメッシュ分割の機能を追加しました。
- MeshRendererのプロパティを上書きする機能を追加しました。
※ ソースコードはこの記事の最後に掲載いたします。
それでは、Webで購入した金閣寺のモデルを使用して、メッシュ最適化の手順を示したいと思います。
オリジナルの金閣寺モデル
図3. メッシュ結合前のモデル(Webで購入後、Blenderでリダクションしたもの) |
金閣寺モデルのメッシュは44個あり、サブメッシュ(同一メッシュ上でマテリアルが異なるポリゴンのグループ)も含めるとさらに多くなります。Hierarchyのウインドウに展開したオブジェクトが見えるとおもいます。(左側のピンク枠)これは、Blenderでのオブジェクト名がそのまま階層になってインポートされた結果です。
すべてStatic属性にしてUnityで実行すると、バッチ数は24(背景で3バッチ使うため、StatusウインドウのBatches: 27から3を引いた数)でした。つまり、44個以上のメッシュをバッチ化し24のドローコールにUnityが自動的に減らしてくれたことになります。これだけでも大きなパフォーマンスアップになります。レンダリングされたポリゴン数(Tris:)は約36.6Kポリゴンです。
メッシュ結合後の金閣寺モデル
図4. MeshCombinerでメッシュを結合した後のモデル |
こちらは、先ほどの金閣寺のモデルをMeshCombinerでメッシュを結合したモデルです。見た目は全く変わりません。
インスペクターでポチっとボタンをワンクリックするだけで、メッシュをまとめたGmaeObjectを生成します。Hierarchyを見ると8個のGameObjectになっているのがわかると思います。これらは、すべて単独のメッシュ&マテリアルになっています。バッチ数も8(表示は11)になっていて、必要最小限にバッチ化されているのがわかると思います。
つまり、Unityが自動的にやってくれる最適化からバッチ数を24から8の3分の1に減らすことができました。
なぜUnityは限界までバッチ化してくれないのか? それは、後述するカリングの問題もあるからです。むやみにメッシュを結合すると、かえってパフォーマンスが悪くなることがあるので、Unityは「用心深く」メッシュをバッチ化するのです。
これで、金閣寺をはじめ、特に数の多い草花といった小物のドローコールをグッと減らすことができ、レンダリングの高速化につながりました。
[2-2] Meshの分割
せっかく結合したのに分割?「何言ってんだコイツ」と思わないでください。
マテリアル毎にメッシュを結合したことで、確かにドローコールは激減しました。その結果、石や草花といった小物のモデルも、同一マテリアルのメッシュが結合されて、シーン全体に及ぶ広範囲なメッシュとなっています。もし、常にシーン全体をレンダリングするならば、最小限のドローコールですべてのモデルをレンダリングできる理想的な状態です。
しかし、現実には視点はシーンの中心付近にあり、視野角はせいぜい100度程度です。つまり、実際にレンダリングされるモデルは、シーン全体の半分以下ということになります。
この場合、ドローコールこそ最小限で済んでいますが、視界の外にあるメッシュまでせっせとレンダリングしようとし、どこかで誰かが視界の外だと判断し、レンダリング対象から除外しています。まず視野ピラミッドの内側にあるか調べられ、内側にあるものは三角形の表裏判定、Cull Back属性によってGPUがラスタライズ時に飛ばします。実際にレンダリングされなくとも、それなりのオーバーヘッドになりますから、できるだけ早い段階で除外したほうが良いのです。
最適化のジレンマ
最良のパフォーマンスを得るために、ドローコールを減らすべきか、レンダリング不要なメッシュを減らすべきか、悩ましいところです。
もちろん、最低限のドローコールを保ちながら、不要なメッシュはレンダリングしないのが理想的なのですが、現実的にはトレードオフになります。
見えない面を排除するカリングの処理も、動的にやるとオーバーヘッドになるので、できるだけ実行前に準備をしておくべきです。
UnityではカリングのメタデータをあらかじめBakeする機能があり、適当な大きさでメッシュを作っておくだけで、高速にカリング処理をしてくれます。
本ゲームでは、シーン全体を5つのエリア(地形は12)に分割してカリングしています。
カリングの状態を確認するために、4つの金閣寺をMeshCombinerで結合し、その中央にカメラを置きます。カメラに映る金閣寺は1つか2つで、残りのモデルはカメラには映りません。下のSceneViewは、4つの金閣寺とカメラの位置を示したものです。
図5. 4個の金閣寺のメッシュを結合すると、隠れたモデルがカリングされない |
ここで注目してほしいのは、StatusウインドウのBatchesとTrisの値です。Batchesの表示は1個の金閣寺をレンダリングをしたときと同じで、4つの金閣寺のメッシュがまとめられていることを示しています。つまり、金閣寺が1つから4つに増えてもバッチ数は変わっていません。
ところが、ポリゴン数を示す"Tris:"の値が、36.6Kから141.5Kに増えており、4倍のポリゴンをレンダリングしています。これは、カメラから見えない残りの3つの金閣寺もレンダリングしている事を示しています。
メッシュを結合する前の4つの金閣寺を同じ位置にカメラを置いて計測するとBatches:は27ですが、Tris:は36.6Kのまま。つまり、カメラに映らない3つの金閣寺はカリングされてレンダリング対象から外れています。
適度にバラバラなメッシュならば、Unityさんが良しなにカリングしてくれますが、さすがに1メッシュになっているものを勝手にメッシュ分割してまでカリングしてくれないようで、ドローコールが減ってもポリゴン数が4倍になったのでは本末転倒な感じです。
前置きが長くなりましたが、そこでメッシュの分割が必要になります。UnityにはOcclusion Cullingの便利な仕組みがあるので、こいつがうまく動作するように適当なエリアを区切ってメッシュを分割すれば、すくなくとも視界の外にあるメッシュの大半はカリングされるはずです。
つまり、メッシュの結合は一定のエリアごとにまとめて、カメラの後ろに回るようなところまでメッシュを纏めない様にするのです。
MeshCombinerにはメッシュを分割するAreaを指定する機能があります。これを使って、4つの金閣寺が別のエリアになるように以下の図のようにエリアを区切りました。
図6. シーンを4つのエリアに分割し、それぞれのエリア毎にメッシュを結合する |
このようにエリアを区切ってMeshCombinerでパックすると、それぞれのエリア内でメッシュ結合が行われます。そうすると、あとはUnityが勝手にカリングしてくれます。下の動画は、Occlusion Cullingの様子をSceneViewで見れるように設定し、カメラを360度回転させるアニメーションを仕込んだものです。
図7. 分割したエリア毎にメッシュを結合したため、見えないモデルがカリングされるようになりました。 |
ポリゴン数を示すStatusウインドウのTris:の値に注目してください。36.6K~64Kで遷移していることを確認できるかとおもいます。メッシュを分割する前は144Kでしたので、うまくカリングされていることがわかります。メッシュを分割しただけで、カリングの処理はUnityがやっています。Batchesは11~18程度なので、メッシュ結合する前のモデルで同じアニメーションを行うと27~50でしたから、カリングを有効にするためにメッシュ分割してもドローコールはかなり削減されていることがわかります。
今回はテニスコートが画面の中心にあり、物陰に隠れたモデルをカリングする必要がないため、UnityのOcclusion Cullingは最も緩い判定にして、事実上視界によるクリッピングのみを使いました。また、地下から地上に向けたガメラでレンダリングする鏡面反射用のカメラではUnity(2021.3.4) のOcclusion Cullingが正常に動作せず、見えなければならないものまでCullingされてしまう不具合がありました。
[2-3] 見えない面の削除
テニスゲームのように、プレイヤーが移動できる範囲が限定されている場合、移動可能な視点からは絶対に表示されない、常に裏側となる面が存在します。その面を排除することでモデルのポリゴン数が削減でき、パフォーマンスを向上させることができます。UnityのOcclusion CullingもBack Surfaceをカリングする機能がありますが、元のメッシュデータを削除したほうがシーンデータも軽くなりますし、カリング対象を減らすことでUnityのOcclusion Cullingのコストも抑えられます。
MeshCombinderで見えない面の削除は簡単です。UnityのOcllusion CullingのウインドウからOcclusion Areaを生成し、サイズを入力します。
それだけで、MeshCombinerのインスペクタ上にある”Cull Back Surface”にチェックを入れると、Occlusion Areaの情報を読み取って、そのエリアの中からは絶対見えない面(常に裏側を向いている面)をメッシュから削除し、新たにメッシュを生成します。
図8. Occlusion Areaの設定。中心座標とサイズを指定する。 |
上の図では、カメラが移動可能な範囲として、幅22m、前後36m、高さ10mをOcclusion Areaに設定してあります。SceneViewには薄い緑色で表示されます。
この状態で、MeshCombinerの"Cull Back Surface"にチェックを入れて、"Pack Children"ボタンを押してメッシュを生成すると、見えない面が削除された状態でメッシュが生成されます。
金閣寺や三重塔はこんな感じになります。
図9. Occlusion Areaから見えない面をカットした金閣寺と三重塔のモデル。裏側になる面が切り取られています。 |
テニスコートから見えない面のポリゴンがスパッと切れました。
ここで一つ問題があります。見えない面を削除すると、モデルは「ハリボテ」となり反対側から見るとスカスカです。この状態では、ライティングがうまく機能しません。ステージではLightMapをBakeして影などをテクスチャに焼き付けていますが、Bakeするときには隠れた面も必要になります。
図10. 見えない面のカリングによって、影の形が不自然になることがあります。 |
この問題を解決するために、削除される「見えない面」を別のメッシュとして生成し、LightMapをBakeするときだけ表示させるという手法をとりました。残念ながら、リアルタイムな影には対応していません。
MeshCombinerでは、"Make Culled Surface Object"にチェックを入れてメッシュを生成すると、カリングされたポリゴンを集めたメッシュが生成されます。
[2-4] メッシュ結合と裏面カリングの結果
シーンデータ上でメッシュの最適化を行った3Dモデルの比較です。金閣寺、三重塔、大仏、鳥居、橋、草木、石などになります。
図11.処理前 | 図12.処理後 |
わかりやすいように表にしました。
- | MeshCombiner処理前 | MeshCombiner処理後 | 削減率 |
---|---|---|---|
バッチ数 | 238 | 94 | -61.5% |
ポリゴン数 | 313.8K | 232.0K | -26.1% |
この表のとおり、バッチ数は約4割になり、ポリゴン数は26%削減されました。
実際のゲーム画面では、シーンデータがすべてレンダリングされることはないので、この値の半分程度です。
とくに、ドローコール(バッチ数)は全体で200程度に納めたいので、背景の地物をすべてレンダリングしても100以下になった事は大きいです。
UnityのOcclusion Cullingが正しく動作しているかを検証するために、先ほど4個の金閣寺で行ったアニメーションをこのシーンで実行してみました。
図13. ステージの地物がカリングされている様子。Batchesは14~24に収まっています。 |
金閣寺や三重塔をはじめ、石や草花といった小物までキチンとカリングされていることがわかるとおもいます。ちょっと文字が見辛いですが、バッチ数は14~24程度、ポリゴン数は100K~142Kぐらいを遷移しています。これに、次の記事でとりあげる樹木のレンダリングと池の鏡面反射の処理が加わることになります。
ポリゴン一覧表
建造物 | ポリゴン数(原型) | Blenderでリダクション後 | 対策後のポリゴン数 | 対策 |
---|---|---|---|---|
金閣寺 | 3,431,093 | 34,964 | 23,249(-34%) | Mesh結合、見えない面の削除 |
平等院 | 12,400,000 | 約2,200,000 | 2 | 128枚のテクスチャ化 |
三重の塔 | 29,702 | 29,702 | 17,521(-41%) | 見えない面の削除 |
大仏 | 72,908 | 30,146 | 20,765(-31%) | 見えない面の削除 |
鳥居 | 1,892 | 1,892 | 1,236(-35%) | Mesh結合、見えない面の削除 |
建造物合計 | 15,935,595 | 2,296,704 | 62,773 | 97%削減 |
地物 | ポリゴン数 | 概要 | 対策後のポリゴン数 | 対策 |
地形 | 49,152 | UnityのTerrainをMesh化 | 41,389(-16%) | Mesh結合、見えない面の削除 |
草花,橋,石など | 56,154 | Mobile向けアセット | 49,155(-12%) | Mesh結合、見えない面の削除 |
樹木 | 695,600 | Mobile向け樹木 500本 | ← | |
地物合計 | 800,906 | - | 786,904 | 山を樹木で埋め尽くしたため樹木が大半 |
全背景合計 | 16,736,501 | 3,157,289 | 849,177 | 目標まであと75%削減が必要 |
[2-4] MeshCombinerの使い方
以下のGitHubからソースコードを2つダウンロードし、Asset以下の適当な場所に置いてください。
メッシュを結合したいGameObjectのインスペクタの"Add Component"から"Mesh Combiner"を選んで追加すると利用できます。
MeshCombinerのソースコード(2つあります)
次回の記事
Unityで、スタンドアロン型VR機のゲームで高ポリゴンな背景モデルを実現する。<3>
へ続きます
Terrainの木を3Dビルボード化
++ 多数のビルボードをone mesh化する
++ 3DTextureによる立体視効果
++ 木の揺れ方