Progressive Path Tracer
by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026
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.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - 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
// Buffer A accumulates eye rays with a per-frame jitter and a cheap Lambertian
// bounce. It is not a full path tracer, but it shows why progressive frames
// converge: the same signal is averaged over many noisy samples.
uniform float uExposure; // @param 0.1..5.0 = 1.0 "Exposure scale"
vec3 tracePixel(vec2 coord, vec2 seed) {
vec2 uv = (coord - 0.5 * iResolution.xy) / iResolution.y;
Ray ray = cameraRay(uv);
return radiance(ray, seed);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 seed = fragCoord + vec2(iTime, iFrame);
vec3 pathSample = tracePixel(fragCoord, seed);
vec3 prev = texelFetch(iChannel0, ivec2(fragCoord), 0).rgb;
// iFrame wraps on WebGL, so use the accumulated weight stored in alpha as a
// stable frame count. Reset on the first frame.
float weight = texelFetch(iChannel0, ivec2(0, 0), 0).a;
weight = (iFrame == 0) ? 1.0 : weight + 1.0;
vec3 accumulated = mix(prev, pathSample, 1.0 / weight);
if (iFrame == 0) accumulated = pathSample;
// Store a small weight reference in the corner pixel alpha.
if (fragCoord.x < 1.0 && fragCoord.y < 1.0) {
fragColor = vec4(accumulated, weight);
} else {
fragColor = vec4(accumulated, 1.0);
}
}
Inputs for this pass
- iChannel0 Buffer A
- iChannel1 Empty
- iChannel2 Empty
- iChannel3 Empty
Main Image1
uniform float uExposure; // @param 0.1..5.0 = 1.0 "Exposure scale"
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 acc = texelFetch(iChannel0, ivec2(fragCoord), 0).rgb;
fragColor = vec4(acc * uExposure, 1.0);
}
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.
Discussion