Comparing Vulkan and D3D12

Recently I wrote the PetitD3D12 to extend my graphics API knowledge to the land of DirectX, well I am surprised to see how similar those modern graphics APIs are. More precisely I think Vulkan is trying to stay close to D3D12 these days for be able to easily translate it. However there are also some noticeable differences, surprisingly I did not find too much “real” API comparison info, the Alain Galvan’s blog post are more just about grouping those API data structures together, not much you will know the difference in using them. With that being said, I am going to talk mostly in the shoes of a Vulkan developer who grabs the hand of D3D12 code to take a close look. Mainly I will cover about pipeline creation, descriptor binding and command execution.

The first 100 lines of code

Well, let’s start when you are writing DX12 code, immediately you will find out the boilerplate code to DX12 is smaller 🙃, within 100 lines of code you are already creating devices. The first thing you do is getting a dxgiFactory. It’s similar to VkInstance, but instead of going through selecting extensions, enabling layers. You just get it with flags. Next step you already start to create devices. BTW, Creating debug layer is also simpler. You don’t set any special printing code, just

WRL::ComPtr<ID3D12Debug> debugInterface;
ThrowIfFailed(D3D12GetDebugInterface(IID_PPV_ARGS(&debugInterface)));
debugInterface->EnableDebugLayer();

It is like validation layers in Vulkan, and it crashes you application immediately when you do anything wrong, like image layout is not correct or something.

In D3D12, The IDXGIAdapter4 is similar to VkPhysicalDevice that you can enumerate, choice is yours, either physical device should be able to present image to OS.

Once you have the device, you create command queue (equal to VkQueue) with

m_DirectCommandQueue = std::make_shared<CommandQueue>(m_d3d12Device, D3D12_COMMAND_LIST_TYPE_DIRECT);
m_ComputeCommandQueue = std::make_shared<CommandQueue>(m_d3d12Device, D3D12_COMMAND_LIST_TYPE_COMPUTE);
m_CopyCommandQueue = std::make_shared<CommandQueue>(m_d3d12Device, D3D12_COMMAND_LIST_TYPE_COPY);

One last thing is getting a window and its swapchain to present images, it is again a little easier in D3D12.

Pipeline creation

Creating a graphics pipeline is always a lot of code, in D3D12, it is called PSO. Similar to Vulkan, you need to set up the shader as ID3DBlob, setting up the render target formats, setting up the vertex input layouts. Determine if you want to enable depth testing, etc. There is a lot of parameters filling, good news is that you can find "d3dx12.h" to fill many of them automatically. The noticeable difference between Vulkan and D3D12 is that D3D12 does not have RenderPass and SubPass. So this means that you do not need to bind any frame buffers when creating pipeline.

Descriptors

The resource binding in D3D12 also goes through the forms of descriptors. But the way of organizing them is slightly different. In D3D12, they are organized together through ID3D12RootSignature. The whole scope of root signature probably deserve its own blog post, you can refer to gep’s excellent tutorial on how to create them. In this post I will try to describe the difference between root signature and its Vulkan counterpart VkPipelineLayout.

D3D12 organize ID3D12RootSignature with root parameters while Vulkan does it with a list of descriptor set layouts (with additional push constants). So in general Vulkan pipeline layout is more flat. Root parameters can be either 32Bit_CONSTANT, inline descriptors CBV_SRV_UAV or descriptor tables. The former two do not require actual descriptors, you can bind buffers and setup constant directly with command list. Whereas descriptor table requires you to allocate descriptor heaps, then bind actual resource over there.

Descriptor Table

In the case of descriptor binding, the descriptor heap can be seen as VkDescriptorSet in Vulkan, you first allocate the heap, then you assign the corresponding resources through

//firstly you get the first handle of the heap.
D3D12_CPU_DESCRIPTOR_HANDLE cbvHandle(mUniformBufferHeap->GetCPUDescriptorHandleForHeapStart());
//then offset it for index.
cbvHandle.ptr = cbvHandle.ptr + mDevice->GetDescriptorHandleIncrementSize(
                                    D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV) *
                                    0;
//lastly you bind the resource view to the slot, in this case a constant buffer view.
mDevice->CreateConstantBufferView(&cbvDesc, cbvHandle);

Descriptor heap differs to VkDescriptorSet in the sense that you can also use it to bind render target and depth stencil buffers. We will see how it is used during command list execution.

Command execution

Both D3D12 and Vulkan are very command based execution model. In the sense that you record a command list (or command buffer) on the CPU. Then you submit it to GPU for execution (then you wait for the fence for it to complete). Both APIs follow almost the same routine when drawing. Firstly you bind the pipeline, then you bind the descriptors, then finally you issue the draw command. Here we review some of D3D12 binding command.

For binding a pipeline, in D3D12 it is: commandlist->SetPipelineState(m_PSO.Get())

For binding the render targets, it is also through descriptor heap. Note that you need to get the GetCPUDescriptorHandleForHeapStart for non-shader-visible resource (render targets).

CD3DX12_CPU_DESCRIPTOR_HANDLE rtv(m_rtvHeap->GetCPUDescriptorHandleForHeapStart(), m_frameIndex, m_rtvDescriptorSize);

CD3DX12_CPU_DESCRIPTOR_HANDLE dsv    = m_DSVHeap->GetCPUDescriptorHandleForHeapStart();


commandList->OMSetRenderTargets(1, &rtv, FALSE, &dsv);

For binding the descriptors

//firstly set the descriptor heaps to be active
ID3D12DescriptorHeap* ppHeaps[] = { m_srvUavHeap.Get() };
m_commandList->SetDescriptorHeaps(_countof(ppHeaps), ppHeaps);

//then you bind the root signature.
commandList->SetGraphicsRootSignature(rootSignature);

//now setting the root parameters
commandList->SetGraphicsRootConstantBufferView(0, m_UniformBuffer->GetGPUVirtualAddress());
commandList->SetGraphicsRoot32BitConstants(1, sizeof(Data) / sizeof(uint32_t), &Data, 0);
CD3DX12_GPU_DESCRIPTOR_HANDLE srvHandle(m_srvUavHeap->GetGPUDescriptorHandleForHeapStart(), srvIndex, m_srvUavDescriptorSize);
commandList->SetGraphicsRootDescriptorTable(2, srvHandle);

Finally you issue the draw calls.

Summary

In terms of function APIs, the 2 modern graphics APIs are very similar indeed. One of the noticeable differences are root signature setup. However, sometimes the difference isn’t really related to API itself, but on the developing experiences. Vulkan has always try to be as platform agnostics as possible, you rely on the API itself and standard C++ headers. Where as for D3D12, you are fiddling with window ComPtr, setup Agility SDKs, getting DirectXMath headers and so on.

Hope you have fun.

comments powered by Disqus