Ambient Pass

Adding Ambient Light in a Fourth Pass

Shadows are not black: light bouncing around the room still reaches them. The fourth pass adds the ambient term everywhere, stencil test off, with additive blending, ONE times the new color plus ONE times the old:

Pass 4: add ambient light to lit and shadowed pixels alikeJavaScript
gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
drawScene(vp, black, ambient);              // lamp off, ambient on

Addition needs black under the scene. The classic recipe clears the canvas to black and masks color in pass 1; on BookNest's cream background that would leave cream under every shadow, which pass 4 brightens further. Writing black in pass 1 fixes it. Alpha stays 1 (1 + 1 is clamped), so the page never shows through. Many engines fold this pass into the first, writing depth and ambient color at once: three passes instead of four.

The additive ambient pass over a cream background: colour masked in pass 1 (left, cream shadows) against black written in pass 1 (right)HTMLLive
<!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>pass 1 with colorMask(false...): cream left under shadows</div><div>pass 1 writes black: ambient adds onto black</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);
}

gl.clearColor(0.93, 0.91, 0.87, 1);                              // BookNest's cream background
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);
  if (pane === 0) gl.colorMask(false, false, false, false);    // the classic recipe: depth only
  drawScene(vp, black, black);                                  // 1
  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);
  gl.stencilOpSeparate(gl.FRONT, gl.KEEP, gl.DECR_WRAP, gl.KEEP);
  drawVolumes(vp);                                              // 2
  gl.colorMask(true, true, true, true); 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);                              // 3
  // Pass 4: add ambient to lit and shadowed pixels alike: ONE * new + ONE * old.
  gl.disable(gl.STENCIL_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
  drawScene(vp, black, ambient);
  gl.disable(gl.BLEND); gl.depthMask(true); gl.depthFunc(gl.LESS);
}
</script>