Skip to content

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:

1
2
3
4
5
6
7
8
9
// 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:

1
VUID-vkCmdDraw-None-02699: Descriptor at binding 1 not initialized!

Partially Bound Descriptors:

With PartiallyBoundBit, uninitialized bindings are allowed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 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:

  1. Bind descriptor set with empty binding
  2. Update binding later (before shader accesses it)
  3. 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:

1
2
3
    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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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);
}

Performance Notes

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