WebGL paints surfaces in submission order. The depth buffer stores, per pixel, the depth of the nearest fragment so far (0 at the near plane, 1 at the far one); with the depth test enabled, a fragment survives only if it passes depthFunc()'s comparison, LESS by default. The buffer exists by default (24 bits here), but the test starts disabled, and the buffer must be cleared each frame. The right pane previews Face Culling:
<canvas id="c" width="1240" height="300" style="width:620px;height:150px"></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 = GLH.context('c'), { attributes, uniforms } = GLH.tint(gl);
const shelf = GLH.shelf(gl, attributes);
GLH.panes(gl, 3, (pane, aspect) => { // panes() clears color and depth
if (pane === 1) gl.enable(gl.DEPTH_TEST); // hidden surfaces removed
if (pane === 2) { gl.enable(gl.CULL_FACE); gl.cullFace(gl.FRONT); } // Section 3.8.4
uniforms.uViewProjection(shelf.camera(aspect, [1.1, 1.2, 3.8]));
shelf.draw(uniforms); // books first, then the plank
});
window.__done = true;
</script>
With no depth test (left), each book's dark back face, drawn after its front, covers it, and the plank's underside covers the books; the depth test (middle) fixes both. depthFunc() also takes LEQUAL (for a second pass over the same geometry), GREATER, GEQUAL, EQUAL, NOTEQUAL, ALWAYS and NEVER. depthMask(false) tests without writing, clearDepth() sets the cleared value (1), and depthRange() squeezes the 0-to-1 mapping.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font-family: monospace; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1200" height="320"></canvas>
<div class="names"><div>no DEPTH_TEST</div><div>enable(DEPTH_TEST), LESS</div><div>CULL_FACE + cullFace(FRONT)</div></div>
<script>
// --- small 4x4 column-major matrix helpers ---
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;
},
box: (x, y, z, sx, sy, sz) => [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, x, y, z, 1], // T * S
};
function cube() { // unit cube, counterclockwise seen from outside: position + normal
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);
}
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; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out float vLight;
void main() {
vLight = 0.3 + 0.7 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.4, 0.8, 0.6))), 0.0);
gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; in float vLight; out vec4 c;
void main() { c = vec4(uColor * vLight, 1.0); }`]]) {
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 u = (n) => gl.getUniformLocation(program, n);
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);
// The shelf: six books, then the plank (drawn last).
const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.6], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];
const items = colors.map((color, i) => ({ color, model: m4.box(-1.25 + i * 0.5, 0.5 + (i % 3) * 0.05, 0, 0.4, 1 + (i % 3) * 0.1, 0.6) }));
items.push({ color: [0.54, 0.38, 0.25], model: m4.box(0, -0.05, 0, 3.4, 0.1, 0.9) });
gl.enable(gl.SCISSOR_TEST);
const viewProjection = m4.multiply(m4.perspective(40, 400 / 320, 0.1, 20), m4.lookAt([1.1, 1.6, 3.8], [0, 0.4, 0]));
gl.uniformMatrix4fv(u('uViewProjection'), false, viewProjection);
for (let pane = 0; pane < 3; pane++) {
gl.viewport(pane * 400, 0, 400, 320); gl.scissor(pane * 400 + 3, 0, 394, 320);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // the depth buffer must be cleared too
gl.disable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE);
if (pane >= 1) { gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LESS); } // hidden surfaces removed
if (pane === 2) { gl.enable(gl.CULL_FACE); gl.cullFace(gl.FRONT); } // only the inner walls remain
for (const { color, model } of items) {
gl.uniformMatrix4fv(u('uModel'), false, model);
gl.uniform3fv(u('uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
}
</script>