前書き
この記事は、2023のUnityアドカレの12/24の記事です。
今年は、完走賞に挑戦してみたいと思います。Qiita君ぬい欲しい!
はじめに
GPUは別名、グラフィックアクセラレータとも呼ばれます。アクセラレータなので、独立して動くことはできず、常にCPUに手綱を握ってもらう必要がありました。しかし、GPUに命令するCPUやその間の伝送経路(PCIe)などにボトルネックが発生してしまうことがあります。GPUはひたすら並列数を持っていけばスループットが上がりますが、CPUや伝送経路は簡単に並列化することも難しく、クロックにも頭打ちがあります。
そこで、昨今では、GPU駆動レンダリングという考え方や機能が実装され始めました。GPUがどんな処理(どのShader)を何回実行するかということはCPUからコマンドで送っていました。DirectXのExecuteIndirect命令は、実行回数の部分にGPUのバッファを指定することができます。GPUのバッファはGPUで書き換えるものですので、実行回数をGPUでコントロールすることができます。あるいは0回にすれば、GPUの判断に基づいて処理を破棄することができます。
WorkGraph
WorkGraphでは、実行回数だけでなく、どのShaderを実行するか、実行するかしないかという部分までGPUに制御させてしまおうという、より完全なGPU駆動レンダリングの実現を目指したものです。
今回はこのWorkGraphについて理解できた部分をふんわりご紹介したいと思います。
歯切れが悪いですが、ミーハーなので飛びついたものの、私には難解でまともに理解するにはとても期限に間に合わないのです…😭雰囲気だけ感じ取っていただければと思います。また間違っていたらご指摘いただけると幸いです🙇♂️
基本思想
WorkGraphはグラフという名の通り、GPUのタスクをノードとして扱い、ノードからノードに処理が移っていくイメージです。公式ドキュメントにはこのように図示されています。
https://github.com/microsoft/DirectX-Specs/blob/master/d3d/WorkGraphs.md#basis
ここから想像できることは、GPUのタスクとしてHLSLのカーネルが存在し、それをノードとして指定し、カーネルのHLSLに別のノードをDispatchするような命令が書かれていそうな気がします。
ノードの宣言
これは単一のノードだけのシンプルなグラフのノードです。
RWByteAddressBuffer Output : register(u0);
struct InputRecord
{
uint3 DispatchGrid : SV_DispatchGrid;
uint index;
};
[Shader("node")] // このカーネルがノードであること
[NodeLaunch("Broadcasting")] // ノードの実行され方
[NodeMaxDispatchGrid(64, 64, 1)]
[NumThreads(1, 1, 1)]
void BroadcastNode(DispatchNodeInputRecord<InputRecord> inputData)
{
Output.InterlockedAdd(inputData.Get().index * 4, 1);
}
DispatchNodeInputRecord<T>
というのが、前のノードから引きついたパラメータのようです。この場合は前のノードはないのでCPU側から渡されます。CPUからは、ID3D12GraphicsCommandList::DispatchGraph
の引数D3D12_DISPATCH_GRAPH_DESC
で指定できるようです。
ノードをつなぐ
上記で、ノードの入り口についてはDispatchNodeInputRecord<T>
を使えばいいということがわかりました。それではノードが次のノードを起動するところはどうなっているのでしょうか?
RWByteAddressBuffer Output : register(u0);
struct InputRecord
{
uint index;
};
[Shader("node")]
[NodeLaunch("Broadcasting")]
[NodeDispatchGrid(1, 1, 1)]
[NumThreads(1, 1, 1)]
void FirstNode(
[MaxRecords(1)]
NodeOutput<InputRecord> SecondNode) // 次のノードのカーネル関数名と一致する必要がある
{
ThreadNodeOutputRecords<InputRecord> record = SecondNode.GetThreadNodeOutputRecords(1);
record.Get().index = 0;
record.OutputComplete();
}
[Shader("node")]
[NodeLaunch("Broadcasting")]
[NodeDispatchGrid(1, 1, 1)]
[NumThreads(1, 1, 1)]
void SecondNode(DispatchNodeInputRecord<InputRecord> inputData)
{
Output.Store(inputData.Get().index * 4, 1);
}
このように、カーネルの引数として、NodeOutput<T>
(次のノードのを起動するランチャーみたいなもの)を宣言し、関数内でGetThreadNodeOutputRecords
で謎の構造体(ThreadNodeOutputRecords<T>
)を取り出し、これをOutputComplete
することで、次のノードを起動しています。引数で宣言したNodeOutput<T>
の変数名で起動したいノードと紐づけるようです。(起動したいノードのカーネルの関数名と完全一致させる)
ノードの起動モード
ノードのカーネルにつけた属性に[NodeLaunch("Broadcasting")]
というものがありました。これにはいくつかの種類があります。
- Broadcasting
- Thread
- Coalescing
- Draw
- DrawIndexed
- DispatchMesh
公式ドキュメントにはそれぞれの違いが細かく書かれていますが、ここでは深入りはしないことにします。
ここまではComputeShader風でしたが、DrawやDispatchMeshがあることから、これを使ってグラフィックタスクを起動していくようです。ただし、これらは現在、まだ提案段階で未実装らしいです。
まとめ
物凄くザックリでしたが、なんとなくWorkGraphがやっていることのイメージがついたでしょうか?これが一般のゲーム内に広く利用されるようにはまだ数年はかかるとは思いますが、何かの参考になれば幸いです。