jakboulton.net

Graphics Samples: Minimal D3D12

7 minute read

It has been a while since I've posted to this blog — over 4 years — but since I've spent the time to give it a nice new lick of paint, it's time to dust off the tomes and get back to blogging about my various software engineering and game development adventures. What better place to start than with a classic Hello, Triangle graphics sample?


Inspiration

A good few years back, I stumbled upon d7samurai's Minimal D3D11 sample and have since used it as a reference point. When starting any graphics-based exploration using D3D11, I reference this example to get started quickly. On top of this, there are further samples that detail how certain graphics effects can be achieved, such as shadow mapping, instanced rendering, and various ways to structure your graphics code.

When seeking a similar sample project for D3D12 or Vulkan, I have been unable to find something quite as simplistic (although I do have to call out the Vulkan Tutorial here as a great way to get started using the API). The only source I've found for D3D12 is the Microsoft-written DirectX Graphics Samples, which are great but are missing the minimal element I'm seeking. I decided to write it myself, both to clarify my own understanding and in case it proves useful to others.


Minimal D3D12

Github link: Secticide/Graphics-Samples/cpp/D3D12/Minimal-D3D12/main.cpp

The first part of the journey is a very simple Hello, Triangle example that shows how to open a window using the Win32 API, set up the render pipeline, and push render commands to a command buffer. Considering the verbosity of modern graphics APIs, it is good to see that all of this is possible in just under 350 lines of C++. Let's take a look through the steps required to get a triangle rendering in DirectX 12.

1. Opening A Window

The first thing any application needs to do is open a window. Most game-style applications use one of the popular cross-platform libraries like SDL2 (we're almost at SDL3) or GLFW. While this approach simplifies porting applications to other platforms, this example is intentionally minimal and therefore sticks to Win32 APIs:

WNDCLASSW wnd_class{ 0, DefWindowProcW, 0, 0, 0, 0, 0, 0, 0, TITLE };

RegisterClassW(&wnd_class);

HWND hwnd{ CreateWindowExW(0, TITLE, TITLE, WS_POPUP | WS_MAXIMIZE | WS_VISIBLE, 0, 0, 0, 0, nullptr, nullptr, nullptr, nullptr) };

This is about as simple as you can make it (we don't even check if the Window was created successfully).

2. Setting Up

With the window set up, we take our first step into the D3D12 APIs by setting up the ID3D12Debug object and enabling it. This helps us understand if we've incorrectly used an API. Most of the time when getting started with graphics programming, if an error is made in the setup of objects or an API function has been used incorrectly, the window will just display black. Enabling the debug layer gives us at least a chance at gaining some more information.

ComPtr<ID3D12Debug> debug_controller{};
check(D3D12GetDebugInterface(IID_PPV_ARGS(&debug_controller)));

debug_controller->EnableDebugLayer();

One other point of note is that I'm using the Windows Runtime Library's ComPtr<T> type, which nicely wraps all D3D12 objects in an RAII wrapper, managing object lifetimes for us. These are the Windows Runtime Library's equivalent of std::shared_ptr. Our next step is to set up our device, queues, and swap chain.

ComPtr<ID3D12Device> device{};
check(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_12_0, IID_PPV_ARGS(&device)));

D3D12_COMMAND_QUEUE_DESC queue_desc{};
queue_desc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
queue_desc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;

ComPtr<ID3D12CommandQueue> cmd_queue{};
check(device->CreateCommandQueue(&queue_desc, IID_PPV_ARGS(&cmd_queue)));

ComPtr<IDXGIFactory4> factory{};
check(CreateDXGIFactory2(DXGI_CREATE_FACTORY_DEBUG, IID_PPV_ARGS(&factory)));

DXGI_SWAP_CHAIN_DESC1 swapchain_desc{};
swapchain_desc.BufferCount = 2;
swapchain_desc.Width = 0; // use window width
swapchain_desc.Height = 0; // use window height
swapchain_desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
swapchain_desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
swapchain_desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
swapchain_desc.SampleDesc.Count = 1;

ComPtr<IDXGISwapChain1> swapchain1{};
check(factory->CreateSwapChainForHwnd(cmd_queue.Get(), hwnd, &swapchain_desc, nullptr, nullptr, &swapchain1));

Something to note is that for this minimal example, I used a check function that ensures the returned HRESULT is a success. If not, the program is terminated via std::exit.

This is followed by the creation of render targets for the swap chain, compilation of shaders, and setup of the general graphics pipeline state, all of which I won't show here (feel free to check the code out in the repository). The most important parts, or the parts that differentiate D3D12 from D3D11, are the following:

With all of these objects set up, we have essentially completed the setup of the graphics pipeline or ID3D12PipelineState. We are now ready to move forward to setting up our data for rendering. Much like D3D11, we start by creating and filling buffers - in this case, a very simple buffer for the vertex data. Some additional key points to mention are the creation of the ID3D12GraphicsCommandList for storing our render commands and a ID3D12Fence for synchronisation between the CPU and GPU.

3. The Render Loop

The render loop consists of the following steps:

cmd_allocator->Reset();

cmd_list->Reset(cmd_allocator.Get(), pipeline_state.Get());
cmd_list->SetGraphicsRootSignature(root_signature.Get());
cmd_list->RSSetViewports(1, &viewport);
cmd_list->RSSetScissorRects(1, &scissor);

D3D12_RESOURCE_BARRIER barrier{};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
barrier.Transition = {
	render_targets[frame_index].Get(),
	D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,
	D3D12_RESOURCE_STATE_PRESENT,
	D3D12_RESOURCE_STATE_RENDER_TARGET
};
cmd_list->ResourceBarrier(1, &barrier);

rtv_handle = rtv_heap->GetCPUDescriptorHandleForHeapStart();
rtv_handle.ptr = SIZE_T(INT64(rtv_handle.ptr) + INT64(frame_index) * INT64(rtv_descriptor_size));
cmd_list->OMSetRenderTargets(1, &rtv_handle, FALSE, nullptr);

const float clear_colour[]{ 0.0f, 0.2f, 0.4f, 1.0f };
cmd_list->ClearRenderTargetView(rtv_handle, clear_colour, 0, nullptr);
cmd_list->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
cmd_list->IASetVertexBuffers(0, 1, &vertex_buffer_view);
cmd_list->DrawInstanced(3, 1, 0, 0);

barrier.Transition = {
	render_targets[frame_index].Get(),
	D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,
	D3D12_RESOURCE_STATE_RENDER_TARGET,
	D3D12_RESOURCE_STATE_PRESENT
};
cmd_list->ResourceBarrier(1, &barrier);
cmd_list->Close();

ID3D12CommandList* cmd_lists[]{ cmd_list.Get() };
cmd_queue->ExecuteCommandLists(std::size(cmd_lists), cmd_lists);

swapchain->Present(1, 0);

const UINT64 current_fence_value{ fence_value };
cmd_queue->Signal(fence.Get(), current_fence_value);
fence_value += 1;

if (fence->GetCompletedValue() < current_fence_value) {
	fence->SetEventOnCompletion(current_fence_value, fence_event);
	WaitForSingleObject(fence_event, INFINITE);
}

frame_index = swapchain->GetCurrentBackBufferIndex();

There we have it, about the simplest D3D12 program that you can write!


What's Next

You may notice a few future plans subtly implied by the repository setup. Firstly, it is titled Graphics Samples, which of course hints at plans to expand the repository beyond the Hello, Triangle sample. I intend to follow a similar journey to d7samurai's samples by showcasing other samples for various graphics techniques. The folder structure also suggests plans to port the samples to Vulkan, and even other languages like Rust and Zig, let's see how far we can get!