RT Graphics Programming
Lesson 25 of 28

Part 2 · Vulkan implementation

25. Secondary Rays Need Bounded State

Replace recursive control flow with an explicit per-invocation path loop, throughput and termination rule.

Graphics glossary 29 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 mirror does not require the compute shader to call itself. It requires the result of one hit to become the origin and direction of another query. On the CPU, Lesson 11 expressed that sequence recursively because the source read naturally. In a compute invocation, a small explicit loop makes the same state and its stopping conditions visible.

Trace one continuation per bounce. A reflective material chooses the reflected direction; a diffuse material terminates and contributes local light. This is not yet a full path tracer because it does not randomly sample a distribution of diffuse directions. It is a bounded secondary-ray renderer whose state can be inspected after every bounce.

What you should be able to account for

  • Represent the current ray, accumulated radiance and remaining throughput as per-invocation path state.
  • Generate a reflected ray with a deliberate origin offset and maximum bounce count.
  • Explain why one continuation fits a simple loop while branching rays require a different work strategy.

Keep path state private to one invocation

Each output sample begins with a camera ray. Initialise radiance to zero and throughput to one. Radiance is light already accumulated for the sample; throughput is the multiplier carried to future contributions.

struct PathState {
    Ray ray;
    vec3 radiance;
    vec3 throughput;
    uint bounce;
};

PathState path;
path.ray = makePrimaryRay(gl_GlobalInvocationID.xy);
path.radiance = vec3(0.0);
path.throughput = vec3(1.0);
path.bounce = 0u;

This state is local to the shader invocation. It does not require a storage buffer or atomic operation while one invocation traces one sample from start to finish. A storage queue becomes relevant when paths are split or scheduled across multiple dispatches.

Give the material an explicit response

Use the material's spare fields for a reflection colour and strength. A teaching record might store base colour in one vec4 and reflected colour plus reflectivity in another. Keep reflectivity between zero and one on the CPU before upload.

struct MaterialGPU {
    vec4 albedoRoughness;
    vec4 reflectionColourStrength;
};

vec3 reflectedDirection = reflect(path.ray.direction, hit.normal);
float strength = material.reflectionColourStrength.w;
vec3 reflectionWeight =
    material.reflectionColourStrength.xyz * strength;

The GLSL reflect operation expects the incident direction to point towards the surface, which our current ray direction does. The hit normal has already been faced against that direction. Normalise the result before using it as the next ray direction, even though ideal reflection of unit vectors should preserve length.

Make the loop state the stopping policy

for (uint bounce = 0u; bounce < control.maximumBounces; ++bounce) {
    Hit hit = closestSceneHit(
        path.ray, control.rayEpsilon, control.maximumDistance);

    if (!hit.found) {
        path.radiance += path.throughput * environment(path.ray.direction);
        break;
    }

    MaterialGPU material = materials[hit.materialIndex];
    vec3 local = visibleDirectLight(hit, path.ray, material);
    float reflectivity = material.reflectionColourStrength.w;

    path.radiance += path.throughput * (1.0 - reflectivity) * local;

    if (reflectivity <= 0.0) {
        break;
    }

    path.throughput *=
        reflectivity * material.reflectionColourStrength.xyz;

    if (max(path.throughput.r,
            max(path.throughput.g, path.throughput.b))
        < control.minimumThroughput) {
        break;
    }

    path.ray.origin = hit.position + hit.normal * control.rayEpsilon;
    path.ray.direction = normalize(
        reflect(path.ray.direction, hit.normal));
    path.bounce = bounce + 1u;
}

There are four visible stopping conditions: maximum bounce count, a miss, a non-reflective material and negligible throughput. The throughput cutoff is an approximation and must be disabled when it interferes with correctness comparisons. The maximum bounce count remains necessary even in a corridor of perfect mirrors.

The expression above divides local and reflected energy using one scalar reflectivity. It is a controlled teaching model, not a complete physically based material. What matters is that the weights are explicit and do not add unbounded energy at every bounce.

Offset on the outgoing side

For reflection, the next origin is displaced along the faced normal. More generally, choose the offset side from the outgoing direction:

float side = dot(outgoingDirection, hit.normal) > 0.0 ? 1.0 : -1.0;
vec3 nextOrigin = hit.position +
                  side * control.rayEpsilon * hit.normal;

This form will remain meaningful if transmission is later added. A refracted ray leaving through the opposite side should not be shifted back into the medium it is trying to leave. The offset addresses numerical representation; it is not a geometric thickness.

Follow throughput through two mirrors

Begin with throughput (1, 1, 1). The first material has white reflection colour and strength 0.8, leaving throughput (0.8, 0.8, 0.8). The second has reflection colour (1, 0.5, 0.25) and strength 0.5, leaving (0.4, 0.2, 0.1). An environment value (0.1, 0.2, 0.4) reached after that second bounce contributes (0.04, 0.04, 0.04) to radiance. The equality of the final channels is a consequence of those particular products, not a greyscale conversion.

Understand the cost of different path lengths

Some invocations miss on the primary ray. Others traverse several mirror bounces, and each hit may launch a shadow query. Neighbouring invocations can therefore remain in the loop for different numbers of iterations. This control-flow divergence is expected. A maximum of four does not mean every sample performs four bounces.

Add counters for paths terminated by miss, material, throughput and bounce limit. Those categories reveal the actual stopping policy. As before, atomic counters are observers that may disturb timing. They should not affect the colour path.

Recognise where the simple loop ends

One incoming state produces at most one outgoing state in this shader. If a glass surface needs both a reflected ray and a refracted ray from the same hit, a single loop cannot follow both simultaneously without duplicating state. There are several honest choices: select one continuation stochastically with a compensating weight, maintain a small per-invocation stack, or append new paths to a work queue for later dispatches.

Do not silently calculate both colours with unbounded recursive calls and assume the GPU will schedule them efficiently. The work topology has changed from a chain into a tree. This lesson deliberately stays with a chain so that every state transition remains visible.

Terminate a weak path

The minimum throughput is 0.02, and the current throughput is (0.018, 0.005, 0.001). Does the loop continue?

The answer

No. The largest component is 0.018, below the stated cutoff, so this approximate policy terminates the path. For a correctness reference, set the cutoff to zero and rely on the finite bounce limit instead.

Official reference: compute invocations and their execution model are defined by the Khronos shader chapter; compute pipeline creation is covered by the pipeline chapter.

Record the state of one path

Choose a pixel that reflects at least twice. Write one debug record per bounce containing origin, direction, hit distance, normal, material index, throughput and radiance after the update. Recalculate the reflected directions and component-wise throughput products on the CPU. Then set the maximum bounce count to zero, one, two and four and identify precisely which contributions appear at each setting. Finally, lower the throughput cutoff until it changes no selected reference pixel at the recorded precision.

Carry bounded paths into sampling

Retain the explicit path state and deterministic bounce limit. Lesson 26 will launch several slightly different primary samples for each pixel and accumulate their linear radiance. Secondary work belongs inside each sample; accumulation combines completed sample estimates rather than mixing partial path states.

The compute renderer now allows one visibility answer to create the next question without hiding that process behind recursion. Radiance records what has arrived, throughput records what future light can still contribute, and the loop states exactly why work stops. The next problem is not another geometric feature but estimation: one central camera ray is still only one sample of a pixel.