Ray Tracing: How to Build a 3D Renderer From Scratch With Code
Ray tracing is the most intuitive way to render 3D scenes: for every pixel on your screen, shoot a ray from your eye into the scene and ask "what does this ray hit?" The color of that pixel is determined by the object it hits, the lights illuminating it, and the materials reflecting, absorbing, or refracting the light. It's how your eyes actually work — just in reverse.
What makes ray tracing magical is that a few dozen lines of math can produce photorealistic images. Reflections, shadows, glass, fog — effects that require complex tricks in rasterization-based renderers — fall out naturally from the physics. Each ray simply follows the laws of optics, and the image assembles itself.
This guide builds a complete ray tracer from zero, step by step, entirely in JavaScript and Canvas. No WebGL, no libraries, no shaders. Just a `
How ray tracing works
The algorithm is simple:
- For each pixel, compute a ray from the camera through that pixel's position on an imaginary screen plane
- Test the ray against every object in the scene — find the closest intersection
- Shade the pixel based on the material at the hit point, the surface normal, and the lights
- Optionally, cast more rays from the hit point — reflection rays, shadow rays, refraction rays — and combine the results
That's the entire algorithm. Everything else is details: how you define shapes, how you compute normals, how materials interact with light, and how you handle secondary rays. Let's build it.
Example 1: Basic ray caster — sphere on a plane
The simplest possible ray tracer: one sphere, one plane, one light. No reflections, no shadows — just ray-object intersection and basic diffuse shading. This is the "hello world" of ray tracing.
var c = document.createElement('canvas');
c.width = 400; c.height = 300;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function sub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function add(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
function scale(a, s) { return [a[0]*s, a[1]*s, a[2]*s]; }
function normalize(a) { var l = Math.sqrt(dot(a,a)); return l > 0 ? scale(a, 1/l) : a; }
var sphere = { center: [0, 1, -5], radius: 1, color: [0.8, 0.2, 0.3] };
var light = normalize([-0.5, 1, -0.3]);
var cam = [0, 1.5, 0];
function hitSphere(ro, rd, sp) {
var oc = sub(ro, sp.center);
var b = dot(oc, rd);
var c2 = dot(oc, oc) - sp.radius * sp.radius;
var disc = b * b - c2;
if (disc < 0) return -1;
var t = -b - Math.sqrt(disc);
return t > 0.001 ? t : -1;
}
function hitPlane(ro, rd) {
if (Math.abs(rd[1]) < 0.0001) return -1;
var t = -ro[1] / rd[1];
return t > 0.001 ? t : -1;
}
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x - c.width / 2) / c.height;
var v = -(y - c.height / 2) / c.height;
var rd = normalize([u, v, -1]);
var col = [0.05, 0.05, 0.15];
var ts = hitSphere(cam, rd, sphere);
var tp = hitPlane(cam, rd);
if (ts > 0 && (tp < 0 || ts < tp)) {
var p = add(cam, scale(rd, ts));
var n = normalize(sub(p, sphere.center));
var diff = Math.max(0, dot(n, light));
col = scale(sphere.color, 0.15 + 0.85 * diff);
} else if (tp > 0) {
var p2 = add(cam, scale(rd, tp));
var checker = ((Math.floor(p2[0]) + Math.floor(p2[2])) % 2 + 2) % 2;
var gray = checker ? 0.4 : 0.2;
var diff2 = Math.max(0, light[1]);
col = [gray * (0.3 + 0.7 * diff2), gray * (0.3 + 0.7 * diff2), gray * (0.3 + 0.7 * diff2)];
}
var idx = (y * c.width + x) * 4;
img.data[idx] = Math.min(255, col[0] * 255);
img.data[idx+1] = Math.min(255, col[1] * 255);
img.data[idx+2] = Math.min(255, col[2] * 255);
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The `hitSphere` function solves the quadratic equation for a ray-sphere intersection — it's the most important equation in ray tracing. The `hitPlane` function is even simpler: just divide the ray origin's height by the ray direction's vertical component. The checkerboard pattern on the floor is a classic visual test that immediately shows whether your geometry is correct.
Example 2: Phong shading — specular highlights
Flat diffuse shading looks plastic and dead. Phong shading adds a specular highlight — the bright spot on a shiny surface where you can almost see the light source reflected. The trick is computing the reflection of the light direction about the surface normal and checking how closely it aligns with the view direction.
var c = document.createElement('canvas');
c.width = 400; c.height = 300;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
function reflect(I,N){return sub(I,scale(N,2*dot(I,N)))}
var spheres = [
{ center: [-1.5, 1, -5], radius: 1, color: [0.9, 0.2, 0.2], shininess: 50 },
{ center: [0, 1, -6], radius: 1, color: [0.2, 0.8, 0.3], shininess: 120 },
{ center: [1.5, 1, -5], radius: 1, color: [0.2, 0.3, 0.9], shininess: 200 }
];
var light = normalize([-0.5, 1.5, 0.5]);
var cam = [0, 2, 2];
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;
var disc=b*b-c2;if(disc<0)return -1;
var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
for(var y=0;y0&&t0.0001){
var tp=-cam[1]/rd[1];
if(tp>0.001&&tp
Notice how each sphere has a different shininess value. Low shininess (50) produces a broad, soft highlight — like rubber or matte plastic. High shininess (200) produces a tight, intense highlight — like polished metal or glass. This single parameter controls how "shiny" a surface looks, and it's the difference between a dead sphere and a convincing material.
Example 3: Hard shadows
Shadows are where ray tracing shines (literally). To check if a point is in shadow, cast a new ray from the surface point toward the light. If that ray hits any object before reaching the light, the point is shadowed. It's beautifully simple — and it produces pixel-perfect, geometrically correct shadows with zero effort.
var c = document.createElement('canvas');
c.width = 400; c.height = 300;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
function reflect(I,N){return sub(I,scale(N,2*dot(I,N)))}
var spheres = [
{ center: [0, 1, -5], radius: 1, color: [0.9, 0.3, 0.2], shin: 80 },
{ center: [-2, 0.6, -4], radius: 0.6, color: [0.2, 0.7, 0.9], shin: 60 },
{ center: [1.5, 0.5, -3.5], radius: 0.5, color: [0.3, 0.9, 0.3], shin: 100 }
];
var lightPos = [2, 5, 0];
var cam = [0, 2, 2];
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;var disc=b*b-c2;
if(disc<0)return -1;var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
function castRay(ro, rd) {
var tMin = 1e9, hit = null, isFloor = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(ro, rd, spheres[i]);
if (t > 0 && t < tMin) { tMin = t; hit = spheres[i]; isFloor = false; }
}
if (Math.abs(rd[1]) > 0.0001) {
var tp = -ro[1] / rd[1];
if (tp > 0.001 && tp < tMin) { tMin = tp; hit = null; isFloor = true; }
}
return { t: tMin, obj: hit, floor: isFloor };
}
function inShadow(p) {
var toLight = normalize(sub(lightPos, p));
var distToLight = Math.sqrt(dot(sub(lightPos, p), sub(lightPos, p)));
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(p, toLight, spheres[i]);
if (t > 0.01 && t < distToLight) return true;
}
return false;
}
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x - c.width/2) / c.height;
var v = -(y - c.height/2) / c.height;
var rd = normalize([u, v, -1]);
var col = [0.02, 0.02, 0.08];
var res = castRay(cam, rd);
if (res.obj) {
var p = add(cam, scale(rd, res.t));
var n = normalize(sub(p, res.obj.center));
var toL = normalize(sub(lightPos, p));
var shadow = inShadow(add(p, scale(n, 0.01))) ? 0.15 : 1;
var diff = Math.max(0, dot(n, toL)) * shadow;
var ref = reflect(scale(toL, -1), n);
var vd = normalize(sub(cam, p));
var spec = Math.pow(Math.max(0, dot(ref, vd)), res.obj.shin) * shadow;
col = [
res.obj.color[0] * (0.08 + 0.82 * diff) + spec * 0.5,
res.obj.color[1] * (0.08 + 0.82 * diff) + spec * 0.5,
res.obj.color[2] * (0.08 + 0.82 * diff) + spec * 0.5
];
} else if (res.floor) {
var fp = add(cam, scale(rd, res.t));
var ck = ((Math.floor(fp[0]) + Math.floor(fp[2])) % 2 + 2) % 2;
var base = ck ? 0.4 : 0.15;
var shadow2 = inShadow(add(fp, [0, 0.01, 0])) ? 0.2 : 1;
var toL2 = normalize(sub(lightPos, fp));
var fDiff = Math.max(0, toL2[1]) * shadow2;
col = [base * (0.15 + 0.85 * fDiff), base * (0.15 + 0.85 * fDiff), base * (0.15 + 0.85 * fDiff)];
}
var idx = (y * c.width + x) * 4;
img.data[idx] = Math.min(255, col[0] * 255);
img.data[idx+1] = Math.min(255, col[1] * 255);
img.data[idx+2] = Math.min(255, col[2] * 255);
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The shadow test is just one more ray cast. The key detail is the bias: we offset the shadow ray origin slightly along the surface normal (`scale(n, 0.01)`) to prevent self-intersection — a classic ray tracing artifact called "shadow acne" where surfaces shadow themselves due to floating-point precision.
Example 4: Mirror reflections
Reflections are the crown jewel of ray tracing. When a ray hits a reflective surface, compute the reflection direction and trace another ray. That ray might hit another reflective surface, spawning yet another ray. This recursive process produces the infinite-mirror effect with just a few lines of code.
var c = document.createElement('canvas');
c.width = 400; c.height = 300;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
function reflectVec(I,N){return sub(I,scale(N,2*dot(I,N)))}
var spheres = [
{ center: [-1.2, 1, -5], radius: 1, color: [0.9, 0.1, 0.1], refl: 0.1 },
{ center: [1.2, 1, -5], radius: 1, color: [0.8, 0.8, 0.9], refl: 0.85 },
{ center: [0, 0.5, -3], radius: 0.5, color: [0.2, 0.9, 0.3], refl: 0.3 }
];
var lightPos = [-2, 6, 1];
var cam = [0, 2, 2];
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;var disc=b*b-c2;
if(disc<0)return -1;var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
function traceRay(ro, rd, depth) {
if (depth > 4) return [0.02, 0.02, 0.06];
var tMin = 1e9, hitObj = null, isFloor = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(ro, rd, spheres[i]);
if (t > 0 && t < tMin) { tMin = t; hitObj = spheres[i]; isFloor = false; }
}
if (Math.abs(rd[1]) > 0.0001) {
var tp = -ro[1] / rd[1];
if (tp > 0.001 && tp < tMin) { tMin = tp; hitObj = null; isFloor = true; }
}
if (!hitObj && !isFloor) return [0.02, 0.02, 0.06];
var p = add(ro, scale(rd, tMin));
var toL = normalize(sub(lightPos, p));
if (hitObj) {
var n = normalize(sub(p, hitObj.center));
var diff = Math.max(0, dot(n, toL));
var localCol = scale(hitObj.color, 0.1 + 0.8 * diff);
if (hitObj.refl > 0.01) {
var reflDir = reflectVec(rd, n);
var reflCol = traceRay(add(p, scale(n, 0.01)), reflDir, depth + 1);
return add(scale(localCol, 1 - hitObj.refl), scale(reflCol, hitObj.refl));
}
return localCol;
}
// Floor with reflection
var ck = ((Math.floor(p[0]) + Math.floor(p[2])) % 2 + 2) % 2;
var base = ck ? 0.35 : 0.12;
var fDiff = Math.max(0, toL[1]);
var floorCol = [base * (0.2 + 0.8 * fDiff), base * (0.2 + 0.8 * fDiff), base * (0.2 + 0.8 * fDiff)];
var reflDir2 = reflectVec(rd, [0,1,0]);
var reflCol2 = traceRay(add(p, [0,0.01,0]), reflDir2, depth + 1);
return add(scale(floorCol, 0.7), scale(reflCol2, 0.3));
}
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x - c.width / 2) / c.height;
var v = -(y - c.height / 2) / c.height;
var rd = normalize([u, v, -1]);
var col = traceRay(cam, rd, 0);
var idx = (y * c.width + x) * 4;
img.data[idx] = Math.min(255, col[0] * 255);
img.data[idx+1] = Math.min(255, col[1] * 255);
img.data[idx+2] = Math.min(255, col[2] * 255);
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The right sphere (refl: 0.85) acts as an almost-perfect mirror — you can see the red sphere, the green sphere, and the checkerboard floor reflected in its surface. The floor also reflects at 30%, giving it a polished look. The recursion depth limit (4 bounces) prevents infinite loops when two mirrors face each other.
Example 5: Refraction — glass and water
Transparent materials bend light. When a ray enters glass, it changes direction according to Snell's law. When it exits, it bends again. This double refraction, combined with Fresnel reflection (more reflection at grazing angles), produces convincing glass, water, and crystal effects.
var c = document.createElement('canvas');
c.width = 400; c.height = 300;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
function reflectV(I,N){return sub(I,scale(N,2*dot(I,N)))}
function refractV(I, N, ior) {
var cosI = -dot(I, N);
var sinT2 = ior * ior * (1 - cosI * cosI);
if (sinT2 > 1) return null; // total internal reflection
var cosT = Math.sqrt(1 - sinT2);
return add(scale(I, ior), scale(N, ior * cosI - cosT));
}
function fresnel(I, N, ior) {
var cosI = Math.abs(dot(I, N));
var sinT2 = ior * ior * (1 - cosI * cosI);
if (sinT2 > 1) return 1;
var cosT = Math.sqrt(1 - sinT2);
var rs = (cosI - ior * cosT) / (cosI + ior * cosT);
var rp = (ior * cosI - cosT) / (ior * cosI + cosT);
return (rs * rs + rp * rp) / 2;
}
var spheres = [
{ center: [0, 1.2, -4.5], radius: 1.2, color: [0.95, 0.95, 1], ior: 1.5, transparent: true },
{ center: [-2.5, 0.7, -5], radius: 0.7, color: [0.9, 0.2, 0.1], ior: 0, transparent: false },
{ center: [2, 0.6, -4], radius: 0.6, color: [0.1, 0.6, 0.9], ior: 0, transparent: false }
];
var lightPos = [-3, 8, 2];
var cam = [0, 2, 3];
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;var disc=b*b-c2;
if(disc<0)return -1;var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
function trace(ro, rd, depth) {
if (depth > 5) return [0.01, 0.01, 0.04];
var tMin = 1e9, hitObj = null, isFloor = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(ro, rd, spheres[i]);
if (t > 0 && t < tMin) { tMin = t; hitObj = spheres[i]; isFloor = false; }
}
if (Math.abs(rd[1]) > 0.0001) {
var tp = -ro[1] / rd[1];
if (tp > 0.001 && tp < tMin) { tMin = tp; hitObj = null; isFloor = true; }
}
if (!hitObj && !isFloor) {
var sky = 0.5 + 0.5 * rd[1];
return [0.02 + sky * 0.05, 0.02 + sky * 0.05, 0.06 + sky * 0.15];
}
var p = add(ro, scale(rd, tMin));
var toL = normalize(sub(lightPos, p));
if (hitObj && hitObj.transparent) {
var n = normalize(sub(p, hitObj.center));
var outside = dot(rd, n) < 0;
var nn = outside ? n : scale(n, -1);
var eta = outside ? 1 / hitObj.ior : hitObj.ior;
var kr = fresnel(rd, nn, eta);
var reflDir = reflectV(rd, nn);
var reflCol = trace(add(p, scale(nn, 0.01)), reflDir, depth + 1);
if (kr < 0.999) {
var refrDir = refractV(rd, nn, eta);
if (refrDir) {
var refrCol = trace(sub(p, scale(nn, 0.01)), normalize(refrDir), depth + 1);
return add(scale(reflCol, kr), scale(refrCol, 1 - kr));
}
}
return reflCol;
}
if (hitObj) {
var n2 = normalize(sub(p, hitObj.center));
var diff = Math.max(0, dot(n2, toL));
return scale(hitObj.color, 0.1 + 0.8 * diff);
}
// Floor
var ck = ((Math.floor(p[0]) + Math.floor(p[2])) % 2 + 2) % 2;
var base = ck ? 0.4 : 0.12;
var fd = Math.max(0, toL[1]);
return [base * (0.2 + 0.8 * fd), base * (0.2 + 0.8 * fd), base * (0.2 + 0.8 * fd)];
}
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x - c.width/2) / c.height;
var v = -(y - c.height/2) / c.height;
var rd = normalize([u, v, -1]);
var col = trace(cam, rd, 0);
var idx = (y * c.width + x) * 4;
img.data[idx] = Math.min(255, col[0] * 255);
img.data[idx+1] = Math.min(255, col[1] * 255);
img.data[idx+2] = Math.min(255, col[2] * 255);
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The glass sphere (ior: 1.5, matching real glass) both reflects and refracts. Fresnel equations determine the split: at head-on angles, most light passes through; at grazing angles, most reflects. You can see the colored spheres behind the glass appear distorted and slightly shifted — that's refraction bending the view rays as they pass through the sphere. Total internal reflection occurs when rays inside the sphere hit the surface at too steep an angle to escape.
Example 6: Soft shadows with area light
Real lights aren't infinitely small points — they have size. An area light produces soft shadows with penumbra (partially shadowed regions) at the edges. To simulate this, cast multiple shadow rays toward random points on the light surface and average the results. More samples = smoother shadows = slower render.
var c = document.createElement('canvas');
c.width = 320; c.height = 240;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
var spheres = [
{ center: [0, 1, -4], radius: 1, color: [0.85, 0.25, 0.2] },
{ center: [-1.8, 0.5, -3], radius: 0.5, color: [0.2, 0.7, 0.85] }
];
var lightCenter = [1, 5, 0];
var lightRadius = 1.5;
var SHADOW_SAMPLES = 16;
var cam = [0, 2, 3];
var seed = 12345;
function rand() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;var disc=b*b-c2;
if(disc<0)return -1;var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
function shadowFactor(p, n) {
var lit = 0;
for (var s = 0; s < SHADOW_SAMPLES; s++) {
var lp = [
lightCenter[0] + (rand() - 0.5) * 2 * lightRadius,
lightCenter[1] + (rand() - 0.5) * 0.4 * lightRadius,
lightCenter[2] + (rand() - 0.5) * 2 * lightRadius
];
var toL = normalize(sub(lp, p));
if (dot(toL, n) < 0) continue;
var dist = Math.sqrt(dot(sub(lp, p), sub(lp, p)));
var blocked = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(add(p, scale(n, 0.01)), toL, spheres[i]);
if (t > 0 && t < dist) { blocked = true; break; }
}
if (!blocked) lit++;
}
return lit / SHADOW_SAMPLES;
}
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x - c.width/2) / c.height;
var v = -(y - c.height/2) / c.height;
var rd = normalize([u, v, -1]);
var col = [0.01, 0.01, 0.04];
var tMin = 1e9, hitObj = null, isFloor = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(cam, rd, spheres[i]);
if (t > 0 && t < tMin) { tMin = t; hitObj = spheres[i]; isFloor = false; }
}
if (Math.abs(rd[1]) > 0.0001) {
var tp = -cam[1] / rd[1];
if (tp > 0.001 && tp < tMin) { tMin = tp; isFloor = true; hitObj = null; }
}
if (hitObj) {
var p = add(cam, scale(rd, tMin));
var n = normalize(sub(p, hitObj.center));
var toL = normalize(sub(lightCenter, p));
var diff = Math.max(0, dot(n, toL));
var sf = shadowFactor(p, n);
col = scale(hitObj.color, 0.08 + 0.82 * diff * sf);
} else if (isFloor) {
var fp = add(cam, scale(rd, tMin));
var ck = ((Math.floor(fp[0]) + Math.floor(fp[2])) % 2 + 2) % 2;
var base = ck ? 0.4 : 0.12;
var toL2 = normalize(sub(lightCenter, fp));
var fd = Math.max(0, toL2[1]);
var sf2 = shadowFactor(fp, [0, 1, 0]);
col = [base * (0.1 + 0.9 * fd * sf2), base * (0.1 + 0.9 * fd * sf2), base * (0.1 + 0.9 * fd * sf2)];
}
var idx = (y * c.width + x) * 4;
img.data[idx] = Math.min(255, col[0] * 255);
img.data[idx+1] = Math.min(255, col[1] * 255);
img.data[idx+2] = Math.min(255, col[2] * 255);
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The shadow edges are now soft and gradual — objects close to the surface cast sharp shadows, while objects farther from the surface cast blurry ones. This is physically accurate: it's why your shadow is sharp directly at your feet but fuzzy around your head on a sunny day. The 16 shadow samples produce visible noise; 64+ samples would be smoother but slower.
Example 7: Ambient occlusion
Ambient occlusion (AO) simulates the darkening that happens in crevices, corners, and places where surfaces are close together. The idea: from each surface point, cast rays in random hemisphere directions and check if they hit nearby geometry. Points in tight spaces will have many blocked rays and appear darker.
var c = document.createElement('canvas');
c.width = 320; c.height = 240;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var img = ctx.createImageData(c.width, c.height);
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
var spheres = [
{ center: [0, 1, -4], radius: 1 },
{ center: [-1.5, 0.5, -3.5], radius: 0.5 },
{ center: [1.2, 0.4, -3], radius: 0.4 },
{ center: [0.3, 0.3, -2.5], radius: 0.3 }
];
var AO_SAMPLES = 24;
var AO_RADIUS = 2.5;
var cam = [0, 2, 3];
var seed = 67890;
function rand(){seed=(seed*1103515245+12345)&0x7fffffff;return seed/0x7fffffff;}
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;var disc=b*b-c2;
if(disc<0)return -1;var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
function hemisphereDir(n) {
var u = rand(), v = rand();
var theta = 2 * Math.PI * u;
var phi = Math.acos(2 * v - 1);
var d = [Math.sin(phi)*Math.cos(theta), Math.sin(phi)*Math.sin(theta), Math.cos(phi)];
if (dot(d, n) < 0) d = scale(d, -1);
return normalize(d);
}
function computeAO(p, n) {
var occluded = 0;
for (var s = 0; s < AO_SAMPLES; s++) {
var dir = hemisphereDir(n);
var origin = add(p, scale(n, 0.01));
var hit = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(origin, dir, spheres[i]);
if (t > 0 && t < AO_RADIUS) { hit = true; break; }
}
if (!hit && Math.abs(dir[1]) > 0.0001) {
var tp = -origin[1] / dir[1];
if (tp > 0 && tp < AO_RADIUS) hit = true;
}
if (hit) occluded++;
}
return 1 - occluded / AO_SAMPLES;
}
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x - c.width/2) / c.height;
var v = -(y - c.height/2) / c.height;
var rd = normalize([u, v, -1]);
var col = [0.01, 0.01, 0.04];
var tMin = 1e9, hitIdx = -1, isFloor = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(cam, rd, spheres[i]);
if (t > 0 && t < tMin) { tMin = t; hitIdx = i; isFloor = false; }
}
if (Math.abs(rd[1]) > 0.0001) {
var tp = -cam[1] / rd[1];
if (tp > 0.001 && tp < tMin) { tMin = tp; hitIdx = -1; isFloor = true; }
}
if (hitIdx >= 0) {
var p = add(cam, scale(rd, tMin));
var n = normalize(sub(p, spheres[hitIdx].center));
var ao = computeAO(p, n);
var hemi = 0.5 + 0.5 * n[1]; // hemisphere lighting
var brightness = hemi * ao;
col = [brightness * 0.85, brightness * 0.75, brightness * 0.7];
} else if (isFloor) {
var fp = add(cam, scale(rd, tMin));
var ao2 = computeAO(fp, [0, 1, 0]);
var ck = ((Math.floor(fp[0]) + Math.floor(fp[2])) % 2 + 2) % 2;
var base = ck ? 0.45 : 0.15;
col = [base * ao2, base * ao2, base * ao2];
}
var idx = (y * c.width + x) * 4;
img.data[idx] = Math.min(255, col[0] * 255);
img.data[idx+1] = Math.min(255, col[1] * 255);
img.data[idx+2] = Math.min(255, col[2] * 255);
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
Notice how the areas where spheres sit on the floor are noticeably darker — that's the contact shadow created by ambient occlusion. The spaces between closely packed spheres are darkened naturally. AO is often rendered as a separate black-and-white pass and multiplied with the final image in production renderers. The 24 samples per pixel give a noisy but readable result; production renderers use 64-256 samples or denoisers.
Example 8: Animated path tracer
A path tracer is the ultimate ray tracer: at each hit point, choose a single random direction (weighted by the material's BRDF) and continue tracing. No explicit light sampling — photons just bounce around until they happen to hit a light. Average hundreds of frames and the noise converges to a beautiful, physically correct image. This example progressively renders, getting smoother over time.
var c = document.createElement('canvas');
c.width = 320; c.height = 240;
document.body.appendChild(c);
var ctx = c.getContext('2d');
function dot(a,b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]}
function sub(a,b){return [a[0]-b[0],a[1]-b[1],a[2]-b[2]]}
function add(a,b){return [a[0]+b[0],a[1]+b[1],a[2]+b[2]]}
function mul(a,b){return [a[0]*b[0],a[1]*b[1],a[2]*b[2]]}
function scale(a,s){return [a[0]*s,a[1]*s,a[2]*s]}
function normalize(a){var l=Math.sqrt(dot(a,a));return l>0?scale(a,1/l):a}
var spheres = [
{ center: [-1, 1, -4], radius: 1, color: [0.9, 0.2, 0.2], emit: [0,0,0] },
{ center: [1.2, 0.8, -3.5], radius: 0.8, color: [0.2, 0.6, 0.9], emit: [0,0,0] },
{ center: [0, 0.4, -2], radius: 0.4, color: [0.3, 0.9, 0.3], emit: [0,0,0] },
{ center: [0, 6, -3], radius: 2, color: [1,1,1], emit: [8,7.5,6.5] } // area light
];
var cam = [0, 1.5, 3];
var accumR = new Float32Array(c.width * c.height);
var accumG = new Float32Array(c.width * c.height);
var accumB = new Float32Array(c.width * c.height);
var sampleCount = 0;
var seed = 42;
function rand(){seed=(seed*1664525+1013904223)&0xFFFFFFFF;return (seed>>>0)/0xFFFFFFFF;}
function cosineDir(n) {
var u = rand(), v = rand();
var r = Math.sqrt(u);
var theta = 2 * Math.PI * v;
var tx, ty;
if (Math.abs(n[0]) > 0.1) { tx = normalize([n[2], 0, -n[0]]); }
else { tx = normalize([0, -n[2], n[1]]); }
ty = [n[1]*tx[2]-n[2]*tx[1], n[2]*tx[0]-n[0]*tx[2], n[0]*tx[1]-n[1]*tx[0]];
return normalize(add(add(scale(tx, r * Math.cos(theta)), scale(ty, r * Math.sin(theta))), scale(n, Math.sqrt(1 - u))));
}
function hitSphere(ro,rd,sp){
var oc=sub(ro,sp.center);var b=dot(oc,rd);
var c2=dot(oc,oc)-sp.radius*sp.radius;var disc=b*b-c2;
if(disc<0)return -1;var t=-b-Math.sqrt(disc);return t>0.001?t:-1;
}
function tracePath(ro, rd) {
var throughput = [1, 1, 1];
var color = [0, 0, 0];
for (var bounce = 0; bounce < 5; bounce++) {
var tMin = 1e9, hitIdx = -1, isFloor = false;
for (var i = 0; i < spheres.length; i++) {
var t = hitSphere(ro, rd, spheres[i]);
if (t > 0 && t < tMin) { tMin = t; hitIdx = i; isFloor = false; }
}
if (Math.abs(rd[1]) > 0.0001) {
var tp = -ro[1] / rd[1];
if (tp > 0.001 && tp < tMin) { tMin = tp; hitIdx = -1; isFloor = true; }
}
if (hitIdx < 0 && !isFloor) {
// Sky
var sky = Math.max(0, rd[1]);
color = add(color, mul(throughput, [0.03 + sky * 0.1, 0.03 + sky * 0.1, 0.06 + sky * 0.2]));
break;
}
var p = add(ro, scale(rd, tMin));
if (hitIdx >= 0) {
var sp = spheres[hitIdx];
color = add(color, mul(throughput, sp.emit));
if (sp.emit[0] > 0) break; // hit a light, done
var n = normalize(sub(p, sp.center));
throughput = mul(throughput, sp.color);
ro = add(p, scale(n, 0.001));
rd = cosineDir(n);
} else {
// Floor
var ck = ((Math.floor(p[0]) + Math.floor(p[2])) % 2 + 2) % 2;
var fCol = ck ? [0.5, 0.5, 0.5] : [0.15, 0.15, 0.15];
throughput = mul(throughput, fCol);
ro = add(p, [0, 0.001, 0]);
rd = cosineDir([0, 1, 0]);
}
// Russian roulette after bounce 2
if (bounce > 1) {
var prob = Math.max(throughput[0], Math.max(throughput[1], throughput[2]));
if (rand() > prob) break;
throughput = scale(throughput, 1 / prob);
}
}
return color;
}
function renderSample() {
for (var y = 0; y < c.height; y++) {
for (var x = 0; x < c.width; x++) {
var u = (x + rand() - 0.5 - c.width/2) / c.height;
var v = -(y + rand() - 0.5 - c.height/2) / c.height;
var rd = normalize([u, v, -1]);
var col = tracePath(cam, rd);
var idx = y * c.width + x;
accumR[idx] += col[0];
accumG[idx] += col[1];
accumB[idx] += col[2];
}
}
sampleCount++;
var imgData = ctx.createImageData(c.width, c.height);
for (var i = 0; i < c.width * c.height; i++) {
var r = Math.sqrt(accumR[i] / sampleCount); // gamma correction
var g = Math.sqrt(accumG[i] / sampleCount);
var b = Math.sqrt(accumB[i] / sampleCount);
imgData.data[i*4] = Math.min(255, r * 255);
imgData.data[i*4+1] = Math.min(255, g * 255);
imgData.data[i*4+2] = Math.min(255, b * 255);
imgData.data[i*4+3] = 255;
}
ctx.putImageData(imgData, 0, 0);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '12px monospace';
ctx.fillText('Samples: ' + sampleCount, 8, 16);
if (sampleCount < 200) requestAnimationFrame(renderSample);
}
renderSample();
Watch it render: the first frame is pure noise, but within seconds patterns emerge — soft shadows, color bleeding (red light from the red sphere tinting nearby surfaces), caustics on the floor. After 50-100 samples the image looks painterly; after 200 it's smooth. This is unbiased rendering — given enough samples, it converges to the mathematically correct solution of the rendering equation. The Russian roulette optimization randomly terminates low-energy paths, keeping computation proportional to visual contribution.
Ray tracing concepts: a reference
Every example above builds on these core concepts:
- Ray: an origin point + a direction vector. Defined as
P(t) = origin + t * directionwhere t is the distance along the ray - Intersection test: solve for the smallest positive t where the ray hits a surface (sphere = quadratic equation, plane = linear equation)
- Surface normal: the outward-pointing direction at the hit point. For a sphere, it's
normalize(hitPoint - center) - Shadow ray: a ray from the surface point to the light. If it hits anything, the point is in shadow
- Reflection:
R = I - 2(I·N)Nwhere I is the incoming direction and N is the normal - Refraction: Snell's law determines the bent direction. The index of refraction (IOR) controls how much bending occurs (air=1.0, water=1.33, glass=1.5, diamond=2.42)
- Fresnel: at grazing angles, surfaces become more reflective. This is why water looks transparent when you look straight down but mirror-like at the horizon
- Path tracing: instead of explicitly sampling lights, bounce rays randomly and let them find light sources naturally. Average many samples to reduce noise
Beyond the basics: where to go next
This guide covers the fundamentals, but ray tracing goes much deeper:
- Add more shapes: boxes (slab intersection), cylinders, tori, and constructive solid geometry (CSG) for boolean combinations
- Use bounding volume hierarchies (BVH) to test millions of triangles efficiently — the key to rendering complex meshes
- Implement texture mapping: wrap images onto surfaces using UV coordinates
- Add volumetric rendering for fog, smoke, and clouds — rays accumulate color and opacity as they pass through participating media
- Use physics simulation to animate scenes between frames — ray trace each frame for physically accurate animated shorts
- Apply mathematical functions as implicit surfaces — ray march through distance fields (SDFs) just like Lumitree's shader worlds
- Combine with fluid simulation data to render realistic water, fire, and explosions
- Move to WebGL compute shaders or WebGPU for real-time ray tracing — the same algorithm, but running on the GPU for 100-1000x speedup
Ray tracing is where code meets optics. Every pixel is a physics experiment: shoot a ray, see what it hits, follow the light. The algorithm fits in your head, but the images it produces are limited only by your imagination and patience. On Lumitree, some of the most stunning micro-worlds use ray marching — a close cousin of ray tracing that evaluates distance fields instead of explicit geometry. Both techniques share the same elegant idea: to see the world, trace the light.