The canvas below simulates photon mapping in 2-D. A point light at the top shoots photons downward through a convex glass lens. Each photon refracts, travels, and lands on the floor — where it is stored as a dot in the photon map. During the gather pass, each floor point queries its neighborhood and sums the photon energy it finds.
<!-- Controls: photon count slider, gather radius slider, shoot button, reset button -->
<div class="controls">
<label>Photons: <span id="photon-val">400</span>
<input type="range" id="photon-count" min="50" max="1200" step="50" value="400">
</label>
<label>Gather radius: <span id="radius-val">18</span>px
<input type="range" id="gather-radius" min="6" max="40" step="2" value="18">
</label>
<button id="shoot-btn" type="button">Shoot photons</button>
<button id="reset-btn" type="button" class="ghost">Reset</button>
</div>
<canvas id="scene" width="460" height="300"></canvas>
<div class="status" id="status">Press "Shoot photons" to start the simulation.</div>
/* Layout and visual style for the photon-mapping demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.controls {
display: flex; flex-wrap: wrap; gap: .5rem .9rem;
align-items: center; margin-bottom: .5rem;
}
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 90px; cursor: pointer; }
button {
font: 600 13px system-ui; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557;
color: #fff; border-radius: 7px; cursor: pointer;
}
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border: 1px solid #dce3ea; border-radius: 8px; max-width: 100%; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; margin-top: .4rem; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Photon-mapping demo: 2-D simulation of pass-1 (emit) and pass-2 (gather).
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Scene geometry constants (pixels)
const LIGHT_X = W / 2;
const LIGHT_Y = 18;
const LENS_CX = W / 2;
const LENS_CY = 110;
const LENS_R = 46; // radius of the convex glass lens
const FLOOR_Y = H - 30;
const IOR = 1.5; // index of refraction for glass
// Snell's law refraction — returns refracted (dx,dy) or null on total internal reflection
function refract(dx, dy, nx, ny, n1, n2) {
const dot = dx * nx + dy * ny;
const r = n1 / n2;
const disc = 1 - r * r * (1 - dot * dot);
if (disc < 0) return null; // total internal reflection — photon lost
const s = r * dot - Math.sqrt(disc);
return { dx: r * dx - s * nx, dy: r * dy - s * ny };
}
// photon map: array of floor-landing x positions
let photons = [];
function shootPhotons(count) {
// Pass 1 — emit photons from the point light, refract through the lens, land on floor
photons = [];
const spread = 0.55; // angular spread of the beam in radians
for (let i = 0; i < count; i++) {
const angle = (Math.random() - 0.5) * spread;
let px = LIGHT_X, py = LIGHT_Y;
let dx = Math.sin(angle), dy = Math.cos(angle);
// intersect photon ray with lens sphere (entry point)
// solve quadratic for ray-sphere intersection (entry)
const A = dx * dx + dy * dy;
const bx = px - LENS_CX, by = py - LENS_CY;
const B = 2 * (dx * bx + dy * by);
const C = bx * bx + by * by - LENS_R * LENS_R;
const disc = B * B - 4 * A * C;
if (disc < 0) continue;
const t1 = (-B - Math.sqrt(disc)) / (2 * A);
if (t1 < 0) continue;
const ex = px + t1 * dx, ey = py + t1 * dy;
const nx1 = (ex - LENS_CX) / LENS_R, ny1 = (ey - LENS_CY) / LENS_R;
// refract on entry (air -> glass), negate normal to face incoming ray
const r1 = refract(dx, dy, -nx1, -ny1, 1.0, IOR);
if (!r1) continue;
// solve quadratic for ray-sphere intersection (exit)
const bx2 = ex - LENS_CX, by2 = ey - LENS_CY;
const B2 = 2 * (r1.dx * bx2 + r1.dy * by2);
const C2 = bx2 * bx2 + by2 * by2 - LENS_R * LENS_R;
const disc2 = B2 * B2 - 4 * A * C2;
if (disc2 < 0) continue;
const t2 = (-B2 + Math.sqrt(disc2)) / (2 * A);
if (t2 < 0.001) continue;
const ex2 = ex + t2 * r1.dx, ey2 = ey + t2 * r1.dy;
const nx2 = (ex2 - LENS_CX) / LENS_R, ny2 = (ey2 - LENS_CY) / LENS_R;
// refract on exit (glass -> air)
const r2 = refract(r1.dx, r1.dy, nx2, ny2, IOR, 1.0);
if (!r2) continue;
// photon travels straight from exit point to floor
if (r2.dy <= 0) continue;
const tFloor = (FLOOR_Y - ey2) / r2.dy;
const fx = ex2 + tFloor * r2.dx;
if (fx < 0 || fx > W) continue;
photons.push({ x: fx, power: 1 });
}
}
function gatherRadiance(x, radius) {
// Pass 2 — gather photons within radius, weighted by proximity
let energy = 0;
for (const p of photons) {
const d = Math.abs(p.x - x);
if (d <= radius) energy += p.power * (1 - d / radius);
}
return energy;
}
function drawScene(gatherR) {
// Render the scene: background, caustic heatmap, photon dots, lens, labels
ctx.clearRect(0, 0, W, H);
// dark background
ctx.fillStyle = '#0d0d1a';
ctx.fillRect(0, 0, W, H);
// floor surface
const floorGrad = ctx.createLinearGradient(0, FLOOR_Y, 0, H);
floorGrad.addColorStop(0, '#1a1830');
floorGrad.addColorStop(1, '#0d0d1a');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, FLOOR_Y, W, H - FLOOR_Y);
// caustic heatmap — sample floor radiance at 200 points
const samples = 200;
for (let i = 0; i <= samples; i++) {
const fx = (i / samples) * W;
const e = gatherRadiance(fx, gatherR);
const t = Math.min(e / 4, 1);
if (t < 0.01) continue;
const r = Math.round(255 * Math.min(t * 2, 1));
const g = Math.round(200 * Math.min(t * 1.4, 1));
const b = Math.round(80 * t);
const alpha = t * 0.85;
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
const bw = W / samples + 1;
const bh = 10 + t * 14;
ctx.fillRect(fx - bw / 2, FLOOR_Y - bh / 2, bw, bh);
}
// show individual photon landing dots
ctx.fillStyle = 'rgba(255,220,100,0.35)';
for (const p of photons) {
ctx.beginPath();
ctx.arc(p.x, FLOOR_Y, 1.5, 0, Math.PI * 2);
ctx.fill();
}
// glass lens circle
ctx.save();
ctx.beginPath();
ctx.arc(LENS_CX, LENS_CY, LENS_R, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(120,200,255,0.12)';
ctx.fill();
ctx.strokeStyle = 'rgba(120,200,255,0.6)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
// lens label
ctx.fillStyle = 'rgba(150,210,255,0.75)';
ctx.font = '11px system-ui';
ctx.fillText('glass', LENS_CX - LENS_R - 2, LENS_CY + 4);
// point light source
ctx.beginPath();
ctx.arc(LIGHT_X, LIGHT_Y, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,240,140,0.9)';
ctx.fill();
ctx.fillStyle = 'rgba(255,240,140,0.9)';
ctx.font = '11px system-ui';
ctx.fillText('light', LIGHT_X + 10, LIGHT_Y + 4);
// floor label
ctx.fillStyle = 'rgba(200,200,220,0.55)';
ctx.font = '11px system-ui';
ctx.fillText('floor (photon map)', 4, FLOOR_Y - 6);
}
function run() {
const count = parseInt(document.getElementById('photon-count').value, 10);
const gatherR = parseInt(document.getElementById('gather-radius').value, 10);
shootPhotons(count);
drawScene(gatherR);
document.getElementById('status').textContent =
i18nFmt('%n% photons landed out of %count% shot — caustic rendered.', { n: photons.length, count: count });
}
function reset() {
photons = [];
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0d0d1a';
ctx.fillRect(0, 0, W, H);
document.getElementById('status').textContent = 'Press "Shoot photons" to start the simulation.';
}
document.getElementById('photon-count').addEventListener('input', function () {
document.getElementById('photon-val').textContent = this.value;
});
document.getElementById('gather-radius').addEventListener('input', function () {
document.getElementById('radius-val').textContent = this.value;
if (photons.length > 0) {
drawScene(parseInt(this.value, 10));
}
});
document.getElementById('shoot-btn').addEventListener('click', run);
document.getElementById('reset-btn').addEventListener('click', reset);
// run once on load
run();
<!-- Controls: photon count slider, gather radius slider, shoot button, reset button -->
<div class="controls">
<label>Photons: <span id="photon-val">400</span>
<input type="range" id="photon-count" min="50" max="1200" step="50" value="400">
</label>
<label>Gather radius: <span id="radius-val">18</span>px
<input type="range" id="gather-radius" min="6" max="40" step="2" value="18">
</label>
<button id="shoot-btn" type="button">Shoot photons</button>
<button id="reset-btn" type="button" class="ghost">Reset</button>
</div>
<canvas id="scene" width="460" height="300"></canvas>
<div class="status" id="status">Press "Shoot photons" to start the simulation.</div>
/* Layout and visual style for the photon-mapping demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.controls {
display: flex; flex-wrap: wrap; gap: .5rem .9rem;
align-items: center; margin-bottom: .5rem;
}
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 90px; cursor: pointer; }
button {
font: 600 13px system-ui; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557;
color: #fff; border-radius: 7px; cursor: pointer;
}
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border: 1px solid #dce3ea; border-radius: 8px; max-width: 100%; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; margin-top: .4rem; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Photon-mapping demo: 2-D simulation of pass-1 (emit) and pass-2 (gather).
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Scene geometry constants (pixels)
const LIGHT_X = W / 2;
const LIGHT_Y = 18;
const LENS_CX = W / 2;
const LENS_CY = 110;
const LENS_R = 46; // radius of the convex glass lens
const FLOOR_Y = H - 30;
const IOR = 1.5; // index of refraction for glass
// Snell's law refraction — returns refracted (dx,dy) or null on total internal reflection
function refract(dx, dy, nx, ny, n1, n2) {
const dot = dx * nx + dy * ny;
const r = n1 / n2;
const disc = 1 - r * r * (1 - dot * dot);
if (disc < 0) return null; // total internal reflection — photon lost
const s = r * dot - Math.sqrt(disc);
return { dx: r * dx - s * nx, dy: r * dy - s * ny };
}
// photon map: array of floor-landing x positions
let photons = [];
function shootPhotons(count) {
// Pass 1 — emit photons from the point light, refract through the lens, land on floor
photons = [];
const spread = 0.55; // angular spread of the beam in radians
for (let i = 0; i < count; i++) {
const angle = (Math.random() - 0.5) * spread;
let px = LIGHT_X, py = LIGHT_Y;
let dx = Math.sin(angle), dy = Math.cos(angle);
// intersect photon ray with lens sphere (entry point)
// solve quadratic for ray-sphere intersection (entry)
const A = dx * dx + dy * dy;
const bx = px - LENS_CX, by = py - LENS_CY;
const B = 2 * (dx * bx + dy * by);
const C = bx * bx + by * by - LENS_R * LENS_R;
const disc = B * B - 4 * A * C;
if (disc < 0) continue;
const t1 = (-B - Math.sqrt(disc)) / (2 * A);
if (t1 < 0) continue;
const ex = px + t1 * dx, ey = py + t1 * dy;
const nx1 = (ex - LENS_CX) / LENS_R, ny1 = (ey - LENS_CY) / LENS_R;
// refract on entry (air -> glass), negate normal to face incoming ray
const r1 = refract(dx, dy, -nx1, -ny1, 1.0, IOR);
if (!r1) continue;
// solve quadratic for ray-sphere intersection (exit)
const bx2 = ex - LENS_CX, by2 = ey - LENS_CY;
const B2 = 2 * (r1.dx * bx2 + r1.dy * by2);
const C2 = bx2 * bx2 + by2 * by2 - LENS_R * LENS_R;
const disc2 = B2 * B2 - 4 * A * C2;
if (disc2 < 0) continue;
const t2 = (-B2 + Math.sqrt(disc2)) / (2 * A);
if (t2 < 0.001) continue;
const ex2 = ex + t2 * r1.dx, ey2 = ey + t2 * r1.dy;
const nx2 = (ex2 - LENS_CX) / LENS_R, ny2 = (ey2 - LENS_CY) / LENS_R;
// refract on exit (glass -> air)
const r2 = refract(r1.dx, r1.dy, nx2, ny2, IOR, 1.0);
if (!r2) continue;
// photon travels straight from exit point to floor
if (r2.dy <= 0) continue;
const tFloor = (FLOOR_Y - ey2) / r2.dy;
const fx = ex2 + tFloor * r2.dx;
if (fx < 0 || fx > W) continue;
photons.push({ x: fx, power: 1 });
}
}
function gatherRadiance(x, radius) {
// Pass 2 — gather photons within radius, weighted by proximity
let energy = 0;
for (const p of photons) {
const d = Math.abs(p.x - x);
if (d <= radius) energy += p.power * (1 - d / radius);
}
return energy;
}
function drawScene(gatherR) {
// Render the scene: background, caustic heatmap, photon dots, lens, labels
ctx.clearRect(0, 0, W, H);
// dark background
ctx.fillStyle = '#0d0d1a';
ctx.fillRect(0, 0, W, H);
// floor surface
const floorGrad = ctx.createLinearGradient(0, FLOOR_Y, 0, H);
floorGrad.addColorStop(0, '#1a1830');
floorGrad.addColorStop(1, '#0d0d1a');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, FLOOR_Y, W, H - FLOOR_Y);
// caustic heatmap — sample floor radiance at 200 points
const samples = 200;
for (let i = 0; i <= samples; i++) {
const fx = (i / samples) * W;
const e = gatherRadiance(fx, gatherR);
const t = Math.min(e / 4, 1);
if (t < 0.01) continue;
const r = Math.round(255 * Math.min(t * 2, 1));
const g = Math.round(200 * Math.min(t * 1.4, 1));
const b = Math.round(80 * t);
const alpha = t * 0.85;
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
const bw = W / samples + 1;
const bh = 10 + t * 14;
ctx.fillRect(fx - bw / 2, FLOOR_Y - bh / 2, bw, bh);
}
// show individual photon landing dots
ctx.fillStyle = 'rgba(255,220,100,0.35)';
for (const p of photons) {
ctx.beginPath();
ctx.arc(p.x, FLOOR_Y, 1.5, 0, Math.PI * 2);
ctx.fill();
}
// glass lens circle
ctx.save();
ctx.beginPath();
ctx.arc(LENS_CX, LENS_CY, LENS_R, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(120,200,255,0.12)';
ctx.fill();
ctx.strokeStyle = 'rgba(120,200,255,0.6)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
// lens label
ctx.fillStyle = 'rgba(150,210,255,0.75)';
ctx.font = '11px system-ui';
ctx.fillText('glass', LENS_CX - LENS_R - 2, LENS_CY + 4);
// point light source
ctx.beginPath();
ctx.arc(LIGHT_X, LIGHT_Y, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,240,140,0.9)';
ctx.fill();
ctx.fillStyle = 'rgba(255,240,140,0.9)';
ctx.font = '11px system-ui';
ctx.fillText('light', LIGHT_X + 10, LIGHT_Y + 4);
// floor label
ctx.fillStyle = 'rgba(200,200,220,0.55)';
ctx.font = '11px system-ui';
ctx.fillText('floor (photon map)', 4, FLOOR_Y - 6);
}
function run() {
const count = parseInt(document.getElementById('photon-count').value, 10);
const gatherR = parseInt(document.getElementById('gather-radius').value, 10);
shootPhotons(count);
drawScene(gatherR);
document.getElementById('status').textContent =
i18nFmt('%n% photons landed out of %count% shot — caustic rendered.', { n: photons.length, count: count });
}
function reset() {
photons = [];
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0d0d1a';
ctx.fillRect(0, 0, W, H);
document.getElementById('status').textContent = 'Press "Shoot photons" to start the simulation.';
}
document.getElementById('photon-count').addEventListener('input', function () {
document.getElementById('photon-val').textContent = this.value;
});
document.getElementById('gather-radius').addEventListener('input', function () {
document.getElementById('radius-val').textContent = this.value;
if (photons.length > 0) {
drawScene(parseInt(this.value, 10));
}
});
document.getElementById('shoot-btn').addEventListener('click', run);
document.getElementById('reset-btn').addEventListener('click', reset);
// run once on load
run();
Crank up the photon count and the caustic sharpens — you are literally trading computation for accuracy. Widen the gather radius and the image blurs as each point averages over a larger neighborhood. This trade-off between speed and quality is the defining tension of the algorithm.
Comments
Loading comments...