DirectXの話 第182

Visibility Buffer

23/02/19 up

Visibility Buffer

現在主流のDeferred Renderingは多くのGBufferを利用します。
ポストプロセスでも利用されるGBufferは非常に重要ですが、高解像度になりライティングモデルも増えてくるとメモリサイズも肥大化しがちです。
GBufferを描画するパスも負荷が高くなりがちです。
可能であればGBufferをなくすか、GBufferへの描画を最小限に留めたいという考えがあるのも理解できます。

この問題への対応方法として、Deferred MaterialDeferred Textureと言った技術が生まれました。
Deferred Textureについては第173回でも実装していますが、通常であればメッシュを描画する段階で処理をするマテリアルや、マテリアルで利用するテクスチャサンプリングをライティング処理の際に行おうという寸法です。

この手法の1つがVisibility Bufferです。
Visibility Bufferは画面に描画されている各ピクセルに表示に関する情報のみを格納します。
その情報とは、どのメッシュのどのポリゴンか、という情報です。
通常はこれを32ビットに圧縮して格納し、ライティング時にそのポリゴンの頂点属性を取得、そこからテクスチャサンプルなどマテリアル計算を行います。
以下の図は簡単なVisibility Bufferの処理概要です。

32ビットのVisibility情報は実装によって違いますが、基本的にはメッシュに関する情報とポリゴンインデックスです。
今回の実装ではDraw Callインデックスと各Draw Callのポリゴンインデックスを格納しています。
データ構造は以下のような構成としています。

Visibility Bufferを利用する場合、可能な限り32ビットに情報を納める必要があります。
これを実現するにはDraw Call数やポリゴン数を制限する必要があります。
今回は各16ビットなので、どちらも65536以内に抑える必要があります。

UE5の場合はメッシュカリングに利用されるクラスターインデックスとポリゴンインデックスを用いているようです。
カリング用のクラスターはポリゴン数も少なく抑えられているため、32ビットが無駄に消費される可能性を減らしてくれるでしょう。

実装

それでは実装を見てみましょう。
サンプルは以下のGitHubで公開しています。

https://github.com/Monsho/VisibilityBuffer

まずVisibility Bufferの描画を行う必要があります。
これはそれほど難しくありません。まずはピクセルシェーダを見てみましょう。

cbVisibilityは定数バッファです。ここにはDraw Callインデックスが含まれています。
また、SV_PrimitiveIDでポリゴンインデックスを取得して、これを32ビットに格納しています。

さて、次にこれをもとにライティングしたいわけですが、マテリアルはそれぞれ別々のテクスチャやマテリアル情報、そもそもシェーダ自体にも違いが出てくることもあります。
シェーダが全て同一であれば、Deferred Textureの時と同様にテクスチャ配列や構造体バッファを利用することで対応できます。
しかしシェーダが違う場合は別の方法が必要になります。

わかりやすいのはUberシェーダを使うことです。つまりすべてのマテリアル演算を分岐で行えばOKです。
マテリアルを自由に作成できないエンジンであればUberシェーダで対応が可能でしょう。

ですが、現在のゲーム開発ではマテリアルを自由に作成できるものが多いです。
特にアプリ開発側が自由に作成できるような場合はUberシェーダが肥大化しすぎてまともに動作しないでしょう。

そこで、マテリアルの分類を行う必要があります。
マテリアルの分類はマテリアルごと、もしくはシェーダごとに行うことになりますが、どちらにしても分類と、分類されたそれぞれでマテリアル処理をしなければなりません。
その手法はいくつかありますが、今回のサンプルではUE5の手法と同様、深度バッファをハックするような形で実装しました。

まず、マテリアルインデックスを深度値へと変換し深度バッファに描画します。
サンプルではmaterial_depth.p.hlslがこれに該当します。

メッシュ描画時の深度バッファを参照している理由は、描画されていないピクセルを排除するためです。
描画されているピクセルはVisibility Bufferから参照したマテリアルインデックスをCLASSIFY_DEPTH_RANGEで除算したもの深度として出力しています。
この定数は最大のマテリアル数となりますので、深度バッファには0~1の値が格納されます。
この深度値は実際のマテリアル計算時に利用されます。

次に分類を行います。これはclassify.c.hlslが該当します。
分類はタイルごとに行います。タイルは今回は64x64で行っています。UE5もこのタイルサイズです。
タイル内の全ピクセルのマテリアルインデックスを取得し、これを共有メモリにフラグとして保存します。

すべてのピクセルに対してフラグ保存が完了したら存在するビットからマテリアルインデックスを戻し、それぞれのマテリアルインデックスの描画情報を書き出します。
この情報はインダイレクト描画用のDrawArgと、インスタンスごとのタイル位置を出力しています。
rwDrawArgが各マテリアルのインダイレクト描画用のDrawArgで、rwTileIndexが描画すべきタイルインデックスです。

分類はこれで完了です。
これから実際のマテリアル描画を行っていきます。
まずは頂点シェーダのmaterial_tile.vv.hlslを見てみましょう。

マテリアル描画は各マテリアルごとにインダイレクト描画を行います。DrawArgは分類時に生成しているものを利用しましょう。
各Draw Callでは描画すべきタイルをすべて埋めるような矩形描画を行います。
TileIndexバッファには描画すべきタイル番号が含まれていますので、ここから座標を生成できます。
上記シェーダの6~13行目までが対象の処理です。

このとき、深度バッファとしてmaterial_depth.p.hlslで描画した深度バッファを用います。
タイルの出力も同じ値を出力します。
パイプラインステート生成時に、深度テストをEqualのときのみパスするようにすれば、同一深度、つまりそのマテリアルのピクセル以外は処理されません。
それ以外のピクセルについては早期深度カリングによってピクセルシェーダの起動すらありません。実際に処理するピクセル数は最小限となるはずです。

material_tile.p.hlslでは実際のマテリアル計算を行います。
このシェーダは非常に長いため、全ては紹介しません。

最初にVisibility Bufferの取得などを行い、頂点座標を頂点バッファから取得します。
頂点座標が分かればBarycentricを求めることが可能です。そして他の属性についても各ピクセルでの値を求めることができます。

Barycentricを求める方法としてレイトレーシングと同様の方法を行うことが可能です。
つまり、ポリゴンとビューベクトルで衝突判定を行う方法です。
しかし、この方法はPerspective Correctを考慮していません。
実際この方法ではあまり良い結果が得られませんでした。
シェーダ内のkBaryCalcTypeを0にすることでこの手法を確認できます。

今回実装したもう1つの方法は以下の論文の実装です。

https://cg.ivd.kit.edu/publications/2015/dais/DAIS.pdf

Partial Derivativesを利用したこの手法はPerspective Correctを考慮したBarycentricを求めることができます。
Partial Derivativesを用いることで隣接ピクセルの座標から中間値を求めることができますので、UVのddx/ddyも求めることができます。

ただし、ニアクリップ面にポリゴンがかかっている場合に問題が発生します。
このような場合、ポリゴンをニアクリップ面でシザリングし、生成された新しいポリゴンで処理をするのが正しい処理です。
正しく処理する方法もあるとは思いますが、その分負荷が高くなることでしょう。
論文でも問題が出ることは把握しており、どのような対応を行ったかは詳しく書かれていません。
単純な方法を用いた、ニアクリップ面にかからないようにポリゴンを縮小した、というようなことが書いてあります。
必要なのはPartial Derivativesなので、これを計算できるならポリゴンを縮小してもいいということかもしれません。
今回のサンプルではこの問題への対応は行っていません。

コードはちょっと複雑ですので、詳細はサンプルのシェーダを確認してください。

追記(23/04/04)

Barycentricを求めるコードを変更しました。
このコードは以下のサイトで公開されたものを利用しています。

http://filmicworlds.com/blog/visibility-buffer-rendering-with-material-graphs/

こちらのコードを利用したところ、おかしなMipLevelが選択されることがなくなりました。
場所によって若干MipLevelが低めに選択されているように見えますが、さほど問題にはならないかなと思います。

また、この修正とともに以下の修正も加えました。

映像の結果

映像的な問題についてはCrytek Sponzaを利用した結果を見てみましょう。

Ray Intersectを利用した手法ではどうしてもジャギのような問題が出てしまったり、微妙なミップマップが求められたりしています。
Partial Derivativesでは布の模様はキレイに出ていると言えます。
しかし、壁のミップマップは微妙にずれていて、Partial Derivativesの方が高いミップを選択されているようです。
逆に低いミップを選択されている場所もあり、計算方法にミスが有るのかもしれません。

また、微妙なピクセルのズレがどちらの手法でも発生するので、ハードウェアで行われているラスタライザと計算が異なっているのだろうと感じます。

Meshletへの対応(23/10/09追記)

テスト的にIntel Sponzaを描画してみたところ、Visibility Bufferでの描画が正常に行われませんでした。
理由は簡単で、Draw Call単位(Submesh単位)のポリゴン数が2byteを超えてしまっていたためです。
Submeshのポリゴン数を制限する機能をメッシュコンバータに追加することも考えたのですが、Meshletにポリゴン数制限を加える機能をそもそも入れていたので、これを利用することにしました。
そして、折角なのでMeshletの視錐台カリングとバックフェースカリングを入れることにしました。
しかし、これを実装する上で問題が発生したので、サンプル用ライブラリの機能追加も行っています。

以前のバージョンでは32ビットのVisibility Bufferに対して、16ビットのDraw Callインデックスと16ビットのポリゴンインデックスを割り当てていました。
ポリゴンインデックスはシェーダ側でPrimitiveIDとして取得できるので問題ないのですが、Draw Callインデックスはシステムから取得できないので自前で設定する必要があります。
自前での設定は定数バッファを利用していたのですが、Meshlet描画になるとそういうわけにもいかなくなります。

Meshlet描画の場合は引数バッファを複数のMeshlet分だけ用意し、それをMulti Draw Indirectで描画します。
当然複数のMeshletが1度に描画されるので、事前に定数バッファを用意することが通常ではできません。
しかし、D3D12ではDraw Indirectに用いるCommand Signatureというものがあり、これはDraw以外にもいくつかの設定を行うことができるようになっています。
今回の更新でCommand SignatureでRoot Constantを設定できるように機能拡張しています。
新しいCommand Signature生成部分は以下のようにしています。

ここで注意が必要です。
Command Signatureを生成する場合、Draw IndirectやDispatch Indirectのみの場合は第2引数がnullptrでも問題ないのですが、Root ConstantやCBV/SRVなどを設定する場合は対象となるRoot Signatureが必要になります。
当然ですがRoot SignatureとCommand Signatureで設定されるRoot Constantなどが違っている場合は生成に失敗します。nullptrにしても失敗します。
本サンプルではRoot Signatureが非常に少ないため単純に実装できたのですが、Root Signatureがマテリアルごとにたくさんある場合はCommand Signatureもその分生成しないといけないのでちょっと不便ですね。

Root Constantを利用する場合、Draw Indirect用のバッファは以下のようにする必要があります。

Root ConstantはNum32bitValuesToSetで設定した数だけ確保する必要があります。
今回はDraw Callインデックス(というかMeshletインデックス)を設定するだけなので32ビット数値1つだけです。その後にDraw Indirect用の引数が20byteとなります。

また私のサンプルライブラリはRoot Constantを基本的に使用できませんでした。Dynamic Resourceの際に利用できるようにしていますが、従来のRoot Signatureでは使えません。
その部分を修正し、通常のRoot SignatureでもRoot Constantを使用できるようにしました。
制限としては、Root Constantはシェーダ単位で設定できず、パイプライン全体で使用する形になります。また、space1のb0でのみ利用できるようにしました。
これらの制限は今後変更される可能性もありますが、今のところはこれで十分だろうと考えています。

当然ですが、Meshlet情報がマテリアル描画時に必要になるため、そのあたりのデータ構造を変更しました。
詳しくはcbuffer.hlsliMeshletData構造体を参照してください。
特に難しいことはしていませんが、参照するバッファが増えてしまうのはあまりよろしくない印象がありますね。

その他、ちょこちょと不具合や扱いにくい部分の修正を行っていますが、そちらは特に重要ではないので解説はしません。

パフォーマンスについて

パフォーマンスは残念ながら、現状では良い結果を出せていません。
サンプルが単純で、通常のDeferred Renderingでもそれほど負荷にならないから、という部分があるかと思います。

以下に2種類のメッシュについて計測したNsight Graphicsの結果を掲載しておきます。

Crytek Sponzaはメッシュ1つを描画するだけです。マテリアル数は20ちょっと。
High Suzanneは6.2万ポリゴンのBlenderのお猿さんを1024個描画しています。マテリアル数は1つ。

GBufferはDeferred RenderingのGBuffer描画、VisibilityはVisibility Bufferの描画です。
どちらもメッシュ描画で、カリングなどは行っていません。
Sponzaの場合は頂点ネックではないため、Visibility描画のほうが高速です。
マテリアルは単純にテクスチャをサンプルするだけですが、より複雑なマテリアル計算が行われているならVisibility Bufferを使うほうが効率的かもしれません。

しかし、頂点ネックになっているSuzanneの方はVisibility描画のほうが圧倒的に遅いです。
Visibility Bufferはピクセルネックを解消するためのものなので高速にならないと言うならわかるのですが、ちょっと解せない結果です。
可能性としては、ピクセルシェーダのSMがまともに動作せず、ネックとなっている頂点側のSMがうまくスケジューリングできていないとかかもしれないです。
頂点ネックのエンジンではVisibility Bufferを導入する理由はあまりなさそうです。

Material Depthはタイル描画時の深度バッファを生成するパスです。
こちらは1440pで0.04msと、非常に高速です。
Classifyはマテリアルの分類ですが、こちらも問題にならない結果となっています。

しかし、やはりLightingとMaterial Tile(Visibility Bufferでのマテリアル処理とライティング)に差が出ています。
と言っても、マテリアル数の多いSponzaよりSuzanneの方が若干パフォーマンスが悪いです。
マテリアル数が増えても、意外とそこまでパフォーマンスが上がらないかもしれません。

なお、これらはRTX4080での計測結果なので、もっと一般的なGPUでは一般的な結果となるかもしれない点に注意してください。

また、Visibility Bufferは重いマテリアルの処理を遅延することで、無駄なヘルパーレーンの負荷を下げる効果が期待できます。
ddx/ddyの計算のため、ピクセルシェーダは2x2のクアッドで実行されます。
ヘルパーレーンはポリゴン単位でエッジ部分に生成されてしまうため、ポリゴンが小さくなればなるほど全体で起動したピクセルシェーダのうちのヘルパーレーンの割合が大きくなります。
ピクセルを描画しないレーンの実行割合が高いパスは可能な限り簡易なピクセルシェーダを実行すべきですが、Deferred Renderingでは重くなりやすいGBuffer描画が実行されるわけです。
Visibility Bufferは処理自体は単純ですので、GBufferパスより高速化しやすいわけです。

しかしながら、頂点ネックの場合はピクセルシェーダのヘルパーレーンはあまり関係がなくなります。
つまり、Visibility Bufferを採用してパフォーマンスを上げたいと思うのであれば、頂点ネックはご法度です。
ポリゴン数を抑える手法が必要になるわけですが、そのためにもクラスターカリングはぜひ入れたいところです。

UE5で実装されているようなソフトウェアラスタライザも悪い選択ではありません。
コンピュートシェーダを利用することで非同期コンピュートの利用も可能になります。

これらの実装もVisibility Bufferには必要になりそうです。

最後に

Visibility Bufferは今後のトレンドとなるかもしれないですが、通常のラスタライザを利用した描画は今のところなくなる気配はありません。
最適化という点では効果が低くなることもあります。映像的な問題も発生しがちです。

もちろん、UE5みたいな映像を自社エンジンで出したい!という要望もあるかもしれないです。
しかしUE5はかなりの期間にわたって研究してきたものの集大成です。
単純なVisibility Buffer実装がなぜかうまくいって超高速化した、ということはほとんどないと思います。

いきなりエイヤッと実装するのではなく、きちんとR&Dしてからにするべきでしょう。