25/07/20 up
なにげに今年初更新という…サボってたわけでは…!ないはず…ないかな…
今回はCompute ShaderでVisibility BufferからGBufferを生成する手法について解説します。
しかし、それだけだとちょっと物足りないかなとも思ったので、今回の実装中に色々と出会ったパフォーマンス的な問題点をどのように解決したかも簡単にですが解説していきます。
今回のサンプルはこちらになります。
Visibility Bufferを利用する場合、そこからライティングを行うまでの流れは主に2つ存在すると思っています。
1つはVisBufferからマテリアル情報を取得して直接ライティングを行う方法です。
この方法は実装が簡単ながら、昨今の多種多様なマテリアルに対応するのが難しいという問題もあります。
そこでもう1つの方法として、VisBufferからGBufferを作成し、そこからライティングをするという流れがあります。
UE5がまさにこの手法ですが、多種多様なマテリアルにも対応できるという利点があります。
欠点としては、普通に実装すると直接GBufferを描画するよりパフォーマンスが低下することがある点でしょうか。
Nanite的なマイクロジオメトリではそうでもないのですが、従来型のジオメトリではパフォーマンスが悪化する可能性があります。
で、私のVisibility Bufferサンプルでは、Pixel Shaderを用いたものとWork Graphを用いたものを実装しています。
今回はそこにCompute Shaderバージョンを追加しようとしたわけです。
その理由としては、今後ソフトウェアVRSをやってみたいというのがあるのですが、こちらについては実装できたら記事を書きます。
本記事ではあくまでもCompute ShaderバージョンのGBuffer生成についてです。
現在実装済みのDepth & Tile手法は以下のような流れになります。
各ピクセルのマテリアルIDを取得し、IDごとにユニークな値を深度バッファに書き込み
この時、各タイルにどのマテリアルIDが含まれるかも求めておく
各マテリアルで描画されるべきタイルを計数してIndirectArgバッファに加算
IndirectArgバッファを利用して各マテリアルごとにタイルを描画
この手法ではハードウェアのサポートをかなり受けることが出来ます。
例えば深度バッファにマテリアルIDが書き込むことで、深度ステンシルテストを利用して特定のIDのみのピクセルでシェーダを実行することが可能です。
ただし、Pixel Shaderは1ピクセルごとにスレッドを割り当てますが、必ず4ピクセルのQuad単位で処理されます。
つまり、Quadに含まれる特定のマテリアルIDが1つだけだったとしても4スレッドが割り当てられ、3つのスレッドが仕事をしないというわけです。
私のサンプルではそのようなQuadは少なく、かなり有利な形でマテリアルが重なっていますが、常にそれを期待できるわけではありません。
そこでCompute Shaderの登場です。
Compute Shaderではタイルごとの処理を行わず、ピクセルごとにビニングして処理します。
この実装については以下の記事が参考になります。こちらはVisBuffer実装時にも紹介したものです。
http://filmicworlds.com/blog/visibility-buffer-rendering-with-material-graphs/
実装そのものは掲載されていませんが、考え方は一通りまとまっています。
実行の流れはこんな感じ。
各ピクセルのマテリアルIDを取得し、マテリアルカウントバッファに各マテリアルを計数する
カウントバッファをPrefix Sumして、マテリアルIDごとのバッファスタート位置を計算する
もう一度VisBufferを調べて、各ピクセルの位置を保存するPixel XYバッファに格納する。この時、3で求めたバッファスタート位置を参考にマテリアルIDごとに固めて保存する
Pixel XYバッファを参照して各マテリアルのCompute Shaderを実行する
これだけ見るとわかりやすいですね。
Prefix Sumを使ってPixel XYバッファを最小限で抑える必要はあるものの、それ以外は簡単な実装に見えます。
例えば、1番のマテリアルを計数するシェーダは以下のように実装することが出来ます。
マテリアルIDの取得部分や範囲チェックなどは行っていませんが、重要なコードは概ねこれだけです。
InterlockedAddを利用するのは当然、複数スレッドで同じバッファの同じアドレスにアクセスすることがあるからです。
これだけ単純ならパフォーマンスも問題がない…と思っていたのが運の尽き。
実際にこれを2560x1440の解像度で実行すると、このシェーダの実行だけで2msでした。
Depth & Tile手法で同じシーンの処理を行うと、GBuffer作成までで0.3ms弱…時間がかかりすぎています。
この原因はInterlockedAddでした。
Nsight Graphicsで調べても、各Throughputが軒並み恐ろしいレベルで低いということはわかるものの、なぜそうなるのかは正確に読み解くことは出来ませんでした。
しかし、InterlockedAddを非Atomicの普通の加算に置き換えてみると、結果は当然おかしくなるのですが、実行時間は0.04msまで減少。
間違いなくAtomic操作が問題であることがわかります。
仕組み上、Atomic操作無しで対応することは不可能です。そもそも、Depth & Tile方式でもAtomic操作は利用しています。
では、このシェーダだけなぜこんなに遅いのか?
正確なことはわかりませんが、単純すぎるシェーダであることが問題なのだろうと思われます。
単純なシェーダなので占有率も高く、大量のスレッドが動作することになるのですが、それらが書き込む先のバッファは同じであり、書き込むアドレスも同一の場所に集中しやすいわけです。
調べてみると、同一バッファの同一アドレスへのAtomic操作は負荷が高くなりやすく、別バッファや別アドレスへのAtomic操作はそこまでではないようです。
また、共有メモリへのAtomic操作もバッファに対してより高速で、シェーダの処理が長めのほうがバッファへのアクセスが集中しにくくて高速のようです。
これらを考慮し、2つの手法をテストしてみました。
まず、カウントバッファを1本ではなく4本にして、InterlockedAddを分散する方法です。
もう1つは共有メモリを利用する方法です。
前者は1.1msくらいにまで高速化したのですが、それでもこの処理だけで1ms以上は厳しい。
後者はなんとか1msを切れましたが、やはり全体処理を考慮すると厳しい。
また、書き込みアドレスを分散させる意味も込めて、処理するタイル幅を8x8ではなく256x1にしてみました。
こちらのほうが少し高速化しており、やはり同一アドレスに対するAtomic操作が重いことがわかります。
しかもここからまた問題があり、処理3でPixel XYバッファに書き込む際にもInterlockedAddが使用されます。
そうでなければPixel XYバッファに書き込むアドレスが競合してしまうためです。
なんとか1msを切った処理と同じような処理がもう1回実行されるというのでは無理があります。
なんとか他の方法を見つけなければなりません。
そこでWave Intrinsicsに目をつけたわけですが、WaveActiveSum命令のような便利関数は今回の事例では利用できません。
True or Falseの、つまり計数する/しないであればこの命令で計数して先頭スレッドでAtomic命令を使えばいいのですが、今回はマテリアルごとに計数する必要があります。
単純にアクティブスレッドの結果を計数するだけの命令ではマテリアルごとに処理することは出来ません。
しかし、同じマテリアルIDのスレッドを計数する方法があったのです。
以下はそれを利用した計数シェーダの実装です。
TILE_Xは8、TILE_Yは4にしており、1Waveの32スレッドで動作するようにしています。
64スレッドのWaveに対応しようとするとちょっと面倒なので、大体どのGPUでも実行可能な32スレッドで動作させています。
15行目で使用されているWaveMatch命令は、入力した値と同じ値を持っているスレッドIDをビットで求める命令です。
つまり、materialIDが同じ値のスレッドがこれでわかるというわけです。
戻り値はuint4で、最大128スレッドの結果を得ることが出来ますが、今回は32スレッドなので戻り値のX要素のみ利用します。
この結果に対してcountbits命令を使うことで、このタイル中で同じmaterialIDがいくつあるか計数できます。
また、firstbitlow命令でWave内でこのmaterialIDを持っている最初のスレッドを取得することが出来ますので、この値とWaveGetLaneIndexの結果が同一であればInterlockedAddを行うようにすることで、同一materialIDについては1回だけAtomic操作が行われるようになるわけです。
なお、この手法は処理3でも利用されることになります。
処理3ではIndirectArgバッファへのInterlockedAddを行いますが、その後にPixel XYバッファへピクセル位置を書き込む必要があります。
ここでもWaveMatch命令が活躍します。
ここでの処理も少しトリッキーです。
WaveMatch命令で同一マテリアルIDのスレッドを求め、firstbitlowで最も若いスレッド番号を取得します。
InterlockedAddをしているのはこの最も若いスレッドのみなので、書き込み先アドレスが分かっているのもこのスレッドのみです。
そこでその値(storeIndex)をWaveReadLaneAt命令で取得します。
また、WaveMatchで取得したビットに対して、自分のスレッド番号より若いスレッドの数を求めます。(5~6行目)
これによってピクセル位置を書き込むアドレスを求めることができるので、Prefix Sumで作成しておいたオフセット値を加算すればOKです。
マテリアルのビニングはこれでおしまいで、その後にはGBufferの生成が行われますが、こちらは特に難しいことはせず、ほぼWork Graphのときと同じような実装になっています。
違いはバインドレスを使っていないくらいですね。
ここまでの処理はピクセル単位でのビニングを行っていますが、現在の実装にたどり着くまでに紆余曲折があり、Depth & Tileと同じようなタイル単位でのビニングを行う処理も作っています。
やっていることはDepth & Tileと同じような感じですが、深度テストが使えない分、各スレッドでマテリアルIDのチェックを行うようにしています。
当然、タイルはスレッドグループ単位で処理されるため、タイル内に特定のマテリアルIDが1つしか使われていなくともタイル全体がスレッドグループとして割り当てられるという弱点があります。
この手法を用いる利点はあまりないのですが、ソフトウェアVRSを使えば少しは良くなるのではないか?という希望的観測で実装しました。
今後、ソフトウェアVRS実装時にはこちらにも適用して、パフォーマンス計測をしていきたいと思っています。
それでは、各手法のパフォーマンスを見ていきましょう。
画像の順番はDepth & Tile手法、ピクセルビニング手法、タイルビニング手法となります。
Depth & Tile手法は深度バッファへの書き込み、分類、がそれぞれ0.04msと非常に高速で、GBufferへの変換も0.2msと最も高速です。
ピクセルビニング手法は計数処理が入る2つの処理の負荷が高めです。
といっても、最初の2msと比べると恐ろしいほどに高速化しています。
Wave Intrinsics最高ですね。
GBufferへの変換は0.24msで、少しDepth & Tile手法より遅いです。
Throughputの傾向はほぼ同一ですが、SM Throughputが若干こちらの方が低めです。
細かな処理の違いのため占有率が低いのかもしれませんね。
タイルビニング手法はビニング処理が圧倒的に高速なのですが、GBuffer変換の負荷が高いです。
これはGPUの仕組みを考えれば致し方ないかとは思います。
実際、Throughputを見るとSMが他と比べて低いので、スレッドがうまく動いていないのでしょう。
正直な話、この手法を利用するならDepth & Tileを使ったほうが良いのではないかと思います。
今回は実際にパフォーマンスが最悪な状態から、どのようにしてパフォーマンスを改善していったかを紹介しました。
Wave Intrinsicsはかなりトリッキーではあるのですが、うまく使えばかなり高速化出来ます。
やはり積極的に使っていくべきでしょう。
とはいえ、まだまだ改善の余地はありそうなので、余裕があるときにもう少しいじってみようかとは思っています。