描画負荷を軽減する
あまりにも文しかないのでそのうちもうちょっとスクショを入れたいと思っています。
描画の仕組みについては大枠を把握できる程度の簡易な説明に留めます。
正確に理解したい場合はUnityのマニュアル等を参照することをオススメします。
ここでは私が見ているポイントについての説明を行います。結果的に、限られた作業時間で現実的にチェック・修正できるポイントを紹介することになっていると思います。
あえて具体的な数字の目安に触れていますが、これは見せたいものや対象とするユーザー層等によって緩めたり無視したり、逆により厳格にしなければならなかったりするので、その点は念頭に置いてください。
描画に関わるCPU側の負荷はおおむねバッチ数で決まります。
バッチ数はGameView右上にStatsを表示することで確認できます。
他にも頂点数(Verts)やポリゴン数(Tris)、最適化で減らしたバッチ数(Saved by batching)が確認できます。
https://scrapbox.io/files/69534c2be4e5954aaf9164c3.png
CPUがGPUに対して行う描画命令をドローコール(SetPass Calls)といい、それを諸々の最適化でまとめた後の数がバッチ数です。
私個人の経験による目安ですが、アバター等の負荷を含めない場合はCliantSim(=非VR)でBatches400前後までであればVRChatユーザーの大半はストレスなく視聴できたという感想になるようです。
VR時のFPSは30-45程度になっていますが、大抵のVRChatユーザーは日常的にこれくらいのFPSでプレイしているため問題ないと感じてくれます。初心者や、非VRChatユーザーを主な対象とする場合はまた変わってくると思います。
アバターが自由かつ人数制限がない場合はより少なくしたうえで、Player、LocalPlayer、MirrorReflectionレイヤーにあたるライトの数や同レイヤーを映すカメラの数を減らします(これらはおおむね全アバターのサブメッシュ数合計に乗算される形でバッチ数を増やしてしまうため)
それでもアバター一体あたりのバッチ数が人により100くらいあったりするのでワールド側を最適化してもどうにもならず、最終的にはユーザー側のカリング設定等に任せるしかない部分も大きいです。
おおむね以下の式でバッチ数が決まります。
カメラの数 × カメラに映るサブメッシュの数 × サブメッシュに当たるリアルタイムライトの数 + 影用の追加描画
サブメッシュとは1つのメッシュの中でマテリアルが異なる部分ごとのグループのことです。
つまり、メッシュ内のサブメッシュの数はマテリアルスロットの数と同じです。
各メッシュのマテリアルスロットの数の合計が、シーンのサブメッシュの数の合計ということになります。
≒描画負荷の観点からはマテリアルが異なるメッシュをまとめる意味はない
追加のカメラを使ったステージ背面スクリーンの設置有無やリアルタイムライトの個数は、いずれもサブメッシュの数に乗算されるため影響が大きい…というのをまず押さえておくと良いです。
追加カメラ・リアルタイムライトどちらも対象レイヤーを限定するのは効果的です。
メインのDirectional Lightを影ありにするかどうか、その対象レイヤーにアバターを含めるかはバッチ数に大きな影響があります。
演者に向ける影付きスポットライトは演者+ステージ床だけのレイヤーを作ってそれを対象にするのがオススメです。
カメラの視野角やClip Farを小さめに設定して映るサブメッシュを減らすこと、ライトのRangeを押さえて影響するサブメッシュを減らすことも効果的です。薄く、全体を照らすようなポイントライトはバッチ数を2倍にするため、ディレクショナルライトや環境光の色に置換できないか検討するのが良いです。
VRは両目で2、撮影用カメラを出すと合計3個のカメラが出ます。CliantSimやデスクトップモードと比べてVRでチェックすると重くなっているのは主にこれと解像度の差が原因で、実際は常に3倍のバッチ数になるということを意識しておく必要があります。
シーンにCubeを1つ出すとBatchesが1増えます。このときVR+撮影カメラでは3増えていて、ポイントライトが2つあれば9増えています。
影付きのリアルタイムライトは使いどころをできるだけ絞るべきですが、特に影ありポイントライトが重いことは押さえておきたいです。これは6方向のシャドウマップを保持・レンダリングするからで、可能な限りスポットライトに変更したほうが良いです。
CPU側の負荷とは別にGPU側の負荷もあり、これはおおむね以下の式で決まります。
頂点側:描画される頂点数 × パス数(≒当たっているライトの数) × 頂点シェーダーの処理の重さ
頂点数に比例して重くなります。
ピクセル側:解像度 × オーバードロー × パス数(≒当たっているライトの数) × フラグメントシェーダーの処理の重さ
同一のピクセルで複数の面を描画していることと、その回数をオーバードローといいます。半透明がたくさん重なると増えるので、不透明にできるものは不透明にしたほうが良いです。
解像度の影響が大きいのはみんな知っていると思います。縦横がそれぞれ2倍になると解像度は4倍なのでフラグメントシェーダーも4倍実行されます。
たいてい頂点数より解像度(ピクセルの数)が大きいので、フラグメントシェーダーで重い処理を行うと重くなります。移せるものは頂点シェーダーで計算してv2fで渡すようにすると軽くなります。
フラグメントシェーダーの記述では、基本的にテクスチャの取得回数やループ処理の回数に注意していれば大きな問題は発生しにくいです。シェーダーの細かい最適化はもちろん行った方が良いですが、バッチ数の削減に比べて効果が小さく、あまりナーバスになって書く必要は無いことが多いです。
パーティクル用の半透明シェーダー等、オーバードローされがちなシェーダーは他より慎重になっても良いかもしれません。
ドローコールの最適化についてはUnityマニュアルにもまとまっています。
SRPバッチャーはURPやHDRPの話なのでVRChatには関係ないです。
最適化のために主に使う機能は以下です。
それほど特殊なことはしておらず、どちらかというとこれらを適切に運用することに難しさがあります。
https://scrapbox.io/files/69535a1c328c40d6be7353ae.png
実行時(Unity上では再生ボタンを押したとき)にBatching Staticにチェックを入れているGameObjectのメッシュを結合します。マテリアルが同じならその分バッチ数が減ります。
Blender等でメッシュを結合するのと同じようなことを、実行時にUnityがやってくれるということです。
モデラーに結合をお願いする必要が無く、また管理上は結合したくないGameObjectをそのままにできます。
最大64000頂点までなので、大きいシーンだと1つになるわけではなく複数の塊にまとまります。
Blender等で結合したほうが良いケースもあるはずですが、そこまでやることはあまり無いです。
以下のようなデメリットがあります。
結合したメッシュは動かせなくなる
原点も変わるためシェーダーで頂点を動かしている場合は動きが変わってしまう
結合したメッシュを保持するためメモリの消費が増える
メッシュが大きくなるのでカメラに映る頂点数は増えがちになる
=元々カメラ外だったオブジェクトが結合されることで含まれることがある
なんでもチェックを入れれば良いというわけではありませんが、それでも最も多く使う機能です。
木のように同じメッシュを大量に配置する場合に使います。
大抵の汎用シェーダーは対応しているので、マテリアル下部のEnable GPU Instancingにチェックを入れるだけです。
https://scrapbox.io/files/69535b21b78fd94f78ce13cc.png
メッシュが同じというのはMeshFilterが参照しているメッシュが同一ということです。
Blender等で普通に複製すると「形状が同じメッシュがFBX内に複数あるだけ」の状態になるので、GPUインスタンシングは効きません。
Blenderの場合はリンク複製したものは同じメッシュを参照するFBXとして出力されるようですが、うまくいかないケースも存在するようで仕様が若干不透明です。最も確実なのはUnity上で同一メッシュの配置を行うことです。
私は簡単なエディタ拡張で再配置を行ったり、MeshFilterの参照だけ上書きしたりしています。
ライトベイクしても、同じライトマップを参照していればGPUインスタンシングは有効になります。
複数のライトマップに分かれてしまう場合はBakery Lightmap Group等を使って同一にする必要があります。
ちゃんと同一のメッシュを使いEnable GPU Instancingにチェックを入れても、思ったようにバッチ数が減らないことがあります。
ShadowCasterを除く追加パスはインスタンシングされないので、ポイントライト等が当たっている場合のFarwardAddパスのバッチ数はそのままです。レイヤーを変更して除外したり、インスタンシング対象に当てるライトはRenderModeをNotImportantにして頂点ライトにすることでForwardBaseに含めたりする必要があります。
自作のシェーダーを使っている場合、ShadowCasterパスのインスタンシング処理の記述を忘れていることがあります。影を描画する場合、ShadowCasterパスにもmulti_compile_instancingやその他必要な記述を行ってください。
FrameDebuggerで描画順を確認すると、他のメッシュの描画が間に挟まっていることがあります。同一キューであればモデルの前後関係でそれが決まるので、Instancingを用いた連続描画が中断されその分バッチ数が増えているということです。Instancing対象のRenderQueueを他と被らない値(2000なら2001等)に変更することで対処できます。
不透明オブジェクトならこのとき微妙にオーバードローが増えることになります。
対応シェーダーを書く場合は以下が参考になります。
名前が非常にわかりにくいのですが、メッシュパーティクルに使えるGPUインスタンシングのことで、通常のGPUインスタンシングとは別物です。
パーティクル全般に使えるわけではなく、メッシュパーティクルだけです。
実はビルボードを出すときもメッシュパーティクルでQuadをGPUインスタンシングしたほうが良いようです。
対応シェーダーを使っていて、Enable Mesh GPU Instancingのチェックが入っていれば使えます。
https://scrapbox.io/files/695374a572d219ab1ad715cb.png
Unity標準のParticles/Standard SurfaceやStandard Unlitは対応しています。
VRChatにおいて、Quest環境でカメラを出したとき正常に動作しません。
なんで?
こちらも対応シェーダーを書く場合は以下が参考になります。
Custom Vertex Streamsも絡めた詳しい説明は描画負荷の話から離れるため、そのうち別記事でまとめる予定です。
どういうときにパーティクル個々の原点が取れるのか、パーティクルシステムの原点が取れるのか…等
以下は時々使います。
非常に優秀なのですが、VRChatにおいては頭部やカメラを壁の中に入れられてしまうのがネックになります。
こちらの記事で紹介されている手法で対策も可能ですが、複雑な地形であるほど追加の作業が必要です。
また動的な壁に対応できないため、演出ワールドにはあまり向いていません。
それでも地形や演出内容等の状況によって選べる場合には有効だと思います。
タイムラインやUdonのエリアスイッチで見えないエリアのオブジェクトを消す
演出ワールドにおいては壁が壊れたり移動したりすることが多々あるので、この方法で地道に不要なオブジェクトを除く形になることが多いです。
その他
FrameDebuggerについての説明は今のところ割愛しています。Stats表示より詳細を確認したい場合は調べてみてください。
私の場合最適化で使うことは多くないです。そこまでは時間をかけられないこと、それで十分であることが多いです。
自作のシェーダーがうまく動作しないときの調査には時々使います。
参考