The second pass draws the volumes with color and depth writes off and culling disabled, so front and back faces both reach the stencil test. Follow the ray from the eye to the visible surface. With the z-fail method only volume faces behind that surface count, the ones that fail the depth test: a back face adds 1, a front face subtracts 1. A nonzero total means the surface is inside a volume, in shadow:

The simpler z-pass method counts the faces in front instead. It breaks when the camera sits inside a volume or the near plane slices one open; counting behind the surface moves the problem to the far end, which caps and an infinite far plane solve. Bill Bilodeau and Mike Songy presented z-fail in 1999, John Carmack found it independently for Doom 3 (hence "Carmack's reverse"), and Creative Labs' patent on it (US 6,384,822) expired in October 2019:
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); // on depth fail: +1
gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP); // on depth fail: -1
drawVolumes(vp);The wrapping operations let the count dip below zero without clamping, so the order of the faces never matters.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; color: #333; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1240" height="340"></canvas>
<div class="names"><div>stencil after pass 2: count 1 red, count 2 orange</div><div>passes 3 and 4: lit only where the count is 0</div></div>
<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; uniform vec4 uColor; out vec4 c; void main() { c = uColor; }`);
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.uniform4f(gl.getUniformLocation(volumeProgram, 'uColor'), 0.9, 0.1, 0.1, 0.25);
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);
}
// A clip-space quad, drawn with the volume program and an identity matrix, to paint stencil values.
const quadVAO = gl.createVertexArray();
gl.bindVertexArray(quadVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 0, 1, 1, -1, 0, 1, -1, 1, 0, 1, 1, 1, 0, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
function overlay(color) {
gl.useProgram(volumeProgram);
gl.uniformMatrix4fv(gl.getUniformLocation(volumeProgram, 'uViewProjection'), false, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
gl.uniform4fv(gl.getUniformLocation(volumeProgram, 'uColor'), color);
gl.bindVertexArray(quadVAO);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
// Pass 2: count hidden volume faces into the stencil buffer (z-fail).
function stencilPass(vp) {
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); // back face behind the surface: +1
gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP); // front face behind the surface: -1
drawVolumes(vp);
gl.colorMask(true, true, true, true);
}
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);
drawScene(vp, pane === 0 ? lampColor : black, pane === 0 ? ambient : black); // pass 1
stencilPass(vp); // pass 2
if (pane === 0) { // show the counts: paint where the stencil equals 1, then 2
gl.disable(gl.DEPTH_TEST); gl.enable(gl.BLEND);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.stencilFunc(gl.EQUAL, 1, 0xff); overlay([0.9, 0.15, 0.1, 0.55]);
gl.stencilFunc(gl.EQUAL, 2, 0xff); overlay([1.0, 0.6, 0.1, 0.65]);
gl.disable(gl.BLEND); gl.enable(gl.DEPTH_TEST); gl.disable(gl.STENCIL_TEST); gl.depthMask(true);
continue;
}
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); // pass 3
gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
drawScene(vp, black, ambient); // pass 4
gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
}
</script>