DirectXの話 第144回
DirectX12事始め その2 15/08/23 up
というわけで その1 からの続きです。
すでにいっぱいいっぱいですが、なんとか頑張ります!
デバイスを作成したら今度はスワップチェインを作成します。
この流れはDX11と変わりません。
・151行目
// スワップチェインを作成
{
DXGI_SWAP_CHAIN_DESC desc = {};
desc.BufferCount = 2; // フレームバッファとバックバッファで2枚
desc.BufferDesc.Width = kWindowWidth;
desc.BufferDesc.Height = kWindowHeight;
desc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
desc.OutputWindow = g_hWnd;
desc.SampleDesc.Count = 1;
desc.Windowed = true;
IDXGISwapChain* pSwap;
hr = factory->CreateSwapChain(g_pCommandQueue, &desc, &pSwap);
assert(SUCCEEDED(hr));
hr = pSwap->QueryInterface(IID_PPV_ARGS(&g_pSwapChain));
assert(SUCCEEDED(hr));
g_frameIndex = g_pSwapChain->GetCurrentBackBufferIndex();
pSwap->Release();
}
スワップチェインの作成はそれほど難しいものではありません。
バッファのサイズとフォーマットを指定して IDXGIFactory4::CreateSwapChain() メソッドで作成するだけです。
作成後に QueryInterface() を行っていますが、g_pSwapChain が IDXGISwapChain3 のポインタであるためです。
IDXGISwapChain3 にする理由はその直後の GetCurrentBackBufferIndex() 命令を持っているのが IDXGISwapChain3 だからです。
これが何を意味するのかはあとで解説します。
次に難関の1つ、DescriptorとDescriptorHeapを作成します。
こいつはDX12をやる上での壁になること間違いなしですが、今のところはできるだけ簡単に解説します。
今後、RootSignatureの解説時により詳しく解説しようと思いますが、RootSignatureの解説に自信がない…
それはともかく、ソースコードを見て行きましょう。
・176行目
// スワップチェインをRenderTargetとして使用するためのDescriptorHeapを作成
{
D3D12_DESCRIPTOR_HEAP_DESC desc = {};
desc.NumDescriptors = 2; // フレームバッファとバックバッファ
desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; // RenderTargetView
desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE; // シェーダからアクセスしないのでNONEでOK
hr = g_pDevice->CreateDescriptorHeap(&desc, IID_PPV_ARGS(&g_pRtvHeap));
assert(SUCCEEDED(hr));
g_rtvDescriptorSize = g_pDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
}
// スワップチェインのバッファを先に作成したDescriptorHeapに登録する
{
D3D12_CPU_DESCRIPTOR_HANDLE handle = g_pRtvHeap->GetCPUDescriptorHandleForHeapStart();
for (int i = 0; i < 2; i++)
{
hr = g_pSwapChain->GetBuffer(i, IID_PPV_ARGS(&g_pRenderTargets[i]));
assert(SUCCEEDED(hr));
g_pDevice->CreateRenderTargetView(g_pRenderTargets[i], nullptr, handle);
handle.ptr += g_rtvDescriptorSize;
}
}
まずはRenderTarget用のDescriptorHeapを作成し、その後にスワップチェインの2枚のバッファ用のDescriptorをDescriptorHeapに登録しています。
DescriptorとはDX11でいうところのViewと考えるのが一番理解しやすいと思います。
例えば、今回のスワップチェインはバッファ数を2で作成しています(154行目)。
このバッファというのはもちろんバックバッファとフレームバッファとして利用するカラーバッファなのですが、バッファというのはそもそも確保されたメモリ空間でしかありません。
ただの void* を渡されたとすると、そもそもそいつは int なのか float なのか、テクスチャ用のバッファなのか頂点バッファなのかさっぱりわかりません。
DX11でも ID3D11Resource インターフェースは実際にはテクスチャなのか頂点バッファなのかさっぱりでしょう。
DX11でこのリソースが何を意味しているのかを指定していたのが RenderTargetView や ShaderResourceView といった View でした。
DX12ではこのViewとなるオブジェクトが存在しませんが、その代わりにDescriptorという、そのバッファが何を示すものなのかを記述したデータが存在しています。
そしてこのDescriptorはDescriptorHeapと呼ばれるヒープにコピーしておく必要があります。
DX12ではDescriptorを直接コマンドに積むのではなく、DescriptorHeapのここにDescriptorが存在するからこいつを使え、という形で指定する必要があります。
ここでまたDescriptorTableやらRootSignatureやらが出てくると混乱の元ですし、RenderTargetとして使用するだけならそれらはいらないのでここで解説を一旦やめます。
重要なのは、そのバッファが何を示すのかを記述したDescriptorをDescriptorHeapに登録しておく必要がある、ということです。
前述コードの前半はDescriptorHeapを作成しています。
スワップチェインのバッファが2つなのでDescriptor2つ分のヒープを作成します。
このヒープはRenderTargetViewとして利用するので、D3D12_DESCRIPTOR_HEAP_DESC::Type は D3D12_DESCRIPTOR_HEAP_TYPE_RTV を指定します。
この設定で CreateDescriptorHeap() 命令を使えばDescriptorHeapが作成できます。
次にDescriptorをDescriptorHeapに登録します。
まずはDescriptorHeapの開始位置のハンドルを GetCPUDescriptorHandleForHeapStart() で取得します。
取得できる D3D12_CPU_DESCRIPTOR_HANDLE 構造体はなかなか凄いもので、中身は SIZE_T 型でアドレスが保存されているだけです。
つまり、ほんとにヒープの先頭アドレスを取得するだけなんですね。
CreateRenderTargetView() 命令でこのハンドルが示すアドレスに対してRenderTargetView用のDescriptorを作成します。
Descriptorは2枚のバッファ分を作成する必要がありますが、ハンドルのポインタはどうやって進めればいいのか、というと GetDescriptorHandleIncrementSize() で取得したRTV用Descriptorのサイズを加算してやるだけです。
186行目で取得したサイズを使って199行目で加算を行っていますね。
加算するサイズはDescriptorの種類で変化しますが、同一種のDescriptorならサイズに変化はないので、アプリ開始時に取得してグローバルに参照できるようにしておけば問題ないでしょう。
なんという原始的なやり方、と思うかもしれませんが、DX12はこういうものです。慣れましょう。
バックバッファをクリアしているソースコードを以下に示します。
・544行目
// バックバッファを描画ターゲットとして設定する
D3D12_CPU_DESCRIPTOR_HANDLE handle = g_pRtvHeap->GetCPUDescriptorHandleForHeapStart();
handle.ptr += (g_frameIndex * g_rtvDescriptorSize);
g_pCommandList->OMSetRenderTargets(1, &handle, false, nullptr);
...
// バックバッファをクリアする
const float kClearColor[] = { 0.0f, 0.0f, 0.6f, 1.0f };
g_pCommandList->ClearRenderTargetView(handle, kClearColor, 0, nullptr);
バックバッファをクリアするだけなら OMSetRenderTargets() 命令を呼ぶ必要はありません。ClearRenderTargetView() だけを呼んでおきましょう。
ただ、2つあるDesctriptorのどちらを利用するかは現在のバックバッファのインデックスを示している g_frameIndex からハンドルのポインタを算出して設定する必要があります。
g_frameIndex はバックバッファのフリップが行われるたびに更新されますが、スワップチェインから都度取得しても問題ないはずです。速度面ではオーバーヘッドがありそうですが。
バックバッファのフリップはコマンドリストの実行後に行います。コードは書きませんが、600行目で行っています。
さて、DX11では Present() 命令を行えば自動的に描画完了を待ってくれました。
しかしDX12はそんなに優しくありません。描画コマンドを実行し終わったら、その終了を待たなければ正常にフリップ処理が行われません。
これを行う方法としてビジーループで処理する方法もありますが、それはそれでどうなの?と思うのでサンプル通りにフェンスを使うことにします。
マルチスレッドプログラミングをやっていればイベントを使って同期をとることはよくやるんじゃないかと思いますが、DX12ではこれと同じようなことをやる必要があります。
面倒ですが、とりあえず関数化しておけばそれほど難しくないのでやってしまいましょう。
・247行目
// 同期をとるためのフェンスを作成する
{
hr = g_pDevice->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&g_pFence));
assert(SUCCEEDED(hr));
g_fenceValue = 1;
g_fenceEvent = CreateEventEx(nullptr, FALSE, FALSE, EVENT_ALL_ACCESS);
assert(g_fenceEvent != nullptr);
}
フェンスを作成するには CreateFence() 命令を使います。
第1引数で指定するのはフェンスが持っている値の初期値を設定しています。
描画処理が完了したかどうかはこのフェンス値がインクリメントされたかどうかで判断する、という大変原始的なものです。
原始的ではありますが、マルチスレッドプログラミングって大抵こんな感じじゃない?
そのフェンス値が指定の値に到達したかどうか(つまりインクリメントされたかどうか)を判断するのに、今回はイベントを利用しています。
フェンスを実際に使用しているのは WaitDrawDone() 命令です。
・603行目
// 描画完了を待つ
void WaitDrawDone()
{
// 現在のFence値がコマンド終了後にFenceに書き込まれるようにする
UINT64 fvalue = g_fenceValue;
g_pCommandQueue->Signal(g_pFence, fvalue);
g_fenceValue++;
// まだコマンドキューが終了していないことを確認する
// ここまででコマンドキューが終了してしまうとイベントが一切発火されなくなるのでチェックしている
if (g_pFence->GetCompletedValue() < fvalue)
{
// このFenceにおいて、fvalue の値になったらイベントを発火させる
g_pFence->SetEventOnCompletion(fvalue, g_fenceEvent);
// イベントが発火するまで待つ
WaitForSingleObject(g_fenceEvent, INFINITE);
}
g_frameIndex = g_pSwapChain->GetCurrentBackBufferIndex();
}
まずは現在のフェンス値をローカル変数に保存しておき、フェンス値をインクリメントします。
インクリメントは描画完了後でもOKですが、サンプルでローカル変数にコピーしているのは速度面が理由でしょうかね?
ID3D12CommandQueue::Signal() 命令は実行されているコマンドリストが完了後、フェンスに対して指定のフェンス値(ここでは fvalue)を書き込みます。
命令を発行後、最初にフェンスが持っている値が fvalue 未満かどうかを確認しています。
もしも Signal() 命令発行時にすでにコマンド処理が終了している場合、直ちにフェンスが持っている値が fvalue になってしまってその後のイベント実行が正しく処理されなくなるからだと思われます。
GPUの処理が終了していなければ if 文の中に入ってきます。
ここではフェンスに対して、フェンスが持っている値が fvalue になったらイベントを発火しろ、という命令を発行しています。SetEventOnCompletion() ですね。
あとはイベントが発火するのを WaitForSingleObject() 命令で待ちます。
最後に、今回のサンプルでは実はやらなくてもそれほど問題ない処理ではあるのですが、リソースハザードについて解説を行います。
リソースハザードは概念としてはそれほど難しくないのですが、DX11以前であればプログラマが意識する必要のない部分でした。
現代のGPUはコアがなんでもできる万能選手です。昔は頂点シェーダ用のコアとピクセルシェーダ用のコアが分かれていたのですが、今の時代はすべてのコアがどんな仕事でもやってくれます。
それ故に、暇なコアにはどんどん仕事が割り当てられるわけです。
GPUの動作を例えるため、コンビニでの店員の動きを考えましょう。
GPUのコアはアルバイトで、そのコアに処理を割り当てるドライバが店長、アプリ側からの指示を流すAPIが本社の社員という体で進めましょう。
まず、本社社員からの指示はこんな感じです。
「AM0時には弁当棚の商品はすべて廃棄し、新しい弁当を棚に並べてください」
この指示には2つの命令が存在しています。
「弁当棚の商品をすべて廃棄する」
「新しい弁当を棚に並べる」
店長はこの指示を受け取るとアルバイトAとBに廃棄の指示を出しました。アルバイトCとDはやることがなくて暇してます。
これを見た店長、CとDに弁当を並べる指示を出しました。
AとBがまだ廃棄している最中にCとDは弁当を棚に並べ始めました。そんなことを気にしないAとBはどんどん廃棄していきます。並んでる弁当を。CとDが並べた弁当も。
もういろいろアウトです。アウトですが、DX12ではこういうことが現実的に起きる可能性があるのです。
これを防ぐのがリソースハザードです。
もっと細かく言うとそれだけではないのですが、まあこういうものだと考えていただけるとわかりやすいと思います。
先の例で言えば、AとBが廃棄している間は弁当棚にキープアウトの黄色いテープが貼られてAとB以外のアルバイトの立ち入りを禁止します。
廃棄が完了すると弁当棚のキープアウトテープが剥がされ、CとDが作業できる状況になるわけです。
このキープアウトテープがバリアです。
このような状態で命令の投入を中断することをパイプラインハザードと呼ぶのですが、ここではリソースに対するハザード処理となるのでリソースハザードと呼ばれます。
リソースハザードが主に行われる処理としては、テクスチャをコピーしてコピー先のテクスチャを描画に利用する際とか、RenderTargetとして描画を行ったバッファをテクスチャとして利用する場合とかです。
RenderTargetはPresentする際にも描画が完了するのを待つ必要があります。
リソースハザードを行っているのがサンプルでは2箇所あります。
・534行目
// バックバッファが描画ターゲットとして使用できるようになるまで待つ
D3D12_RESOURCE_BARRIER barrier;
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; // バリアはリソースの状態遷移に対して設置
barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
barrier.Transition.pResource = g_pRenderTargets[g_frameIndex]; // リソースは描画ターゲット
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT; // 遷移前はPresent
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET; // 遷移後は描画ターゲット
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
g_pCommandList->ResourceBarrier(1, &barrier);
・578行目
// バックバッファの描画完了を待つためのバリアを設置
D3D12_RESOURCE_BARRIER barrier;
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; // バリアはリソースの状態遷移に対して設置
barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
barrier.Transition.pResource = g_pRenderTargets[g_frameIndex]; // リソースは描画ターゲット
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; // 遷移前は描画ターゲット
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT; // 遷移後はPresent
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
g_pCommandList->ResourceBarrier(1, &barrier);
前者は描画開始時、後者は描画終了時のバリアです。
どちらも D3D12_RESOURCE_BARRIER_TYPE_TRANSITION というタイプで設定されています。
これはリソースの状態遷移が可能になるまでバリアを設置する、という意味のようです。
状態遷移は描画前であれば Present状態からRenderTarget状態へ移行できるようになったら、後者はその逆です。
大半のリソースバリアはこのタイプで指定することになります。コピーの際にもこのタイプです。
他には D3D12_RESOURCE_BARRIER_TYPE_ALIASING と D3D12_RESOURCE_BARRIER_TYPE_UAV がありますが、前者は Tile Resource 用なんですかね?後者はUAVようだと思いますが。
まあ使う段階で解説を行いますが、UAVはともかく、ALIASINGは使わない気がするなぁ…
バリアの設置はリソースの状態遷移には欠かさず行う必要があります。これは安全のためにもするべきです。
しかし、バリアを設置したからといって命令投入待ちが発生するかどうかはわかりません。
というのも、実際に遷移する段階ではすでに処理が完了していて、すぐにでも遷移可能な状態になっているかもしれないのです。
このような状況は、例えばRenderTargetとして描画したバッファを次の描画ですぐにテクスチャとして使用するか、それともあとで使用するかによって変わってきます。
例えば、RenderTargetAに描画→RenderTargetAを使って描画→RenderTargetBに描画、という流れだと最初のRTAへの描画が終わるまで2番めの処理が実行できなくなります。
しかし、RTAに描画→RTBに描画→RTAを使って描画の順番なら3番目の描画を行う前に最初の描画は終わってるかもしれませんし、たとえ終わってなくとも2番めの描画が進行しているはずなので前者の処理よりは高速になる可能性が高いです。
キャッシュとかの問題もあるのですべてがそんなに簡単ではないかもしれませんが、そういう部分もきちんと考えるきっかけにはなるんじゃないでしょうか。
とりあえず、ここまで解説した内容を利用すればバックバッファをクリアしてウィンドウに表示する、というところまで出来るはずです。
おまけですが、もしも描画を完了を待たなかったらどうなるか?
WaitForSingleObject() をコメントアウトすれば描画完了を待つことなく先に進みます。
結果はというと、自分の環境では動きがガクガクとして、終了時にドライバが応答しなくなったから再起動しました的なメッセージを受け取りました。
コンシューマ機だと当たり前のように描画完了を待つのですが、DX11とかは隠蔽されていたからあんまり気にしないですよね。
次回は頑張って大変なDescriptor周りやRootSignatureの解説を行いたいと思いますが、盛大に嘘書くかもしれないので有識者はツッコミ入れてもらえるとありがたいです。
まあ、今回のリソースハザードも結構突っ込みどころありそうな印象なんですがね…