A canvas has no stencil buffer unless you ask for one: the stencil context attribute defaults to false. Pass it in the first getContext() call, since later calls return the same context and ignore new attributes:
const plain = document.createElement('canvas').getContext('webgl2');
const gl = document.createElement('canvas').getContext('webgl2', { stencil: true });
for (const c of [plain, gl]) {
console.log(c.getContextAttributes().stencil, c.getParameter(c.STENCIL_BITS));
}Output
false 0 true 8
With zero bits the stencil test always passes, so shadow code on a plain canvas fails silently. GLH.context() passes no attributes, so create the context yourself, and clear the stencil with gl.STENCIL_BUFFER_BIT.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.row { display: flex; gap: 10px; max-width: 600px; }
.cell { flex: 1; text-align: center; }
canvas { display: block; width: 100%; background: #fff; }
code { display: block; margin-top: 4px; }
</style>
<div class="row">
<div class="cell"><canvas id="plain" width="280" height="220"></canvas><code id="plainInfo"></code></div>
<div class="cell"><canvas id="stencil" width="280" height="220"></canvas><code id="stencilInfo"></code></div>
</div>
<script>
function demo(id, attributes) {
const gl = document.getElementById(id).getContext('webgl2', attributes); // attributes: first call only
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPos; out vec2 vPos;
void main() { vPos = aPos; gl_Position = vec4(aPos, 0.0, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec2 vPos; uniform bool uStripes; out vec4 c;
void main() {
if (!uStripes) { c = vec4(1.0); return; }
float s = step(0.5, fract((vPos.x + vPos.y) * 6.0));
c = vec4(mix(vec3(0.12, 0.37, 0.55), vec3(0.88, 0.6, 0.06), s), 1.0);
}`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
// A five-pointed star as a triangle fan, then a full-canvas quad.
const star = [0, 0];
for (let i = 0; i <= 10; i++) {
const a = Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? 0.35 : 0.85;
star.push(Math.cos(a) * r * 220 / 280, Math.sin(a) * r);
}
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([...star, -1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clearStencil(0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
gl.enable(gl.STENCIL_TEST);
// 1. Write 1 into the stencil wherever the star covers, without touching colour.
gl.stencilFunc(gl.ALWAYS, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.colorMask(false, false, false, false);
gl.uniform1i(gl.getUniformLocation(program, 'uStripes'), 0);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 12);
// 2. Paint stripes over the whole canvas, only where the stencil holds 1.
gl.colorMask(true, true, true, true);
gl.stencilFunc(gl.EQUAL, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.uniform1i(gl.getUniformLocation(program, 'uStripes'), 1);
gl.drawArrays(gl.TRIANGLE_STRIP, 12, 4);
document.getElementById(id + 'Info').textContent =
`getContext('webgl2'${attributes ? ", { stencil: true }" : ''})\n` +
` -> stencil ${gl.getContextAttributes().stencil}, STENCIL_BITS ${gl.getParameter(gl.STENCIL_BITS)}`;
}
demo('plain'); // no bits: the stencil test always passes
demo('stencil', { stencil: true }); // 8 bits: the star masks the stripes
</script>