DirectXの話 第165回

Multi Draw Indirect

19/07/27 up

上の画像は決して壊れてるとかではなくて、こんな感じでカリングされてますよ~ってのを示しているだけですのでご注意ください。

サンプルはいつもの場所に。Sample016 が対象のサンプルです。

GitHub

Multi Draw Indirectとは?

今回実装してみた Multi Draw Indirect (以下MDI) というものについて解説します。

第132回 Draw Indirect ではインスタンス描画の高速化手法として Draw Indirect について解説をしました。

Draw Indirect 自体は D3D11 時代から存在する機能で、通常の Draw系命令 (DrawInstanced, DrawIndexedInstanced) の引数をバッファに収め、このバッファを指定することで描画を行う特殊な Draw命令です。

この手法の何が重要かというと、バッファ内の引数については CPU だけでなく GPU からでも設定が可能という点です。

つまり、引数バッファを GPU で更新して、その後に Draw Indirect で描画を行えば、Draw命令の発行自体は CPU からではありますが、どのようなパラメータで描画するかは GPU 側が決定できるわけです。

先のサンプルではこれを利用し、背景描画→インスタンスのオクルージョンカリング→カリングされなかったインスタンスのみ Draw Indirect で描画、という方法で高速化を実現していました。

フラスタムカリングならまだしも、オクルージョンカリングを CPU で実装するのはなかなか大変ですし、Predication を利用するなどした場合は実際の判定が数フレーム遅れたりしてしまいます。

1フレームで正確なカリングを行って、かつ無駄な Draw Call を行わない手法として、Draw Indirect は有用な方法です。

しかし Draw Indirect は Draw Call と1対1関係にあります。

Draw Indirect 命令による Draw Call 1回は通常の Draw系命令の Draw Call 1回に相当するわけです。

先のサンプルのようにインスタンス数と各インスタンスの姿勢行列を GPU で生成するだけなら難しくない上に効果が高いですが、カリングされないポリゴンのインデックスバッファを GPU で生成して Draw Indirect で描画というのはあまり現実的とは言えないでしょう。

実際、D3D11 の Draw Indirect 命令は DrawInstancedIndirect, DrawIndexedInstancedIndirect の2種類しかなく (DispatchIndirect は除く) インスタンス描画に特化していることがわかります。

インスタンス数を GPU で調整する、というのが Microsoft 的にも考えていたことなのではないかと思います。

ですが、OpenGL の拡張から、この Draw Indirect を拡張する形の機能が追加されました。

それが MDI です。

名前のとおり、Draw Indirect を複数回呼び出しができる命令と考えてください。

基本的な考えとしては、Draw Indirect で使用していた引数バッファを複数回の Draw Call に対応して、引数バッファサイズ x Draw Call数のバッファサイズを確保し、連続的に呼ばれる複数回の Draw Call に対してそれぞれの引数を割り当ててやるという感じです。

イメージとしてはこんな感じ。

MDI は指定された複数個の Draw Indirect 命令に展開されます。

そして、MDI 発行時に渡された引数バッファには、展開された Draw Indirect 命令が参照する引数情報が順番に格納されています。

あとは Draw Indirect 命令が連続で発行されるだけ、というものになります。

これによって何が実現できるのかというと、やろうと思えば1ポリゴンに Draw Indirect 1回を適用し、ポリゴン数分だけ MDI で Call、カリングされたポリゴンについては Draw Indirect の引数の描画インデックス数を 0 にすることで無駄なポリゴン描画を行わない手法が可能となります。

この手法は実際には効率が悪くなるのではないかと思われるので、実際にはポリゴンをある程度のセグメントで分割し、そのセグメントごとにカリング処理を走らせることになるかと思います。

D3D12の ExecuteIndirect

D3D11 では Indirect系命令は Draw系が2つ、Dispatch系が1つの計3つでした。

D3D12 では、Indirect系命令は1つに集約されています。

それが ExecuteIndirect です。

以下が ExecuteIndirect 命令のインターフェースです。

pCommandSignature はコマンドシグネチャで、これが Indirectで実行するコマンドを意味します。

詳しくは後述。

MaxCommandCount はコマンドシグネチャで指定されたコマンドを最大何回呼び出すかを指定します。

pArgumentBuffer, ArgumentBufferOffset は引数バッファのリソースと、実際に引数情報を引き出し始めるオフセットを示します。

pCountBuffer, CountBufferOffset はオプションのカウントバッファです。

コマンド実行回数は MaxCommandCount で示されますが、この方法ではコマンド実行回数は CPU 任せです。

実行回数を GPU から指定したい場合にこのカウントバッファを使用します。

ExecuteIndirect 命令は引数からもわかるように、コマンドシグネチャで定義されたコマンドを MaxCommandCount 回実行するという命令です。

コマンド定義自体はコマンドシグネチャ側に存在しています。

では、コマンドシグネチャの生成命令を見てみましょう。

重要なのは D3D12_COMMAND_SIGNATURE_DESC の内容であることはすぐに分かるのではないかと思います。

ByteStride はコマンドシグネチャ全体を1回実行するのに必要な引数バッファのストライドを指定します。

引数バッファを作成する場合はこの ByteStride x MaxCommandCount が最低サイズとなることに注意してください。

NumArgumentDescs, pArgumentDescs は引数という名前がついてはいますが、実際には発行するコマンド自体を示しています。

ここで指定できるコマンドについては D3D12_INDIRECT_ARGUMENT_TYPE を見るとわかりますが、Draw系、Dispatch系以外にも、頂点バッファ・インデックスバッファの切り替えや定数バッファ・シェーダリソースの切り替えもできたりします。

ただ、現在は Descriptor Table の切り替えはできませんので、第163回 DescriptorHeapのコピー戦略 で実装したDescriptorを使用する場合は使用できません。

また、シェーダリソースなどを切り替える場合は、コマンドシグネチャ生成時に Root Signature も指定しなければいけません。

今回はシェーダリソースの切り替えは不要なので、Root Signature の指定も不要です。

逆に指定してしまうとエラーとなるので注意してください。

実際のコマンドシグネチャ生成コードは以下になります。

今回は DrawIndexedInstanced 命令を複数呼び出したいわけですから、D3D12_INDIRECT_ARGUMENT_TYPE_DRAW_INDEXED を指定します。

この場合、引数バッファのサイズは D3D12_DRAW_INDEXED_ARGUMENTS 構造体のサイズ以上が必要となります。

メッシュの分割

今回のサンプルは今後追加される予定の Mesh Shader を意識しています。

Mesh Shader についてはまだ自分でも詳しく知らないので詳細は解説しませんが、その目的の1つは複雑化した頂点処理を単純化するためのものです。

現在の頂点処理は最も単純なものであれば頂点シェーダだけで済むのですが、最も複雑なものになると Vertex, Hull, Domain, Geometry の4つのシェーダを使うことになります。

しかもこの場合はテッセレータも動作しますので、ステージとしては頂点処理だけで5つです。

これを Mesh Shader に統一(正確には Task Shader と Mesh Shader の2つ)するのが目的です。

もう1つの目的はまさに今回やっていることです。

GPU へ投入される頂点数を極力無駄がないようにするのが目的です。

そのためにはメッシュを適切に分割し、Meshlet という単位でレンダリングすることになります。

とはいえ、Meshlet を複数作ってその数の Draw Call を現在の普通のシェーダステージで行うのは Draw Call 数的に現実的ではありません。

しかし MDI を使えば話は別。

通常1回の Draw Call を複数の Meshlet に分割したとしても ExecuteIndirect は1回です。

もちろん、カリング処理や引数バッファの生成などで GPU に負荷はかかりますが、その分投入頂点が減ってくれれば GPU の総合的な負荷は下がる可能性があります。

今回のサンプルでは Meshlet を GPU でフラスタムカリングするということをやっていますが、サンプルの Sponza 内を歩き回る分には多くの場合で MDI を使用した場合のほうが軽くなっています。

フラスタムカリングのみなので、Sponza 全体が描画されるような状況では流石に…ですが。

今回の Meshlet の作成は非常に単純で効率も悪いものとなっています。

サブメッシュのインデックスバッファを頭から調べていき、256トライアングルごとに1つの Meshlet としています。

最後の余り分は 128トライアングル以上であれば1つの Meshlet に、未満であれば前の Meshlet に追加です。

このような実装なので Sponza のインデックス割り振りに左右されてしまっていますが、見た感じかなり効率悪い形状になってます。

インデックス配列を綺麗にしたい場合はいくつかアルゴリズムがありますが、meshoptimizer というライブラリを使うのがいいのではないかと思います。

バージョン自体は 0.12 (2019/07/27現在) となっていますが、機能的には不満はなさそうな印象です。

Meshlet 1つは以下の構造体で表現されます。

aabbMin, aabbMax はAABBの最小・最大値です。これはカリング処理で使用されます。

indexOffset, indexCount はこの Meshlet がインデックスバッファのどこから、いくつのインデックスを使っているかを示します。

これはそのまま引数バッファに渡されます。

Meshlet 構造体は StructuredBuffer として使用されます。もちろん配列となっているので、各サブメッシュの Meshlet を1つの StructuredBuffer にまとめて、View でサブメッシュ単位に分けています。

カリングとMDIの実装

カリング処理は GPU でのフラスタムカリングのみです。

もともと CPU でのフラスタムカリングは行っていなかったので、MDI を使わない場合はすべての頂点が GPU に投入されます。

カリングは今回はグラフィクスパイプで、Z Pre Pass の前に行っています。

この結果は Z Pre Pass と Lighting Pass の両方で使用しています。

カリングシェーダは frustum_cull.c.hlsl で行っています。

サブメッシュごとにカリングシェーダを実行し、Meshlet のフラスタムカリングを行います。

カリングされた Meshlet は破棄、カリングされなかったものは引数バッファに積んでいきます。

今回はカウントバッファも使用しているので、カウントバッファは InterlockedAdd で Atomic なインクリメントを行い、取得した値をインデックスとして引数バッファに書き込んでいます。

詳しくはシェーダを読んでいただければ、短いコードなのですぐに分かることでしょう。

このシェーダ内ではカウントバッファはインクリメントするだけですので、カリングシェーダを起動する前の段階でカウントバッファをクリアするシェーダを動かしています。

MDI 自体はやはりサブメッシュごとに ExecuteIndirect を実行するだけなので、特に難しいことはありません。

Draw命令が置き換わるだけですので、パイプラインに変化はありません。

デバッグ機能として [Indirect Draw] のON/OFFで MDI と通常の Draw の切り替えが行なえます。

また、[Indirect Draw] がONの際に [Freeze Cull] をONにすると、その時のカメラ情報でカリング処理が行われます。

この状態でカメラを動かすと、ONにしたときにどのようにカリングされたかわかるでしょう。

それを見ると Meshlet の分割が効率悪いのを理解できるのではないでしょうか…

最終的にはオクルージョンカリングも実装したいところですが、Sponza だけだとオクルージョンカリングは意味ないんですよね…

問題やペナルティはないの?

まず、使用するバッファが増えます。

今回はカウントバッファを使用していますが、使用しなくても大丈夫なはずです。

ですが、引数バッファとカリング処理に必要な各種データを格納した Meshlet バッファは必要になるでしょう。

どの単位で分割すべきか、というのはなかなか難しいところです。

細かく分割しすぎると引数バッファも Meshlet バッファも増えてしまうでしょうし、MDI で実質的な Draw Call が増えることがネックになる可能性は否定できません。

この値は GPU やドライバに依存するんでしょうかね?

いわゆるスケルタルメッシュの場合はカリングに使用される AABB をどのように持つかも問題になりそうですね。

UE4のスキンキャッシュ的な手法を使って GPU でAABB計算してしまうというのもありっちゃありかもしれませんが、どうなんでしょうね?

まあ、人間キャラくらいなら分割しなくても問題なさそうですが、ドラゴンや巨像のようなでかいボスモンスターの場合は分割したくなりそうですね。

あと、大きな問題としてはハードウェアによってはサポートされてないものもあるかもです。

特にモバイルプラットフォームは注意が必要で、Vulkan API自体は MDI をサポートしていますが、Android端末すべてが対応しているかどうかは怪しいのではないかと思っています。

この辺はモバイルに詳しい人に聞いてみたいですね。