RT Graphics Programming
Lesson 17 of 28

Part 2 · Vulkan implementation

17. Descriptors Give the Shader Its Resources

Make the storage image visible to a compute shader through one inspectable descriptor and pipeline layout.

Graphics glossary 43 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

A shader can contain the correct pixel calculation and still have no image to write. A host program can create the correct image view and still expose it at the wrong binding. Vulkan makes the interface between those two descriptions explicit. The shader declares a descriptor set number, binding number, descriptor-compatible type and access. The host creates a matching descriptor set layout, updates an allocated descriptor set with the actual resource and includes that layout in the pipeline layout.

The tempting proxy is a successfully created pipeline. Pipeline creation establishes that the shader module and declared layout satisfy Vulkan's creation rules. It does not establish that the descriptor set later bound at execution contains the intended image, or that the image is in a layout suitable for storage access. The entire static and resource interface must therefore be accounted for before a dispatch is recorded.

What you should be able to account for

  • Compile a compute shader to SPIR-V and create a shader module from the resulting words.
  • Make one storage-image declaration agree across shader, descriptor set layout, descriptor update and pipeline layout.
  • Explain why pipeline creation, descriptor allocation and descriptor binding are distinct operations.

Declare the smallest useful compute shader

The first shader writes a diagnostic gradient rather than tracing a scene. Its purpose is to prove that each global invocation can address one texel in the storage image. A 16 by 16 local workgroup is a choice, not a claim that 256 invocations is optimal for every device or shader. Lesson 18 will calculate the required workgroup count and guard the edge.

#version 460

layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;
layout(set = 0, binding = 0, rgba8)
uniform writeonly image2D outputImage;

void main() {
    ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
    ivec2 extent = imageSize(outputImage);

    if (pixel.x >= extent.x || pixel.y >= extent.y) {
        return;
    }

    vec2 samplePosition =
        (vec2(pixel) + vec2(0.5)) / vec2(extent);

    imageStore(
        outputImage,
        pixel,
        vec4(samplePosition, 0.15, 1.0));
}

The format qualifier and image view format must be compatible. The shader requests a storage image at set zero, binding zero. writeonly states that this declaration does not read existing texels; it does not create synchronisation or change the image layout.

Compile source into the module input

Vulkan's traditional shader ingestion path consumes SPIR-V words. Keep the shader source and compiler command in the project so the binary can be reproduced. For example, the Vulkan SDK's glslc tool can compile the file as a compute shader:

glslc shaders/raytrace.comp -o shaders/raytrace.comp.spv

Read the binary as 32-bit words and reject a byte count that is not divisible by four. The shader module copies or consumes the supplied code during creation as specified, so the file buffer need not remain alive for the life of the pipeline.

std::vector<uint32_t> spirv = readSpirvWords(
    "shaders/raytrace.comp.spv");

VkShaderModuleCreateInfo moduleInfo{
    .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
    .codeSize = spirv.size() * sizeof(uint32_t),
    .pCode = spirv.data()
};

VkShaderModule shaderModule = VK_NULL_HANDLE;
check(vkCreateShaderModule(
    device,
    &moduleInfo,
    nullptr,
    &shaderModule));

A shader module is not a compute pipeline. It supplies code from which a pipeline stage will select the main entry point. The pipeline also needs a layout describing the resources that entry point may access.

Describe the binding before allocating it

VkDescriptorSetLayoutBinding outputBinding{
    .binding = 0,
    .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
    .descriptorCount = 1,
    .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT
};

VkDescriptorSetLayoutCreateInfo setLayoutInfo{
    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
    .bindingCount = 1,
    .pBindings = &outputBinding
};

VkDescriptorSetLayout setLayout = VK_NULL_HANDLE;
check(vkCreateDescriptorSetLayout(
    device,
    &setLayoutInfo,
    nullptr,
    &setLayout));

VkPipelineLayoutCreateInfo pipelineLayoutInfo{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
    .setLayoutCount = 1,
    .pSetLayouts = &setLayout
};

VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
check(vkCreatePipelineLayout(
    device,
    &pipelineLayoutInfo,
    nullptr,
    &pipelineLayout));

The descriptor set layout states what a compatible set must contain. The pipeline layout states which set layouts and push-constant ranges belong to the pipeline interface. Neither object contains the output image view. That concrete association belongs to a descriptor set.

Create the compute pipeline

VkPipelineShaderStageCreateInfo stageInfo{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
    .stage = VK_SHADER_STAGE_COMPUTE_BIT,
    .module = shaderModule,
    .pName = "main"
};

VkComputePipelineCreateInfo pipelineInfo{
    .sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
    .stage = stageInfo,
    .layout = pipelineLayout
};

VkPipeline computePipeline = VK_NULL_HANDLE;
check(vkCreateComputePipelines(
    device,
    VK_NULL_HANDLE,
    1,
    &pipelineInfo,
    nullptr,
    &computePipeline));

vkDestroyShaderModule(device, shaderModule, nullptr);

Destroying the shader module after pipeline creation does not destroy the pipeline. The pipeline owns the compiled implementation state it requires. Retain the module only if the application needs it for another pipeline creation. The pipeline layout and descriptor set layout must remain alive while required by their dependent use.

Allocate and update the concrete descriptor set

A descriptor pool supplies storage for descriptor sets. The pool count describes how many storage-image descriptors may be allocated from it; maxSets describes how many sets. Our single set requires one of each.

VkDescriptorPoolSize poolSize{
    .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
    .descriptorCount = 1
};

VkDescriptorPoolCreateInfo poolInfo{
    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
    .maxSets = 1,
    .poolSizeCount = 1,
    .pPoolSizes = &poolSize
};

VkDescriptorPool descriptorPool = VK_NULL_HANDLE;
check(vkCreateDescriptorPool(
    device, &poolInfo, nullptr, &descriptorPool));

VkDescriptorSetAllocateInfo allocateInfo{
    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
    .descriptorPool = descriptorPool,
    .descriptorSetCount = 1,
    .pSetLayouts = &setLayout
};

VkDescriptorSet descriptorSet = VK_NULL_HANDLE;
check(vkAllocateDescriptorSets(
    device, &allocateInfo, &descriptorSet));

Now associate binding zero with the view created in Lesson 16. The descriptor states VK_IMAGE_LAYOUT_GENERAL because that is the layout in which the compute shader will access it. The recorded transition remains Lesson 18's responsibility.

VkDescriptorImageInfo imageDescriptor{
    .sampler = VK_NULL_HANDLE,
    .imageView = outputView,
    .imageLayout = VK_IMAGE_LAYOUT_GENERAL
};

VkWriteDescriptorSet write{
    .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
    .dstSet = descriptorSet,
    .dstBinding = 0,
    .dstArrayElement = 0,
    .descriptorCount = 1,
    .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
    .pImageInfo = &imageDescriptor
};

vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);

Follow binding zero

The shader says set 0, binding 0, storage image, compute stage. The descriptor set layout says binding 0, storage image, count one, compute-stage visibility. The pipeline layout places that set layout at set 0. The allocated descriptor set conforms to that layout. Its update writes the output view at binding 0 and declares GENERAL. Changing only dstBinding to 1 does not create a second interpretation. It leaves the shader's requested binding without the intended descriptor and violates the interface.

Locate the actual resource

Which object in this lesson contains the association with outputView: the descriptor set layout, pipeline layout, compute pipeline or updated descriptor set?

The answer

The updated descriptor set contains the descriptor association with the image view. The set layout declares what may occupy binding zero. The pipeline layout includes that declaration in the shader interface. The pipeline uses the layout, but none of those static objects substitutes for the concrete descriptor update.

Official reference: see the Khronos chapters on resource descriptors and compute pipelines, together with the guides to mapping data to shaders and high-level shader languages.

Break one part of the contract at a time

With validation enabled, change the host layout binding to 1 while leaving the shader at 0. Restore it, then change the descriptor type to sampled image while leaving the shader declaration as storage image. Record the diagnostic produced at the earliest point and the later operation that would otherwise rely on the mismatch. Finally, restore the matching interface and retain a printed table of set, binding, descriptor type, count and stage flags. This table becomes more valuable when later lessons add camera and scene buffers.

Carry one stable interface forward

Keep output image at set 0, binding 0 throughout the compute sequence. Later resources will take new bindings rather than silently renumbering this one. Stable binding numbers make shader and host diffs easier to inspect. Lesson 18 will bind the pipeline and descriptor set in a command buffer, launch enough workgroups to cover the image and declare when the resulting shader writes become visible to the copy used for presentation.

The pipeline exists, the descriptor set names a real storage image and the shader can calculate a diagnostic colour. Nothing has executed. This distinction is the point at which copied Vulkan samples often become obscure: construction success is mistaken for submitted work. Lesson 18 records the missing commands and makes their dependencies explicit.