Below is a miniature ray tracer running in your browser. It shoots one ray per pixel into a scene of colored spheres sitting on a floor plane, computing reflections and a point light source. Drag the Reflections slider to limit how many times a ray can bounce, and watch the mirror world deepen.
<!-- Ray tracing demo — canvas rendered by JS -->
<div class="controls">
<label>Reflections: <span id="depthVal">3</span>
<input type="range" id="depthSlider" min="0" max="5" value="3">
</label>
<button id="renderBtn" type="button">Render</button>
</div>
<canvas id="canvas" width="360" height="280" title="Ray-traced scene — spheres with reflections"></canvas>
<div id="status" class="status"></div>
/* Dark theme to match the rendered scene */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #0d1117; color: #e6edf3; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .5rem 0 .6rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 110px; accent-color: #58a6ff; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #58a6ff;
background: #1f6feb; color: #fff; border-radius: 6px; cursor: pointer; }
button:hover { background: #388bfd; }
canvas { display: block; border-radius: 6px; border: 1px solid #30363d; cursor: default; }
.status { font-size: .8rem; color: #8b949e; margin-top: .4rem; min-height: 1.2em; }
// Minimal ray tracer: shoot one ray per pixel, intersect spheres + floor plane,
// compute diffuse + reflection up to maxDepth bounces.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Scene: 4 colored spheres with varying reflectivity
const SPHERES = [
{ cx: -1.0, cy: 0.0, cz: 4.5, r: 0.9, col: [220, 60, 60], refl: 0.3 },
{ cx: 1.2, cy: 0.3, cz: 3.5, r: 0.7, col: [ 60, 180, 90], refl: 0.4 },
{ cx: 0.0, cy:-0.6, cz: 3.0, r: 0.45, col: [ 80, 140, 220], refl: 0.5 },
{ cx: -0.3, cy: 0.9, cz: 3.8, r: 0.35, col: [230, 190, 50], refl: 0.6 }
];
// Floor plane at y = FLOOR_Y
const FLOOR_Y = -1.0;
// Single point light
const LIGHT = { x: 1.5, y: 3.0, z: 1.0 };
const BG = [15, 20, 30]; // background color
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function norm(v) { const l = Math.sqrt(dot(v,v)); return [v[0]/l, v[1]/l, v[2]/l]; }
function add(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 scale(v, s) { return [v[0]*s, v[1]*s, v[2]*s]; }
function reflect(d, n) { return sub(d, scale(n, 2*dot(d,n))); }
function clamp(x) { return Math.max(0, Math.min(255, x)); }
// Ray-sphere intersection: solve quadratic |o + t*d - center|^2 = r^2
function hitSphere(ox, oy, oz, dx, dy, dz, s) {
const ex = ox-s.cx, ey = oy-s.cy, ez = oz-s.cz;
const a = dx*dx+dy*dy+dz*dz;
const b = 2*(ex*dx+ey*dy+ez*dz);
const c = ex*ex+ey*ey+ez*ez - s.r*s.r;
const disc = b*b - 4*a*c;
if (disc < 0) return -1;
const t = (-b - Math.sqrt(disc)) / (2*a);
return t > 1e-4 ? t : -1;
}
// Ray-plane intersection: y = FLOOR_Y => t = (FLOOR_Y - oy) / dy
function hitFloor(oy, dy) {
if (Math.abs(dy) < 1e-6) return -1;
const t = (FLOOR_Y - oy) / dy;
return t > 1e-4 ? t : -1;
}
// Recursive trace: returns [r,g,b] for a ray from (ox,oy,oz) in direction (dx,dy,dz)
function trace(ox, oy, oz, dx, dy, dz, depth, maxDepth) {
let tMin = Infinity, hitIdx = -1, hitFloorHit = false;
// find nearest intersection
for (let i = 0; i < SPHERES.length; i++) {
const t = hitSphere(ox, oy, oz, dx, dy, dz, SPHERES[i]);
if (t > 0 && t < tMin) { tMin = t; hitIdx = i; hitFloorHit = false; }
}
const tf = hitFloor(oy, dy);
if (tf > 0 && tf < tMin) { tMin = tf; hitFloorHit = true; hitIdx = -1; }
if (tMin === Infinity) return BG; // ray missed everything — return background
// compute surface normal and base color at hit point
const px = ox+dx*tMin, py = oy+dy*tMin, pz = oz+dz*tMin;
let nx, ny, nz, baseCol, refl;
if (hitFloorHit) {
// checkerboard floor
nx = 0; ny = 1; nz = 0;
const tile = (Math.floor(px) + Math.floor(pz)) & 1;
baseCol = tile ? [200, 200, 200] : [50, 50, 60];
refl = 0.2;
} else {
const s = SPHERES[hitIdx];
nx = (px-s.cx)/s.r; ny = (py-s.cy)/s.r; nz = (pz-s.cz)/s.r;
baseCol = s.col; refl = s.refl;
}
// diffuse shading from point light
const lx = LIGHT.x-px, ly = LIGHT.y-py, lz = LIGHT.z-pz;
const ld = Math.sqrt(lx*lx+ly*ly+lz*lz);
const lnx = lx/ld, lny = ly/ld, lnz = lz/ld;
const diff = Math.max(0, nx*lnx + ny*lny + nz*lnz);
const ambient = 0.18;
const light = ambient + (1-ambient)*diff;
let r = baseCol[0]*light, g = baseCol[1]*light, b = baseCol[2]*light;
// recursive reflection
if (depth < maxDepth && refl > 0) {
const rx = reflect([dx,dy,dz], [nx,ny,nz]);
const rc = trace(px+rx[0]*1e-4, py+rx[1]*1e-4, pz+rx[2]*1e-4,
rx[0], rx[1], rx[2], depth+1, maxDepth);
r = r*(1-refl) + rc[0]*refl;
g = g*(1-refl) + rc[1]*refl;
b = b*(1-refl) + rc[2]*refl;
}
return [clamp(r), clamp(g), clamp(b)];
}
let rendering = false;
function render() {
if (rendering) return;
rendering = true;
const maxDepth = parseInt(document.getElementById('depthSlider').value);
document.getElementById('status').textContent = 'Rendering...';
document.getElementById('renderBtn').disabled = true;
// render into an ImageData buffer row by row
const img = ctx.createImageData(W, H);
const aspect = W / H;
// camera at origin looking +z, vertical FOV 60 degrees
const fov = Math.tan(Math.PI / 6); // half-angle tangent (60 deg)
let row = 0;
function renderRow() {
const y = row;
for (let x = 0; x < W; x++) {
// compute ray direction for this pixel (perspective projection)
const px2 = (2*(x+0.5)/W - 1)*aspect*fov;
const py2 = (1 - 2*(y+0.5)/H)*fov;
const d = norm([px2, py2, 1.0]);
const col = trace(0, 0.5, 0, d[0], d[1], d[2], 0, maxDepth);
const idx = (y*W + x)*4;
img.data[idx] = col[0];
img.data[idx+1] = col[1];
img.data[idx+2] = col[2];
img.data[idx+3] = 255;
}
row++;
if (row < H) {
// show partial progress every 20 rows
if (row % 20 === 0) ctx.putImageData(img, 0, 0);
requestAnimationFrame(renderRow);
} else {
ctx.putImageData(img, 0, 0);
document.getElementById('status').textContent =
i18nFmt('Rendered with %n% reflection bounce(s). Drag the slider and re-render.', { n: maxDepth });
document.getElementById('renderBtn').disabled = false;
rendering = false;
}
}
requestAnimationFrame(renderRow);
}
document.getElementById('renderBtn').addEventListener('click', render);
document.getElementById('depthSlider').addEventListener('input', function() {
document.getElementById('depthVal').textContent = this.value;
});
render();
<!-- Ray tracing demo — canvas rendered by JS -->
<div class="controls">
<label>Reflections: <span id="depthVal">3</span>
<input type="range" id="depthSlider" min="0" max="5" value="3">
</label>
<button id="renderBtn" type="button">Render</button>
</div>
<canvas id="canvas" width="360" height="280" title="Ray-traced scene — spheres with reflections"></canvas>
<div id="status" class="status"></div>
/* Dark theme to match the rendered scene */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #0d1117; color: #e6edf3; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .5rem 0 .6rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 110px; accent-color: #58a6ff; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #58a6ff;
background: #1f6feb; color: #fff; border-radius: 6px; cursor: pointer; }
button:hover { background: #388bfd; }
canvas { display: block; border-radius: 6px; border: 1px solid #30363d; cursor: default; }
.status { font-size: .8rem; color: #8b949e; margin-top: .4rem; min-height: 1.2em; }
// Minimal ray tracer: shoot one ray per pixel, intersect spheres + floor plane,
// compute diffuse + reflection up to maxDepth bounces.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Scene: 4 colored spheres with varying reflectivity
const SPHERES = [
{ cx: -1.0, cy: 0.0, cz: 4.5, r: 0.9, col: [220, 60, 60], refl: 0.3 },
{ cx: 1.2, cy: 0.3, cz: 3.5, r: 0.7, col: [ 60, 180, 90], refl: 0.4 },
{ cx: 0.0, cy:-0.6, cz: 3.0, r: 0.45, col: [ 80, 140, 220], refl: 0.5 },
{ cx: -0.3, cy: 0.9, cz: 3.8, r: 0.35, col: [230, 190, 50], refl: 0.6 }
];
// Floor plane at y = FLOOR_Y
const FLOOR_Y = -1.0;
// Single point light
const LIGHT = { x: 1.5, y: 3.0, z: 1.0 };
const BG = [15, 20, 30]; // background color
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function norm(v) { const l = Math.sqrt(dot(v,v)); return [v[0]/l, v[1]/l, v[2]/l]; }
function add(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 scale(v, s) { return [v[0]*s, v[1]*s, v[2]*s]; }
function reflect(d, n) { return sub(d, scale(n, 2*dot(d,n))); }
function clamp(x) { return Math.max(0, Math.min(255, x)); }
// Ray-sphere intersection: solve quadratic |o + t*d - center|^2 = r^2
function hitSphere(ox, oy, oz, dx, dy, dz, s) {
const ex = ox-s.cx, ey = oy-s.cy, ez = oz-s.cz;
const a = dx*dx+dy*dy+dz*dz;
const b = 2*(ex*dx+ey*dy+ez*dz);
const c = ex*ex+ey*ey+ez*ez - s.r*s.r;
const disc = b*b - 4*a*c;
if (disc < 0) return -1;
const t = (-b - Math.sqrt(disc)) / (2*a);
return t > 1e-4 ? t : -1;
}
// Ray-plane intersection: y = FLOOR_Y => t = (FLOOR_Y - oy) / dy
function hitFloor(oy, dy) {
if (Math.abs(dy) < 1e-6) return -1;
const t = (FLOOR_Y - oy) / dy;
return t > 1e-4 ? t : -1;
}
// Recursive trace: returns [r,g,b] for a ray from (ox,oy,oz) in direction (dx,dy,dz)
function trace(ox, oy, oz, dx, dy, dz, depth, maxDepth) {
let tMin = Infinity, hitIdx = -1, hitFloorHit = false;
// find nearest intersection
for (let i = 0; i < SPHERES.length; i++) {
const t = hitSphere(ox, oy, oz, dx, dy, dz, SPHERES[i]);
if (t > 0 && t < tMin) { tMin = t; hitIdx = i; hitFloorHit = false; }
}
const tf = hitFloor(oy, dy);
if (tf > 0 && tf < tMin) { tMin = tf; hitFloorHit = true; hitIdx = -1; }
if (tMin === Infinity) return BG; // ray missed everything — return background
// compute surface normal and base color at hit point
const px = ox+dx*tMin, py = oy+dy*tMin, pz = oz+dz*tMin;
let nx, ny, nz, baseCol, refl;
if (hitFloorHit) {
// checkerboard floor
nx = 0; ny = 1; nz = 0;
const tile = (Math.floor(px) + Math.floor(pz)) & 1;
baseCol = tile ? [200, 200, 200] : [50, 50, 60];
refl = 0.2;
} else {
const s = SPHERES[hitIdx];
nx = (px-s.cx)/s.r; ny = (py-s.cy)/s.r; nz = (pz-s.cz)/s.r;
baseCol = s.col; refl = s.refl;
}
// diffuse shading from point light
const lx = LIGHT.x-px, ly = LIGHT.y-py, lz = LIGHT.z-pz;
const ld = Math.sqrt(lx*lx+ly*ly+lz*lz);
const lnx = lx/ld, lny = ly/ld, lnz = lz/ld;
const diff = Math.max(0, nx*lnx + ny*lny + nz*lnz);
const ambient = 0.18;
const light = ambient + (1-ambient)*diff;
let r = baseCol[0]*light, g = baseCol[1]*light, b = baseCol[2]*light;
// recursive reflection
if (depth < maxDepth && refl > 0) {
const rx = reflect([dx,dy,dz], [nx,ny,nz]);
const rc = trace(px+rx[0]*1e-4, py+rx[1]*1e-4, pz+rx[2]*1e-4,
rx[0], rx[1], rx[2], depth+1, maxDepth);
r = r*(1-refl) + rc[0]*refl;
g = g*(1-refl) + rc[1]*refl;
b = b*(1-refl) + rc[2]*refl;
}
return [clamp(r), clamp(g), clamp(b)];
}
let rendering = false;
function render() {
if (rendering) return;
rendering = true;
const maxDepth = parseInt(document.getElementById('depthSlider').value);
document.getElementById('status').textContent = 'Rendering...';
document.getElementById('renderBtn').disabled = true;
// render into an ImageData buffer row by row
const img = ctx.createImageData(W, H);
const aspect = W / H;
// camera at origin looking +z, vertical FOV 60 degrees
const fov = Math.tan(Math.PI / 6); // half-angle tangent (60 deg)
let row = 0;
function renderRow() {
const y = row;
for (let x = 0; x < W; x++) {
// compute ray direction for this pixel (perspective projection)
const px2 = (2*(x+0.5)/W - 1)*aspect*fov;
const py2 = (1 - 2*(y+0.5)/H)*fov;
const d = norm([px2, py2, 1.0]);
const col = trace(0, 0.5, 0, d[0], d[1], d[2], 0, maxDepth);
const idx = (y*W + x)*4;
img.data[idx] = col[0];
img.data[idx+1] = col[1];
img.data[idx+2] = col[2];
img.data[idx+3] = 255;
}
row++;
if (row < H) {
// show partial progress every 20 rows
if (row % 20 === 0) ctx.putImageData(img, 0, 0);
requestAnimationFrame(renderRow);
} else {
ctx.putImageData(img, 0, 0);
document.getElementById('status').textContent =
i18nFmt('Rendered with %n% reflection bounce(s). Drag the slider and re-render.', { n: maxDepth });
document.getElementById('renderBtn').disabled = false;
rendering = false;
}
}
requestAnimationFrame(renderRow);
}
document.getElementById('renderBtn').addEventListener('click', render);
document.getElementById('depthSlider').addEventListener('input', function() {
document.getElementById('depthVal').textContent = this.value;
});
render();
Notice how setting reflections to 0 gives flat, unlit colors; each extra bounce adds mirror depth and costs proportionally more computation. A real production renderer uses the same recursive loop — just with physically accurate materials, area lights, and thousands of samples per pixel for Monte Carlo noise reduction.
Comments
Loading comments...