The finished page puts a wall behind the shelf, a lamp above and in front of it, and gives each book a shadow volume. The left pane draws the volumes as translucent red geometry over the unshadowed scene; the right pane runs the four passes:
<canvas id="c" width="1240" height="340" style="width:620px;height:170px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script>
const gl = document.getElementById('c').getContext('webgl2', { stencil: true });
const { mat4, mat3, glMatrix: { toRadian } } = glMatrix;
const lit = GLH.lit(gl, GLH.shelfFS), u = lit.uniforms, shelf = GLH.shelf(gl, lit.attributes);
const lamp = [-0.2, 1.3, 1.1], eye = [0.9, 0.9, 2.9], black = [0, 0, 0];
const lampColor = [3, 2.8, 2.55], ambient = [0.28, 0.29, 0.33];
const wall = { color: GLH.rgb('#cdbfa8'), model: mat4.fromTranslation([], [0, 0.13, -0.45]) };
mat4.scale(wall.model, wall.model, [3.5, 1.3, 0.04]);
wall.normal = mat3.normalFromMat4([], wall.model);
const scene = [...shelf.items, wall], books = shelf.items.slice(0, 6);
const volumes = new Float32Array(books.flatMap((book) => // six volumes, one buffer
[...GLH.shadowVolume(GLH.box(), book.model, lamp).vertices]));
const shadow = GLH.load(gl, `#version 300 es
in vec4 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * aPosition; }`, `#version 300 es
precision mediump float; out vec4 c; void main() { c = vec4(0.9, 0.1, 0.1, 0.25); }`);
const vao = GLH.vao(gl, shadow.attributes, volumes, [['aPosition', 4]]);
const drawScene = (vp, lampColor, ambient) => {
gl.useProgram(lit.program); u.uViewProjection(vp); u.uEye(eye); u.uShininess(40); u.uFoil(1);
u['uLights[0].position'](lamp); u['uLights[0].color'](lampColor);
u['uLights[1].color'](black); u.uAmbient(ambient);
shelf.draw(u, scene);
};
const drawVolumes = (vp) => {
gl.useProgram(shadow.program); shadow.uniforms.uViewProjection(vp);
gl.bindVertexArray(vao); gl.drawArrays(gl.TRIANGLES, 0, volumes.length / 4);
};
gl.clearColor(0.93, 0.91, 0.87, 1); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
GLH.panes(gl, 2, (pane, aspect) => {
gl.clear(gl.STENCIL_BUFFER_BIT); // panes() clears color and depth
const vp = mat4.multiply([], mat4.perspective([], toRadian(40), aspect, 0.1, Infinity),
mat4.lookAt([], eye, [0.1, -0.2, 0], [0, 1, 0]));
if (pane === 0) { // the volumes, for debugging
drawScene(vp, lampColor, ambient);
gl.enable(gl.BLEND); gl.depthMask(false);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
drawVolumes(vp);
gl.disable(gl.BLEND); gl.depthMask(true);
return;
}
drawScene(vp, black, black); // 1: depth (and black)
gl.colorMask(false, false, false, false); gl.depthMask(false); gl.disable(gl.CULL_FACE);
gl.enable(gl.STENCIL_TEST); gl.stencilFunc(gl.ALWAYS, 0, 0xff);
gl.stencilOpSeparate(gl.BACK, gl.KEEP, gl.INCR_WRAP, gl.KEEP);
gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP);
drawVolumes(vp); // 2: stencil counts
gl.colorMask(true, true, true, true); gl.enable(gl.CULL_FACE); gl.depthFunc(gl.LEQUAL);
gl.stencilFunc(gl.EQUAL, 0, 0xff); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
drawScene(vp, lampColor, black); // 3: lamp where count is 0
gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
drawScene(vp, black, ambient); // 4: add ambient
gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
});
window.__done = true;
</script>
The volumes fan out away from the lamp and run off the canvas toward infinity. On the right, the books throw hard-edged shadows onto the plank and the wall behind them; gl.getError() stays 0, and SwiftShader 2,546 draws the same image to within one pixel.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="340"></canvas>
<script>
const m4 = {
perspective(fovy, aspect, near, far) { // far = Infinity gives the infinite projection
const f = 1 / Math.tan(fovy * Math.PI / 360);
if (far === Infinity) return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, -1, -1, 0, 0, -2 * near, 0];
const 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;
},
model(x, y, z, sx, sy, sz, turn = 0) { // T * Ry(turn) * S
const c = Math.cos(turn), s = Math.sin(turn);
return [c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s * sz, 0, c * sz, 0, x, y, z, 1];
},
normal(m) {
const [a, b, c, , d, e, f, , g, h, i] = m;
const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g, b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
const det = a * n[0] + b * n[1] + c * n[2];
return n.map((v) => v / det);
},
};
const CUBE = (() => { // 36 vertices: position, normal (counterclockwise outside)
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 out = [];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
return new Float32Array(out);
})();
// The shadow volume of a box under a point light, as world-space x, y, z, w triangles:
// front cap (lit faces), back cap (unlit faces pushed to infinity, w = 0) and silhouette sides.
function shadowVolume(model, light) {
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];
const at = (i) => [0, 1, 2].map(r => model[r] * CUBE[i * 6] + model[4 + r] * CUBE[i * 6 + 1] + model[8 + r] * CUBE[i * 6 + 2] + model[12 + r]);
const key = (a, b) => [...a, ...b].map((k) => k.toFixed(4)).join();
const far = (p) => [...sub(p, light), 0]; // a direction away from the light: a point at infinity
const out = [], edges = new Map();
for (let t = 0; t < 36; t += 3) {
const [a, b, c] = [t, t + 1, t + 2].map(at);
const lit = dot(cross(sub(b, a), sub(c, a)), sub(light, a)) > 0;
out.push(...[a, b, c].flatMap((p) => (lit ? [...p, 1] : far(p))));
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()) { // a lit face's edge next to an unlit face
if (!lit || edges.get(key(q, p))?.lit !== false) continue;
out.push(...q, 1, ...p, 1, ...far(p), ...q, 1, ...far(p), ...far(q));
}
return out;
}
function program(vs, fs) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
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(p, s);
}
gl.linkProgram(p);
return p;
}
const gl = document.getElementById('c').getContext('webgl2', { stencil: true }); // ask for stencil bits
const lit = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix; out vec3 vPosition, vNormal;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal; gl_Position = uViewProjection * w; }`,
`#version 300 es
precision highp float;
in vec3 vPosition, vNormal; uniform vec3 uColor, uLamp, uLampColor, uAmbient, uEye; out vec4 c;
void main() {
vec3 n = normalize(vNormal), toLamp = uLamp - vPosition; float d = length(toLamp); vec3 l = toLamp / d;
float lambert = max(dot(n, l), 0.0), fade = 1.0 / (d * d);
float spec = lambert > 0.0 ? pow(max(dot(n, normalize(l + normalize(uEye - vPosition))), 0.0), 40.0) : 0.0;
c = vec4(uColor * (uAmbient + uLampColor * lambert * fade) + uLampColor * spec * fade * 0.2, 1.0);
}`);
const volumeProgram = program(`#version 300 es
layout(location = 0) in vec4 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * aPosition; }`, `#version 300 es
precision mediump float; out vec4 c; void main() { c = vec4(0.9, 0.1, 0.1, 0.25); }`);
const cubeVAO = gl.createVertexArray();
gl.bindVertexArray(cubeVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, CUBE, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
const covers = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const books = covers.map((hex, i) => ({ color: rgb(hex), model: m4.model(-0.81 + i * 0.32, 0, 0, 0.26, 0.9, 0.2, (20 - i * 8) * Math.PI / 180) }));
const scene = [...books,
{ color: rgb('#8a6240'), model: m4.model(0, -0.495, 0, 2.3, 0.08, 0.7) }, // plank
{ color: rgb('#cdbfa8'), model: m4.model(0, 0.13, -0.45, 3.5, 1.3, 0.04) }]; // wall
const lamp = [-0.2, 1.3, 1.1], eye = [0.9, 0.9, 2.9], black = [0, 0, 0];
const lampColor = [3, 2.8, 2.55], ambient = [0.28, 0.29, 0.33];
const volumes = new Float32Array(books.flatMap((book) => shadowVolume(book.model, lamp))); // one buffer
const volumeVAO = gl.createVertexArray();
gl.bindVertexArray(volumeVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, volumes, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
function drawScene(vp, lampLight, ambientLight) {
gl.useProgram(lit);
const u = (n) => gl.getUniformLocation(lit, n);
gl.uniformMatrix4fv(u('uViewProjection'), false, vp);
gl.uniform3fv(u('uLamp'), lamp); gl.uniform3fv(u('uEye'), eye);
gl.uniform3fv(u('uLampColor'), lampLight); gl.uniform3fv(u('uAmbient'), ambientLight);
gl.bindVertexArray(cubeVAO);
for (const { color, model } of scene) {
gl.uniformMatrix4fv(u('uModel'), false, model);
gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
gl.uniform3fv(u('uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
}
function drawVolumes(vp) {
gl.useProgram(volumeProgram);
gl.uniformMatrix4fv(gl.getUniformLocation(volumeProgram, 'uViewProjection'), false, vp);
gl.bindVertexArray(volumeVAO);
gl.enable(gl.POLYGON_OFFSET_FILL); // push the volume a hair back so its front cap never
gl.polygonOffset(1, 1); // z-fight with the lit faces they sit on
gl.drawArrays(gl.TRIANGLES, 0, volumes.length / 4);
gl.disable(gl.POLYGON_OFFSET_FILL);
}
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
const vp = m4.multiply(m4.perspective(40, 620 / 340, 0.1, Infinity), m4.lookAt(eye, [0.1, -0.2, 0]));
for (let pane = 0; pane < 2; pane++) {
gl.viewport(pane * 620, 0, 620, 340); gl.scissor(pane * 620 + 3, 0, 614, 340);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
if (pane === 0) { // the volumes, for debugging
drawScene(vp, lampColor, ambient);
gl.enable(gl.BLEND); gl.depthMask(false);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
drawVolumes(vp);
gl.disable(gl.BLEND); gl.depthMask(true);
continue;
}
drawScene(vp, black, black); // 1: depth (and black)
gl.colorMask(false, false, false, false); gl.depthMask(false); gl.disable(gl.CULL_FACE);
gl.enable(gl.STENCIL_TEST); gl.stencilFunc(gl.ALWAYS, 0, 0xff);
gl.stencilOpSeparate(gl.BACK, gl.KEEP, gl.INCR_WRAP, gl.KEEP);
gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP);
drawVolumes(vp); // 2: stencil counts (z-fail)
gl.colorMask(true, true, true, true); gl.enable(gl.CULL_FACE); gl.depthFunc(gl.LEQUAL);
gl.stencilFunc(gl.EQUAL, 0, 0xff); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
drawScene(vp, lampColor, black); // 3: lamp where the count is 0
gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
drawScene(vp, black, ambient); // 4: add ambient everywhere
gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
}
</script>