21/07/23 up
Occlusion Cullingは現在の大量にレンダリングするシーンでは欠かせない機能となっています。
しかし実装方法はいくつかあり、ものによっては結構面倒だったりします。
CPU上ですべて対応しようとすると、低解像度のソフトウェアラスタライザを利用したりということになるでしょうか。
この場合は Occluder(遮蔽物)に簡易メッシュを使うことになるかと思いますが、簡易メッシュは誰が作るかといえばアーティストさんになるのではないでしょうか。
Houdiniなど使えば自動生成も可能かもしれませんね。
この方法では Occluder と Occludee(被遮蔽物)を明確に分ける必要があるでしょう。
GPUとCPUを利用する場合は Occlusion Query を利用します。
ピクセルシェーダを用いたレンダリング時に、ピクセルが描画されたかどうかをチェックし、その結果を取得してCPUで描画の有無を決定します。
この方法は Unreal Engine 4 などで使われており、結構昔から使われる手法です。
問題はCPUがGPUの結果を取得するのに数フレーム待つ必要があり、高速移動したりすると一瞬オブジェクトが描画されないでポッピングしたりします。
UE4のゲームでは割と見かける現象ですね。
バウンディングボックスのサイズを調整してある程度は対応できるものの、完全な対応は難しいです。
この方法ではやはり Occluder と Occludee を分けるか、もしくは前回の深度バッファを用います。
どちらの方法でもGPUの結果をCPUが受け取るのにはタイムラグが発生しますし、CPUを経由するのはやはり無駄です。
GPUのみの Occlusion Culling はサンプルを以前作成しています。
第122回ではパーティクルの Occlusion Culling を、第132回では大量のインスタンスをカリングして Draw Indirect で描画しています。
しかしこれらの方法では Occluder と Occludee を明確に分けており、Occluder についてはカリングされません。
大体の場合、Occluder は背景の静的オブジェクトとなります。壁とか天井とかビルディングとかとか…
これらのオブジェクトは昔ならただの板とか箱とかだったりしたので、Occluder として描画しても問題なかったかもしれません。
現在ではこれらの形状も複雑化し、しかも多く配置されるために Occluder として常に全部描画するというのは現実的ではなくなってきました。
つまり、今まで Occludee だったものはそのまま Occludee として、以前は Occluder だったものも Occludee として取り扱いたくなっているのが現状です。
そして最近は Meshlet を用いた GPU Driven Rendering が主流になりつつあります。
特に巨大でポリゴン数も多い背景オブジェクトなどは Meshlet Culling が非常に効果的です。
ただでさえオープンワールドなゲームが増えてきていて、その上メッシュのディテールはどんどん上がってきているので、無駄を避けたカリングをしなければなりません。
もちろん Frustum Culling や Backface Culling はそれだけでも有効なのですが、それだけでは足りなくなってきているのも事実です。
しかもロードをなくすために建物の中もレベルに含まれていたりすると、壁で遮蔽されて見えないのに Frustum に入っているから描画されたりします。
建物の中と外を扉の開閉に合わせて表示/非表示を切り替える手法もあるといえばありますが、そのような方法はゲーム側にも相応の対応が求められますし、だいたいやりたくないと言われます。気持ちもわかりますがね。
簡易な Occluder メッシュを用意する方法もアーティストさんに嫌われます。表示メッシュのリテイクに合わせて簡易メッシュも作り直すことがあったりするので、これまた気持ちもわかります。
可能ならプログラマだけで完結したい。特にレンダリング側だけで完結したい。ゲーム側のプログラマやアーティストが面倒なものを作成しなくて済むようにしたい。と思うのは当たり前のことです。
それを解決する手法が今回実装した 2-phase Occlusion Culling です。
仕組みはさほど難しくはありません。
名前の通り2つのフェーズがあり、第1フェーズでは前回の深度バッファを用いて Occlusion Culling を行います。
このとき、カリングされてしまった Meshlet は False Negative(偽陰性)としてマークします。
第2フェーズでは第1フェーズでレンダリングされた深度を用いて Occlusion Culling を行います。
このときカリング処理に回すのは False Negative としてマークされたものだけです。
図で書くと以下のようになります。
現在のフレーム(N Frame)の前にグレーの前回フレーム(N-1 Frame)があるとします。
N-1 Frameでは箱が2つ描画されています。この震度バッファは N Frame でも使用されます。
N Frameでは最初に Frustum Culling を行います。Backface Culling も同様です。
ここでは視野範囲外の五角形がカリングされるのがわかります。
次に Compute Shader で Occlusion Culling を行います。前回の深度バッファを利用するので青で表示された楕円と四角は描画されることになります。
楕円はともかく、その後ろに隠れるように配置されている四角形も描画されてしまいます。
このように本来描画されなくて済むものが描画されてしまう状態を False Positive(偽陽性)と言いますが、次のフレームでは大きくカメラが移動しない限り遮蔽されることになるので、False Positive が長持ちすることはあまりないはずです。
オレンジの球と薄い緑の三角形は第1フェーズで前回の深度バッファによってカリングされます。
第1フェーズで Occlusion Culling されたものはすべて False Negative としてマークするので、一旦ここではマークして描画はしません。
第2フェーズのカリングの前に第1フェーズでカリングされなかった楕円と四角が深度バッファに描画されます。
第2フェーズではこの深度バッファを用います。
第1フェーズではカリングされたオレンジの球は今回の深度バッファではカリングされないため、描画されることになります。正しく偽陰性だったわけです。
しかし薄い緑の三角は今回の深度バッファでも手前の楕円で遮蔽されます。こいつは偽陰性ではなく、実際に陰性だったということです。
この手法の大きな利点は Occluder と Occludee を分ける必要がないという点です。
前回の深度バッファがそのまま Occluder として機能し、今回描画するすべてのオブジェクトが Occludee として処理されます。
このことから、簡易な Occluder 専用メッシュも不要になります。
また、第2フェーズを処理したあとには偽陰性でカリングされるオブジェクト、つまり、本来はカリングされてはいけないのにカリングされてしまうオブジェクトが存在しなくなります。
UE4ではよく見かける、一瞬オブジェクトが消えてしまう現象は皆無です。
しかし偽陽性、カリングされるべきなのにカリングされないオブジェクトもまた、存在してしまいます。
CPUで処理するにしろ、GPUで処理するにしろ、バウンディングボックスで遮蔽を検証する以上、偽陽性は避けられません。
しかしながら、この手法ではバウンディングボックスが完全に遮蔽されていても偽陽性が発生します。
ただし前述したとおり、偽陽性で描画されたオブジェクトが連続して偽陽性で描画されにくいのも事実です。
例えば上図で N+1 Frame が N Frame と同じカメラになったとすると、前回フレームの深度バッファには楕円が描画されるので四角は2フェーズ両方で遮蔽されて描画されません。
このように利点は色々あるのですが、欠点としてはカリング処理が2回行われる部分でしょう。
Frustum Culling と Backface Culling は1回だけですが、それらより処理が重い Occlusion Culling が最大で2回行われるので、負荷は結構馬鹿にできないはずです。
深度バッファでカリングする場合、フル解像度の深度バッファを利用するのは負荷が高いので、階層深度(HiZ)を作成する必要もあります。
しかもこれは各フェーズ完了後に行う必要があるため、シーン全体で2回行う必要が出てきます。
深度バッファを書き込みから読み込みにステート遷移すると深度バッファの圧縮解除やキャッシュフラッシュを伴うので、決して軽いとは言えなかったりします。
実際、今回のサンプルでは、Occlusion Culling の有無で負荷はほとんど変わっていません。
逆に遅くなっている部分すらあり、十分な効果が出ているとは言いづらいです。
しかし Crytek Sponza は数十万ポリゴン程度ですし、実際のゲームのように数百、数千万のポリゴンを扱っていません。
オブジェクト数が増え、描画範囲が広くなり、ポリゴン数が増えると効果が出てくる可能性があります。
実装は前述の理論をほぼそのまま実装しているので特に解説することもあまりなさそうですが、一部を解説しておきます。
サンプルはいつもどおりにGitHubにコミット済みです。
今回のサンプルはSample027です。
まず、最初のフレームや大きくカメラが移動した場合などは前回の深度バッファが使い物にならない場合があります。
この場合、Occlusion Culling はやるだけ無駄なので、第1フェーズで Frustum Culling と Backface Culling だけを行うようにしています。
isOcclusionReset_フラグが true の場合は第1フェーズのみの処理になるようになっています。
このフラグはフレーム開始時と、isOcclusionCulling_ フラグ、isFreezeCull_ フラグが変化した場合に true となり、フレーム最後に false にしています。
Occlusion Culling のシェーダコードは以下のようにしています。
texHiZ がHiZのミップチェインで、今回は5レベルまでのレベルを作成しています。
深度をチェックするミップレベルはスクリーン上のAABBのサイズから決定しています。
5行目の desiredMip が利用するミップレベルとなります。
HiZテクスチャはR16G16フォーマットで、Rに深度最小値(手前側)、Gに深度最大値(奥側)が保存されています。
通常は奥側の値と比較することになるのですが、maskMinMax を利用することでどちらと比較するかを決定できます。
奥側と比較したい場合は float2(0, 1) を利用し、手前側と比較したい場合は float2(1, 0) を利用します。
第2フェーズでは必ず奥側と比較しなければいけませんが、第1フェーズは手前側と比較しても問題ありません。
第1フェーズで手前側と比較すると偽陰性としてマークされる Meshlet が増えます。逆に奥側で比較すると偽陽性が増えることになると思います。
今回のサンプルでは偽陰性側に多めに渡したほうがわずかに高速な感じがあったので第1フェーズは手前側と比較しています。
若干面倒なのは2フェーズで処理するため、Draw Indirect用の変数バッファに第1フェーズ用と第2フェーズ用を用意しなければならなかった点です。
GBuffer描画は1度のレンダリングでいいのですが、深度バッファ描画は2度行う必要があるため、第1フェーズと第2フェーズで変数バッファを分けています。
第1フェーズで偽陰性とマークされる Meshlet インデックスも保存しなければならないため、両フェーズ用変数バッファ、両フェーズ用カウントバッファ、偽陰性インデックスバッファ、偽陰性カウントバッファがメッシュごとに必要になります。
これらは SceneMesh クラスが持つようになっているのでそちらを参照してもらうとわかりやすいです。
各バッファはメッシュ単位で持つようにしていますが、利用はサブメッシュ単位となります。
コードだけだとわかりにくいかもしれないので、図に書いておきます。
第2フェーズでは第2フェーズ用のDraw Indirect変数バッファへの書き込みと、第1フェーズ用のDraw Indirect変数バッファへの書き込みを行っています。
まあ、バッファ自体は同じもので、オフセットで変更しているだけですが。
オフセットはサブメッシュごとに定数バッファで指定するようにしています。
今回のサンプルでは、Occlusion Cull のチェックボックスで Occlusion Culling のOn/Offを変更できます。
Offにした瞬間に一瞬表示がおかしくなりますが、UIの変更と実際のレンダリングパラメータ更新のタイミングがずれているために発生しているだけです。
また、Freeze Cull のチェックボックスで前回のカリング情報を利用した描画に変更されます。
外壁ドアップ状態で Freeze Cull して中身を見るとごっそりと消えてるのがわかると思います。
Draw Count は描画している Meshlet の個数になります。
初期カメラの状態で Occlusion Culling をOn/Offしても300個くらいしか減りませんが、後ろに下がって外壁が表示される状態だと3300ー>7くらいまで減ります。
確実に減ってはいますが、パフォーマンスにはさほど影響がないです。
今後の課題としては非同期コンピュート化とかでしょうか。
ただ、シャドウマップあたりの描画でも行わないと Occlusion Culling の裏で描画できるものってあまりないんですよね…
レイトレシャドウやるとあまり意味がないという悲しい現実。