Seen from the light, an object's outline is its silhouette: the edges shared by a face turned toward the light and a face turned away from it, as seen from the light, not the viewer. Stretching each silhouette edge away from the light into a quad gives the sides of the volume; the light-facing triangles close it at the front, and the other triangles, pushed to infinity, close it at the back (Capping Volumes). The fifteenth part of webgl-helper.js builds this on the CPU for any closed mesh in GLH.box()'s layout:
// Section 3.11: the shadow volume of a closed mesh (GLH.box layout) under a point light, as
// world-space x, y, z, w triangles: front cap, silhouette sides, and back cap at infinity
GLH.shadowVolume = function (mesh, model, light) {
const { vec3 } = glMatrix, out = [], edges = new Map();
const at = (i) => vec3.transformMat4([], mesh.vertices.subarray(i * 8, i * 8 + 3), model);
const key = (a, b) => [...a, ...b].map((k) => k.toFixed(4)).join();
const far = (p) => [...vec3.sub([], p, light), 0]; // w = 0: a direction, at infinity
for (let t = 0; t < mesh.indices.length; t += 3) {
const [a, b, c] = [0, 1, 2].map((k) => at(mesh.indices[t + k]));
const n = vec3.cross([], vec3.sub([], b, a), vec3.sub([], c, a));
const lit = vec3.dot(n, vec3.sub([], light, a)) > 0; // faces the light?
out.push(...[a, b, c].flatMap((p) => (lit ? [...p, 1] : far(p)))); // the two caps
for (const [p, q] of [[a, b], [b, c], [c, a]]) edges.set(key(p, q), { p, q, lit });
}
let silhouette = 0;
for (const { p, q, lit } of edges.values()) { // lit face next to an unlit one
if (!lit || edges.get(key(q, p))?.lit !== false) continue;
out.push(...q, 1, ...p, 1, ...far(p), ...q, 1, ...far(p), ...far(q)); // side quad
silhouette++;
}
return { vertices: new Float32Array(out), silhouette };
};A shared edge runs in opposite directions in its two counterclockwise triangles, so looking up the reversed key finds the neighbor. The side quad takes the lit face's edge reversed, which keeps it counterclockwise seen from outside the volume; the stencil pass depends on that winding. A light straight above a cube sees 4 silhouette edges; a light off a corner sees 6:
const box = GLH.box(), identity = glMatrix.mat4.create();
for (const light of [[0, 3, 0], [3, 3, 3]]) {
const { vertices, silhouette } = GLH.shadowVolume(box, identity, light);
const triangles = vertices.length / 12; // 3 vertices of 4 floats each
console.log(`light at ${light}: ${silhouette} silhouette edges, ${triangles} triangles`);
}light at 0,3,0: 4 silhouette edges, 20 triangles light at 3,3,3: 6 silhouette edges, 24 triangles
Twelve cap triangles plus two per edge: cheap to rebuild on the CPU for a few boxes.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="600" height="320"></canvas>
<p id="out"></p>
<script>
const m4 = {
perspective(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
},
lookAt(eye, target, up = [0, 1, 0]) {
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const unit = (v) => v.map((c) => c / Math.hypot(...v));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
},
multiply(a, b) {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
},
};
const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
[[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
const box = []; // triangles of a book-shaped box, world space
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
box.push([0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2 * [0.9, 1.2, 0.5][i] + [0, 0.6, 0][i]));
const sub = (a, b) => a.map((v, i) => v - b[i]);
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
// Silhouette: edges between a face turned toward the light and one turned away (seen from the light).
function silhouette(light) {
const key = (a, b) => [...a, ...b].map((k) => k.toFixed(4)).join();
const edges = new Map(), result = [];
for (let t = 0; t < box.length; t += 3) {
const [a, b, c] = box.slice(t, t + 3);
const lit = dot(cross(sub(b, a), sub(c, a)), sub(light, a)) > 0;
for (const [p, q] of [[a, b], [b, c], [c, a]]) edges.set(key(p, q), { p, q, lit });
}
for (const { p, q, lit } of edges.values())
if (lit && edges.get(key(q, p))?.lit === false) result.push([p, q]); // the reversed key is the neighbour
return result;
}
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * vec4(aPosition, 1.0); gl_PointSize = 12.0; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec4 uColor; out vec4 c; void main() { c = uColor; }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
const uColor = gl.getUniformLocation(program, 'uColor');
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
function draw(mode, points, color) {
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points.flat()), gl.DYNAMIC_DRAW);
gl.uniform4fv(uColor, color);
gl.drawArrays(mode, 0, points.length);
}
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uViewProjection'), false,
m4.multiply(m4.perspective(45, 600 / 320, 0.1, 30), m4.lookAt([2.6, 2.6, 4.2], [0, 0.4, 0])));
gl.enable(gl.DEPTH_TEST);
function frame(ms) {
const a = ms * 0.0006;
const light = [Math.cos(a) * 2.2, 2.6 + Math.sin(a * 0.7) * 0.4, Math.sin(a) * 1.4];
const edges = silhouette(light);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
draw(gl.TRIANGLES, [[-3, 0, -3], [3, 0, 3], [3, 0, -3], [-3, 0, -3], [-3, 0, 3], [3, 0, 3]], [0.82, 0.78, 0.7, 1]);
// Faces toward the light in blue, away in dark blue.
for (let t = 0; t < box.length; t += 3) {
const [p, q, r] = box.slice(t, t + 3);
const lit = dot(cross(sub(q, p), sub(r, p)), sub(light, p)) > 0;
draw(gl.TRIANGLES, [p, q, r], lit ? [0.3, 0.55, 0.8, 1] : [0.1, 0.2, 0.32, 1]);
}
draw(gl.POINTS, [light], [0.95, 0.75, 0.1, 1]);
// Each silhouette edge stretched away from the light (a finite length here, infinite in the real volume).
gl.enable(gl.BLEND); gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
for (const [p, q] of edges) {
const far = (v) => v.map((c, i) => c + (c - light[i]) * 1.4);
draw(gl.TRIANGLES, [q, p, far(p), q, far(p), far(q)], [0.85, 0.15, 0.1, 0.25]);
}
gl.depthMask(true); gl.disable(gl.BLEND);
gl.disable(gl.DEPTH_TEST);
draw(gl.LINES, edges.flat(), [0.9, 0.1, 0.05, 1]); // the silhouette itself
gl.enable(gl.DEPTH_TEST);
document.getElementById('out').textContent = `light at (${light.map(v => v.toFixed(1)).join(', ')}): ` +
`${edges.length} silhouette edges -> ${edges.length * 2} side triangles + 12 cap triangles`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>