A step-by-step guide to drawing your first triangle with DirectX 12.
Welcome to the world of DirectX 12! In this tutorial, we'll guide you through the fundamental process of rendering a single, solid-colored triangle on the screen. This is a foundational step that will unlock more complex graphics rendering capabilities.
By the end of this tutorial, you will understand:
Before you begin, ensure you have the following:
Geometry in DirectX is defined by vertices. Each vertex typically contains positional data (x, y, z coordinates) and may include other attributes like color or texture coordinates. For a simple triangle, we need at least three vertices.
We'll define our triangle's vertices in clip space, where x and y range from -1.0 to 1.0. A simple triangle can be defined as follows:
struct Vertex {
float x, y, z;
float r, g, b; // Color components
};
std::vector<Vertex> triangleVertices = {
{ 0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f }, // Top vertex (red)
{-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f }, // Bottom-left vertex (green)
{ 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f } // Bottom-right vertex (blue)
};
The Pipeline State Object (PSO) encapsulates the entire graphics pipeline's state. This includes shader programs, blending settings, depth testing, rasterizer state, and more. Creating a PSO is a crucial step in DirectX 12.
For rendering a simple triangle, our PSO will need:
DirectX 12 uses command lists to record drawing commands. These lists are then submitted to a command queue for execution by the GPU. Recording commands involves setting the pipeline state, binding vertex buffers, and issuing draw calls.
This involves creating the Direct3D device, swap chain, command queue, and command allocator.
Note: Proper error handling is critical for each DirectX 12 API call. For brevity, error checking code is omitted in some examples but should be implemented in a real application.
Upload your vertex data to the GPU by creating a vertex buffer. This requires creating a resource on the GPU and copying the data into it.
// Assuming 'device' is your ID3D12Device and 'commandList' is your ID3D12GraphicsCommandList
// Create a default heap for the vertex buffer
D3D12_HEAP_PROPERTIES heapProp = {};
heapProp.Type = D3D12_HEAP_TYPE_UPLOAD; // Upload heap for CPU write access
D3D12_RESOURCE_DESC resourceDesc = {};
resourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
resourceDesc.Width = sizeof(Vertex) * triangleVertices.size();
resourceDesc.Height = 1;
resourceDesc.DepthOrArraySize = 1;
resourceDesc.MipLevels = 1;
resourceDesc.SampleDesc.Count = 1;
resourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
Microsoft::WRL::ComPtr<ID3D12Resource> vertexBuffer;
ThrowIfFailed(device->CreateCommittedResource(
&heapProp,
D3D12_HEAP_FLAG_NONE,
&resourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&vertexBuffer)
));
// Copy data to the vertex buffer
void* dataPtr = nullptr;
D3D12_RANGE readRange = {};
ThrowIfFailed(vertexBuffer->Map(0, &readRange, &dataPtr));
memcpy(dataPtr, triangleVertices.data(), sizeof(Vertex) * triangleVertices.size());
vertexBuffer->Unmap(0, nullptr);
// Describe the vertex buffer view
D3D12_VERTEX_BUFFER_VIEW vbView = {};
vbView.BufferLocation = vertexBuffer->GetGPUVirtualAddress();
vbView.SizeInBytes = sizeof(Vertex) * triangleVertices.size();
vbView.StrideInBytes = sizeof(Vertex);
This is where you configure the graphics pipeline. You'll need to compile and link your vertex and pixel shaders.
For this tutorial, we'll assume simple, hardcoded shaders for demonstration. In a real application, you would load compiled shader bytecode.
Vertex Shader (HLSL):
struct VS_INPUT {
float4 pos : POSITION;
float4 color : COLOR;
};
struct VS_OUTPUT {
float4 pos : SV_POSITION;
float4 color : COLOR;
};
VS_OUTPUT VSMain(VS_INPUT input) {
VS_OUTPUT output;
output.pos = input.pos;
output.color = input.color;
return output;
}
Pixel Shader (HLSL):
struct PS_INPUT {
float4 pos : SV_POSITION;
float4 color : COLOR;
};
float4 PSMain(PS_INPUT input) : SV_TARGET {
return input.color;
}
The code to create the PSO involves setting up various structures like D3D12_GRAPHICS_PIPELINE_STATE_DESC.
Before drawing, you need to reset the command list, set the viewport, scissor rectangle, render target, and the PSO. Then, bind the vertex buffer and issue the draw call.
auto commandAllocator = ...; // Get command allocator
auto commandList = ...; // Get command list
// Reset command list
ThrowIfFailed(commandList->Reset(commandAllocator.Get(), nullptr));
// Set render target (e.g., back buffer)
auto renderTargetView = ...; // Get RTV
D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = ...;
CD3DX12_CPU_DESCRIPTOR_HANDLE rtvDescriptorHandle(rtvHandle);
// Clear render target
FLOAT clearColor[] = { 0.07f, 0.13f, 0.17f, 1.0f }; // Dark blue-gray
commandList->ClearRenderTargetView(rtvDescriptorHandle, clearColor, 0, nullptr);
// Set pipeline state
commandList->SetPipelineState(pso.Get()); // Assuming 'pso' is your ID3D12PipelineState
// Set descriptor heaps (if any are used for shaders, not strictly needed for this basic example)
// ID3D12DescriptorHeap* descriptorHeaps[] = {};
// commandList->SetDescriptorHeaps(_countof(descriptorHeaps), descriptorHeaps);
// Set graphics root signature (if PSO uses one, not strictly needed for this basic example)
// commandList->SetGraphicsRootSignature(rootSignature.Get());
// Set viewport and scissor
D3D12_VIEWPORT viewport = { 0.0f, 0.0f, static_cast<FLOAT>(width), static_cast<FLOAT>(height), 0.0f, 1.0f };
D3D12_RECT scissorRect = { 0, 0, width, height };
commandList->RSSetViewports(1, &viewport);
commandList->RSSetScissorRects(1, &scissorRect);
// Bind render target
commandList->OMSetRenderTargets(1, &rtvDescriptorHandle, FALSE, nullptr);
// Bind vertex buffer
commandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
commandList->IASetVertexBuffers(0, 1, &vbView);
// Draw call
commandList->DrawInstanced(3, 1, 0, 0); // 3 vertices for one triangle, 1 instance
// Close command list
ThrowIfFailed(commandList->Close());
Submit the command list to the command queue, wait for the GPU to finish, and then present the rendered frame using the swap chain.
Tip: Synchronization between the CPU and GPU is crucial. Use fences to ensure commands are completed before proceeding.
Here are some key parts of the code you might find in a full implementation.
Setting up the Device:
Microsoft::WRL::ComPtr<ID3D12Device> device;
D3D12CreateDevice(adapter.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device));
Creating a Command Queue:
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
Microsoft::WRL::ComPtr<ID3D12CommandQueue> commandQueue;
device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&commandQueue));
Creating a Swap Chain:
DXGI_SWAP_CHAIN_DESC swapChainDesc = {};
swapChainDesc.BufferCount = 2;
swapChainDesc.BufferDesc.Width = width;
swapChainDesc.BufferDesc.Height = height;
swapChainDesc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
swapChainDesc.SampleDesc.Count = 1;
Microsoft::WRL::ComPtr<IDXGISwapChain> swapChain;
factory->CreateSwapChain(commandQueue.Get(), &swapChainDesc, &swapChain);
Common issues include incorrect vertex data formats, missing pipeline state configurations, or synchronization problems. Always check the return values of DirectX API calls for HRESULT errors.
Warning: Incorrectly configured PSOs or shaders can lead to rendering artifacts or application crashes.
Congratulations on rendering your first triangle! From here, you can explore:
Keep experimenting and building upon these fundamentals. The journey into DirectX 12 graphics is exciting!