Bindless Descriptors¶
Zest uses a bindless descriptor model where all resources are indexed into global arrays. This eliminates per-object descriptor set management and enables flexible resource access.
Why Bindless?¶
In the case of Vulkan (not so much with Direct X and Metal), it requires creating and binding separate descriptor sets for each object's resources. This creates overhead from: - Allocating descriptor sets from pools - Updating descriptors when resources change - Binding different sets between draw calls
Bindless descriptors solve this by putting all resources into large arrays indexed by integers. You bind the global descriptor set once, then pass indices via push constants or uniform buffers to select which resources each draw call uses.
How Bindless Works¶
The following pseudocode illustrates the conceptual difference:
Traditional Model¶
// Per-object descriptor sets (Vulkan traditional)
VkDescriptorSet object1_set = CreateDescriptorSet(texture1, sampler);
VkDescriptorSet object2_set = CreateDescriptorSet(texture2, sampler);
// Must bind different sets per object
vkCmdBindDescriptorSets(..., object1_set);
vkCmdDraw(...);
vkCmdBindDescriptorSets(..., object2_set);
vkCmdDraw(...);
Bindless Model¶
// Single global set with all resources - bound once at frame start
// (Zest binds this automatically during frame graph execution)
// Pass indices via push constants to select resources
push.tex_index = object1_texture_index;
zest_cmd_SendPushConstants(cmd, &push, sizeof(push));
zest_cmd_Draw(cmd, vertex_count, 1, 0, 0);
push.tex_index = object2_texture_index;
zest_cmd_SendPushConstants(cmd, &push, sizeof(push));
zest_cmd_Draw(cmd, vertex_count, 1, 0, 0);
Resource Types¶
Zest's bindless system supports:
| Binding | Constant | Resource Type | Array |
|---|---|---|---|
| 0 | zest_sampler_binding | Samplers | sampler[] |
| 1 | zest_texture_2d_binding | 2D Textures | texture2D[] |
| 2 | zest_texture_cube_binding | Cube Textures | textureCube[] |
| 3 | zest_texture_array_binding | Texture Arrays | texture2DArray[] |
| 4 | zest_texture_3d_binding | 3D Textures | texture3D[] |
| 5 | zest_storage_buffer_binding | Storage Buffers | buffer[] |
| 6 | zest_storage_image_binding | Storage Images | image2D[] |
| 7 | zest_uniform_buffer_binding | Uniform Buffers | uniform[] |
Take note of the binding numbers as that's what you need to use to correctly set up your shaders.
Cross-backend binding contract¶
The slot numbers above are part of Zest's API contract and will not change. Your application code only deals in slot numbers (zest_binding_number_type) and the indexes returned by the zest_Acquire*Index functions, so it is identical on every backend. Only the shader-side declaration is per-language:
- GLSL/SPIR-V (Vulkan, today):
layout(set = 0, binding = <slot>)as shown in the examples below. - HLSL (D3D12, future): the backend maps each slot to a register/space (one space per slot).
- MSL (Metal, future): the backend maps each slot to an argument buffer id (
[[id(<slot>)]]).
Writing shaders in Slang lets you declare bindings once and target every backend.
Note also that samplers and images are always separate bindings — there is no combined image sampler descriptor type (D3D12 and Metal have no equivalent). Combine them at sample time in the shader, e.g. texture(sampler2D(textures[i], samplers[j]), uv).
Descriptor update-after-bind behaviour is handled by the backend automatically: you can acquire new indexes mid-frame for everything except uniform buffers (see Uniform Buffers below).
Acquiring Indices¶
Sampled Images (Textures)¶
zest_image image = zest_GetImage(image_handle);
// Acquire index for 2D texture
zest_uint tex_index = zest_AcquireSampledImageIndex(
device,
image,
zest_texture_2d_binding // Binding type
);
Samplers¶
zest_sampler sampler = zest_GetSampler(sampler_handle);
zest_uint sampler_index = zest_AcquireSamplerIndex(device, sampler);
Storage Images¶
Storage Buffers¶
Uniform Buffers¶
//(Indexes are acquired automatically when the uniform buffer is created)
zest_uniform_buffer_handle ubo_handle = zest_CreateUniformBuffer(context, "camera", sizeof(camera_t));
zest_uniform_buffer ubo = zest_GetUniformBuffer(ubo_handle);
zest_uint ubo_index = zest_GetUniformBufferDescriptorIndex(ubo);
Create uniform buffers during setup, not mid-frame. Their descriptors are written once at creation and must not be updated while the set is in flight (the global layout deliberately supports older/mobile GPUs here). All other resource types can be acquired mid-frame.
Shader Setup¶
GLSL Descriptor Layout¶
#version 450
#extension GL_EXT_nonuniform_qualifier : enable
// Bindless arrays at set 0 (binding numbers match zest_binding_number_type)
layout(set = 0, binding = 0) uniform sampler samplers[]; // zest_sampler_binding
layout(set = 0, binding = 1) uniform texture2D textures[]; // zest_texture_2d_binding
layout(set = 0, binding = 2) uniform textureCube cubemaps[]; // zest_texture_cube_binding
layout(set = 0, binding = 3) uniform texture2D texture_arrays[]; // zest_texture_array_binding
layout(set = 0, binding = 5) buffer StorageBuffers { // zest_storage_buffer_binding
float data[];
} storage_buffers[];
layout(set = 0, binding = 6, rgba16f) uniform image2D storage_images[]; // zest_storage_image_binding
layout(set = 0, binding = 7) uniform UniformBuffers { // zest_uniform_buffer_binding
mat4 view;
mat4 projection;
} uniforms[];
// Push constants for indices
layout(push_constant) uniform PushConstants {
uint texture_index;
uint sampler_index;
uint ubo_index;
} push;
void main() {
// Sample texture using indices
vec4 color = texture(
sampler2D(textures[push.texture_index], samplers[push.sampler_index]),
uv
);
// Access uniform buffer
mat4 vp = uniforms[push.ubo_index].view * uniforms[push.ubo_index].projection;
}
Slang Descriptor Layout¶
// Bindless resources
Sampler2D textures[];
SamplerState samplers[];
struct PushConstants {
uint textureIndex;
uint samplerIndex;
};
[[vk::push_constant]] PushConstants push;
float4 main() : SV_Target {
return textures[push.textureIndex].Sample(samplers[push.samplerIndex], uv);
}
Push Constants¶
Push constants are the primary way to pass indices to shaders.
Defining Push Constants¶
struct push_constants_t {
zest_matrix4 transform;
zest_uint texture_index;
zest_uint sampler_index;
zest_uint ubo_index;
float time;
};
Sending Push Constants¶
void RenderCallback(zest_command_list cmd, void* data) {
app_t* app = (app_t*)data;
push_constants_t push = {};
push.transform = app->model_matrix;
push.texture_index = app->texture_index;
push.sampler_index = app->sampler_index;
push.ubo_index = app->ubo_index;
push.time = app->current_time;
zest_cmd_SendPushConstants(cmd, &push, sizeof(push));
zest_cmd_Draw(cmd, 6, 1, 0, 0); // vertex_count, instance_count, first_vertex, first_instance
}
Releasing Indices¶
// Release image indices (sampled or storage images)
// Pass the image and the binding type used when acquiring
zest_ReleaseImageIndex(device, image, zest_texture_2d_binding);
zest_ReleaseImageIndex(device, image, zest_storage_image_binding);
// Release storage buffer index (uses array index directly)
zest_ReleaseStorageBufferIndex(device, buffer_index);
Note: Image indices, Sampler indices and uniform buffer indices are managed automatically when the resource is freed.
Bindless Layout Access¶
Access the device's bindless descriptor set layout:
Per-Instance Indices¶
For instanced rendering, store indices in instance data:
struct instance_t {
zest_vec3 position;
zest_uint texture_index; // Each instance can have different texture
zest_uint material_index;
};
In shader:
#extension GL_EXT_nonuniform_qualifier : require
layout(location = 3) in flat uint in_texture_index;
void main() {
// Varies per instance within the draw, so this index is NOT dynamically uniform
// and must be wrapped. Requires zest_capability_nonuniform_sampled_image_indexing.
vec4 color = texture(
sampler2D(textures[nonuniformEXT(in_texture_index)], samplers[0]),
uv
);
}
If you need to support hardware without that capability, use a texture array and vary the layer per instance instead — the layer index is an ordinary integer, not a descriptor index, so it carries no such requirement. That is what Zest's own billboard and font shaders do.
Best Practices¶
- Acquire indices at load time - Not every frame
- Release indices when done - Prevents descriptor pool exhaustion
- Use push constants for dynamic indices - Fast to update
- Store static indices in instance data - For per-object textures
Limitations¶
- Maximum resources depend on GPU limits (usually 500K+ descriptors)
- Some older GPUs have lower limits
GL_EXT_nonuniform_qualifiermust be enabled in GLSL to declare unsized bindless arrays (textures[]), but see the note below before usingnonuniformEXT()itself
Uniform vs non-uniform indexing¶
Zest only requires dynamically uniform indexing of the bindless arrays — the core Vulkan 1.0 shader*ArrayDynamicIndexing features, which are near-universally supported. An index is dynamically uniform when every invocation in a draw or dispatch reads the same value; a push constant always qualifies, which is why every index in Zest's own shaders is delivered by push constant.
Indexing with a value that varies within a draw — a per-instance vertex attribute, or a value read from a storage buffer per invocation — is non-uniform, and needs nonuniformEXT() in the shader plus hardware support. That support is optional, so check for it before relying on it:
if (zest_DeviceFeatureEnabled(device, zest_capability_nonuniform_sampled_image_indexing)) {
// safe to use a shader variant with nonuniformEXT() on sampled-image indices
}
The matching capabilities are zest_capability_nonuniform_sampled_image_indexing, ..._storage_buffer_indexing, ..._uniform_buffer_indexing and ..._storage_image_indexing. All are auto-enabled when the hardware supports them.
Do not add nonuniformEXT() "just in case": it emits the SPIR-V ShaderNonUniform capability, which makes the shader fail to load on hardware that would otherwise have run it fine. The Per-Instance Indices pattern below is one of the cases that genuinely needs it.
Example: Multi-Textured Scene¶
// At load time
struct object_t {
zest_uint texture_index;
zest_uint normal_index;
zest_uint index_count;
zest_uint index_offset;
};
object_t objects[MAX_OBJECTS];
for (int i = 0; i < object_count; i++) {
objects[i].texture_index = zest_AcquireSampledImageIndex(device,
zest_GetImage(textures[i]), zest_texture_2d_binding);
objects[i].normal_index = zest_AcquireSampledImageIndex(device,
zest_GetImage(normals[i]), zest_texture_2d_binding);
}
// At render time
void RenderCallback(zest_command_list cmd, void* data) {
scene_t* scene = (scene_t*)data;
push_constants_t push = {};
for (int i = 0; i < scene->object_count; i++) {
push.texture_index = scene->objects[i].texture_index;
push.normal_index = scene->objects[i].normal_index;
zest_cmd_SendPushConstants(cmd, &push, sizeof(push));
zest_cmd_DrawIndexed(cmd,
scene->objects[i].index_count, // index_count
1, // instance_count
scene->objects[i].index_offset, // first_index
0, // vertex_offset
0); // first_instance
}
}
See Also¶
- Images - Image creation and bindless indices
- Pipelines - Push constant configuration
- API Reference - Bindless functions