Descriptor Heaps and Render Target Views

Understand why DX12 requires descriptors, create an RTV descriptor heap for the swap chain back buffers, and learn how descriptor handle arithmetic works.

Early Draft Work in progress — GitHub links coming soon.

In the previous chapter we created the swap chain and retrieved pointers to the two back buffer resources. Those pointers represent GPU memory, but the output merger stage — the part of the pipeline that writes pixels — cannot use a raw resource pointer directly. It needs a render target view (RTV): a small descriptor that tells the GPU the format, dimensionality, and mip level to write to.

By the end of this chapter you will have:

What Descriptors Replace

In DX11 you bound resources directly. Calls like OMSetRenderTargets took ID3D11RenderTargetView* pointers and the driver dealt with the rest. DX12 removes all of that. Every resource binding goes through descriptors stored in descriptor heaps.

The reason is performance. DX11’s implicit binding model required the driver to validate and translate state on every draw call. DX12 moves all of that work to explicit creation time. By the time you record a draw call, every descriptor has already been validated and laid out in GPU-accessible memory.

There are four descriptor heap types:

TypeStores
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAVConstant buffer views, shader resource views, unordered access views
D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLERTexture samplers
D3D12_DESCRIPTOR_HEAP_TYPE_RTVRender target views
D3D12_DESCRIPTOR_HEAP_TYPE_DSVDepth-stencil views

We need an RTV heap for the back buffers. RTV and DSV heaps are CPU-side only — shaders do not access render targets or depth buffers through the shader binding table. The SHADER_VISIBLE flag is only valid for CBV_SRV_UAV and SAMPLER heaps.

Creating a Descriptor Heap

D3D12_DESCRIPTOR_HEAP_DESC heapDesc = {};
heapDesc.Type           = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
heapDesc.NumDescriptors = FrameCount;
heapDesc.Flags          = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
heapDesc.NodeMask       = 0;

Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> rtvHeap;
device->CreateDescriptorHeap(&heapDesc, IID_PPV_ARGS(&rtvHeap));

NumDescriptors is two — one per back buffer. Flags is NONE because RTV heaps are CPU-only.

Descriptor Size and Handle Arithmetic

Descriptor sizes are not standardised. Different GPU vendors use different internal layouts. You must always query the size from the device:

UINT rtvDescriptorSize = device->GetDescriptorHandleIncrementSize(
    D3D12_DESCRIPTOR_HEAP_TYPE_RTV);

To address a specific descriptor within the heap, start from the heap’s base handle and add multiples of the increment size:

D3D12_CPU_DESCRIPTOR_HANDLE handle = rtvHeap->GetCPUDescriptorHandleForHeapStart();

// Descriptor at index i:
handle.ptr += i * rtvDescriptorSize;

D3D12_CPU_DESCRIPTOR_HANDLE is just a SIZE_T wrapped in a struct. The arithmetic is manual pointer math, not C++ pointer arithmetic. It looks awkward at first, but it is straightforward once you see it a few times.

Creating Render Target Views

Once we have the heap and know the descriptor size, we can create an RTV for each back buffer. CreateRenderTargetView writes the descriptor directly into the heap at the specified handle:

D3D12_CPU_DESCRIPTOR_HANDLE handle = rtvHeap->GetCPUDescriptorHandleForHeapStart();

for (UINT i = 0; i < FrameCount; ++i)
{
    device->CreateRenderTargetView(backBuffers[i], nullptr, handle);
    handle.ptr += rtvDescriptorSize;
}

The second argument is an D3D12_RENDER_TARGET_VIEW_DESC* structure that lets you specify format overrides, mip levels, and array slices. Passing nullptr means “use the resource’s default format and full subresource range”, which is correct for swap chain back buffers.

Retrieving a Handle for a Specific Frame

During the render loop you will need to retrieve the RTV handle for whichever back buffer is current. A helper that computes the handle from an index keeps that arithmetic in one place:

D3D12_CPU_DESCRIPTOR_HANDLE HandleFor(UINT frameIndex) const
{
    D3D12_CPU_DESCRIPTOR_HANDLE handle = heap_->GetCPUDescriptorHandleForHeapStart();
    handle.ptr += frameIndex * descriptorSize_;
    return handle;
}

This returns a value, not a pointer — D3D12_CPU_DESCRIPTOR_HANDLE is cheap to copy.

Wrapping in a Class

Add RTVHeap.h and RTVHeap.cpp to the project:

dx12-sandbox/
    CMakeLists.txt
    src/
        main.cpp
        Window.cpp
        Window.h
        DXGIContext.cpp
        DXGIContext.h
        D3DDevice.cpp
        D3DDevice.h
        CommandQueue.cpp
        CommandQueue.h
        SwapChain.cpp
        SwapChain.h
        RTVHeap.cpp   ← new
        RTVHeap.h     ← new

RTVHeap.h

#pragma once

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <d3d12.h>
#include <wrl/client.h>
#include "SwapChain.h"

class RTVHeap
{
public:
    bool Init(ID3D12Device* device, const SwapChain& swapChain);

    D3D12_CPU_DESCRIPTOR_HANDLE HandleFor(UINT frameIndex) const;

private:
    Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> heap_;
    UINT                                         descriptorSize_ = 0;
};

RTVHeap.cpp

#include "RTVHeap.h"

bool RTVHeap::Init(ID3D12Device* device, const SwapChain& swapChain)
{
    D3D12_DESCRIPTOR_HEAP_DESC heapDesc = {};
    heapDesc.Type           = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
    heapDesc.NumDescriptors = FrameCount;
    heapDesc.Flags          = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
    heapDesc.NodeMask       = 0;

    if (FAILED(device->CreateDescriptorHeap(&heapDesc, IID_PPV_ARGS(&heap_))))
    {
        return false;
    }

    descriptorSize_ = device->GetDescriptorHandleIncrementSize(
        D3D12_DESCRIPTOR_HEAP_TYPE_RTV);

    D3D12_CPU_DESCRIPTOR_HANDLE handle = heap_->GetCPUDescriptorHandleForHeapStart();

    for (UINT i = 0; i < FrameCount; ++i)
    {
        device->CreateRenderTargetView(swapChain.BackBuffer(i), nullptr, handle);
        handle.ptr += descriptorSize_;
    }

    return true;
}

D3D12_CPU_DESCRIPTOR_HANDLE RTVHeap::HandleFor(UINT frameIndex) const
{
    D3D12_CPU_DESCRIPTOR_HANDLE handle = heap_->GetCPUDescriptorHandleForHeapStart();
    handle.ptr += frameIndex * descriptorSize_;
    return handle;
}

Update CMakeLists.txt

cmake_minimum_required(VERSION 3.24)

project(DX12Sandbox LANGUAGES CXX)

add_executable(DX12Sandbox WIN32
    src/main.cpp
    src/Window.cpp
    src/DXGIContext.cpp
    src/D3DDevice.cpp
    src/CommandQueue.cpp
    src/SwapChain.cpp
    src/RTVHeap.cpp
)

target_include_directories(DX12Sandbox PRIVATE src)

target_compile_features(DX12Sandbox PRIVATE cxx_std_20)

target_link_libraries(DX12Sandbox PRIVATE
    dxgi
    d3d12
)

main.cpp

#include "Window.h"
#include "DXGIContext.h"
#include "D3DDevice.h"
#include "CommandQueue.h"
#include "SwapChain.h"
#include "RTVHeap.h"

int RunRenderLoop()
{
    MSG msg = {};

    while (msg.message != WM_QUIT)
    {
        if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessageW(&msg);
        }
        else
        {
            // Later: RenderFrame();
        }
    }

    return static_cast<int>(msg.wParam);
}

int WINAPI wWinMain(
    HINSTANCE hInstance,
    HINSTANCE,
    PWSTR,
    int nCmdShow)
{
    Window window;
    if (!window.Create(hInstance, 1280, 720, nCmdShow))
    {
        MessageBoxW(nullptr, L"Window creation failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    DXGIContext dxgi;
    if (!dxgi.Init(true))
    {
        MessageBoxW(nullptr, L"No suitable GPU found.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    D3DDevice d3d;
    if (!d3d.Init(dxgi.Adapter(), true))
    {
        MessageBoxW(nullptr, L"D3D12 device creation failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    CommandQueue queue;
    if (!queue.Init(d3d.Device()))
    {
        MessageBoxW(nullptr, L"Command queue creation failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    SwapChain swapChain;
    if (!swapChain.Init(dxgi.Factory(), queue.Get(), window.Handle(),
                        window.Width(), window.Height()))
    {
        MessageBoxW(nullptr, L"Swap chain creation failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    RTVHeap rtvHeap;
    if (!rtvHeap.Init(d3d.Device(), swapChain))
    {
        MessageBoxW(nullptr, L"RTV heap creation failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    return RunRenderLoop();
}

Common Mistakes

If CreateDescriptorHeap returns E_INVALIDARG, the most likely cause is using D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE on an RTV heap. That flag is not valid for RTV or DSV heaps.

If the wrong descriptor is used during rendering, check HandleFor arithmetic. Off-by-one errors on the frame index are common. The descriptor at index 0 corresponds to BackBuffer(0), which is what GetCurrentBackBufferIndex returns on the first frame.

If you change the back buffer format after creating the RTV, or if there is a mismatch between the swap chain format and the RTV descriptor format, the debug layer will report a format incompatibility when ClearRenderTargetView or a draw call touches that render target.

Quick Checkpoint

You are ready to move on if these ideas feel solid:

What’s Next

Next we create command allocators and a command list. An allocator is the backing memory pool for recorded commands. A command list is the interface for recording them. Both are needed before we can write our first render loop.