Skip to main content
GameDev.net gamedev.net

Progressive Path Tracer

by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026

Use in your engine

Run the shader to adjust these controls.

This project accumulates noisy eye rays in a buffer pass and displays the running average. It is a deliberately simplified path tracer: one light, one hard shadow, one Lambertian bounce, and many frames.

What it demonstrates

Progressive accumulation in a feedback buffer. Jittered sampling per frame. Why many cheap samples beat one perfect ray.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common
struct Ray { vec3 ro; vec3 rd; };

struct Sphere { vec3 c; float r; vec3 col; };

float hitSphere(Ray ray, Sphere s) {
    vec3 oc = ray.ro - s.c;
    float b = dot(oc, ray.rd);
    float d = b * b - (dot(oc, oc) - s.r * s.r);
    if (d < 0.0) return -1.0;
    d = sqrt(d);
    float t = -b - d;
    return t > 0.001 ? t : -b + d;
}

vec3 sceneNormal(vec3 p, Sphere s) {
    return normalize(p - s.c);
}

Ray cameraRay(vec2 uv) {
    vec3 ro = vec3(0.0, 1.1, 3.5);
    vec3 look = vec3(0.0, 0.0, 0.0);
    vec3 cw = normalize(look - ro);
    vec3 cu = normalize(cross(vec3(0.0, 1.0, 0.0), cw));
    vec3 cv = cross(cw, cu);
    return Ray(ro, normalize(cw + uv.x * cu + uv.y * cv));
}

float hash(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

vec3 trace(Ray ray, Sphere spheres[3], out float tOut, out int idxOut) {
    tOut = 1e9;
    idxOut = -1;
    for (int i = 0; i < 3; i++) {
        float t = hitSphere(ray, spheres[i]);
        if (t > 0.001 && t < tOut) {
            tOut = t;
            idxOut = i;
        }
    }
    return idxOut < 0 ? vec3(0.0) : sceneNormal(ray.ro + ray.rd * tOut, spheres[idxOut]);
}

vec3 radiance(Ray ray, inout vec2 seed) {
    Sphere spheres[3];
    spheres[0] = Sphere(vec3(-0.6, 0.4, -0.2), 0.4, vec3(0.85, 0.25, 0.25));
    spheres[1] = Sphere(vec3(0.4, 0.55, 0.1), 0.55, vec3(0.25, 0.55, 0.85));
    spheres[2] = Sphere(vec3(0.0, -100.0, 0.0), 100.0, vec3(0.8));

    vec3 sun = normalize(vec3(0.4, 0.7, 0.25));
    vec3 col = vec3(0.0);
    vec3 throughput = vec3(1.0);

    for (int bounce = 0; bounce < 3; bounce++) {
        float t;
        int idx;
        vec3 n = trace(ray, spheres, t, idx);
        if (idx < 0) {
            col += throughput * vec3(0.35, 0.55, 0.75);
            break;
        }
        vec3 p = ray.ro + ray.rd * t;

        // Sun is a hard directional light; not path tracing, but a cheap
        // direct-light sample that still accumulates over frames.
        Ray shadow;
        shadow.ro = p + n * 0.001;
        shadow.rd = sun;
        float tShadow;
        int idxShadow;
        trace(shadow, spheres, tShadow, idxShadow);
        float lit = idxShadow < 0 ? 1.0 : 0.0;

        float diff = max(dot(n, sun), 0.0);
        col += throughput * spheres[idx].col * (0.04 + 0.6 * diff * lit);

        // Lambertian bounce for the next ray.
        seed += vec2(0.7, 0.3);
        float u = hash(seed + vec2(float(bounce)));
        float v = hash(seed + vec2(float(bounce) + 1.0));
        float theta = 6.283185 * u;
        float phi = acos(2.0 * v - 1.0);
        vec3 h;
        h.x = sin(phi) * cos(theta);
        h.y = sin(phi) * sin(theta);
        h.z = cos(phi);
        if (dot(h, n) < 0.0) h = -h;
        ray = Ray(p + n * 0.001, h);
        throughput *= spheres[idx].col;
    }
    return col;
}
Buffer A4
1 // Buffer A accumulates eye rays with a per-frame jitter and a cheap Lambertian
2 // bounce. It is not a full path tracer, but it shows why progressive frames
3 // converge: the same signal is averaged over many noisy samples.
4
5 uniform float uExposure; // @param 0.1..5.0 = 1.0 "Exposure scale"
6
7 vec3 tracePixel(vec2 coord, vec2 seed) {
8 vec2 uv = (coord - 0.5 * iResolution.xy) / iResolution.y;
9 Ray ray = cameraRay(uv);
10 return radiance(ray, seed);
11 }
12
13 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
14 vec2 seed = fragCoord + vec2(iTime, iFrame);
15 vec3 pathSample = tracePixel(fragCoord, seed);
16
17 vec3 prev = texelFetch(iChannel0, ivec2(fragCoord), 0).rgb;
18 // iFrame wraps on WebGL, so use the accumulated weight stored in alpha as a
19 // stable frame count. Reset on the first frame.
20 float weight = texelFetch(iChannel0, ivec2(0, 0), 0).a;
21 weight = (iFrame == 0) ? 1.0 : weight + 1.0;
22
23 vec3 accumulated = mix(prev, pathSample, 1.0 / weight);
24 if (iFrame == 0) accumulated = pathSample;
25
26 // Store a small weight reference in the corner pixel alpha.
27 if (fragCoord.x < 1.0 && fragCoord.y < 1.0) {
28 fragColor = vec4(accumulated, weight);
29 } else {
30 fragColor = vec4(accumulated, 1.0);
31 }
32 }
33

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty
Main Image1
1 uniform float uExposure; // @param 0.1..5.0 = 1.0 "Exposure scale"
2
3 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
4 vec3 acc = texelFetch(iChannel0, ivec2(fragCoord), 0).rgb;
5 fragColor = vec4(acc * uExposure, 1.0);
6 }
7

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty

Learn from this shader

How it works

Buffer A casts a camera ray for the pixel, traces it against three spheres, adds direct sun light plus a Lambertian bounce, and stores the result. The buffer averages the new sample with the previous accumulated value using a weight derived from the frame count kept in the corner pixel alpha channel. The image pass simply reads Buffer A and scales by exposure.

Try changing

Add more bounces in the radiance loop. Each bounce multiplies throughput, so noise grows too. Change the sphere positions or add another material channel. Replace the cosine-weighted hemisphere bounce with a mirror bounce for a different look. Raise exposure if the accumulated image looks dark.

Using it in a game

Real-time path tracing is usually done with denoising and temporal accumulation. This project is the temporal-accumulation part without the denoiser: it shows why a stable camera lets you spend GPU time on many samples, but it also shows why moving the camera makes the image noisy again.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...