Sun
30
Jan 2022
One reason the new graphics APIs – Direct3D 12 and Vulkan – are so complicated is that there are so many levels of indirection in accessing data. Having some data prepared in a normal C++ CPU variable, we have to go a long way before we can access it in a shader. Some time ago I’ve written an article: “Vulkan: Long way to access data”. Because I have a privilege of working with both APIs in my everyday job, I thought I could also write a D3D12 equivalent, so here it is. If you are a graphics or game programmer who already know the basics of D3D12, but may feel some confusion about all the concepts defined there, this article is for you.
Here is a diagram, followed by an explanation of all its elements:
Let's start from the final destination of our data – the shader. We are in the realm of the shader code written in HLSL language, denoted by yellow background. Consider an example of a vertex shader, where we want to multiply vertex position by a combined world-view-projection matrix, to transform it from local model space (this is how vertices of a model are stored in memory) to DirectX clip space (this is the space required for vertex positions on the vertex shader output). Because our matrix is 4x4, we extend the 3-component vertex position to homogenous coordinates by adding 4th component equal to 1 – a thing that is out of scope of this article.
output.pos = mul(perObjectConstants.worldViewProj, float4(pos, 1));
Here we have a first entity, a first name for our data: perObjectConstants.worldViewProj
. This is a symbol valid only inside the code of our vertex shader, defined in its global scope as a constant buffer:
struct PerObjectConstants
{
float4x4 worldViewProj;
};
ConstantBuffer<PerObjectConstants> perObjectConstants : register(b3, space0);
Here is a place where we cross the realms from yellow to green background. On the shader side, this specific constant buffer is referred as perObjectConstants
. For the outside world, we give it another identifier: “register b3 in space 0”. Registers (or slots) where we bind various resources to be accessible to shaders is a concept known in many graphics APIs for a long time.
In D3D12, the register pool is actually 2-dimensional, with the second dimension called “space”. This is because we could define a resource as an unbounded array that has no defined limit on the number of elements, so all higher slots are reserved and so we have to jump to a higher space to have more slots available – but this is out of scope of this article. We normally just use space 0. In this case, the space0
part in HLSL code could be omitted.
Moving to the realm of C++ code that uses D3D12, we refer to our constant buffer in a root signature. This is an object that describes all the resources that will be bound to our vertex, pixel, and other shaders. Root signature can be defined programmatically by filling structure D3D12_ROOT_SIGNATURE_DESC
, calling function D3D12SerializeRootSignature
to get a raw data buffer with its serialized content, then pass this to device->CreateRootSignature
to create a ID3D12RootSignature
object, which later becomes part of a pipeline state object (ID3D12PipelineState
). It also has to be set in a recorded command list by calling ID3D12GraphicsCommandList::SetGraphicsRootSignature
. Alternatively, a root signature can be defined inside HLSL code, using a not-so-pretty syntax in form of a string.
But this is all a side note here. What is important to us is that after we define a root parameter describing our constant buffer in register b3
, we move to another representation of it. From now on, we will refer to the constant buffer by the index at which we define our root parameter in the root signature, for example: 2:
D3D12_ROOT_PARAMETER params[10];
// ...
params[2].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS;
params[2].ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX;
params[2].Constants.ShaderRegister = 3;
params[2].Constants.RegisterSpace = 0;
params[2].Constants.Num32BitValues = 16;
// ...
Here, our diagram branches in to 3 options, marked as A), B), C). These are 3 different ways of passing a constant buffer, which differ in the number of levels of indirection. We choose it by setting appropriate values in the D3D12_ROOT_PARAMETER::ParameterType
member. Let’s start with the easiest one A), which is called “32BIT_CONSTANTS”, also known as “root constants”. As you can see in the code above, we only specify register, space, and the number of 32-bit values, which is 16 in case of a 4x4 matrix of float
numbers. ShaderVisibility
is common to all types of root parameters and can be a specific shader stage (like vertex shader in this example) or D3D12_SHADER_VISIBILITY_ALL
. Strangely, this is not a bit flag where we could specify any combination of shader stages. But this is unimportant here.
The reason this approach A) is the simplest is because we can pass the data straight from a pointer to a C++ variable while recording a command list, like this:
glm::mat4 worldViewProj = ...
commandList->SetGraphicsRoot32BitConstants(
2, // RootParameterIndex
16, // Num32BitValuesToSet
&worldViewProj, // pSrcData
0); // DestOffsetIn32BitValues
Note the “CPU_data” in the diagram belong to the realm of “Preparation (CPU)” (blue background), which means the pointer doesn’t need to remain valid for the time when the command list is executed on the GPU, only for the call to SetGraphicsRoot32BitConstants
. This function makes a copy of the pointed data and stores them inside the command list, in between draw calls and other commands that we are recording.
Because of this, it is recommended not to over-use this feature. It is better to avoid it or use it only for passing some small amount of data that really changes every draw call. Some index of an instance that a shader will use to refer to some larger record fetched from a buffer may be a good application of the “32BIT_CONSTANTS” root parameter type. Passing a whole 4x4 matrix, as we do in this example, is not so much.
So let’s see the option B), called “CBV”, also known as “root descriptor”. All we need to do when defining the root signature is to specify that register 3 space 0 will be of type CBV
:
params[2].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV;
params[2].ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX;
params[2].Descriptor.ShaderRegister = 3;
params[2].Descriptor.RegisterSpace = 0;
Here we have an additional level of indirection involved – a real constant buffer. Note that in approach A) we didn’t have any such thing, despite we defined ConstantBuffer
in the shader code. This is because it was kind of shortcut, an “imaginary constant buffer” created by D3D12 from directly passed root constants. Now we will have a real constant buffer object. While recording our command buffer, we have to set our root argument 2 to the address of that constant buffer, using function ID3D12GraphicsCommandList::SetGraphicsRootConstantBufferView
.
Note that words “descriptor” and “view” can be used somewhat interchangeably when discussing D3D12. Unlike in Vulkan, “views” are not separate objects here. Functions called Create*View
just setup descriptors in D3D12.
ID3D12Resource* constBuf = ...
D3D12_GPU_VIRTUAL_ADDRESS constBufGPUAddr = constBuf->GetGPUVirtualAddress();
commandList->SetGraphicsRootConstantBufferView(
2, // RootParameterIndex
constGPUBufAddr); // BufferLocation
D3D12_GPU_VIRTUAL_ADDRESS
is just a typedef
to UINT64
. D3D12, more often than Vulkan, allows to manipulate raw memory addresses. Thanks to this, there is no need for a separate parameter for an offset from the beginning of the buffer to the beginning of the data we want to bind. We can just increment the address by the right amount of bytes. This is a big upgrade compared to Direct3D 11 with functions like ID3D11DeviceContext::VSSetConstantBuffers
, where there was no offset or address, so every data record had to have its own buffer to start at the beginning. Only a later update to the API in DirectX 11.1 added function ID3D11DeviceContext1::VSSetConstantBuffers1
that accepted an offset, allowing to bundle many records of data in a single buffer object.
Back to the DirectX 12, we now need to discuss how a constant buffer is created. Actually, there is no such concept as a “constant buffer” per se. In D3D12 there are just “buffers” and “textures”, together called “resources”. To create a resource, we have to allocate some memory for it. There are two ways to do it, again differing in the number of levels of indirection.
First and the easiest option to create a resource is to call ID3D12Device::CreateCommittedResource
. This function creates a buffer or texture and allocates memory for it in one call. The resource will then have its own dedicated memory block. We do it like this:
D3D12_HEAP_PROPERTIES heapProps = {};
heapProps.Type = D3D12_HEAP_TYPE_UPLOAD;
D3D12_RESOURCE_DESC resDesc = {};
resDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
resDesc.Alignment = 0;
resDesc.Width = sizeof(glm::mat4);
resDesc.Height = 1;
resDesc.DepthOrArraySize = 1;
resDesc.MipLevels = 1;
resDesc.Format = DXGI_FORMAT_UNKNOWN;
resDesc.SampleDesc.Count = 1;
resDesc.SampleDesc.Quality = 0;
resDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
resDesc.Flags = D3D12_RESOURCE_FLAG_NONE;
ID3D12Resource* constBuf;
HRESULT res = device->CreateCommittedResource(
&heapProps,
D3D12_HEAP_FLAG_NONE,
&resDesc,
D3D12_RESOURCE_STATE_COPY_DEST, // InitialResourceState
nullptr, // pOptimizedClearValue
IID_PPV_ARGS(&constBuf));
Structure D3D12_HEAP_PROPERTIES
describes the memory where we want to allocate our buffer, e.g. D3D12_HEAP_TYPE_DEFAULT
in case of GPU memory and D3D12_HEAP_TYPE_UPLOAD
in case of system memory. Structure D3D12_RESOURCE_DESC
describes all the parameters of our resource. In case of a buffer, most of them take some default values, like in this example. Only Width
is the proper length of the buffer, in bytes. All other members take some meaningful values when we create textures, as they have a more complex structure with more parameters needed to describe them. But in this article we focus on buffers.
Creating all the resources this way is fine in D3D12, as we don’t have a small upper limit on the number of allocations we can make – unlike in Vulkan, where the limit is only 4096 on most GPUs. Nonetheless, memory allocation can be a slow operation, so it might be more efficient to pre-allocate larger memory blocks and dedicate parts of them to our small textures and buffers. <CommercialBreak> There is a free library that can help with this: D3D12 Memory Allocator from AMD :) </CommercialBreak>
If we want to do it by ourselves, we take the second path, where another object appears – a ID3D12Heap
. It represents a block of memory allocated by D3D12. Having such heap and an offset at which we want to place our new buffer inside of it, we can create the buffer like this, using function ID3D12Device::CreatePlacedResource
, which could be much faster:
ID3D12Heap* heap = ...
UINT64 offsetInHeap = ...
ID3D12Resource* constBuf;
HRESULT res = device->CreatePlacedResource(
heap,
offsetInHeap,
&resDesc,
D3D12_RESOURCE_STATE_COPY_DEST, // InitialResourceState
nullptr, // pOptimizedClearValue
IID_PPV_ARGS(&constBuf));
Now we have to learn how to allocate the heap. This is quite easy. The only caveat is that we should check D3D12_FEATURE_DATA_D3D12_OPTIONS::ResourceHeapTier
during initialization of our app. When it is D3D12_RESOURCE_HEAP_TIER_2
, we can freely mix all kinds of resources in one heap. However, if we find its value to be D3D12_RESOURCE_HEAP_TIER_1
(e.g. on NVIDIA GeForce GTX 900 series and earlier), we have to keep 3 types of resources (buffers, render target or depth stencil textures, other textures) in separate heaps and create them with flags like D3D12_HEAP_FLAG_ALLOW_ONLY_BUFFERS
in this example. The D3D12MA library would do it automatically.
D3D12_HEAP_DESC heapDesc = {};
heapDesc.SizeInBytes = 64ull * 1024 * 1024; // 64 MB
heapDesc.Properties.Type = D3D12_HEAP_TYPE_DEFAULT;
heapDesc.Alignment = D3D12_DEFAULT_RESOURCE_PLACEMENT_ALIGNMENT;
heapDesc.Flags = D3D12_HEAP_FLAG_ALLOW_ONLY_BUFFERS;
ID3D12Heap* heap;
HRESULT res = device->CreateHeap(&heapDesc, IID_PPV_ARGS(&heap));
To have the whole approach B) fully described, the only remaining part is the way to transfer our data from raw CPU void*
pointer to the constant buffer. Again, there are two ways to do it. If the buffer was created in the CPU memory, like D3D12_HEAP_TYPE_UPLOAD
, we can call ID3D12Resource::Map
to “map” its memory, get a raw CPU pointer to its data and just do memcpy
. GPU executing our shader can then read straight from this buffer, but the access will cause transfers over PCI Express bus, which may be slow.
The other option is to have another “staging” buffer created in CPU memory (D3D12_HEAP_TYPE_UPLOAD
), our main buffer created in GPU memory (D3D12_HEAP_TYPE_DEFAULT
) and explicitly issue a copy operation between them, to be executed at the optimal moment. It can be a call to ID3D12GraphicsCommandList::CopyResource
or some other copy function, e.g. CopyBufferRegion
.
Note that in the diagram, “Map()” lies on the side of “Preparation (CPU)”, which means it happens at the exact moment we call this code in C++. On the other hand, “CopyResource()” is a command recorded to the command list and executed later by the GPU, so it lies on the green side. We can record such copy operation to a command list executed on the graphics queue, to happen at the exact moment between our draw calls, or do it on copy queue or async compute queue, to happen asynchronously. You can watch my talk “Efficient Use of GPU Memory in Modern Games” to learn which way is more efficient in what cases.
Another important thing to note is that in the diagram, “constBuf” lies on the edge between the realm of command list execution and the pure CPU code. It means that the buffer itself (the one that we pass as the root argument) and the data inside of it (at least the part that we read in the shader) must remain alive and unchanged from the moment we submit the command buffer for execution until we are sure it finished executing on the GPU. Otherwise, bad things may happen, resulting in non-deterministic, hard to find bugs. We can ensure this e.g. by allocating and freeing data in a ring-buffer fashion.
It is time to move on to the last approach C), called “DESCRIPTOR_TABLE”. It will use everything we learned so far plus additional level of indirection in form of a descriptor heap. Although the most complicated, this is also the recommended way for typical use cases, as it puts the least pressure on the precious space in the root signature. One root parameter can describe the an entire set of shader registers and they can be even indexed dynamically in the shader (watch out for NonUniformResourceIndex when doing this!). Still, we will limit ourselves to just a single register b3
in this example, for simplicity.
D3D12_DESCRIPTOR_RANGE descRange = {};
descRange.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_CBV;
descRange.NumDescriptors = 1;
descRange.BaseShaderRegister = 3;
descRange.RegisterSpace = 0;
descRange.OffsetInDescriptorsFromTableStart = 0;
params[2].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE;
params[2].ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX;
params[2].DescriptorTable.NumDescriptorRanges = 1;
params[2].DescriptorTable.pDescriptorRanges = &descRange;
This time the ParameterType
is D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE
. We have another (surprise!) level of indirection here which allows us to specify an entire array of “descriptor ranges”, but we specify just one. Each descriptor range specifies shader register, space, number of descriptors, and an additional offset, which I will mention later.
Now we must create descriptorHeap
. An object of type ID3D12DescriptorHeap
is a special piece of D3D12 memory allocated to store descriptors. In other words, what we’ve set directly as a “CBV” root argument in the approach B), we will now store in a special place that the root signature will access. While creating the descriptor heap, we need to specify the type of descriptors we need to store (either CBV + SRV + UAV, SAMPLER, RTV, or DSV) and its maximum capacity. Having such an object, we can just refer to its content by indexing the descriptors.
D3D12_DESCRIPTOR_HEAP_DESC descriptorHeapDesc = {};
descriptorHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
descriptorHeapDesc.NumDescriptors = 1000;
descriptorHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
descriptorHeapDesc.NodeMask = 0;
ID3D12DescriptorHeap* descriptorHeap;
HRESULT res = device->CreateDescriptorHeap(
&descriptorHeapDesc, IID_PPV_ARGS(&descriptorHeap)));
To set our root argument 2 to use a specific descriptor from our newly created descriptor heap, we have to call two functions. First, ID3D12GraphicsCommandList::SetDescriptorHeaps
, just sets the whole descriptor heap as the current one. There can be only one descriptor heap of type CBV_SRV_UAV
and one of type SAMPLER
set at a time, so you must keep all the descriptors you may need for a draw call in one big heap.
commandList->SetDescriptorHeaps(1, &descriptorHeap);
The second call is to function ID3D12GraphicsCommandList::SetGraphicsRootDescriptorTable
. It sets our specific root argument 2 to point to a specific descriptor in the heap. One caveat is that the function expects a D3D12_GPU_DESCRIPTOR_HANDLE
, which is a structure containing (again!) a raw memory address. We can fetch an address of the beginning of the heap by calling ID3D12DescriptorHeap::GetGPUDescriptorHandleForHeapStart
. Then we need to increment it by the index of our descriptor in the heap, multiplied by a size of a descriptor, as the address is expressed in bytes.
Descriptors of a specific kind have a constant size that can be fetched using function ID3D12Device::GetDescriptorHandleIncrementSize
. We can do it just once, during program startup. The value is implementation-defined, but we can expect it to be fairly small. For example, on the PC that I now have handy, with GeForce RTX 2080 Ti card installed, the CBV_SRV_UAV
descriptor size is 32 bytes, so there should be no problem to use a heap capable of storing many thousands of such descriptors.
D3D12_GPU_DESCRIPTOR_HANDLE GPUDescriptorHandle =
descriptorHeap->GetGPUDescriptorHandleForHeapStart();
GPUDescriptorHandle.ptr += descriptorIndex *
device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
commandList->SetGraphicsRootDescriptorTable(
2, // RootParameterIndex
&GPUDescriptorHandle); // BaseDescriptor
Note that although the function is called SetGraphicsRootDescriptorTable
, there is no such thing as “descriptor table” as a separate type of object. A “descriptor table” is just a sequence of 1 or more descriptors in a descriptor heap, denoted by an address of the first one.
Note also that while D3D12_GPU_VIRTUAL_ADDRESS
was just a typedef
to UINT64
, D3D12_GPU_DESCRIPTOR_HANDLE
(and D3D12_CPU_DESCRIPTOR_HANDLE
described later) are structures that have the address as member ptr
. It is a bit of inconsistency, but it makes sense for them to be different types so the developer doesn’t confuse them by accident.
Note also that despite similar names, ID3D12DescriptorHeap
is not a kind of ID3D12Heap
, but a completely separate type of object. ID3D12Heap
is a piece of D3D12 memory intended to create “placed” resources (buffers and textures) in it, while ID3D12DescriptorHeap
is used for storing descriptors. Flags for selecting memory type are also different. ID3D12Heap
uses D3D12_HEAP_TYPE_DEFAULT
, UPLOAD
(mentioned above), or READBACK
, while ID3D12DescriptorHeap
just accepts D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE
set or not set (which is described below).
We discussed the creation of the descriptor heap, as well as setting it as the root parameter. One thing that remains is actually filling the contents of the descriptor. Similarly to what we did with a root descriptor in approach B) by calling SetGraphicsRootConstantBufferView
, a descriptor in a descriptor heap can be initialized using function ID3D12Device::CreateConstantBufferView
.
In the following code, structure D3D12_CONSTANT_BUFFER_VIEW_DESC
describes the parameters of the constant buffer our descriptor should point to, including its GPU virtual address and size. Second parameter of the CreateConstantBufferView
function is the address of the descriptor that should be written. Once again, it has to be a memory address in bytes, so we need to fetch the address of the heap start and then increment it by the descriptor index multiplied by the size of a descriptor. This time, however, it is a CPU address.
D3D12_GPU_VIRTUAL_ADDRESS constBufGPUAddr = constBuf->GetGPUVirtualAddress();
D3D12_CONSTANT_BUFFER_VIEW_DESC CBVDesc = {};
CBVDesc.BufferLocation = constBufGPUAddr;
CBVDesc.SizeInBytes = sizeof(glm::mat4);
D3D12_CPU_DESCRIPTOR_HANDLE CPUDescriptorHandle =
descriptorHeap->GetCPUDescriptorHandleForHeapStart();
CPUDescriptorHandle.ptr += descriptorIndex *
device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
device->CreateConstantBufferView(&CBVDesc, CPUDescriptorHandle);
Note that in the considerations above, we met 3 different ways of indexing descriptors inside the heap, which should not be confused:
SetGraphicsRootDescriptorTable
, we use an address of type D3D12_GPU_VIRTUAL_ADDRESS
(note the “GPU” part of the address type). It is expressed in bytes, so any descriptor index has to be multiplied by the “DescriptorHandleIncrementSize”. This operation is a command recorded to a command buffer, hence it lies on the green background.CreateConstantBufferView
, we use the address of type D3D12_CPU_VIRTUAL_ADDRESS
. It is also expressed in bytes, so a descriptor index has to be multiplied by the same “DescriptorHandleIncrementSize” – this value is the same on CPU and GPU side. This operation happens immediately on the CPU, hence it lies on the blue background.D3D12_DESCRIPTOR_RANGE::OffsetInDescriptorsFromTableStart
. As its name says, it is expressed in descriptors, not bytes, so it doesn’t need to be multiplied by anything. In our example, we just set it to 0.Note that “descriptorHeap” is depicted at the edge between command buffer and CPU realm, just like “constBuf”. It means that the object as a whole, as well as its descriptors that are referred by our shader, also needs to remain alive and unchanged from the moment we submit the command buffer for execution until we are sure it finished executing on the GPU. Otherwise, bad things will certainly happen. This is very important, as accessing bad descriptors is a notorious source of GPU crashes, while D3D Debug Layer cannot detect them, only GPU-based validation can!
Similarly to the constant buffer, a descriptor heap can also be set up in two different ways. A more direct way is to create just one heap with D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE
and write to it using functions like CreateConstantBufferView
, as in the example above. How descriptor heaps work internally is an implementation detail, but we can imagine a possible implementation of storing them in the special region of GPU memory visible to the CPU called Base Address Register (BAR). Then, the CreateConstantBufferView
call would write data through PCI Express bus.
The other option is to create one “staging” descriptor heap without D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE
flag that will probably end up in the system memory, another one with this flag, and do a copy between them using function ID3D12Device::CopyDescriptorsSimple
or, less efficient and thus not recommended, CopyDescriptors
. Note that unlike CopyResource
function mentioned earlier, copying descriptors is a CPU operation happening immediately, not a command recorded to a command buffer, thus the loop arrow lies on the blue background.
This is all. We went through the whole diagram. Congratulations on reading that far! Let me now mention briefly how binding other types of resources differs from binding a constant buffer we described above. Obviously, the A) “32BIT_CONSTANT” variant is available only for constants. Other types of resources can only use B) or C).
With buffers and textures bound as Shader Resource View (SRV), we define them in HLSL code as global variables of type: Texture1D
, Texture2D
, Texture3D
, Texture2DMS
(for multisampled), TextureCube
, Texture2DArray
etc. or StructuredBuffer
, ByteAddressBuffer
and assign them a register like t3
. On the CPU side, we keep them in the same descriptor heap of type D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV
. When root parameter is of type D3D12_ROOT_PARAMETER_TYPE_SRV
, a root descriptor can be set up using function ID3D12GraphicsCommandList::SetGraphicsRootShaderResourceView
(textures are not supported here, only buffers). In case of using a descriptor table, a descriptor in a descriptor heap is set up using ID3D12Device::CreateShaderResourceView
. The D3D12_SHADER_RESOURCE_VIEW_DESC
we have to fill is more extensive than in case of constant buffers and gives an opportunity to specify a “view” into a texture limited to certain mip levels or array slices.
Buffers and textures can also be bound as Unordered Access View (UAV), which allows to read as well as write them in a shader. For this, a global variable must be defined in HLSL of type RWTexture2D
, RWStructuredBuffer
, RWByteAddressBuffer
etc. with a register assigned like u3
. On the CPU side, we keep them in the same descriptor heap of type D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV
. When root parameter is of type D3D12_ROOT_PARAMETER_TYPE_UAV
, a root descriptor can be set up using function ID3D12GraphicsCommandList::SetGraphicsRootUnorderedAccessView
(again, only buffers are supported). In case of using a descriptor table, a descriptor in a descriptor heap is set up using ID3D12Device::CreateUnorderedAccessView
.
Samplers are different, because a sampler is just a set of parameters used when sampling a texture, so there are no data that it would refer to. Everything is stored in the sampler itself, which can be compared to the parameters passed while setting up a descriptor. In a shader, you need to define a global variable of type SamplerState
and assign it a register like s3
. On the CPU side, there must be a separate descriptor heap created for them of type D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER
. The size of a single sampler can also be different, so you need to fetch it using device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER)
. Setting up a sampler is done using function ID3D12Device::CreateSampler
. You then need to set this descriptor set with samplers, along with the one for CBV_SRV_UAV
, to ID3D12GraphicsCommandList::SetDescriptorHeaps
and point a root argument to a specific sampler using SetGraphicsRootDescriptorTable
.
There is also a shortcut called “static samplers”. You can define a sampler differently, without using a descriptor heap, by filling member D3D12_ROOT_SIGNATURE_DESC::pStaticSamplers
and NumStaticSamplers
. Then, parameters of a sampler are “baked” into the root signature.
Finally, Render Target View (RTV) and Depth Stencil View (DSV) are special kinds of descriptors. We also need to create a descriptor heap for them, of type D3D12_DESCRIPTOR_HEAP_TYPE_RTV
or D3D12_DESCRIPTOR_HEAP_TYPE_DSV
, respectively, but unlike CBV_SRV_UAV
and SAMPLER
we don’t need to set such descriptor heap as “current” using ID3D12GraphicsCommandList::SetDescriptorHeaps
. We can directly pass CPU addresses of these descriptors (of type D3D12_CPU_DESCRIPTOR_HANDLE
) to functions that operate on render-target or depth-stencil textures, like ID3D12GraphicsCommandList::ClearRenderTargetView
, ClearDepthStencilView
, OMSetRenderTargets
.
You can also read article “Resource binding in HLSL” in Microsoft documentation about a similar topic, and my new article: “Shapes and forms of DX12 root signatures”.