The demo below runs a 2D version of the full algorithm. There is one point light, two rectangular blockers, and a receiver plane. Drag the light to move it and watch the shadows swing.
<!-- Shadow mapping demo — canvas receives shadow based on light position -->
<div class="hint">Move the sliders or <b>drag directly on the canvas</b> to reposition the light. The algorithm records the nearest blocker distance along every ray (the shadow map), then checks each point on the ground against that record to decide: lit or in shadow?</div>
<div class="scene-wrap">
<canvas id="scene" width="420" height="320"></canvas>
<div class="coords" id="coords"></div>
</div>
<div class="controls">
<label>Light X: <input type="range" id="lx" min="60" max="360" value="210"></label>
<label>Light Y: <input type="range" id="ly" min="20" max="140" value="50"></label>
</div>
<div class="status" id="status">Drag the light to cast shadows.</div>
<div class="legend">
<span class="leg-lit">■ Lit</span>
<span class="leg-shadow">■ Shadow</span>
<span class="leg-blocker">■ Blocker</span>
<span class="leg-light">◯ Light</span>
</div>
/* Scene styles — dark background simulates night scene */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.5; }
.scene-wrap { position: relative; display: inline-block; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #0a0f1a; }
.coords { position: absolute; top: 6px; right: 8px; font-size: .75rem; color: #aaa; pointer-events: none; }
.controls { display: flex; flex-direction: column; gap: .35rem; margin: .5rem 0; }
label { font-size: .85rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; }
.status { font-size: .9rem; font-weight: 600; color: #1d3557; min-height: 1.3em; margin: .3rem 0; }
.legend { display: flex; flex-wrap: wrap; gap: .6rem; font-size: .78rem; margin-top: .3rem; }
.leg-lit { color: #f4c842; }
.leg-shadow { color: #2d3d5a; }
.leg-blocker { color: #7a5c2e; }
.leg-light { color: #ffe680; }
// Shadow mapping 2D demo — interactive light with depth-based shadow test
// Algorithm: (1) build shadow map (nearest blocker distance per ray angle), (2) shade receiver by comparing point distance to shadow map entry.
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// The horizontal receiver plane (ground) at the bottom of the canvas
const RECEIVER_Y = 280;
// Two rectangular blockers that cast shadows
const BLOCKERS = [
{ x: 90, y: 160, w: 80, h: 30 },
{ x: 250, y: 190, w: 90, h: 25 }
];
// Shadow map = array of nearest blocker distances per ray direction
// One entry per discrete angle around the light source
const RAYS = 420;
// Current light position (pixels)
let lightX = 210, lightY = 50;
function buildShadowMap(lx, ly) {
// Cast one ray per angle slot; record nearest blocker distance
const map = new Float32Array(RAYS);
for (let i = 0; i < RAYS; i++) {
// Evenly-spaced angle for this ray
const angle = (i / RAYS) * Math.PI * 2;
const dx = Math.cos(angle), dy = Math.sin(angle);
let minDist = Infinity;
// Test intersection with every blocker and keep the nearest hit
for (const b of BLOCKERS) {
const d = rayBoxIntersect(lx, ly, dx, dy, b);
if (d > 0 && d < minDist) minDist = d;
}
map[i] = minDist;
}
return map;
}
function rayBoxIntersect(ox, oy, dx, dy, b) {
// Slab method ray-AABB intersection; returns distance along ray or -1 if no hit
// Early-out when the ray is parallel to an axis and the origin is outside that slab
if (dx === 0 && (ox < b.x || ox > b.x + b.w)) return -1;
if (dy === 0 && (oy < b.y || oy > b.y + b.h)) return -1;
const tMinX = dx !== 0 ? (b.x - ox) / dx : -Infinity;
const tMaxX = dx !== 0 ? (b.x + b.w - ox) / dx : Infinity;
const tMinY = dy !== 0 ? (b.y - oy) / dy : -Infinity;
const tMaxY = dy !== 0 ? (b.y + b.h - oy) / dy : Infinity;
const tEnter = Math.max(Math.min(tMinX, tMaxX), Math.min(tMinY, tMaxY));
const tLeave = Math.min(Math.max(tMinX, tMaxX), Math.max(tMinY, tMaxY));
if (tLeave < 0 || tEnter > tLeave) return -1;
return tEnter > 0 ? tEnter : tLeave;
}
function angleTo(lx, ly, px, py) {
// Angle from light to point in [0, 2*pi)
let a = Math.atan2(py - ly, px - lx);
if (a < 0) a += Math.PI * 2;
return a;
}
function isInShadow(lx, ly, px, py, shadowMap) {
// Look up the shadow map at the angle toward the point; shadow if point is farther than recorded depth
const angle = angleTo(lx, ly, px, py);
const idx = Math.round((angle / (Math.PI * 2)) * RAYS) % RAYS;
const dist = Math.hypot(px - lx, py - ly);
// Small bias to avoid self-shadowing artifacts (shadow acne)
const BIAS = 2;
return dist > shadowMap[idx] + BIAS;
}
function draw() {
const shadowMap = buildShadowMap(lightX, lightY);
ctx.clearRect(0, 0, W, H);
// Draw the receiver strip pixel by pixel — lit or shadowed
for (let x = 0; x < W; x++) {
const inShadow = isInShadow(lightX, lightY, x, RECEIVER_Y, shadowMap);
ctx.fillStyle = inShadow ? '#1a2540' : '#f4c842';
ctx.fillRect(x, RECEIVER_Y, 1, 14);
}
// Draw faint light rays to visualize the shadow map sampling
ctx.globalAlpha = 0.07;
ctx.strokeStyle = '#ffe680';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 8) {
ctx.beginPath();
ctx.moveTo(lightX, lightY);
ctx.lineTo(x, RECEIVER_Y);
const inShadow = isInShadow(lightX, lightY, x, RECEIVER_Y, shadowMap);
ctx.strokeStyle = inShadow ? '#334' : '#ffe680';
ctx.stroke();
}
ctx.globalAlpha = 1;
// Draw solid blocker rectangles
for (const b of BLOCKERS) {
ctx.fillStyle = '#7a5c2e';
ctx.strokeStyle = '#c8982a';
ctx.lineWidth = 1.5;
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
// Visualize the shadow map as dots on an arc around the light
// Red dot = blocker hit within arc radius; green = open ray
const ARC_R = 36;
ctx.strokeStyle = '#334466';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(lightX, lightY, ARC_R, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < RAYS; i += 4) {
const angle = (i / RAYS) * Math.PI * 2;
const r = Math.min(shadowMap[i], ARC_R * 2);
const ex = lightX + Math.cos(angle) * Math.min(r, ARC_R);
const ey = lightY + Math.sin(angle) * Math.min(r, ARC_R);
ctx.fillStyle = r < ARC_R * 2 ? '#ff6644' : '#226622';
ctx.fillRect(ex - 0.8, ey - 0.8, 1.6, 1.6);
}
// Draw the glowing light source
ctx.beginPath();
ctx.arc(lightX, lightY, 10, 0, Math.PI * 2);
ctx.fillStyle = '#ffe680';
ctx.shadowColor = '#ffe680';
ctx.shadowBlur = 18;
ctx.fill();
ctx.shadowBlur = 0;
// Label the light source
ctx.font = '11px system-ui';
ctx.fillStyle = '#fff';
ctx.fillText('Light', lightX + 13, lightY + 4);
// Update lit-percentage status text
const litCount = [...Array(W)].filter((_, x) =>
!isInShadow(lightX, lightY, x, RECEIVER_Y, shadowMap)).length;
document.getElementById('status').textContent =
`${Math.round((litCount / W) * 100)}% of the ground is lit`;
document.getElementById('coords').textContent =
`Light: (${lightX}, ${lightY})`;
}
// Update light position from sliders
document.getElementById('lx').addEventListener('input', e => {
lightX = +e.target.value; draw();
});
document.getElementById('ly').addEventListener('input', e => {
lightY = +e.target.value; draw();
});
// Allow clicking / dragging on the canvas to move the light
let dragging = false;
canvas.addEventListener('mousedown', e => { dragging = true; moveTo(e); });
canvas.addEventListener('mousemove', e => { if (dragging) moveTo(e); });
canvas.addEventListener('mouseup', () => { dragging = false; });
canvas.addEventListener('mouseleave', () => { dragging = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); dragging = true; moveTo(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (dragging) moveTo(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchend', () => { dragging = false; });
function moveTo(e) {
// Convert mouse/touch position to canvas coordinates and update light
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
lightX = Math.round((e.clientX - rect.left) * scaleX);
lightY = Math.round((e.clientY - rect.top) * scaleY);
lightX = Math.max(10, Math.min(W - 10, lightX));
lightY = Math.max(10, Math.min(RECEIVER_Y - 10, lightY));
document.getElementById('lx').value = lightX;
document.getElementById('ly').value = lightY;
draw();
}
draw();
<!-- Shadow mapping demo — canvas receives shadow based on light position -->
<div class="hint">Move the sliders or <b>drag directly on the canvas</b> to reposition the light. The algorithm records the nearest blocker distance along every ray (the shadow map), then checks each point on the ground against that record to decide: lit or in shadow?</div>
<div class="scene-wrap">
<canvas id="scene" width="420" height="320"></canvas>
<div class="coords" id="coords"></div>
</div>
<div class="controls">
<label>Light X: <input type="range" id="lx" min="60" max="360" value="210"></label>
<label>Light Y: <input type="range" id="ly" min="20" max="140" value="50"></label>
</div>
<div class="status" id="status">Drag the light to cast shadows.</div>
<div class="legend">
<span class="leg-lit">■ Lit</span>
<span class="leg-shadow">■ Shadow</span>
<span class="leg-blocker">■ Blocker</span>
<span class="leg-light">◯ Light</span>
</div>
/* Scene styles — dark background simulates night scene */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.5; }
.scene-wrap { position: relative; display: inline-block; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #0a0f1a; }
.coords { position: absolute; top: 6px; right: 8px; font-size: .75rem; color: #aaa; pointer-events: none; }
.controls { display: flex; flex-direction: column; gap: .35rem; margin: .5rem 0; }
label { font-size: .85rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; }
.status { font-size: .9rem; font-weight: 600; color: #1d3557; min-height: 1.3em; margin: .3rem 0; }
.legend { display: flex; flex-wrap: wrap; gap: .6rem; font-size: .78rem; margin-top: .3rem; }
.leg-lit { color: #f4c842; }
.leg-shadow { color: #2d3d5a; }
.leg-blocker { color: #7a5c2e; }
.leg-light { color: #ffe680; }
// Shadow mapping 2D demo — interactive light with depth-based shadow test
// Algorithm: (1) build shadow map (nearest blocker distance per ray angle), (2) shade receiver by comparing point distance to shadow map entry.
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// The horizontal receiver plane (ground) at the bottom of the canvas
const RECEIVER_Y = 280;
// Two rectangular blockers that cast shadows
const BLOCKERS = [
{ x: 90, y: 160, w: 80, h: 30 },
{ x: 250, y: 190, w: 90, h: 25 }
];
// Shadow map = array of nearest blocker distances per ray direction
// One entry per discrete angle around the light source
const RAYS = 420;
// Current light position (pixels)
let lightX = 210, lightY = 50;
function buildShadowMap(lx, ly) {
// Cast one ray per angle slot; record nearest blocker distance
const map = new Float32Array(RAYS);
for (let i = 0; i < RAYS; i++) {
// Evenly-spaced angle for this ray
const angle = (i / RAYS) * Math.PI * 2;
const dx = Math.cos(angle), dy = Math.sin(angle);
let minDist = Infinity;
// Test intersection with every blocker and keep the nearest hit
for (const b of BLOCKERS) {
const d = rayBoxIntersect(lx, ly, dx, dy, b);
if (d > 0 && d < minDist) minDist = d;
}
map[i] = minDist;
}
return map;
}
function rayBoxIntersect(ox, oy, dx, dy, b) {
// Slab method ray-AABB intersection; returns distance along ray or -1 if no hit
// Early-out when the ray is parallel to an axis and the origin is outside that slab
if (dx === 0 && (ox < b.x || ox > b.x + b.w)) return -1;
if (dy === 0 && (oy < b.y || oy > b.y + b.h)) return -1;
const tMinX = dx !== 0 ? (b.x - ox) / dx : -Infinity;
const tMaxX = dx !== 0 ? (b.x + b.w - ox) / dx : Infinity;
const tMinY = dy !== 0 ? (b.y - oy) / dy : -Infinity;
const tMaxY = dy !== 0 ? (b.y + b.h - oy) / dy : Infinity;
const tEnter = Math.max(Math.min(tMinX, tMaxX), Math.min(tMinY, tMaxY));
const tLeave = Math.min(Math.max(tMinX, tMaxX), Math.max(tMinY, tMaxY));
if (tLeave < 0 || tEnter > tLeave) return -1;
return tEnter > 0 ? tEnter : tLeave;
}
function angleTo(lx, ly, px, py) {
// Angle from light to point in [0, 2*pi)
let a = Math.atan2(py - ly, px - lx);
if (a < 0) a += Math.PI * 2;
return a;
}
function isInShadow(lx, ly, px, py, shadowMap) {
// Look up the shadow map at the angle toward the point; shadow if point is farther than recorded depth
const angle = angleTo(lx, ly, px, py);
const idx = Math.round((angle / (Math.PI * 2)) * RAYS) % RAYS;
const dist = Math.hypot(px - lx, py - ly);
// Small bias to avoid self-shadowing artifacts (shadow acne)
const BIAS = 2;
return dist > shadowMap[idx] + BIAS;
}
function draw() {
const shadowMap = buildShadowMap(lightX, lightY);
ctx.clearRect(0, 0, W, H);
// Draw the receiver strip pixel by pixel — lit or shadowed
for (let x = 0; x < W; x++) {
const inShadow = isInShadow(lightX, lightY, x, RECEIVER_Y, shadowMap);
ctx.fillStyle = inShadow ? '#1a2540' : '#f4c842';
ctx.fillRect(x, RECEIVER_Y, 1, 14);
}
// Draw faint light rays to visualize the shadow map sampling
ctx.globalAlpha = 0.07;
ctx.strokeStyle = '#ffe680';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 8) {
ctx.beginPath();
ctx.moveTo(lightX, lightY);
ctx.lineTo(x, RECEIVER_Y);
const inShadow = isInShadow(lightX, lightY, x, RECEIVER_Y, shadowMap);
ctx.strokeStyle = inShadow ? '#334' : '#ffe680';
ctx.stroke();
}
ctx.globalAlpha = 1;
// Draw solid blocker rectangles
for (const b of BLOCKERS) {
ctx.fillStyle = '#7a5c2e';
ctx.strokeStyle = '#c8982a';
ctx.lineWidth = 1.5;
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
// Visualize the shadow map as dots on an arc around the light
// Red dot = blocker hit within arc radius; green = open ray
const ARC_R = 36;
ctx.strokeStyle = '#334466';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(lightX, lightY, ARC_R, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < RAYS; i += 4) {
const angle = (i / RAYS) * Math.PI * 2;
const r = Math.min(shadowMap[i], ARC_R * 2);
const ex = lightX + Math.cos(angle) * Math.min(r, ARC_R);
const ey = lightY + Math.sin(angle) * Math.min(r, ARC_R);
ctx.fillStyle = r < ARC_R * 2 ? '#ff6644' : '#226622';
ctx.fillRect(ex - 0.8, ey - 0.8, 1.6, 1.6);
}
// Draw the glowing light source
ctx.beginPath();
ctx.arc(lightX, lightY, 10, 0, Math.PI * 2);
ctx.fillStyle = '#ffe680';
ctx.shadowColor = '#ffe680';
ctx.shadowBlur = 18;
ctx.fill();
ctx.shadowBlur = 0;
// Label the light source
ctx.font = '11px system-ui';
ctx.fillStyle = '#fff';
ctx.fillText('Light', lightX + 13, lightY + 4);
// Update lit-percentage status text
const litCount = [...Array(W)].filter((_, x) =>
!isInShadow(lightX, lightY, x, RECEIVER_Y, shadowMap)).length;
document.getElementById('status').textContent =
`${Math.round((litCount / W) * 100)}% of the ground is lit`;
document.getElementById('coords').textContent =
`Light: (${lightX}, ${lightY})`;
}
// Update light position from sliders
document.getElementById('lx').addEventListener('input', e => {
lightX = +e.target.value; draw();
});
document.getElementById('ly').addEventListener('input', e => {
lightY = +e.target.value; draw();
});
// Allow clicking / dragging on the canvas to move the light
let dragging = false;
canvas.addEventListener('mousedown', e => { dragging = true; moveTo(e); });
canvas.addEventListener('mousemove', e => { if (dragging) moveTo(e); });
canvas.addEventListener('mouseup', () => { dragging = false; });
canvas.addEventListener('mouseleave', () => { dragging = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); dragging = true; moveTo(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (dragging) moveTo(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchend', () => { dragging = false; });
function moveTo(e) {
// Convert mouse/touch position to canvas coordinates and update light
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
lightX = Math.round((e.clientX - rect.left) * scaleX);
lightY = Math.round((e.clientY - rect.top) * scaleY);
lightX = Math.max(10, Math.min(W - 10, lightX));
lightY = Math.max(10, Math.min(RECEIVER_Y - 10, lightY));
document.getElementById('lx').value = lightX;
document.getElementById('ly').value = lightY;
draw();
}
draw();
Notice what happens step by step: the algorithm first traces rays from the light to record the nearest distance it can see along each angle (the shadow map), then checks every surface point against that recorded distance. Points the light cannot reach directly — because a blocker is closer — fall into shadow. The comparison is instantaneous; the tricky part is storing and sampling the depth values accurately enough to avoid the shimmering artifact known as shadow acne.
Comments
Loading comments...