RT Graphics Programming
Lesson 21 of 28

Part 2 · Vulkan implementation

21. Scene Buffers and the Closest Hit

Place several sphere records in shader storage and retain the smallest valid intersection independently of record order.

Graphics glossary 41 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.

One sphere proved that the shader can reproduce the manual equation. A scene begins when the same shader can inspect several records and preserve the nearest valid answer. That apparently modest change introduces three Vulkan responsibilities at once: the host representation, the shader representation and the transfer that places identical bytes where the shader will read them.

Use a shader storage buffer rather than one push-constant record. The buffer holds an array of spheres, and every invocation reads that same array while tracing a different primary ray. No invocation modifies the scene. The only reduction is private to the invocation: each accepted hit shortens the remaining interval.

What you should be able to account for

  • Define one sphere record whose C++ size and GLSL std430 stride agree.
  • Upload immutable scene data through a staging buffer and bind it as a storage buffer.
  • Show why changing the order of sphere records cannot change the closest visible surface.

Give the record a boring shape

A sphere requires a centre and radius. A single four-component vector provides all four values without hidden padding between members. We shall add a separate material index in Lesson 22 rather than prematurely making this record clever.

struct alignas(16) SphereGPU {
    float centreX;
    float centreY;
    float centreZ;
    float radius;
};

static_assert(sizeof(SphereGPU) == 16);
static_assert(alignof(SphereGPU) == 16);

const std::array<SphereGPU, 3> spheres{{
    { 0.0f, -0.1f, -3.0f, 0.9f },
    {-1.2f,  0.1f, -4.2f, 1.0f },
    { 1.3f,  0.2f, -4.8f, 1.2f }
}};

The matching shader declaration is equally plain:

struct SphereGPU {
    vec4 centreRadius;
};

layout(std430, set = 0, binding = 2) readonly buffer SphereBuffer {
    SphereGPU spheres[];
} scene;

layout(push_constant) uniform TraceControl {
    uint sphereCount;
} control;

The readonly qualifier communicates that this shader does not alter the scene. It is not a replacement for correct descriptor use, but it narrows what the source claims. The count is supplied separately because an unsized shader array does not tell the algorithm how many meaningful records the application uploaded.

Ask the device what the buffer requires

Create the device-local scene buffer with storage-buffer and transfer-destination usage. As with the image in Lesson 16, creation produces an object with memory requirements rather than storage. Query those requirements, select a compatible device-local memory type, allocate and bind it. The allocation may contain several suballocated resources in a mature renderer; our first implementation may use one allocation so that the ownership remains inspectable.

VkBufferCreateInfo sceneInfo{
    .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
    .size = sizeof(spheres),
    .usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
             VK_BUFFER_USAGE_TRANSFER_DST_BIT,
    .sharingMode = VK_SHARING_MODE_EXCLUSIVE
};

vkCreateBuffer(device, &sceneInfo, nullptr, &sceneBuffer);

VkMemoryRequirements requirements{};
vkGetBufferMemoryRequirements(device, sceneBuffer, &requirements);
// Select a memory type allowed by requirements.memoryTypeBits.
// Allocate at least requirements.size with the required alignment.
vkBindBufferMemory(device, sceneBuffer, sceneMemory, 0);

Do not assume that sizeof(spheres) is the allocation size or that any device-local memory type is compatible. The requirements returned for the particular buffer settle both questions.

Stage the bytes deliberately

A host-visible staging buffer is the temporary source. Map its bound memory, copy the exact array bytes, flush the written range when the chosen memory is not host coherent, and unmap it. Record vkCmdCopyBuffer from staging to scene. A following memory barrier makes the transfer writes available and visible to compute shader storage reads.

VkBufferCopy copy{
    .srcOffset = 0,
    .dstOffset = 0,
    .size = sizeof(spheres)
};
vkCmdCopyBuffer(commandBuffer, stagingBuffer, sceneBuffer, 1, &copy);

VkBufferMemoryBarrier2 ready{
    .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER_2,
    .srcStageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT,
    .srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT,
    .dstStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT,
    .dstAccessMask = VK_ACCESS_2_SHADER_STORAGE_READ_BIT,
    .buffer = sceneBuffer,
    .offset = 0,
    .size = VK_WHOLE_SIZE
};

Place that barrier in a VkDependencyInfo and record vkCmdPipelineBarrier2. Waiting for a queue to become idle would also serialize the work, but it would conceal the actual producer and consumer and would be a poor default for every scene upload.

Add the third descriptor binding

The descriptor set layout gains binding 2 with type VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, count one and compute-stage visibility. The pool must be large enough for the added descriptor. Update the allocated set with a VkDescriptorBufferInfo that covers the uploaded records.

VkDescriptorBufferInfo sceneDescriptor{
    .buffer = sceneBuffer,
    .offset = 0,
    .range = sizeof(spheres)
};

VkWriteDescriptorSet write{
    .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
    .dstSet = descriptorSet,
    .dstBinding = 2,
    .descriptorCount = 1,
    .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
    .pBufferInfo = &sceneDescriptor
};

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

The buffer range and the count used by the shader must describe the same valid records. Supplying a count of ten for a three-record descriptor range is not a scene with seven empty spheres; it is an out-of-bounds read.

Shrink the interval as hits arrive

Hit closestSceneHit(Ray ray, float tMin, float tMax) {
    Hit closest = noHit();
    float closestDistance = tMax;

    for (uint index = 0; index < control.sphereCount; ++index) {
        vec4 sphere = scene.spheres[index].centreRadius;
        Hit candidate = intersectSphere(
            ray, sphere.xyz, sphere.w, tMin, closestDistance);

        if (candidate.found) {
            closest = candidate;
            closestDistance = candidate.t;
        }
    }
    return closest;
}

The key is not merely the final comparison. The current closest distance becomes the maximum for the next query. A farther root can then be rejected inside the primitive function. This is the GPU form of the invariant established in Lesson 5: after record n, the retained hit is the nearest valid hit among records zero through n.

Every invocation owns its local closest and closestDistance. No atomic operation is required because neighbouring pixels are not competing to update one shared answer. They happen to inspect the same read-only records.

Trace an order change

Suppose a ray meets sphere A at distance 6.0 and sphere B at 2.5. In order A, B the first candidate sets the maximum to 6.0 and B then replaces it with 2.5. In order B, A the first candidate sets the maximum to 2.5; A is rejected because 6.0 is outside the shortened interval. Both orders retain B. If swapping records changes the selected colour, the shader has made record order part of visibility.

Test record identity without confusing it with colour

Temporarily add a uint primitiveIndex to the private hit record and set it when a candidate is retained. Write an unmistakable diagnostic colour derived from that index. Move two spheres until their silhouettes overlap and inspect a pixel in the overlap. Then reverse the CPU array and update the buffer. The visible surface should remain the same physical sphere although its diagnostic index changes.

For a stronger witness, read back the selected pixel's distance and centre. The index alone is not stable under reordering, and colour alone can be affected by later display conversion. The tuple (found, t, centre, radius) identifies what the shader actually retained.

Account for a farther candidate

The current closest distance is 3.2. The next sphere has valid roots 4.0 and 6.0. What does the primitive query return when it receives [0.001, 3.2]?

The answer

It returns a miss for this interval. Both mathematical roots lie beyond the current maximum, so neither can replace the retained hit. The sphere exists, but it is not a closer visible answer for this ray.

Official reference: buffer creation and binding are defined by the Khronos resource chapter, while shader-side buffer layout and interface matching are covered by the mapping data to shaders guide.

Prove that storage order is not visibility order

Create at least four overlapping spheres and record ten selected pixels containing different combinations of hits. Save each pixel's retained distance and sphere centre. Reverse the array, upload it again and repeat the record. The primitive indices may change, but every retained distance and physical sphere must agree within a stated tolerance. If one does not, reduce the scene to the two records that expose the disagreement and trace the interval after each loop iteration.

Carry geometry forward without smuggling in appearance

Retain the scene buffer, upload path and closest-hit loop. Lesson 22 will associate each geometry record with a material record and will introduce a light, but the geometry query will still report geometry rather than perform lighting internally. That separation lets a shadow ray ask only whether anything blocks an interval in Lesson 23.

The compute tracer now has a real scene in the limited but important sense that buffer order does not decide visibility. Each invocation asks every primitive and retains the closest permissible answer. The cost is still linear in the number of spheres. We accept that cost while building a trustworthy reference; Lesson 27 will replace exhaustive triangle traversal only after its contract is visible.