Path Tracing in WebGL (GLSL): A Real-Time Ray Tracer From Scratch
Rasterization approximates light transport with tricks — shadow maps, cubemaps, screen-space reflections. Path tracing instead simulates light transport directly by solving the rendering equation with Monte Carlo integration. This tutorial builds a physically-based path tracer that runs entirely in a single GLSL fragment shader, accumulating samples in real time in the browser.
1. The Rendering Equation
Every physically-based renderer is, at its core, an attempt to solve Kajiya's rendering equation. It states that the outgoing radiance Lo from a point x in direction ωo equals emitted radiance plus the integral of incoming radiance weighted by the surface's BRDF and a cosine term:
This integral has no closed-form solution for arbitrary scenes — Li itself depends recursively on the same equation evaluated at whatever surface the ray from x hits next. We approximate it with Monte Carlo integration: trace many random light paths, average the results, and let the noise cancel out over time. That averaging is exactly what a real-time path tracer does frame after frame.
2. Full-Screen Quad and Accumulation Buffer
The path tracer itself is a fragment shader painted onto a full-screen triangle. Because a single frame at 1 sample per pixel is extremely noisy, we render into a floating-point texture and accumulate samples across frames, then divide by the frame count when displaying:
const gl = canvas.getContext("webgl2");
gl.getExtension("EXT_color_buffer_float"); // needed for RGBA32F render targets
function createAccumTarget(w, h) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
return { tex, fbo };
}
// Ping-pong: read previous accumulation while writing the new one
let targets = [createAccumTarget(width, height), createAccumTarget(width, height)];
let frameIndex = 0;
function render() {
const src = targets[frameIndex % 2];
const dst = targets[(frameIndex + 1) % 2];
gl.bindFramebuffer(gl.FRAMEBUFFER, dst.fbo);
gl.useProgram(pathTraceProgram);
gl.uniform1i(uPrevSample, 0); // bound to src.tex
gl.uniform1i(uFrame, frameIndex);
gl.drawArrays(gl.TRIANGLES, 0, 3); // full-screen triangle, no VBO needed
// Second pass: divide by frameIndex+1 and tonemap to the visible canvas
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(displayProgram);
gl.drawArrays(gl.TRIANGLES, 0, 3);
frameIndex++;
requestAnimationFrame(render);
}
gl_VertexID in the vertex shader —
no vertex buffer required, and it avoids the diagonal seam
artifact some GPUs show on quads.
3. Camera Ray Generation
Each fragment corresponds to one pixel. We map its screen-space coordinate to a normalized device coordinate in [-1, 1], then unproject it into a ray direction using the camera's field of view and aspect ratio:
struct Ray { vec3 origin; vec3 dir; };
Ray getCameraRay(vec2 uv, vec3 camPos, vec3 camTarget, float fovY) {
vec2 ndc = uv * 2.0 - 1.0; // [0,1] -> [-1,1]
ndc.x *= uResolution.x / uResolution.y; // correct for aspect ratio
vec3 fwd = normalize(camTarget - camPos);
vec3 right = normalize(cross(fwd, vec3(0.0, 1.0, 0.0)));
vec3 up = cross(right, fwd);
float tanHalfFov = tan(radians(fovY) * 0.5);
vec3 dir = normalize(fwd + ndc.x * tanHalfFov * right + ndc.y * tanHalfFov * up);
return Ray(camPos, dir);
}
For anti-aliasing and to feed the Monte Carlo estimator, jitter the
pixel coordinate by a random subpixel offset
(rand() - 0.5) before converting to NDC —
each accumulated frame samples a slightly different point inside
the pixel footprint, so over hundreds of frames the edges
naturally anti-alias for free.
4. Scene Representation: Analytic Spheres
A minimal path tracer scene is a list of spheres (and a ground plane) with material properties. Ray-sphere intersection has a closed-form solution from the quadratic formula:
struct Sphere { vec3 center; float radius; vec3 albedo; float roughness; vec3 emissive; };
bool intersectSphere(Ray ray, Sphere s, out float t, out vec3 n) {
vec3 oc = ray.origin - s.center;
float b = dot(oc, ray.dir);
float c = dot(oc, oc) - s.radius * s.radius;
float disc = b * b - c;
if (disc < 0.0) return false;
float sq = sqrt(disc);
t = -b - sq;
if (t < 0.001) t = -b + sq; // try far root if near one is behind origin
if (t < 0.001) return false;
n = normalize(ray.origin + t * ray.dir - s.center);
return true;
}
// Nearest-hit scene traversal: loop every sphere, keep the closest t
bool intersectScene(Ray ray, out float tHit, out vec3 nHit, out Sphere hit) {
tHit = 1e9;
bool found = false;
for (int i = 0; i < NUM_SPHERES; i++) {
float t; vec3 n;
if (intersectSphere(ray, spheres[i], t, n) && t < tHit) {
tHit = t; nHit = n; hit = spheres[i]; found = true;
}
}
return found;
}
5. Cosine-Weighted Hemisphere Sampling
For diffuse (Lambertian) surfaces, the reflected radiance is proportional to cos θ between the outgoing direction and the normal. If we sample bounce directions uniformly over the hemisphere, most samples land near the horizon where they contribute little — wasted work. Instead we importance sample proportionally to cos θ, which cancels the cosine term in the estimator entirely:
// PRNG: cheap, deterministic per-pixel hash-based random (PCG-style)
float rand(inout uint seed) {
seed = seed * 747796405u + 2891336453u;
uint word = ((seed >> ((seed >> 28u) + 4u)) ^ seed) * 277803737u;
return float((word >> 22u) ^ word) / 4294967295.0;
}
// Maps two uniform randoms to a cosine-weighted point on the hemisphere
// around normal n, via Malley's method: uniform disk + project up
vec3 cosineSampleHemisphere(vec3 n, inout uint seed) {
float u1 = rand(seed);
float u2 = rand(seed);
float r = sqrt(u1);
float theta = 6.2831853 * u2; // 2*PI
float x = r * cos(theta);
float y = r * sin(theta);
float z = sqrt(max(0.0, 1.0 - u1)); // height above the tangent plane
// Build an orthonormal basis (tangent, bitangent) around n
vec3 up = abs(n.z) < 0.999 ? vec3(0.0,0.0,1.0) : vec3(1.0,0.0,0.0);
vec3 tangent = normalize(cross(up, n));
vec3 bitangent = cross(n, tangent);
return normalize(x * tangent + y * bitangent + z * n);
}
6. The Bounce Loop and Russian Roulette
A path tracer walks the ray through multiple bounces, multiplying a running throughput term by each surface's albedo and adding any emissive light encountered along the way. GLSL (pre-ES 3.1) forbids true recursion, so the bounce chain is an explicit loop with a fixed maximum depth:
vec3 tracePath(Ray ray, inout uint seed) {
vec3 radiance = vec3(0.0);
vec3 throughput = vec3(1.0);
for (int bounce = 0; bounce < MAX_BOUNCES; bounce++) {
float t; vec3 n; Sphere hit;
if (!intersectScene(ray, t, n, hit)) {
radiance += throughput * skyColor(ray.dir); // escaped to environment
break;
}
radiance += throughput * hit.emissive; // hit a light source
throughput *= hit.albedo; // diffuse BRDF, cosine already cancelled
vec3 hitPoint = ray.origin + t * ray.dir;
vec3 newDir = cosineSampleHemisphere(n, seed);
ray = Ray(hitPoint + n * 0.001, newDir); // epsilon offset avoids self-intersection
// Russian roulette: stochastically terminate low-contribution paths
if (bounce > 3) {
float p = max(throughput.r, max(throughput.g, throughput.b));
if (rand(seed) > p) break;
throughput /= p; // unbiased: rescale surviving paths
}
}
return radiance;
}
7. Progressive Accumulation and Denoising
A single sample per pixel is far too noisy to look acceptable —
each pixel is an independent random variable with high variance.
Because Monte Carlo error decreases as
1/√N for N samples, running the same shader every
frame and averaging results converges toward the correct image:
out vec4 fragColor;
void main() {
uint seed = uint(gl_FragCoord.x) * 1973u + uint(gl_FragCoord.y) * 9277u + uint(uFrame) * 26699u | 1u;
vec2 jitter = vec2(rand(seed), rand(seed)) - 0.5;
vec2 uv = (gl_FragCoord.xy + jitter) / uResolution;
Ray ray = getCameraRay(uv, uCamPos, uCamTarget, 45.0);
vec3 sample = tracePath(ray, seed);
vec3 prev = texelFetch(uPrevSample, ivec2(gl_FragCoord.xy), 0).rgb;
vec3 accumulated = prev * float(uFrame) + sample; // running sum, not running average
fragColor = vec4(accumulated, 1.0);
}
// Display pass divides by (uFrame + 1) and applies Reinhard tonemap + gamma:
// vec3 color = accumulated / float(uFrame + 1);
// color = color / (1.0 + color); // Reinhard
// fragColor = vec4(pow(color, vec3(1.0/2.2)), 1.0); // gamma correction
Any camera movement invalidates the accumulation — reset
frameIndex to 0 whenever the camera or scene changes,
then let it climb back up while the view is static. At 200+
accumulated frames a simple scene of a few spheres looks
essentially noise-free at 1080p on a mid-range GPU.
8. The Complete Fragment Shader
Putting every piece together — scene, camera rays, cosine sampling, bounce loop, and accumulation — gives a self-contained GLSL ES 3.0 fragment shader. Uniforms for sphere data, camera, and frame index are set from JavaScript each frame:
#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform vec3 uCamPos;
uniform vec3 uCamTarget;
uniform int uFrame;
uniform sampler2D uPrevSample;
out vec4 fragColor;
#define NUM_SPHERES 4
#define MAX_BOUNCES 6
struct Ray { vec3 origin; vec3 dir; };
struct Sphere { vec3 center; float radius; vec3 albedo; vec3 emissive; };
Sphere spheres[NUM_SPHERES] = Sphere[](
Sphere(vec3(0.0, -100.5, 0.0), 100.0, vec3(0.6), vec3(0.0)), // ground
Sphere(vec3(-1.0, 0.0, 0.0), 0.5, vec3(0.9,0.2,0.2), vec3(0.0)),
Sphere(vec3( 1.0, 0.0, 0.0), 0.5, vec3(0.2,0.5,0.9), vec3(0.0)),
Sphere(vec3( 0.0, 5.0, 0.0), 1.5, vec3(1.0), vec3(4.0,3.8,3.5)) // area light
);
// hash / rand / cosineSampleHemisphere / intersectSphere / intersectScene
// / getCameraRay / tracePath — as defined in sections 3-6 above
vec3 skyColor(vec3 dir) {
float t = 0.5 * (dir.y + 1.0);
return mix(vec3(1.0), vec3(0.5,0.7,1.0), t) * 0.3; // dim gradient, light does the work
}
void main() {
uint seed = uint(gl_FragCoord.x) * 1973u + uint(gl_FragCoord.y) * 9277u + uint(uFrame) * 26699u | 1u;
vec2 jitter = vec2(rand(seed), rand(seed)) - 0.5;
vec2 uv = (gl_FragCoord.xy + jitter) / uResolution;
Ray ray = getCameraRay(uv, uCamPos, uCamTarget, 45.0);
vec3 col = tracePath(ray, seed);
vec3 prev = texelFetch(uPrevSample, ivec2(gl_FragCoord.xy), 0).rgb;
fragColor = vec4(prev * float(uFrame) + col, 1.0);
}
This entire scene — three diffuse spheres and one emissive area light — needs no textures, no acceleration structure, and no external assets. Swap the analytic sphere list for a small BVH over triangles and you have the core of a WebGPU or WebGL compute path tracer capable of arbitrary meshes.
Frequently Asked Questions
What will I learn in this tutorial?
Build a real-time path tracer in WebGL2 and GLSL: rendering equation, Monte Carlo integration, cosine-weighted hemisphere sampling, progressive accumulation, and a full fragment shader.
What topics are covered in this tutorial?
This tutorial covers: The Rendering Equation, Full-Screen Quad and Accumulation Buffer, Camera Ray Generation, Scene Representation: Analytic Spheres, Cosine-Weighted Hemisphere Sampling, The Bounce Loop and Russian Roulette, Progressive Accumulation and Denoising, The Complete Fragment Shader.
How long does this tutorial take?
This tutorial takes approximately 28 minutes to complete.
What prerequisites do I need before starting?
This is a Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.