DirectXの話 第189

Mesh ShaderとVisibility Bufferと2-phase Occlusion Culling

23/11/25 up

これまでの軌跡

Mesh Shaderの記事は第171回、またAmplification Shader(以下Amp Shader)を利用してのフラスタムカリングは第172回でやりました。
2-phase Occlusion Cullingは第177回、Visibility Bufferは第182回でした。

今回はこれらの機能をフルに利用してどの程度パフォーマンスが向上するかを確認してみました。
なお、上の画像はNsight GraphicsでのGPU Traceの結果です。上が今回のMesh Shaderを利用したもの、下はDraw Indirectを利用したフラスタムカリングを利用したもの(これまでの実装)です。
今回バージョンは2-phaseの描画とHiZ生成をまとめた部分を選択して、このDurationが0.28ms、前回バージョンのVisibility Bufferパスのみで0.49msです。
前回バージョンはCompute Shaderによるカリング処理もありますが、こちらは0.01ms未満でした。

余談ですが、2-phase Occlusion Cullingを実装する前にフラスタムカリングのみのMesh Shaderを実装していますが、この段階でもDraw IndirectバージョンよりMesh Shaderバージョンのほうが高速でした。
Mesh Shaderは十分に実装する価値があると思います。GPUによるかもしれませんが。

Visibility Bufferサンプルに追加する形で実装していますので、以下のリポジトリを参照してください。

Visibility Bufferサンプル

Mesh Shader+Visibility Buffer+2-phase Occlusion Cullingは相性がいい

第177回で2-phase Occlusion Cullingを実装した際、結構面倒だなと思ったのはDraw Argument Bufferを用意する部分でした。
2-phase Occlusion Cullingを実装する場合、どうしてもメッシュの描画が2回になりますので、Draw Argument Bufferを2つ用意しなければなりません。
しかも第177回では通常のDeferred Renderingでしたので、Depth Pre PassとGBuffer Passの2回でメッシュを描画しています。
Occ CullingはDepth Pre Passで行っていますが、その結果をGBuffer Passでも利用していました。
結局、Occ Cullingの1st phaseと2nd phase、そしてGBuffer Pass用で3つのDraw Argument Bufferが必要になりました。
もうちょっと綺麗な実装方法もあるのかもしれませんが、単純な実装ではこのように多くのバッファが必要になります。

しかしMesh Shaderの場合、Draw Argument Bufferは不要です。
もちろん1st phaseで描画したMeshletを2nd phaseで描画しないようにするためのフラグが必要にはなるのですが、フラグだけで済むのでバッファサイズの削減になります。

しかもVisibility Bufferの場合は基本的にメッシュ描画が1回で済みます。
1st phaseで描画したもの、及びフラスタムカリングでカリングされたものは2nd phaseで処理する必要がないため、処理不要のフラグを立てるだけです。
もしDeferred Renderingのように2回の描画が必要な場合、フラスタムカリングによって描画不要とされたものか、描画が必要なものかを何らかの方法でチェックする必要があります。
と言ってもフラグが1つ増える程度で済むので、やはりDraw Indirectバージョンよりバッファサイズは減らせます。

Mesh Shaderでは頂点属性フェッチは自前で行う必要がありますが、これもまたVisibility Bufferと相性がいい理由です。
Visibility Bufferではマテリアル処理時にプリミティブの計算を行うため、頂点属性フェッチを時前で実装する必要がありますし、頂点バッファやインデックスバッファをまとめる必要があります。
これらの情報はMesh Shaderでも役に立ちます。実際、頂点バッファやインデックスバッファ周りはVisibility Bufferの処理から流用しています。

と、このように相性がいいと個人的には思うわけです。

Amp Shaderの実装

今回の2-phase Occ Cullingでシェーダに違いがあるのはAmp Shaderのみです。
Mesh Shader、及びPixel Shaderには違いがありません。
Amp ShaderがCompute Shaderの代わりと言えます。

visibility_mesh.a.hlslが該当シェーダとなります。ここで定義されている OCC_PASS_INDEX が 1 or 2 でパスを切り替えることができます。
1st phaseのコードは以下のようになります。

最初にフラスタムカリングとバックフェースカリングを行っています。
この段階でカリングされたものは 2nd phase で描画をする必要がないため、nonOccCull フラグを立てます。

カリングされなかったものはこの後に Occ Culling を行います。
ToScreenAABB関数はスクリーン空間のAABBを生成する関数ですが、trueが返ってくる場合はニアクリップ面に接触しています。
接触したものは描画対象となり、visible フラグを立てます。
ニアクリップ面に接触していないものは IsOcclusionCull関数でOcc Cullingを行います。
カリングされなければやはり visible フラグを立てます。
visible フラグが立っているMeshletは描画対象となります。

最後に描画フラグバッファを書き換えます。
描画するもの、もしくはフラスタムカリングされているものは 1 を、そうでないものは 0 を設定します。
なお、ただのフラグですので 1bit で十分なのですが、ビット演算の場合は InterlockedOr を使う必要があります。
これはAtomic命令ですので、パフォーマンスに少し問題が出るかもしれません。なので今回はフラグに 1byte を割り当てました。

次に 2nd phase のコードです。

フラスタムカリングの代わりに 1st phase で書き換えた描画フラグをチェックします。
また、ニアクリップ面に接触しているMeshletはすでに描画済みですので、スクリーン空間のAABBの計算後はそのまま Occ Culling をします。

これらの処理を行った後は 1st phase も 2nd phase も同様の処理となります。

Mesh Shaderの実装

Mesh Shaderの実装についても第172回と違いがあります。

まずトライアングルと頂点の最大数です。どちらも256程度まで増やしています。
現在のMesh Shaderはこれらを最大256としていますので、ここまで増やすことは問題ありませんが、これ以上は増やせません。
しかしMesh Shaderのスレッド数は128が最大ですので、1スレッドで最大2プリミティブ、2頂点を処理することになります。

出力する頂点情報は以下のようになります。

通常、Visibility Bufferの場合は頂点座標のみ(Maskedマテリアルの場合はUVも)ですが、今回は補間なしのMeshletIndexを出力しています。
Draw Indirectの場合はRoot Constantを利用してPixel Shaderに送ったMeshletIndexですが、Mesh Shaderの場合はそのような手法が利用できません。
そのためMesh Shader側からMeshletIndexを渡す必要があるため、頂点情報として渡すようにしています。

Mesh Shaderの出力は32k byteまでです。
Deferred RenderingのようにPixel Shaderに送る必要がある頂点属性は多くなる傾向にあります。
そのため、256頂点も送る場合は32k byteでは足りなくなるおそれがあります。
しかしVisibility Bufferでは頂点座標+MeshletIndexで20byte、UVを1つ追加しても28byteなのでだいぶ余裕があります。
この点でもVisibility Bufferと相性がいいと言えますね。

そしてもう1つ、前回の実装では存在しなかった出力があります。

out primitivesとして出力されている情報はプリミティブ単位の情報です。
Mesh Shaderパイプラインでは、通常のVertex Shaderパイプラインと違ってシステムが生成するセマンティクスが生成されません。
ここで問題となるのがVisibility Bufferで使用する SV_PrimitiveID です。
プリミティブID、つまりポリゴンインデックスがVisibility Bufferには必要なのですが、このセマンティクスが生成されないのは困ります。
一応、頂点座標に情報を持たせて送る手法もあるのですが、この方法は色々と事前準備が必要で面倒です。

しかしMesh Shaderはout primitivesを利用することでシステムセマンティクスを生成することができます。
今回は SV_PrimitiveID のみですが、他にも SV_ViewportIndexSV_ShadingRate なんかの設定も可能です。
ユーザー定義の情報も出力できるらしいので、MeshletIndexはこちらで出力してもいいかもしれません。

パフォーマンスについて

今回の実装でパフォーマンスは明らかに向上しています。
特にOcc Cullingが働きやすい状況では確実に結果が良くなっています。
Nsight GraphicsでGPU Traceの結果を比較すると以下のようになっています。

1stがMesh Shader、2ndがDraw Indirectです。
Mesh Shaderは通常の頂点処理を行わないため、PD/VAF Throughputが0%です。
テストで利用しているIntel Sponzaは結構ポリゴン数が多いため、Draw IndirectバージョンではPDが結構高い値を示しています。

L1TEXやL2が1stの方が高いのはHiZバッファへのアクセスによるものと考えます。

SMが高くなる要因は SM Warp Occupancy を見るとわかりやすいですが、Vertex Warpsが2ndの7倍近いです。
これはAmp ShaderとMesh ShaderのSMがここに入っているからだと思われます。
2ndでは別のCompute Shaderパイプラインで実行していた部分が入ってきているので高速化していると考えられますが、高めのPDの負荷がなくなったためもあると思われます。

他にもVPC、RASTER、ZROP、CROPなども軒並み高くなっており、PDによって制限されていた部分がうまく分散していると言えます。
パフォーマンスを改善する方法の1つはボトルネックとなっている部分を分散することです。
今回はそれがうまく回ったと言えるでしょう。

最後に

最近、『ALAN WAKE 2』がMesh Shaderを使っていることが話題となりました。
Mesh Shaderの検証自体は多くのメーカーが行っていると思われますが、製品に積極的に使っている作品は初めて聞きました。
Mesh Shaderを利用する場合、問題となるのは対応GPUです。
NVIDIAであればRTX2000番台以降、AMDであればRX6000以上となります。
実際、『ALAN WAKE 2』も最低動作環境がこれらのGPUでした。

Mesh Shaderベースのシステムを組むということは、これ以前のGPUを切り捨てることになります。
エンジンチームの規模が小さい場合、Vertex/Mesh Shader両対応はなかなか厳しいと思います。
そう考えるとMesh Shader実装は見送られがちなのではないかなぁ、と思います。
自分がTDとして描画チームを率いているのだとしたら、研究までしても実装までは見送るかなぁと。
その点でRemedyは思い切ってますね。

しかし、Naniteのような高ポリゴンシーンは今後必要になってくると思われますので、どこかのタイミングで転換する可能性があるのではないでしょうか。