DirectXの話 第145回

DirectX12事始め その3 15/08/28 up

予定ではこれとあと1回で終了。

わかりやすくちゃんと説明しようとするとこうなるという典型例。

わかりやすく説明出来てるかどうかは自分ではわからないですが。

今回は Descriptor, DescriptorHeap, DescriptorTable, RootSignature の4つを解説します。

この4つはけっこう大きい壁になると思いますが、理解してしまえばまた壁が存在する厄介なシロモノです。

理解したあと、リソース管理どうしよう…と頭を悩ませる事になるんじゃないかと思います。

Descriptor と DescriptorHeap については前回も少し解説しました。

前回は、Descriptor はDX11で言うところの View である、と書きましたが、MSのドキュメントを読んだ感じだと、どうやらDX11のViewも内部に Descriptor を抱えてたようです。

そしてやっぱりそっちでも DescriptorHeap に相当する部分に Descriptor をコピーしていたようです。

DX11では ID3D11DeviceContext::XSSetShaderResources() 命令などでViewを各シェーダのスロットに割り当てを行っていました。

このようにViewが切り替わると、DescriptorHeap 的なものに Descriptor をコピーし、これをコマンドとして流していたのではないかと思われます。

DescriptorHeap 的なものはメモリ確保していたのではないかと思われますが、あくまで想像ですね。

これも想像ではありますが、このような実装になっていたのはViewが破棄される恐れがあったからではないかと思います。

View、もしくはViewが抱えるDescriptorへのポインタでも処理的に問題があるわけではないのですが、GPUがそのViewを使用して描画を行っている最中にCPU側でViewを破棄されてしまうと困ってしまうからではないかと。

その手のミスでアクセスバイオレーションが発生したりメモリ破壊したりするのはマルチスレッドプログラミングあるあるですね。

DX11とDX12でDescriptorのサイズが同じかはわかりませんが、DX12ではSRVのDescriptorサイズは32バイトのようです。

32バイトのコピーは決して重くはないですが、DrawCallが増えればHeapの確保やコピーは重くのしかかってくることになるでしょう。

DX12でDescriptorと同Heapの管理がユーザ側になったのも、これらの処理を高速化することが目的だと思われます。

今回のサンプルでは、シェーダにバインドするリソースとして、定数バッファを1つ使っています。

頂点シェーダで頂点の位置を移動するためだけに使っているので、アクセスは頂点シェーダからのみ出来れば問題ないですね。

では、定数バッファを作成するコードを見ていきましょう。

・434行目

// 定数バッファを作成する

{

// 定数バッファ用のDescriptorHeapを作成

{

D3D12_DESCRIPTOR_HEAP_DESC desc = {};

desc.NumDescriptors = 1; // 定数バッファは1つ

desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; // CBV, SRV, UAVはすべて同じタイプで作成する

// DescriptorHeap内にCBV, SRV, UAVは混在可能

// DescriptorHeapのどの範囲をどのレジスタに割り当てるかはルートシグネチャ作成時のRangeとParameterで決定する

desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; // シェーダからアクセスする

hr = g_pDevice->CreateDescriptorHeap(&desc, IID_PPV_ARGS(&g_pCbvHeap));

assert(SUCCEEDED(hr));

}

// 定数バッファリソースを作成

// 実際の定数バッファはここに書き込んでシェーダから参照される

{

D3D12_HEAP_PROPERTIES prop;

prop.Type = D3D12_HEAP_TYPE_UPLOAD;

prop.CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN;

prop.MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN;

prop.CreationNodeMask = 1;

prop.VisibleNodeMask = 1;

D3D12_RESOURCE_DESC desc;

desc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;

desc.Alignment = 0;

desc.Width = 256; // とりあえず256バイトとっておく

// 定数バッファサイズは256バイトでアラインメントされる必要があるらしい

desc.Height = 1;

desc.DepthOrArraySize = 1;

desc.MipLevels = 1;

desc.Format = DXGI_FORMAT_UNKNOWN;

desc.SampleDesc = { 1, 0 };

desc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;

desc.Flags = D3D12_RESOURCE_FLAG_NONE;

hr = g_pDevice->CreateCommittedResource(

&prop, D3D12_HEAP_FLAG_NONE, &desc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&g_pConstBuffer));

assert(SUCCEEDED(hr));

}

// 定数バッファのDescriptorをHeapに設定する

{

D3D12_CONSTANT_BUFFER_VIEW_DESC desc = {};

desc.BufferLocation = g_pConstBuffer->GetGPUVirtualAddress();

desc.SizeInBytes = 256;

g_pDevice->CreateConstantBufferView(&desc, g_pCbvHeap->GetCPUDescriptorHandleForHeapStart());

}

// 定数バッファをマップしておく

g_pConstBuffer->Map(0, nullptr, &g_pConstBufferData);

}

最初にDescriptorHeapを作成しています。

RenderTargetの時とはTypeに指定しているものが異なります。

D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV は定数バッファ、シェーダリソース、UAVのためのHeapを作成するのに使用します。

DescriptorHeapにはこの3種類のDescriptorを同時に保存することが出来ます。Descriptorのサイズは同一と思われます。

Flags には D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE を入れてシェーダから見ることが出来るようにしておきます。

次に定数バッファそのものを作成します。

実際に定数バッファとして使用される、つまりシェーダが実行される時にGPUが見に行くバッファがこれです。

D3D12_HEAP_PROPERTIES::Type に D3D12_HEAP_TYPE_UPLOAD を指定していますが、これはCPUからもGPUからもアクセス可能なヒープ領域らしいです。

詳しくはこちらのサイトを確認いただければ良いかと思います。

また、定数バッファは256バイトでアラインメントされている必要があるらしいので、サイズは256の倍数を指定します。

今回は256バイトも使用しないので256を直接指定しています。

次はDescriptorHeapにこの定数バッファのDescriptorを作成します。

DescriptorHeapの先頭アドレスに対して CreateConstantBufferView() 命令で作成を行います。

ここで、D3D12_CONSTANT_BUFFER_VIEW_DESC::BufferLocation に定数バッファリソースのGPU仮想アドレスを渡しています。

このアドレスが、このDescriptorが示すリソースを置いてある位置ですよ、という意味になりますね。

これまでのDXでは完全に隠蔽されていたリソースのアドレスを指定してView的なものを作成する、というのはちょっと怖いものがありますね。

最後に定数バッファを Map() でマッピングしておきます。

ここで取得したアドレスが定数バッファの位置をまさに示しているわけですが、ここをGPUの描画中に変更すると描画が壊れたりする恐れがあります。

サンプルでは、定数バッファの書き換え→描画コマンドをキック→描画終了待ち、という流れになっているので適当に作ってはいますが、本来であれば安全を考慮した実装にするべきです。

例えば、定数バッファを2つ確保しておいて、1つが描画に利用されているうちにもう1つを更新、というバックバッファ的な使い方を検討すべきです。

まあ、コンシューマでやってる人は大半が経験済みでしょうけど。

さて、ここまでで定数バッファのための Descriptor が保存された DescriptorHeap が作成されました。

しかしこのままではシェーダのどこにこの定数バッファをバインドしてやればいいのかシステムはわかりません。

これを行うのが DescriptorTable と RootSignature です。

この2つの存在はかなりわかりにくいので、図と実例を交えて解説しようと思います。

まず、あるピクセルシェーダのことを考えます。

このピクセルシェーダは定数バッファを3つ、テクスチャを2枚、サンプラを1つ使用するものとします。

シェーダ内で使用されているレジスタは b0, b1, b2, t0, t1, s0 です。

DescriptorHeapはDescriptorを抱えたヒープでしかないので、このヒープとシェーダのレジスタをバインドしてやる必要があります。

そこで出てくるのがDescriptorTableです。

ここでは b0 がシーンのカメラやプロジェクションに関する情報を持っていて、b1, b2 はマテリアルに紐付けられた定数バッファだとしましょう。

t0, t1 もマテリアルに紐付いたアルベドマップと法線マップだとします。

s0 は2枚のテクスチャをサンプリングする際に使用するサンプラーですが、今回は簡単のためにこちらは考えません。

b0 に関してだけは寿命の違いからDescriptorHeapが別になっています。b1, b2, t0, t1 はマテリアルと寿命が同一なので1つのDescriptorHeapに入っています。

図にするとこんな感じ。

次にDescriptorTableを考えます。

前提として、DescriptorTableにはDescriptorHeapを1つしかバインドすることは出来ません。

DrawCallのたびにバインドするHeapを変更することは可能ですが、2つのHeapを同時に1つのDescriptorTableにバインドすることは出来ません。

ただし、Tableに1つもHeapをバインドしない、ということは可能です。クラッシュもしませんでした。

さて、DescriptorTableはRangeという情報を複数持っています。

このRangeという情報は、シェーダのレジスタ番号n番からx個のレジスタに、Heapのm番からのDescriptorを割り当てます、という情報です。

もちろんレジスタの種類が違えばRangeも違ってくるので、現在の例ではマテリアル用のDescriptorHeapに対して1つのDescriptorTableがあり、そいつがRangeを2つ持つことになります。

言葉で説明してもわかりにくいですね。下の図を見てください。

b0 にはシーン定数を割り当てる必要があるので、b0 から1つのレジスタに、シーンHeapの0番からのDescriptorを割り当てます。

b1, b2 にはマテリアル定数A, Bを割り当てる必要があるので、b1 から2つのレジスタに、マテリアルHeapの0番からのDescriptorを割り当てます。

同様に、t0 から2つのレジスタに、マテリアルHeapの2番からのDescriptorを割り当てます。

DescriptorTableというのはこれだけのものです。それほど難しくはないですよね?

ここまで理解できたらあとはRootSignatureだけです。

こいつは確かにわかりにくいところがありますが、わかってしまえばどうということはない…と思いたい。

DescriptorTableは1つで1つのHeapとシェーダレジスタをバインドしてくれますが、描画を行う場合は1つのDescriptorTableだけで処理できるとは限りません。

実際、上の例では2つのDescriptorTableが存在しているわけですから。

この、複数のDescriptorTableをまとめあげている存在、それが RootSignature です。

これだけなら何の事はない、DescriptorTableの配列みたいなものということになるわけですが、実際にはそれ以外にも機能を持っています。わかりにくいのがその部分じゃないかと思います。

まず、RootSignatureは作成する際に RootParameter という情報を複数定義する必要があります。

こいつは D3D12_ROOT_PARAMETER という構造体の配列として定義することが出来ます。

この RootParameter は1つで DescriptorTable 1つを定義することが出来ます。

わかりやすい例として今回のサンプルの該当箇所を見てみましょう。

・261行目

D3D12_DESCRIPTOR_RANGE ranges[1];

D3D12_ROOT_PARAMETER rootParameters[1];

ranges[0].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_CBV; // このDescriptorRangeは定数バッファ

ranges[0].NumDescriptors = 1; // Descriptorは1つ

ranges[0].BaseShaderRegister = 0; // シェーダ側の開始インデックスは0番

ranges[0].RegisterSpace = 0; // TODO: SM5.1からのspaceだけど、どういうものかよくわからない

ranges[0].OffsetInDescriptorsFromTableStart = D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND; // TODO: とりあえず-1を入れておけばOK?

rootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; // このパラメータはDescriptorTableとして使用する

rootParameters[0].DescriptorTable.NumDescriptorRanges = 1; // DescriptorRangeの数は1つ

rootParameters[0].DescriptorTable.pDescriptorRanges = ranges; // DescriptorRangeの先頭アドレス

rootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX; // このパラメータは頂点シェーダからのみ見える

// D3D12_SHADER_VISIBILITY_ALL にすればすべてのシェーダからアクセス可能

ranges が DescriptorTable が持つ Range です。

バインドするのは定数バッファの0番(b0) から1つのレジスタ(つまりb0のみ)に、バインドされたDescriptorHeapの0番からを割り当てます。

rootParameters が RootParameter です。

この RootParameter は DescriptorTable として使用し、Range は1つで ranges を直接渡しています。Rangeの数は1つです。

そして頂点シェーダからのみアクセス可能(つまり、読み取りが可能)です。

前述の例の場合はDescriptorTableが2つなのでRootParameterは2つになります。

と、これだけなら難しくはないのですが、D3D12_ROOT_PARAMETER::ParameterType というメンバを見てください。

サンプルコードではここに D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE を指定してます。

わざわざこのパラメータはDescriptorTableですよ、と指定するということは、つまり他にもパラメータとして指定できるものがあるということです。

存在するタイプは5つあり、残りの4つは 32BIT_CONSTANTS, CBV, SRV, UAV です。

この内、CBV, SRV, UAV の3つはDescriptorを直接指定することが出来るタイプです。

指定できるのは1つのDescriptorだけですので、1つのレジスタにしか設定できません。

32BIT_CONSTANTS は定数バッファレジスタに対して直接定数を設定するためのパラメータです。

D3D12_ROOT_PARAMETER::Constants.Num32BitValues で32ビット(4バイト)の定数をいくつ割り当てるかを指定します。

例えば float4 1本を割り当てたいのであれば 4 を指定することになります。

Table以外の指定方法がある理由は、Tableにするほどではないような小さなデータを扱いやすいとか、そもそも更新頻度が少ないので直接指定してもコピー回数が多くならないとかの理由があるかと思います。

Tableは、Tableが指定するシェーダレジスタの番号とHeapの位置は変更することは出来ませんが、TableがどのHeapを参照するかという情報はコマンドリストの命令で簡単に切り替えることが可能です。

この特性から、Tableの主な使い方はDrawCallごとに切り替えが多く発生するレジスタに対して有効です。マテリアルパラメータやテクスチャはその典型です。

逆に、1シーンで1回しか切り替わらないものはDescriptorを直接使用した方がHeapを余分に作成しない分、管理が簡単になりやすいです。

また、float4 程度の小さな定数であればわざわざ定数バッファを作成して面倒な管理をしなくても、32ビット定数を使用した方が簡単でしょう。

つまり、複雑に使用した場合のRootSignatureは下図のようになります。

DescriptorTableはRangeパラメータを経由して複数のレジスタにバインドすることが出来ますが、32ビット定数とDescriptorは直接1つのレジスタにバインドします。

RootSignatureの制限ですが、64DWORDまでとここに書かれています。

DescriptorTableは1つで1DWORD、32ビット定数も1つで1DWORD、Descriptorは1つ2DWORDとなっていますが、Descriptorは他のページでは4DWORDっぽく書かれています。

どちらが正解かはわかりませんが、多分4DWORDが正解かと。

最終的にすべてDescriptorTableで対応しても構いませんが、DescriptorTableはDescriptorHeapを必要としますので、どんなに小さな単位でもHeapの管理を行わなくてはいけません。

DescriptorHeapは特に今までにない概念で、リソースの管理をどの単位で行っていくべきかが重要になるかと思います。

64DWORDなので、すべてTableで対応すると、Tableすべてが1つのDescriptorしかバインドしないとしても64個のDescriptorをバインド出来るということです。

サンプラレジスタも含めて64個もレジスタ使うという状況はあまりないんじゃないかと思うのですが、どうでしょう?

なので、1つのHeapに1つのDescriptorで、1つのTableで1つのレジスタにバインド、ということを徹底しても機能的には問題ないと思います。

問題は速度的にどうなるか、ですね。こればっかりや試してみないとわかりません。

効率のよい使用方法は、1つのHeapに同じ寿命のDescriptorを複数抱えさせて、これを1~のDescriptorTableでバインドする、という方法のはずです。

このため、リソースは複数の同じ寿命のものを1つのHeapと結びつけて管理する必要があります。

リソース1つが複数のViewを持って、シェーダごとに任意の場所にバインドする、というDX11のやり方はDX12では効率が悪いやり方になるはずです。出来ないわけではないですが。

リソースとDescriptorの管理手法についてはまだ手探り感がありますので、このやり方が一番!という方法がありましたら教えていただきたいです。

では、最後にコードを見ていきます。

RootSignatureの作成はRootParameterを設定した直後です。

・275行目

D3D12_ROOT_SIGNATURE_DESC desc;

desc.NumParameters = _countof(rootParameters);

desc.pParameters = rootParameters;

desc.NumStaticSamplers = 0;

desc.pStaticSamplers = nullptr;

desc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;

ID3DBlob* pSignature;

hr = D3D12SerializeRootSignature(&desc, D3D_ROOT_SIGNATURE_VERSION_1, &pSignature, nullptr);

assert(SUCCEEDED(hr));

hr = g_pDevice->CreateRootSignature(0, pSignature->GetBufferPointer(), pSignature->GetBufferSize(), IID_PPV_ARGS(&g_pRootSignature));

assert(SUCCEEDED(hr));

D3D12_ROOT_SIGNATURE_DESC::Flags は、描画に使用する場合は必ず D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT を立てておきましょう。

グラフィクスパイプラインで使用する場合は頂点の入力が必要になるはずなので、これが立っていないとパイプラインステートオブジェクトの作成で失敗します。

他のフラグとしては指定のシェーダからのアクセスを禁止するフラグがあります。

あまり使わない気がしますが、使用しないシェーダからのアクセスを禁止しておいた方が高速に動作したりするんですかね?

desc の設定が終わったら D3D12SerializeRootSignature() 命令でRootSignatureを作成するのに必要なバッファを確保し、Tableの情報をシリアライズします。

このバッファを指定して、CreateRootSignature() でRootSignatureを作成します。

使用は比較的簡単です。

・561行目

// ルートシグネチャを設定

g_pCommandList->SetGraphicsRootSignature(g_pRootSignature);

// DescriptorHeapを設定

g_pCommandList->SetDescriptorHeaps(1, &g_pCbvHeap);

g_pCommandList->SetGraphicsRootDescriptorTable(0, g_pCbvHeap->GetGPUDescriptorHandleForHeapStart());

まずは SetGraphicsRootSignature() でグラフィクスパイプラインのRootSignatureを設定します。

次に SetDescriptorHeaps() メソッドで描画に使用するすべてのDescriptorHeapを設定します。設定し忘れるとアクセスバイオレーションが発生するようです。

最後に SetGraphicsRootDescriptorTable() でGPU側から見たアドレスを持ったハンドル(というか、それしか持っていない)を渡します。

このメソッドで使用するHeapを渡してるのですが、SetDescriptorHeaps() は別に呼ばなければいけません。まあ、コマンド的な意味なんでしょうけど。

なお、DescriptorHandleはアドレスを任意に移動することが出来ますので、それを利用して割り当てるHeapの番号をずらすことも可能だったりします。

例えば、ローカル-ワールド変換行列を持った定数バッファのDescriptorが複数入ったHeapを利用する場合、1回のDrawCallごとに使用するDescriptorの開始位置をずらすことでDrawCallごとに別の位置に描画することが出来たりします。

そういう使い方をするのであればインスタンシングした方がいいですけどね。

DescriptorTable以外を設定する場合は、SetGraphicsRoot32BitConstant()SetGraphicsRootConstantBufferView() などを利用します。

機会があれば使ってみたいと思います。

以上、RootSignatureなどの解説でした。

だいぶわかりやすくなったと思いますが、それでもわからないという方はどこがわからないかお伝えいただければ解説を加えます。

次回はパイプラインステートオブジェクトなどの残った部分を解説してやっと事始めが終了です。

それでやってることが三角ポリゴン1つを描画するだけとか…