Descriptor bindings¶
Shaders access buffers, images and samplers through descriptors. This guide explains how to describe which resource a descriptor points to, and the rules both backends impose on it. LiteFX checks those rules whenever a resource is bound, and throws an exception that names the violated rule, rather than leaving you with undefined behavior on the GPU.
The rules apply to both backends. Code that passes them on one backend is portable to the other, except for the format and size limits of the device, which can differ between GPUs.
Describing bindings¶
A DescriptorBinding describes how a resource is bound: which resource, at which binding point, and which part of it. You pass bindings
when allocating a descriptor set, and when updating one later. The easiest way to create them are the factory functions, one for each kind
of descriptor:
| Factory function | Binds |
|---|---|
DescriptorBinding::constantBuffer(buffer, element, elements) |
Constant buffer elements, each to its own descriptor |
DescriptorBinding::structuredBuffer(buffer, firstElement, elements, elementsPerDescriptor, stride) |
A (writable) structured buffer |
DescriptorBinding::byteAddressBuffer(buffer, firstElement, elements, elementsPerDescriptor) |
A (writable) byte address buffer |
DescriptorBinding::texelBuffer(buffer, texelFormat, firstElement, elements, elementsPerDescriptor) |
A (writable) texel buffer |
DescriptorBinding::texture(image, firstLevel, levels, firstLayer, layers) |
A texture, optionally a range of its levels and layers |
DescriptorBinding::writableTexture(image, level, firstLayer, layers) |
A single level of a writable texture |
DescriptorBinding::inputAttachment(image) |
An input attachment |
DescriptorBinding::sampler(sampler) |
A sampler |
DescriptorBinding::accelerationStructure(accelerationStructure) |
A ray tracing acceleration structure |
All parameters after the resource are optional. Two functions adjust a binding afterwards:
at(binding)sets the binding point. Without it, a binding in a list targets the binding point of its position in that list.toDescriptor(index)starts binding at a descriptor other than the first one of a descriptor array in a descriptor set.
// Allocate a descriptor set, binding a constant buffer at binding point 0 and a texture at binding point 1.
auto bindings = layout.allocate({ DescriptorBinding::constantBuffer(*cameraBuffer), DescriptorBinding::texture(*texture) });
// Later, replace the texture.
bindings->update(DescriptorBinding::texture(*otherTexture).at(1));
Designated initializers work as well, for example { .resource = *buffer, .firstElement = 1, .elements = 1 }, which is convenient for bindings
that only need a few of the fields. A binding without a resource binds nothing, which lets you allocate a descriptor set first and fill it
later.
Descriptor ranges, which hold descriptors directly on the global descriptor heap (see the dynamic descriptors
sample), are updated with the same bindings. A range has no layout that defines
descriptor types, so the type is passed along with the bindings. The binding point selects the descriptor within the range: at(index)
starts binding at descriptor index, and without it, a binding in a list starts at its position in that list:
// Bind the instances, starting at the first descriptor of the range.
descriptorRange->update(DescriptorType::StructuredBuffer, DescriptorBinding::structuredBuffer(*instanceBuffer, 0u, NUM_INSTANCES));
// Bind another buffer to the descriptor at index 16 of the range.
descriptorRange->update(DescriptorType::StructuredBuffer, DescriptorBinding::structuredBuffer(*otherBuffer).at(16));
Buffers¶
The type of a buffer decides which descriptors it can be bound to. Descriptors that allow shaders to write additionally require the buffer to
be created with ResourceUsage::AllowWrite:
| Descriptor type | HLSL | Buffer type | Requires AllowWrite |
|---|---|---|---|
ConstantBuffer |
ConstantBuffer<T> |
Uniform |
|
StructuredBuffer |
StructuredBuffer<T> |
Storage or Indirect |
|
ByteAddressBuffer |
ByteAddressBuffer |
Storage or Indirect |
|
RWStructuredBuffer |
RWStructuredBuffer<T> |
Storage or Indirect |
|
RWByteAddressBuffer |
RWByteAddressBuffer |
Storage or Indirect |
|
Buffer |
Buffer<T> |
Texel |
|
RWBuffer |
RWBuffer<T> |
Texel |
The element range you bind (firstElement and elements) has to lie within the buffer. Elements are aligned to the requirements of the
descriptors a buffer can be bound to (see buffer layout), and their size is limited: constant
buffer elements must not exceed the device's limit for constant buffers (64 KiB in DirectX 12), storage buffer elements the limit for
storage buffers. Byte address buffers additionally require an element size that is a multiple of 4 bytes.
Elements and descriptors¶
A buffer binding distributes the bound elements across descriptors, which elementsPerDescriptor controls:
1(the default) binds each element to its own descriptor. Binding several elements thus fills consecutive descriptors of a descriptor array, as the bindless sample does with its instances.0binds all bound elements to a single descriptor. The shader then sees them as one array, for example aStructuredBuffer<T>that it indexes itself.- Any other value distributes the elements across
elements / elementsPerDescriptordescriptors, each viewing that many elements. The number of bound elements must be a multiple of it.
A descriptor that views more than one element requires a buffer without padding between its elements, i.e., an aligned element size that equals the element size. If the alignment rules pad the elements, binding fails, and the elements have to be bound to their own descriptors.
Constant buffers always bind each element to its own descriptor, which DescriptorBinding::constantBuffer reflects: arrays of constant
buffers are expressed as descriptor arrays.
Structured buffers¶
A structured buffer descriptor sees its memory as an array of structures, and the stride is the size of one structure. By default
(stride is 0), it is derived from the element size of the buffer, so each element holds one structure. This is usually what you want:
store one structure per element, and bind the elements with elementsPerDescriptor set to 0 to view them as an array.
Only set the stride explicitly if an element holds more than one structure, for example if a single element contains all draw commands of a frame:
auto bindings = layout.allocate({ DescriptorBinding::structuredBuffer(*commandsBuffer, 0, 1, 1, sizeof(IndirectIndexedBatch)) });
The stride has to divide both the element size and the aligned element size, so that the structures line up with every element. If that is not the case, pad the structure to a size that does.
Portability of the default stride
On Vulkan, the stride is always defined by the shader, so a missing stride goes unnoticed there. On DirectX 12, the descriptor uses the stride you provide, or the buffer's element size. If an element holds several structures and no stride is set, the descriptor only covers the first structure of each element: reads beyond it return zeros, and writes are discarded.
Texel buffers¶
Texel buffers (Buffer<T> and RWBuffer<T>) interpret their memory in a texture format, which you pass as texelFormat when binding. The
format is required, and the device must support it for the descriptor, which you can check upfront with
IGraphicsDevice::supportsTexelBufferFormat. The element size has to be a multiple of the texel size, and an element must not contain more
texels than the device supports.
Reading writable texel buffers
DirectX 12 only guarantees typed loads from a RWBuffer<T> for the formats R32_FLOAT, R32_UINT and R32_SINT. Other formats can
still be written, but reading them returns undefined values on devices that do not support it. The engine logs a warning when you bind
such a buffer.
Images¶
Images can be bound to Texture, RWTexture and InputAttachment descriptors, with these restrictions:
- Writable images (
RWTexture) must be created withResourceUsage::AllowWrite, and their format must support storage reads and writes on the device. You can check that withIGraphicsDevice::supportsStorageImageFormat. - The mip levels and layers you bind have to lie within the image.
- 3D images are always bound with their full depth: Vulkan cannot select individual depth slices.
- Multisampled images have no mip levels, so only their first level can be bound.
- Cube map arrays are currently not supported.
Prefer RGBA for images written by shaders
HLSL accesses RWTexture without declaring a format in the shader. Vulkan does not guarantee that BGRA formats such as B8G8R8A8_UNORM
support this, so binding such an image fails on some devices. Use R8G8B8A8_UNORM for images that shaders write. If the image is copied into the swap chain afterwards, the
swap chain needs the same format, since copies between images require matching formats. The compute
sample does this.
Samplers¶
Samplers can only be bound to Sampler descriptors.