Saturday, July 9, 2016

DX12でGPUメモリを自分で管理する

今回のソースは https://github.com/yorung/dx12playground/tree/huge_global_constant です。

問題提起


前回シンプルなRoot Signatureをやってみましたが、毎回書き換えるConstant bufferのアドレスを固定してしまうのは何かと不都合です。

DX11までは度々以下のようなコードで描画することがありました。

ConstantBuffer cb;
for (obj : objs) {
void *p;
cb.Map(&p);
memcpy(p, obj.data, sizeof());
cb.Unmap();
Draw()
}
見た目のConstantBufferは1個なのですが、内部的なConstant Bufferがobjの数だけ確保され、それぞれ別のアドレスを持ちます(リネーム)。もし内部でも見た目通り1個のConstant Bufferしかなければ、Mapする時に前回のDrawが終わっていなければいけません。前回のDrawを待つなら遅くなりますし、待たなければGPUが参照中のメモリを壊す事態となります。

DX12で同じことをやるとGPUが参照中のメモリを壊してしまいます。複数のobjを描画するためにはobjの数だけConstantBufferを確保しなければならず、結局Constant bufferはアドレスを変えながらバインドしなければなりません。そうなると、Constant bufferのアドレスを保持するDescriptor Heapもまたバインドの数だけ必要になります。


GPUメモリのアロケータを作る


そこで、Constant BufferとDescriptor Heapを動的に確保するシステムを作ります。 

DX12のいいところは直接GPUのアドレスを指定できることです。巨大なConstant BufferやDescriptor Heapを1個ずつだけ作って、その中から適当な先頭アドレスをGPUに渡して描画、ということが出来るようになりました。

以下はその唯一のConstant Bufferを作るところです。

static const UINT maxConstantBufferBlocks = 1000;
ComPtr<ID3D12Resource> constantBuffer;
struct { char buf[0x100]; } *mappedConstantBuffer = nullptr;
int size = maxConstantBufferBlocks * 0x100;
D3D12_HEAP_PROPERTIES prop = { D3D12_HEAP_TYPE_UPLOAD, D3D12_CPU_PAGE_PROPERTY_UNKNOWN, D3D12_MEMORY_POOL_UNKNOWN, 1, 1 };
D3D12_RESOURCE_DESC desc = { D3D12_RESOURCE_DIMENSION_BUFFER, 0, (UINT64)size, 1, 1, 1, DXGI_FORMAT_UNKNOWN, { 1, 0 }, D3D12_TEXTURE_LAYOUT_ROW_MAJOR, D3D12_RESOURCE_FLAG_NONE };
device->CreateCommittedResource(&prop, D3D12_HEAP_FLAG_NONE, &desc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&constantBuffer));
D3D12_RANGE readRange = {};
HRESULT hr = constantBuffer->Map(0, &readRange, (void**)&mappedConstantBuffer);

Constant Bufferの先頭アドレスとサイズは256バイトのアラインメントの制限があります。ここでは256 x 1000バイト確保しました。また、MapしっぱなしにしてmemcpyだけすればGPUから変更が見えているというのもDX12のいいところです。

次は、唯一のDescriptor Heapを作ってみます。

static const UINT maxSrvs = 1024;
ComPtr<ID3D12DescriptorHeap> srvHeap;
const D3D12_DESCRIPTOR_HEAP_DESC srvHeapDesc = { D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV, maxSrvs, D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE };
device->CreateDescriptorHeap(&srvHeapDesc, IID_PPV_ARGS(&srvHeap));

1024個作ってみます。個数に特に意味はありません。
使用者はここから必要な数だけ確保し、CBVやSRVを書き込んでAPIに渡します。

int DeviceManDX12::AssignDescriptorHeap(int numRequired)
{
if (numAssignedSrvs + numRequired > maxSrvs) {
assert(0);
return -1;
}
int head = numAssignedSrvs;
numAssignedSrvs = numAssignedSrvs + numRequired;
return head;
}
void DeviceManDX12::AssignSRV(int descriptorHeapIndex, ComPtr<ID3D12Resource> res)
{
D3D12_CPU_DESCRIPTOR_HANDLE ptr = srvHeap->GetCPUDescriptorHandleForHeapStart();
ptr.ptr += device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV) * descriptorHeapIndex;
device->CreateShaderResourceView(res.Get(), nullptr, ptr);
}
void DeviceManDX12::SetAssignedDescriptorHeap(int descriptorHeapIndex)
{
ID3D12DescriptorHeap* ppHeaps[] = { srvHeap.Get() };
deviceMan.GetCommandList()->SetDescriptorHeaps(_countof(ppHeaps), ppHeaps);
D3D12_GPU_DESCRIPTOR_HANDLE addr = srvHeap->GetGPUDescriptorHandleForHeapStart();
addr.ptr += descriptorHeapIndex * device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
deviceMan.GetCommandList()->SetGraphicsRootDescriptorTable(0, addr);
}

AssignDescriptorHeapが確保関数で、先頭から順に返しているだけで、足し算しかしていません。使用者は「先頭から何番目を何個」という情報を知っているだけで十分だからです。ちなみに、numAssignedSrvsは毎フレーム0にリセットしています。

AssignSRVは確保したDescriptor HeapにSRVを書き込みます。CreateShaderResourceViewはDX11と名前が同じですが、何か新たにインスタンスが生成されたりするわけではなく、書き込むだけです。

最後に、SetAssignedDescriptorHeapでDescriptor HeapをGPUに渡します。今回のサンプルも前回のように単純化のためにDescriptor Tableは1個だけということにしています。descriptorHeapIndexを先頭位置として、何個のSRVやCBVを参照するかはDescriptor Tableの定義次第です。

以下はSRVの代わりにConstant Buffer開始位置をDescriptor Heapに書き込み、更にそれも唯一のConstant Bufferから動的に確保したものを使う関数です。

void DeviceManDX12::AssignConstantBuffer(int descriptorHeapIndex, const void* buf, int size)
{
int sizeAligned = (size + 0xff) & ~0xff;
int numRequired = sizeAligned / 0x100;
if (numAssignedConstantBufferBlocks + numRequired > maxConstantBufferBlocks) {
assert(0);
return;
}
int top = numAssignedConstantBufferBlocks;
numAssignedConstantBufferBlocks += numRequired;
memcpy(mappedConstantBuffer + top, buf, size);
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc = {};
cbvDesc.BufferLocation = constantBuffer->GetGPUVirtualAddress() + top * 0x100;
cbvDesc.SizeInBytes = sizeAligned;
assert((cbvDesc.SizeInBytes & 0xff) == 0);
D3D12_CPU_DESCRIPTOR_HANDLE ptr = srvHeap->GetCPUDescriptorHandleForHeapStart();
ptr.ptr += deviceMan.GetDevice()->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV) * descriptorHeapIndex;
deviceMan.GetDevice()->CreateConstantBufferView(&cbvDesc, ptr);
}
唯一のConstant Bufferから256バイトにアラインメントされた単位で先頭から確保しますが、これもただの足し算です。渡されたデータを書き込むのはmemcpyです。これでGPUから見えます。
最後に、CreateConstantBufferViewでDescriptor Heapに先頭アドレスを書き込んで完了です。

実際の描画コードはこんな感じです。

afSetPipeline(pipelineState, rootSignature);
Mat invVP = inv(matV * matP);
int descriptorHeapIndex = deviceMan.AssignDescriptorHeap(2);
deviceMan.AssignConstantBuffer(descriptorHeapIndex, &invVP, sizeof(invVP));
deviceMan.AssignSRV(descriptorHeapIndex + 1, texId);
deviceMan.SetAssignedDescriptorHeap(descriptorHeapIndex);
afDraw(PT_TRIANGLESTRIP, 4);
1個だけのDescriptor Tableは2つの連続したDescripor Heapを参照し、最初がCBV、次がSRVになっています。使用者がGPUメモリを管理する手間が減ってコードがすっきりしました。

まとめ


GPUメモリのアロケータをやってみました。

こうしてみると、最後にGPUに渡すのはGPUのアドレスになっていて、GPUメモリの管理方法はほぼアプリケーション開発者に委ねられているのがわかります。

上では複数オブジェクトを描画するときのDX11のリネームを例にあげましたが、単一のオブジェクトを描画していても次のフレームのDraw Callの時点で前のフレームをまだGPUが処理していた場合、リネームが発生します。
つまり、DX12ではダブルバッファリングやトリプルバッファリングを行う際にレンダーターゲットだけでなくコンスタントバッファもプログラマの責任で多重化します。

今回の作業はDX12を使ったダブルバッファリングやトリプルバッファリングの下準備でもあります。

今回はシンプルに実装するために全てを動的に確保するようにしましたが、マテリアル情報のような変化しないConstant Bufferを変化しないDescriptor Heapに入れるなど、変化の有無でDescriptor Tableを分けるような工夫が必要になりそうです。

No comments:

Post a Comment