RT Graphics Programming
Lesson 11 of 28

Part 1 · Ray tracing by hand

11. Reflection and Recursive Rays

Let one successful intersection create another ray and place a strict limit on the resulting computation.

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

Local specular lighting produces a highlight, but it does not show the scene reflected in a surface. A mirror requires another visibility question. At the hit point, the incoming direction is reflected about the surface normal. The new ray searches the scene, and its returned colour contributes to the original pixel.

This is the first time a successful hit creates a ray whose result depends on another hit. The arithmetic of reflection is simple. The important engineering question is how to stop the chain. Two facing mirrors can keep generating rays without reaching a miss. A renderer must impose a termination rule and account for the work it permits.

What you should be able to account for

  • Derive and calculate a direction reflected about a unit normal.
  • Trace a reflected ray from a biased surface origin and combine its contribution with the local result.
  • Use a bounce limit as an explicit termination rule and measure its cost.

Reflect the direction

Let D be the incoming unit direction and N the unit normal oriented against it. The component of D along N is (D · N)N. Subtracting twice that component mirrors the vector to the other side of the tangent plane:

R = D − 2(D · N)N

function reflect(direction, normal) {
    return subtract(
        direction,
        scale(normal, 2 * dot(direction, normal))
    );
}

If D and N are unit vectors, the result should also have unit length apart from finite-precision error. Normalising it defensively is inexpensive in this teaching renderer. More importantly, the signs must agree with the direction convention. Our D points towards the surface and N points against it. Changing either convention without changing the formula can send the reflected ray into the object.

Make tracing a function that can call itself

The trace function takes a ray and remaining depth. A miss returns sky. An opaque diffuse hit returns local lighting. A reflective hit constructs R, offsets the origin and calls trace again with one less permitted bounce.

function trace(ray, remainingBounces) {
    const hit = closestHit(ray, scene.objects, epsilon, farLimit);
    if (hit === null) {
        return sky(ray.direction);
    }

    const local = shadeLocal(ray, hit);
    const amount = hit.material.reflectivity;

    if (amount === 0 || remainingBounces === 0) {
        return local;
    }

    const reflectedRay = {
        origin: add(hit.position, scale(hit.normal, epsilon)),
        direction: normalise(reflect(ray.direction, hit.normal))
    };

    const reflected = trace(reflectedRay, remainingBounces - 1);
    return mix(local, reflected, amount);
}

The simple mix uses reflectivity as a teaching weight. A physically based conductor model would use wavelength-dependent Fresnel response and energy-aware material parameters. We are establishing recursive transport and work accounting before replacing the material approximation.

Reflection-work laboratory

See What One More Bounce Costs

Increase the reflection limit and compare the unchanged primary-ray count with the additional reflected routes that are actually created.

Change this, then watch this: Change the bounce limit. The reflection direction stays fixed; only permitted depth and the number of successful reflection rays can change.

Reflected sceneA mirror sphere rendered with the selected maximum reflection depth.Ready
A mirror sphere rendered with the selected maximum reflection depth.
One reflected routeAn incident direction, normal, reflected direction and the selected recursion limit.Calculation
An incident direction, normal, reflected direction and the selected recursion limit.
Bounce limit
3
Primary rays
0
Reflection rays
0
Primary + reflection
0

R = D − 2(D · N)N

What this establishes:

Count what recursion buys

Set the bounce limit to zero. The mirror sphere can show only its local term. Increase the limit and it begins to return the plane, sky and nearby objects reached by reflected rays. The primary-ray count does not change; the reflection-ray count and primitive-test count do.

A higher limit is not automatically a better image. If later bounces carry negligible contribution, the added work may not produce a visible difference. Conversely, one required mirror-to-mirror route may remain incomplete at a low limit. The operative criterion is the error and contribution permitted by the termination policy, not the largest number the device can tolerate.

A three-step path

A primary ray hits a mirror with three bounces remaining. It creates a reflection with two remaining. That ray hits a second reflective surface and creates one with one remaining. The third hit may create a final reflection with zero remaining. At the next reflective hit the trace returns local contribution without spawning another ray. A miss at any earlier stage ends that branch immediately with the sky value.

Recursion is a description, not a requirement

The JavaScript function uses recursion because it expresses the path clearly. A renderer can perform the same process iteratively, carrying the current ray, accumulated colour and throughput through a loop. GPU implementations often prefer iterative control because call stacks, shader stages or implementation limits make general recursion unsuitable.

Do not confuse the code shape with the light path. The essential state is a current ray, remaining budget and accumulated contribution. Whether a programming language stores that state in stack frames or explicit variables is an implementation choice.

Contribution should also guide termination

A fixed depth gives a clear upper bound and reproducible teaching result. It can waste work on tiny contributions and truncate an important path at the same depth. More advanced tracers track path throughput and may use Russian roulette to terminate low-contribution paths without introducing systematic bias when weighted correctly.

We shall not add that estimator here because it would combine material transport, probability and termination before refraction has been introduced. The limitation is nevertheless important: a bounce count proves finiteness, not optimality and not physical completeness.

Check the reflected direction

An incoming unit direction is D = (1, -1, 0) / √2 and the normal is N = (0, 1, 0). What direction results from the reflection formula?

Reveal the calculation

D · N = -1/√2. The term 2(D · N)N is (0, -√2, 0). Subtracting it from D changes the negative Y component to positive while preserving X, giving (1, 1, 0) / √2. The incident and reflected angles match across the surface normal.

Trace one reflected pixel on paper

Select a pixel that hits the mirror. Record the primary hit position and normal, calculate the reflected direction, then record the next object and distance. Repeat until the laboratory's bounce limit ends the path or it misses. Compare the written chain with the reflection-ray counter. If they disagree, locate the first unaccounted ray.

Keep this model for Vulkan

Vulkan ray tracing can express secondary rays through ray-generation, hit and miss shaders. A compute implementation may use an explicit loop and ray queries. Both need a payload or state record, a termination policy and a numerical origin rule. The API changes how the work is scheduled; it does not decide which reflected direction or contribution is correct.

Reflection keeps the ray on the incident side of a boundary. Lesson 12 allows a transparent material to transmit a ray into another medium and uses Fresnel response to divide contribution between the reflected and transmitted routes.