RT Graphics Programming
Lesson 18 of 28

Part 2 · Vulkan implementation

18. Record, Dispatch, Synchronise, Present

Turn a pipeline and its resources into ordered device work whose image writes are made visible before presentation.

Graphics glossary 49 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 compute pipeline does not run when it is created. A descriptor set does not become active when it is updated. Vulkan commands must be recorded into a command buffer, the command buffer must be submitted to a compatible queue, and the resources touched by one operation must be made available and visible to later operations. The apparent order of C++ statements is not a substitute for a device dependency.

The first presented image in Part II is deliberately only the diagnostic gradient from Lesson 17. A recognisable scene would provide a tempting distraction from the execution route we need to inspect: acquire a presentation image, transition the storage image, bind and dispatch compute work, make its writes visible to a transfer, copy the image, transition the destination for presentation, submit and present with suitable synchronisation.

What you should be able to account for

  • Allocate a command buffer from a pool associated with the selected queue family.
  • Calculate dispatch workgroups from image extent and local size while guarding extra invocations.
  • State the source operation, destination operation, accesses and layouts represented by each image barrier.

Record commands for the selected family

A command pool is associated with one queue-family index. Command buffers allocated from it may record operations supported by that family. The compute-and-present family selected in Lesson 15 also supports the transfer operations used in this route. If an application chooses separate families, it must additionally account for queue ownership transfers and cross-queue synchronisation. We shall not add that complexity before the single-family route is correct.

VkCommandPoolCreateInfo poolInfo{
    .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
    .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
    .queueFamilyIndex = queueFamilyIndex
};

VkCommandPool commandPool = VK_NULL_HANDLE;
check(vkCreateCommandPool(
    device, &poolInfo, nullptr, &commandPool));

VkCommandBufferAllocateInfo commandInfo{
    .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
    .commandPool = commandPool,
    .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
    .commandBufferCount = 1
};

VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
check(vkAllocateCommandBuffers(
    device, &commandInfo, &commandBuffer));

Resetting or re-recording this buffer is permitted only when no pending submission still uses it. A per-frame fence will provide that evidence. The flag permits individual reset; it does not make unsafe reset valid.

Make the image ready for compute writes

On its first use, the output image moves from UNDEFINED to GENERAL. No previous contents need to be preserved, so the source stage and access may be none. The destination is the compute shader's storage write.

VkImageMemoryBarrier2 toGeneral{
    .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,
    .srcStageMask = VK_PIPELINE_STAGE_2_NONE,
    .srcAccessMask = VK_ACCESS_2_NONE,
    .dstStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
    .dstAccessMask = VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT,
    .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
    .newLayout = VK_IMAGE_LAYOUT_GENERAL,
    .image = outputImage,
    .subresourceRange = colourRange
};

VkDependencyInfo firstUse{
    .sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
    .imageMemoryBarrierCount = 1,
    .pImageMemoryBarriers = &toGeneral
};

vkCmdPipelineBarrier2(commandBuffer, &firstUse);

On later frames, the old layout must describe the layout left by the previous route, or a separate barrier must restore GENERAL. Hard-coding UNDEFINED every frame discards the previous contents, which is acceptable for a complete rewrite but not for the accumulation image introduced in Lesson 26.

Bind state and count workgroups

The shader uses local size 16 by 16. A 640 by 360 image therefore requires 40 workgroups horizontally and 23 vertically. The last vertical group launches invocations for rows 368 through 367 in its full mathematical range, of which rows 360 through 367 are outside the image. The shader's bounds check prevents those invocations from writing.

vkCmdBindPipeline(
    commandBuffer,
    VK_PIPELINE_BIND_POINT_COMPUTE,
    computePipeline);

vkCmdBindDescriptorSets(
    commandBuffer,
    VK_PIPELINE_BIND_POINT_COMPUTE,
    pipelineLayout,
    0,
    1,
    &descriptorSet,
    0,
    nullptr);

const uint32_t groupsX = (width  + 15) / 16;
const uint32_t groupsY = (height + 15) / 16;
vkCmdDispatch(commandBuffer, groupsX, groupsY, 1);

Dispatch dimensions count workgroups, not pixels. Multiplying 40 by 23 by 256 gives 235,520 invocations for 230,400 texels. The 5,120 extras are a deliberate consequence of ceiling division. Removing the shader guard would turn an arithmetic convenience into out-of-range image access.

Make compute writes visible to the copy

The copy follows the dispatch in command order, but ordering alone does not state that shader storage writes are available and visible to transfer reads. The barrier names both operations and transitions the output image into the source layout required by the copy.

VkImageMemoryBarrier2 toTransferSource{
    .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,
    .srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
    .srcAccessMask = VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT,
    .dstStageMask = VK_PIPELINE_STAGE_2_COPY_BIT,
    .dstAccessMask = VK_ACCESS_2_TRANSFER_READ_BIT,
    .oldLayout = VK_IMAGE_LAYOUT_GENERAL,
    .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
    .image = outputImage,
    .subresourceRange = colourRange
};

VkDependencyInfo beforeCopy{
    .sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
    .imageMemoryBarrierCount = 1,
    .pImageMemoryBarriers = &toTransferSource
};

vkCmdPipelineBarrier2(commandBuffer, &beforeCopy);

VK_PIPELINE_STAGE_2_COPY_BIT identifies the destination operation more precisely than a broad all-transfer stage. The access is a transfer read because the output image supplies copy data. The destination swapchain image requires its own transition to TRANSFER_DST_OPTIMAL, a transfer write access and a later transition to PRESENT_SRC_KHR.

Do not assume the swapchain accepts the route

Before creating the swapchain with transfer-destination usage, check the surface capabilities' supportedUsageFlags. The copy route also needs compatible source and destination formats and extents. If the surface cannot provide transfer-destination swapchain images, use a supported presentation route such as sampling the compute output in a fullscreen graphics pass. The lesson's criterion is not “copy at all costs”. It is an explicit route whose usage, format and synchronisation requirements are satisfied.

VkImageCopy copyRegion{
    .srcSubresource = {
        .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
        .mipLevel = 0,
        .baseArrayLayer = 0,
        .layerCount = 1
    },
    .dstSubresource = {
        .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
        .mipLevel = 0,
        .baseArrayLayer = 0,
        .layerCount = 1
    },
    .extent = { width, height, 1 }
};

vkCmdCopyImage(
    commandBuffer,
    outputImage,
    VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
    swapchainImages[imageIndex],
    VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
    1,
    &copyRegion);

After the copy, a barrier transitions the swapchain image from transfer destination to present source. Its source names the copy write. Presentation itself is outside a pipeline stage, so the layout transition and semaphore relationship together establish the route used by the presentation engine.

Submit with host and presentation synchronisation

Acquire supplies an image index and signals an image-available semaphore. Submission waits for that semaphore before the transfer stage needs the swapchain image, executes the command buffer and signals a render-complete semaphore. Presentation waits for render complete. A fence attached to the submission lets the host know when it may reset this frame's command buffer and reuse its per-frame resources.

VkCommandBufferSubmitInfo commandSubmit{
    .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO,
    .commandBuffer = commandBuffer
};

VkSemaphoreSubmitInfo waitAcquire{
    .sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO,
    .semaphore = imageAvailable,
    .stageMask = VK_PIPELINE_STAGE_2_COPY_BIT
};

VkSemaphoreSubmitInfo signalComplete{
    .sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO,
    .semaphore = renderComplete,
    .stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT
};

VkSubmitInfo2 submit{
    .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2,
    .waitSemaphoreInfoCount = 1,
    .pWaitSemaphoreInfos = &waitAcquire,
    .commandBufferInfoCount = 1,
    .pCommandBufferInfos = &commandSubmit,
    .signalSemaphoreInfoCount = 1,
    .pSignalSemaphoreInfos = &signalComplete
};

check(vkQueueSubmit2(queue, 1, &submit, frameFence));

Separate coverage from visibility

A dispatch may launch exactly the required invocations and still produce an invalid presentation route if its writes are not made visible to the copy. Conversely, a correct barrier does not repair too few workgroups. For 641 by 361 with local size 16 by 16, use 41 by 23 workgroups. That launches 241,408 invocations for 231,401 texels. The edge guard handles 10,007 extras. The compute-to-copy barrier then handles an entirely different question: whether the copy can observe accepted shader writes.

Name the source and destination

A compute shader writes a storage image, and the next command copies from it. What source stage/access and destination stage/access belong in the dependency?

The answer

The source is compute shader with shader storage write access. The destination is copy with transfer read access. The image moves from GENERAL to TRANSFER_SRC_OPTIMAL. Using a barrier that names only layouts but no relevant writes and reads fails to state the memory dependency we require.

Official reference: compare this route with the Khronos synchronisation examples, the specification's command-buffer chapter and the synchronisation chapter.

Prove each part of the route separately

Log the image extent, local size, group count, launched invocation count and out-of-range count. Then remove only the shader bounds check and choose an extent not divisible by the local size; validation should provide evidence of the invalid edge access where supported. Restore it. Next remove only the compute-to-copy dependency while leaving command order unchanged. Do not accept a visually correct frame as proof that the omission is safe. Restore the barrier and record its source, destination, access masks and layouts in plain language.

Keep the execution route stable

Lessons 19 to 26 will replace the diagnostic shader calculation while retaining the storage-image descriptor, dispatch, compute-to-copy dependency and presentation route. That stability is useful. If the camera or intersection shader later produces a wrong pixel, we can compare it with a presentation path already demonstrated by the gradient. Lesson 19 now supplies camera state and reconstructs the same primary direction that Part I calculated in JavaScript.

The first Part II image is now visible, but its content is only a diagnostic coordinate field. That modest result establishes more than an unexplained sphere would: one descriptor reaches one shader, workgroups cover the image, edge invocations are rejected, writes become visible to a copy and presentation waits for completion. We can now change what each invocation computes without changing how its answer reaches the screen.