Partially Bound Descriptors
This example shows how to use the descriptorBindingPartiallyBound feature to create descriptor sets where some bindings haven't been initialized yet. Normally, binding an incomplete descriptor set causes validation errors. Partially bound descriptors allow conditional resource access in shaders - you only need to populate bindings that the shader will actually use. This enables dynamic resource management, lazy loading, and optional shader features.
The example uses the KDGpuExample helper API for simplified setup.
Overview
What this example demonstrates:
- Enabling
descriptorBindingPartiallyBound feature
- Creating descriptor set layouts with
PartiallyBoundBit flag
- Binding descriptor sets with uninitialized bindings
- Updating partially bound descriptors at runtime
- Conditional resource access in shaders
Use cases:
- Optional shader features (enable/disable via uniforms)
- Lazy resource loading (populate descriptors on demand)
- Conditional texture sampling (material variations)
- Resource streaming (bind textures as they load)
- Shader permutations without pipeline changes
Vulkan Requirements
- Vulkan Version: 1.2+ (descriptor indexing promoted to core)
- Extensions: VK_EXT_descriptor_indexing (core in 1.2)
- Features:
descriptorBindingPartiallyBound
- Validation: Only accessed bindings must be valid
Key Concepts
Traditional Descriptor Binding:
Normally, all bindings in a descriptor set must be initialized:
| // Descriptor set layout:
binding 0: Uniform buffer
binding 1: Combined image sampler
// Create descriptor set:
resources = {
{binding 0, uniformBuffer}, // Must be provided!
{binding 1, texture} // Must be provided!
};
|
Binding without initializing all resources causes validation error:
| VUID-vkCmdDraw-None-02699: Descriptor at binding 1 not initialized!
|
Partially Bound Descriptors:
With PartiallyBoundBit, uninitialized bindings are allowed:
| // Layout with partially bound flag:
binding 0: Uniform buffer (required)
binding 1: Combined image sampler (PartiallyBoundBit - optional!)
// Create descriptor set (binding 1 left empty):
resources = {
{binding 0, uniformBuffer} // Only provide binding 0
};
// Valid! Shader must not access binding 1, or update it before access
|
Shader:
1
2
3
4
5
6
7
8
9
10
11
12
13 | layout(set = 0, binding = 0) uniform Params {
bool useTexture;
};
layout(set = 0, binding = 1) uniform sampler2D optionalTexture;
void main() {
if (params.useTexture) {
color = texture(optionalTexture, uv); // Only access if bound!
} else {
color = baseColor;
}
}
|
Rules:
- Uninitialized bindings must not be accessed
- Accessing uninitialized binding causes undefined behavior (not validated!)
- Programmer responsible for ensuring correctness
Spec: https://registry.khronos.org/vulkan/specs/1.3-extensions/man/html/VK_EXT_descriptor_indexing.html
Update After Bind:
Partially bound is often used with "update after bind" to modify descriptors after binding:
- Bind descriptor set with empty binding
- Update binding later (before shader accesses it)
- Draw with updated binding
Implementation
Creating Texture:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 | // Create a texture
{
// Load the image data and size
auto imageFile = KDGpuExample::assetDir().file("textures/samuel-ferrara-1527pjeb6jg-unsplash.jpg");
ImageData image = loadImage(imageFile);
const TextureOptions textureOptions = {
.type = TextureType::TextureType2D,
.format = image.format,
.extent = { .width = image.width, .height = image.height, .depth = 1 },
.mipLevels = 1,
.usage = TextureUsageFlagBits::SampledBit | TextureUsageFlagBits::TransferDstBit,
.memoryUsage = MemoryUsage::GpuOnly,
.initialLayout = TextureLayout::Undefined
};
m_texture = m_device.createTexture(textureOptions);
// Upload the texture data and transition to ShaderReadOnlyOptimal
const std::vector<BufferTextureCopyRegion> regions = {
{
.textureSubResource = { .aspectMask = TextureAspectFlagBits::ColorBit },
.textureExtent = { .width = image.width, .height = image.height, .depth = 1 },
},
};
const TextureUploadOptions uploadOptions = {
.destinationTexture = m_texture,
.dstStages = PipelineStageFlagBit::AllGraphicsBit,
.dstMask = AccessFlagBit::MemoryReadBit,
.data = image.pixelData,
.byteSize = image.byteSize,
.oldLayout = TextureLayout::Undefined,
.newLayout = TextureLayout::ShaderReadOnlyOptimal,
.regions = regions
};
uploadTextureData(uploadOptions);
// Create a view and sampler
m_textureView = m_texture.createView();
m_sampler = m_device.createSampler();
}
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
Texture created but not immediately bound to descriptor set.
Descriptor Layout with Partially Bound Flag:
1
2
3
4
5
6
7
8
9
10
11
12 | // Create a bind group layout with the PartiallyBoundBit
m_textureBindGroupLayout = m_device.createBindGroupLayout(BindGroupLayoutOptions{
.bindings = {
{
.binding = 0,
.resourceType = ResourceBindingType::CombinedImageSampler,
.shaderStages = ShaderStageFlagBits::FragmentBit,
.flags = { ResourceBindingFlagBits::PartiallyBoundBit },
// No resource set at this point
},
},
});
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
ResourceBindingFlagBits::PartiallyBoundBit marks binding 0 as optionally uninitialized.
Empty Descriptor Set:
| m_textureBindGroup = m_device.createBindGroup(BindGroupOptions{
.layout = m_textureBindGroupLayout,
});
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
Created with no resources - valid because of PartiallyBoundBit.
Push Constants for Conditional Access:
| m_transformCountPushConstant = PushConstantRange{
.offset = 0,
.size = sizeof(glm::mat4),
.shaderStages = ShaderStageFlagBits::VertexBit
};
m_textureInUsePushConstant = PushConstantRange{
.offset = sizeof(glm::mat4),
.size = sizeof(TextureInfoPushConstant),
.shaderStages = ShaderStageFlagBits::FragmentBit
};
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
Push constants can control whether shader accesses the binding.
Pipeline Creation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33 | // Create a pipeline layout (array of bind group layouts)
m_pipelineLayout = m_device.createPipelineLayout(PipelineLayoutOptions{
.bindGroupLayouts = { m_textureBindGroupLayout },
.pushConstantRanges = { m_transformCountPushConstant, m_textureInUsePushConstant },
});
// Create a pipeline
m_pipeline = m_device.createGraphicsPipeline(GraphicsPipelineOptions{
.shaderStages = {
{ .shaderModule = vertexShader, .stage = ShaderStageFlagBits::VertexBit },
{ .shaderModule = fragmentShader, .stage = ShaderStageFlagBits::FragmentBit },
},
.layout = m_pipelineLayout,
.vertex = {
.buffers = {
{ .binding = 0, .stride = sizeof(Vertex) },
},
.attributes = {
{ .location = 0, .binding = 0, .format = Format::R32G32B32_SFLOAT }, // Position
{ .location = 1, .binding = 0, .format = Format::R32G32B32_SFLOAT, .offset = sizeof(glm::vec3) } // Color
},
},
.renderTargets = {
{
.format = m_swapchainFormat,
},
},
.depthStencil = {
.format = m_depthFormat,
.depthWritesEnabled = true,
.depthCompareOperation = CompareOperation::Less,
},
});
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
Standard pipeline setup.
Runtime Descriptor Update:
On each frame update, we will update a rotation matrix.
| void BindGroupPartiallyBound::updateScene()
{
static float angle = 0.0f;
const float angularSpeed = 3.0f; // degrees per second
const float dt = engine()->deltaTimeSeconds();
angle += angularSpeed * dt;
if (angle > 360.0f)
angle -= 360.0f;
m_transform = glm::rotate(glm::mat4(1.0f), glm::radians(angle), glm::vec3(0.0f, 0.0f, 1.0f));
}
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
Update binding before shader access. Can be done anytime before draw that uses it.
As for the actual rendering, first we will conditionally bind our TextureViewSamplerBinding to the bind group. Note that until we have rendered 1000 frames, our texture binding will remain unbound.
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | static uint32_t frameCounter = 0;
const VkBool32 useTexture = frameCounter++ > 1000;
if (useTexture) {
// Only bind the Texture to the BindGroup after a while
// hence the Partially Bound Bind Group
m_textureBindGroup.update({
.binding = 0,
.resource = TextureViewSamplerBinding{
.textureView = m_textureView,
.sampler = m_sampler,
},
});
}
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
Then we will simply bind the pipeline, buffers and bind groups and render. The information whether we use the texture or not will be provided to the shader through the pushConstant.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 | auto commandRecorder = m_device.createCommandRecorder();
auto opaquePass = commandRecorder.beginRenderPass(KDGpu::RenderPassCommandRecorderOptions{
.colorAttachments = {
{
.view = m_swapchainViews.at(m_currentSwapchainImageIndex),
.clearValue = { 0.3f, 0.3f, 0.3f, 1.0f },
.finalLayout = TextureLayout::PresentSrc,
},
},
.depthStencilAttachment = {
.view = m_depthTextureView,
},
});
opaquePass.setPipeline(m_pipeline);
opaquePass.setVertexBuffer(0, m_buffer);
opaquePass.setIndexBuffer(m_indexBuffer);
// Push Constant
opaquePass.pushConstant(m_transformCountPushConstant, &m_transform);
const TextureInfoPushConstant textureInfoPushConstant{
.viewportSize = { float(m_window->width()), float(m_window->height()) },
.useTexture = useTexture,
};
opaquePass.pushConstant(m_textureInUsePushConstant, &textureInfoPushConstant);
// Bind bindGroups
opaquePass.setBindGroup(0, m_textureBindGroup);
const DrawIndexedCommand drawCmd = { .indexCount = 3 };
opaquePass.drawIndexed(drawCmd);
opaquePass.end();
m_commandBuffer = commandRecorder.finish();
|
Filename: bindgroup_partially_bound/bindgroup_partially_bound.cpp
When it comes to our shader, the interesting part lies in the fragment shader which will conditionally read from our texture based on the useTexture field of the pushConstant.
1
2
3
4
5
6
7
8
9
10
11
12
13 | layout(set = 0, binding = 0) uniform sampler2D colorTexture;
layout(push_constant) uniform PushConstants
{
layout(offset = 64) vec2 viewportSize;
bool useTexture;
}
fragPushConstants;
void main()
{
fragColor = fragPushConstants.useTexture ? texture(colorTexture, gl_FragCoord.xy / fragPushConstants.viewportSize) : vec4(color, 1.0);
}
|
Benefits:
- Reduced descriptor set allocations (no need for dummy resources)
- Lazy resource loading (bind only when available)
- Simplified resource management
Costs:
- Programmer must ensure correctness (no validation for uninitialized access)
- Debugging harder (uninitialized access is UB, not an error)
Best Practices:
- Use push constants or specialization constants to control access
- Document which bindings are optional
- Assert in debug builds that required bindings are populated
- Consider
descriptorBindingUpdateAfterBind for dynamic updates
See Also
Further Reading
Updated on 2026-03-31 at 00:02:07 +0000