Face Culling

Front and Back Face Culling in Practice

For a closed object, culling back faces removes the half of its triangles that could never be seen before any fragment is shaded, so nearly every 3D renderer calls gl.enable(gl.CULL_FACE) once at startup and disables it only around draws that need both sides: leaves, paper, the open pages of Two-Sided Lighting. Culling is no substitute for the depth test. Run on the shelf without depth testing (tried for this section), it fixed each book, which is convex, but the plank, drawn last, still covered the books: culling judges each triangle alone, never one object against another. Culling front faces keeps only the inner walls (the right pane of The Depth Buffer and depthFunc's figure), the view you want from inside a room or a sky box. If a model vanishes when culling is on, its winding is reversed: check frontFace() and any negative scale before editing the geometry.

Back-face culling with depth test, culling alone (the plank still covers the books), and front-face culling for a roomHTMLLive
<!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="340"></canvas>
<div class="names"><div>cull BACK + DEPTH_TEST</div><div>cull BACK, no depth test</div><div>inside a room: cull FRONT for its walls</div></div>
<script>
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],
};
function cube() {
  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.35 + 0.65 * abs(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.4, 0.8, 0.6))));
  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);

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 shelf = 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) }));
shelf.push({ color: [0.54, 0.38, 0.25], model: m4.box(0, -0.05, 0, 3.4, 0.1, 0.9) });   // plank, drawn last
const room = { color: [0.85, 0.8, 0.7], model: m4.box(0, 1.2, 0, 7, 3, 7) };

function draw(items) {
  for (const { color, model } of items) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}
gl.enable(gl.SCISSOR_TEST);
gl.enable(gl.CULL_FACE);                     // on once at startup, like most renderers
function frame(ms) {
  const eye = [Math.sin(ms * 0.0004) * 2, 1.6, 3.2];
  for (let pane = 0; pane < 3; pane++) {
    gl.viewport(pane * 400, 0, 400, 340); gl.scissor(pane * 400 + 3, 0, 394, 340);
    gl.clearColor(0.93, 0.91, 0.87, 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.uniformMatrix4fv(u('uViewProjection'), false,
      m4.multiply(m4.perspective(pane === 2 ? 70 : 42, 400 / 340, 0.1, 30), m4.lookAt(eye, [0, 0.4, 0])));
    if (pane === 1) gl.disable(gl.DEPTH_TEST); else gl.enable(gl.DEPTH_TEST);
    if (pane === 2) {                          // from inside, the walls' inner sides are back faces
      gl.cullFace(gl.FRONT);
      draw([room]);
    }
    gl.cullFace(gl.BACK);
    draw(shelf);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>