DirectXの話 第171回

Mesh Shader

20/07/04 up

Windows10の最新バージョン2004からDirectX12 Ultimateが使用できるようになりました。

一応DirectX12をXboxとWindowsで共通に使用するためのものとしての位置づけらしいですが、低レイヤー側の人間としては口だけは達者だなwwwくらいの感覚です。

私としては新しい機能の追加がメインで、しかしこれらの機能がうまく使えば高速化に寄与することは間違いないと思ってる、くらいなものです。

そんな新機能の中でも注目度が高いのが今回試したMesh Shaderです。

Mesh Shaderは肥大化した頂点シェーダ周りを刷新する機能として注目されていてVulkanにはすでに拡張としてですが提供されていました。

これがDirectX12 Ultimateでやっと使えるようになったわけです。

今回のサンプルはいつも通りGitHubにコミットしています。

Sample024が対象となりますので、こちらをご覧ください。

GitHub

実行する場合の注意ですが、Windows10のバージョンを2004にアップデート、NVIDIAのドライババージョンを450以上にアップデート、WindowsSDKも最新のものをインストールしてください。

また、Visual Studioも2019を使用しましょう。

頂点パイプラインの刷新

現在、頂点パイプラインは無駄に肥大化しています。

普通に描画するだけなら VS-PS のパイプラインで対応できますが、頂点の増減をしたい、テッセレーションを使いたいという場合は GS, HS, DS を使用しなければなりません。

パイプラインの組み合わせは、VS-PS, VS-GS-PS, HS-VS-DS-PS, HS-VS-DS-GS-PS の4種類あり、その割に使用するのはほとんどが VS-PS パイプラインのみでしょう。

GSがないと困る!とか、テッセレーションから離れられない!という人は極少数ではないでしょうか?

正直な話、自分も最近GSとかテッセレーションとか使ってないです。一応、インハウスエンジンでは使えるようにしましたが。

もちろん、使うことがないというわけではないのですが、パイプラインごとにいろいろ制約があったりと結構面倒なわけです。

某エンジンとか某プラットフォームだけ使えない機能とかあったりしましたし、結局さほど使わない機能の割に制約や気をつけなきゃいけないことが多かったりするわけです。

それでもまあ高速に動作するのであればいいのですが、使った方が遅くなったりも割とあったりで、できるだけ使わない方向で実装を行うようにしていました。

そんな感じで肥大化してる割に使わないという宝の持ち腐れ状態になってきてたわけです。

GPU駆動レンダリングへ

そんな微妙な機能よりも確実に高速化につながる機能が頂点パイプラインには求められていました。

それがGPU駆動レンダリングです。

高精細な現代CGの映像を制作する上では描画するメッシュ量は当然多くなります。結果として大量の頂点を処理しなければならないわけです。

CPUで可能な限りカリングするのは当然としても、CPUで可能なカリングはメッシュ単位がいいところです。ポリゴン単位でカリングするのは無理という話。

ではメッシュを細かく分ければカリングもできるかというと、それはそれでCPUに大きな負荷をかけることになります。

なによりフラスタムカリングならともかく、オクルージョンカリングは大量に実行するのは難しいですし、CPUでの実装はたいてい数フレーム遅れます。

そもそも、レンダリングするかしないかをCPUで判断するのは限界があるのです。

メッシュを細かいクラスターに分けてそれぞれでカリングすることができるだけでもだいぶ処理する頂点数は減るわけですが、CPUでこれを処理するのは大量すぎて難しいでしょう。

そんな、CPUでは難しいこともGPUなら簡単な場合も存在します。単純計算を大量にするような、例えばクラスターごとにカリングするとかね。

GPUがレンダリングするかしないかを判断できるのにレンダリング命令自体は発行できないというのでは片手落ちです。

判断したならそのまま命令を出してしまうのがシンプルでわかりやすいわけです。

それこそがGPU駆動レンダリングの求めるところですね。

それが現実的になったのがインダイレクト描画でした。

通常、CPUから描画命令を発行する場合は描画するトライアングル数などを予め指定する必要があったわけですが、インダイレクト描画はGPUが読み取ることができるバッファにトライアングル数などを書き込むことで、GPUがそのバッファからパラメータを読み取って描画命令を発行してくれます。

実際には描画命令自体はCPUが行いますが、そのパラメータはGPUが提供するという形です。

これができるなら、パラメータにトライアングル数0を入れておけば、描画命令は発行したけどGPUは何もしないで終了、ということができるわけです。

第165回で実装した Multi Draw Indirect はまさにこれです。

Mesh Shader…それは、Vertex ShaderとCompute Shaderを組み合わせたまったく新しいShader!

うおぉぉぉぉぉぉ!!!!!

というわけで、Mesh Shaderの登場です。風雲拳と似てますね。

Mesh Shaderの基本は前述のインダイレクト描画をCompute Shaderを使わずそのまま1つのパイプラインでやってしまおうという話なのです。

インダイレクト描画は2パスに処理を分けなければならず、しかもいちいち情報をバッファに書き出さなければなりません。

処理の流れとしては冗長と言われても仕方ない状態です。

Mesh ShaderはCompute Shaderで計算、バッファへの出力、頂点シェーダ起動、頂点シェーダ処理という流れを1つで行うシェーダです。

シェーダコードを見るとわかりやすいですが、非常にCompute Shaderに似ています。

エントリーポイントとなるメイン関数を見てみるとわかりやすいでしょう。

思い切りCSっぽい NumThreads の記述があり、メイン関数の入力は頂点情報ではなくスレッドIDやグループIDです。

違いは OutputTopology という出力トポロジー(上記の例ではトライアングル)と、出力するインデックス情報と頂点情報です。

Mesh ShaderではCSのように描画情報をバッファに出力する必要はないと前述しました。

しかし、Mesh Shaderのスレッドグループは関数の出力からもわかるようにトライアングルのインデックスと頂点を出力します。

これらの情報はラスタライザに持ち込まれるわけですが、その前段階で出力を保持しておく必要があります。

このバッファはユーザーが指定する必要はありませんが、CSでいうところの共有メモリを使用することになります。

共有メモリはスレッドグループ単位で最大32kB割り振られるので、この数値以内なら使用できるということになります。

つまり、Mesh ShaderはMesh Shader自体がコード内で使用する共有メモリと出力インデックスのバッファサイズ、そして出力頂点のバッファサイズの合計が32kBの範囲に収められなければならないというわけです。

これはつまり、スレッドグループ1つで1つのメッシュを描画するのが困難ということでもあります。

そのため、Mesh Shaderを使用する場合はカリングをするかしないかにかかわらず、スレッドグループごとに処理可能なサイズにメッシュを分割しなければならないというわけです。

この分割した単位が Meshlet と呼ばれます。

Mesh Shaderを使用する場合はどうしても Meshlet 分割を行わなければならないので、普通にVertex Shaderを使うより面倒が増えます。

まあ、どうせ面倒が増えるなら Meshlet ごとにカリングした方がよっぽど有意義です。というか、しないと速度が出ません。

もう1つVertex Shaderと比べて面倒なのは Input Layout が存在しない点です。

Input Layout が存在しないなら Pipeline State を生成する場合には楽ができるとも言えますが、いわゆるパックされた頂点情報をアンパックするのもユーザーにゆだねられるということでもあります。

32ビット整数で頂点カラーを表現したりした場合、Input Layout を使用すれば頂点シェーダ内では自動的に入力頂点カラーが float4 に変換されていましたが、これが使えなくなるということです。

ノーマルやタンジェントも処理コストなどの意味でパックすることが多いですが、これらもアンパックは自前でやらなければいけないということになります。

頂点単位の情報なのかインスタンス単位の情報なのかという部分も当然自前で対応しなければなりません。

今まで意識しなくてよかった部分を意識しなければならないというのはちょっと面倒ですね。

それでも、パイプライン1つでGPU駆動レンダリングが可能になる、というは大きなアドバンテージになると思います。

実際のシェーダ処理を見てみる

今回のサンプルはSample021の Multi Draw Indirect を Mesh Shader に書き換えているものです。

以前のサンプルはインダイレクト描画を行うかどうかという設定が行えましたが、今回はフラスタムカリングをするかしないかという設定を変更できるだけで、内部の処理はすべてMesh Shaderに統一しています。

Mesh Shaderを使用しているのは Z-pre-pass と Lighting-pass の2か所で、どちらも同じカメラから見た同じメッシュを描画しています。

以前のサンプルではフラスタムカリングを1回CSで行って、その結果からそれぞれのパスでインダイレクト描画を行っていました。

今回は各パスごとにMesh Shader上でフラスタムカリングを行っているので、少し冗長かもしれません。

Mesh Shaderでレンダリングを行う場合、スレッドグループごとに頂点とインデックスを出力することはすでに述べましたが、どの頂点を利用するかというインデックス情報はローカルなものとなります。

つまり、Mesh Shader が Meshlet ごとに出力する頂点バッファに対するインデックスを出力しなければならないということになります。

前出のメイン関数の場合、出力される頂点は最大64個なので、出力されるインデックスは0~63になるわけです。

これに対応するために通常のインデックスバッファの代わりとして2つのバッファを用意しました。

パック化プリミティブバッファと頂点インデックスバッファです。

前者はまあいいとして、後者はなにかもうちょっといい名前にしたいところですが、意味合い的には間違ってないので何ともという感じです。

関係性がちょっとわかりにくいので図にしてみました。

まず、頂点バッファは今までと同様です。

図ではわかりやすくインターリーブ頂点バッファで示していますが、属性ごとに分けてあっても構いません。

最終的にはシェーダリソースとしてシェーダに送られます。

頂点インデックスバッファはこれまでのインデックスバッファと似ていますが、根本的には違います。

Vertex Shaderで使用されるインデックスバッファはそれそのものがプリミティブ(トライアングルとか)を形成していますが、頂点インデックスバッファは Meshlet ごとの頂点バッファを作成するためのインデックスバッファです。

パック化プリミティブバッファは要素1つが32ビット整数ですが、その中に10ビットで3つのインデックスを格納しています。

つまり、32ビット整数1つで1プリミティブ(1トライアングル)を形成しています。

Vertex Shader用のインデックスバッファは16ビット、もしくは32ビット整数が必要になりますが、Mesh Shader の出力として必要なのは Meshlet 単位で出力される頂点バッファに対するインデックスバッファです。

64頂点しか存在しないバッファなら10ビットもあれば十分おつりが来ます。

Mesh Shader が32kBのサイズしか出力できないわけですから、10ビットあれば確実に対応できるので、32ビットに1プリミティブをパック化しておくのは理にかなっています。

最終的に出力されるのは、頂点インデックスバッファで指定された頂点データを集めた頂点バッファと、パック化プリミティブバッファからuint3に変換されたトライアングルのインデックスバッファとなります。

出力するバッファは頂点なら out vertices、インデックスなら out indices の修飾をつけます。

Mesh Shader が出力するインデックスや頂点を計算する前に SetMeshOutputCounts() という関数を呼んでいます。

この関数はMesh Shaderが出力する頂点数とプリミティブ数を指定する関数で、Mesh Shader は必ずこの関数を呼ばなければなりません

そして、この関数を複数呼び出すのは禁止です。例えば Meshlet がカリングされたから呼び出さないでリターンするとか、カリングされた場合の分岐で引数を0指定してリターンするとかはNGです。

たとえその処理が Meshlet 内で同じ分岐を通るようになっていたとしても、[branch]をつけて動的分岐をしようとしたとしても、複数回の呼び出しや1回も呼び出ししないというのはダメなようです。

また、同じスレッドグループ内で別の値を出力するのも多分ダメです。

サンプルではグループスレッドIDが0番の時のみフラスタムカリングの計算を行い、その結果をグループ共有メモリに書き込み、グループの同期を行った後にカリング結果を見て、これに応じて SetMeshOutputCounts() 命令を呼び出すようにしています。

それ以外の部分はわかりにくいことはないと思います。

スレッドごとに1プリミティブの出力、及び1頂点の出力を行っています。

スレッドを無駄にしないためにも1スレッドが1つだけ出力するようにした方がよいでしょう。

C++コードの実装を見てみる

C++コード側にももちろん修正は入れています。

まずは Root Signature ですが、今回は Mesh Shader の場合にだけ Root Signature 1.1 を使用するようにしています。

多分これは不要な処理と思われますが、PIX for Windows の最新版で Root Signature をチェックした時に Shader Visibility が INVALID と表示されていたので、1.0じゃダメなのかと思ってバージョンアップしました。

1.1は使い方次第では高速化できるらしいのですが、Descriptor のコピー戦略をとる場合はたぶん恩恵を受けられません。

Root Signature で変更したのは Shader Visibility と Deny 系のフラグです。

これらは Mesh Shader と Amplification Shader の分が追加されていますので、そちらを利用するように修正しました。

また、パイプラインが従来のグラフィクスパイプラインと別なので、Root Signature も Mesh Shader パイプライン用に生成するようにしています。

もちろん、それに合わせて Descriptor コピー部分も変更しています。

大きな変更は Pipeline State の生成部分です。

これまでは D3D12_GRAPHICS_PIPELINE_STATE_DESC 構造体に情報を書き込んで Pipeline State を生成していましたが、Mesh Shader パイプラインの場合はこの構造体に相当するものが存在しません。

この構造体の代わりに D3D12_PIPELINE_STATE_STREAM_DESC 構造体を使用しています。

こちらの構造体は Pipeline State 生成に必要な情報をユーザーが取捨選択することができるもので、使用には一手間かかりますが、例えば VS, PS しか使わないから他のシェーダバイナリは設定として含めない、とかができるようになります。

まあ、でも、できたから何がうれしいかと言われてもさほどうれしくはないんですが。

Mesh Shader の場合はこれを使用しないといけないので全面的にこちらに刷新しています。

また、注意点として、Mesh Shader パイプラインを生成する場合は Input Layout を指定してはいけません。必ず nullptr を設定しましょう。

レンダリング時の変更点は頂点バッファ、インデックスバッファを設定しなくなったことでしょう。

正確には設定するのですが、専用命令での設定ではなくシェーダリソースとして設定します。

描画命令は DispatchMesh() 関数となります。もう DrawCall って言えなくなりそうですね。

引数は Dispatch()DispatchRays() と同様にXYZのスレッドグループ数を指定することになります。

ほとんどの場合はXのみを指定することになるとは思います。

なんか描画できない!どうやってデバッグするの!?

残念ながら、現在の段階ではデバッグがしづらいです。

PIX for Windows は DispatchMesh イベントに対応していますが、描画メッシュのワイヤーフレーム表示は対応していないようです。

イベントとシェーダ、シェーダリソースやステート情報は見られるので、そちらでデバッグするのが今のところはいいでしょう。

NVIDIA Nsight Graphics は 2020.3.0 ではまだ対応していません。

キャプチャはできますがイベントが対応していないため何かやってるけどわからん状態になります。

GPU Traceも同様ですが、頂点ユニットが動作しているのはチェックできます。

自前のPIXイベントを組み込めばそのイベントごとにチェックはできそうかな?

もしくはコマンドリストを分けるかですね。

もうしばらくはデバッグが大変になるんじゃないかな、とは思います。

これからMesh Shaderに切り替わっていくの?

Mesh Shader パイプラインは従来のグラフィクスパイプラインを置き換えるものであるのは否定できないです。

とはいえ、すぐに切り替わるというのはないでしょうし、従来のパイプラインはしばらくは残ると思います。

特にマルチプラットフォーム対応のエンジンを開発しているところはすぐにMesh Shaderに飛びつく!ってわけにはいかないと思います。

Mesh Shaderが使えないプラットフォームや、PCでもハードによっては使えないですし、いまだにD3D11も現役です。

使用可能なプラットフォームやハードウェアが限定されている技術というのはエンジン開発側からすると結構面倒です。

自分がエンジン開発してるのであれば、とりあえず使わないで Multi Draw Indirect で対応します。

とはいえ、GPU駆動レンダリングは確実に主流となっていくと思っています。

Unreal Engine 5も明らかにGPU駆動レンダリングによる恩恵を受けていますし、その方が利点が大きいので。

その中で Mesh Shader への移行も進むかもしれませんが、すぐということはないでしょう。

でもまあ、新しい技術って楽しいよね、とは思うので、これからも使っていきたい所存。