stencilFunc(func, ref, mask) sets the test: a fragment passes when (ref & mask) func (stored & mask) holds, with func one of NEVER, LESS, LEQUAL, GREATER, GEQUAL, EQUAL, NOTEQUAL or ALWAYS. stencilOp(fail, zfail, zpass) says what to write when the stencil test fails, when it passes but the depth test fails, and when both pass:
| Operation | Stored value becomes |
|---|---|
| KEEP | unchanged (the default for all three) |
| ZERO / REPLACE | 0 / ref |
| INCR / DECR | +1 / -1, clamped to 0..255 |
| INCR_WRAP / DECR_WRAP | +1 / -1, wrapping 255 to 0 and 0 to 255 |
| INVERT | every bit flipped |
stencilFuncSeparate(face, ...) and stencilOpSeparate(face, ...) take FRONT, BACK or FRONT_AND_BACK first, which is what lets one draw count both kinds of face. WebGL adds a rule that OpenGL ES lacks: the front and back reference values and masks must match, or the draw call fails:
const gl = document.createElement('canvas').getContext('webgl2', { stencil: true });
GLH.tint(gl); // any linked program
gl.enable(gl.STENCIL_TEST);
gl.stencilFuncSeparate(gl.FRONT, gl.ALWAYS, 1, 0xff);
gl.stencilFuncSeparate(gl.BACK, gl.ALWAYS, 0, 0xff);
gl.drawArrays(gl.TRIANGLES, 0, 3);
console.log(gl.getError() === gl.INVALID_OPERATION);true
Different functions and operations per face are allowed. The stencil also masks mirrors and portals: draw a shape with REPLACE and reference 1, then stencilFunc(gl.EQUAL, 1, 0xff) paints only inside it.
<!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 id="check"></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 round lens as a triangle fan in clip space (w = 1), drawn with the volume program.
const lensVAO = gl.createVertexArray();
gl.bindVertexArray(lensVAO);
const lensBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, lensBuffer);
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
function drawLens(cx, cy) {
const fan = [cx, cy, 0, 1];
for (let i = 0; i <= 40; i++) { const a = i / 40 * Math.PI * 2; fan.push(cx + Math.cos(a) * 0.3, cy + Math.sin(a) * 0.55, 0, 1); }
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.bindVertexArray(lensVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, lensBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fan), gl.DYNAMIC_DRAW);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 42);
}
// WebGL's extra rule: front and back reference values and masks must match.
gl.enable(gl.STENCIL_TEST);
gl.stencilFuncSeparate(gl.FRONT, gl.ALWAYS, 1, 0xff);
gl.stencilFuncSeparate(gl.BACK, gl.ALWAYS, 0, 0xff);
drawVolumes(m4.perspective(40, 1, 0.1, 10));
const mismatch = gl.getError() === gl.INVALID_OPERATION;
document.getElementById('check').textContent =
`stencilFuncSeparate with FRONT ref 1, BACK ref 0 -> draw fails with INVALID_OPERATION: ${mismatch}`;
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.viewport(0, 0, 1240, 340);
const vp = m4.multiply(m4.perspective(40, 1240 / 340, 0.1, Infinity), m4.lookAt([0.9, 0.9, 2.6], [0.1, -0.1, 0]));
function frame(ms) {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
gl.enable(gl.STENCIL_TEST);
// 1. The lens writes ref 1 wherever it covers: stencilOp(fail, zfail, zpass) = KEEP, KEEP, REPLACE.
gl.stencilFunc(gl.ALWAYS, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.colorMask(false, false, false, false); gl.disable(gl.DEPTH_TEST);
drawLens(Math.sin(ms * 0.0007) * 0.6, 0.05);
gl.colorMask(true, true, true, true); gl.enable(gl.DEPTH_TEST);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); // the stencil no longer changes
// 2. Outside the lens (ref 1 != stored): the ordinary lit scene.
gl.stencilFunc(gl.NOTEQUAL, 1, 0xff);
drawScene(vp, lampColor, ambient);
// 3. Inside the lens (ref 1 == stored): the same scene with its shadow volumes revealed.
gl.stencilFunc(gl.EQUAL, 1, 0xff);
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);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>