Shelf Shadows

Shadow-Mapping BookNest's Shelf Scene

The finished page lights Stencil Shadow Volumes's scene, the shelf with a wall behind it and the lamp at the same place, with a 512 x 512 shadow map instead of shadow volumes. Three panes show the stages: no bias, bias with single nearest-texel comparisons, and bias with 3 x 3 PCF on a LINEAR comparison texture:

Shadow mapping: acne without bias (left), bias (middle), bias and PCF (right)HTML
<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'), { mat4, mat3, glMatrix: { toRadian } } = glMatrix;
const lamp = [-0.2, 1.3, 1.1], eye = [0.9, 0.9, 2.9], size = 512;
const lit = GLH.lit(gl, `#version 300 es
  precision highp float;
  in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
  uniform highp sampler2DShadow uShadowMap;
  uniform mat4 uLightViewProjection;
  uniform vec3 uColor, uLamp;
  uniform float uBias, uSpread;
  void main() {
    vec4 p = uLightViewProjection * vec4(vPosition, 1.0);
    vec3 s = p.xyz / p.w * 0.5 + 0.5;               // shadow-map u, v and depth, all 0..1
    vec2 texel = uSpread / vec2(textureSize(uShadowMap, 0));
    float lit = 0.0;
    for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++) {   // 3 x 3 PCF
      lit += texture(uShadowMap, vec3(s.xy + vec2(x, y) * texel, s.z - uBias));
    }
    vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition);
    outColor = vec4(uColor * (0.3 + 0.9 * max(dot(n, l), 0.0) * lit / 9.0), 1.0);
  }`);
const shelf = GLH.shelf(gl, lit.attributes);
const wall = { color: GLH.rgb('#cdbfa8'), model: mat4.fromTranslation([], [0, 0.13, -0.45]) };
mat4.scale(wall.model, wall.model, [3.5, 1.3, 0.04]);
wall.normal = mat3.normalFromMat4([], wall.model);
const scene = [...shelf.items, wall];
const depth = GLH.tint(gl), map = GLH.framebuffer(gl, size, size, { color: false });
const light = mat4.multiply([], mat4.perspective([], toRadian(90), 1, 0.5, 5),
  mat4.lookAt([], lamp, [0.1, -0.2, -0.3], [0, 1, 0]));
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.bindFramebuffer(gl.FRAMEBUFFER, map.fbo);                 // pass 1: depth from the lamp
gl.viewport(0, 0, size, size);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(depth.program); depth.uniforms.uViewProjection(light);
shelf.draw(depth.uniforms, scene);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);                    // pass 2: the camera's view
gl.useProgram(lit.program); gl.bindTexture(gl.TEXTURE_2D, map.depth);
lit.uniforms.uLightViewProjection(light); lit.uniforms.uLamp(lamp);
GLH.panes(gl, 3, (pane, aspect) => {
  lit.uniforms.uBias([0, 0.002, 0.004][pane]); lit.uniforms.uSpread([0, 0, 1.5][pane]);
  const filter = pane === 2 ? gl.LINEAR : gl.NEAREST;           // LINEAR compares 4 texels
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
  lit.uniforms.uViewProjection(shelf.camera(aspect, eye));
  shelf.draw(lit.uniforms, scene);
});
window.__done = true;
</script>
Browser output of Listing 3.105
Browser output of 105

Without bias, every lit surface is striped with acne; with it, hard shadows of the books fall on the plank and the wall, and PCF softens their edges. The shadows match Stencil Shadow Volumes's, from two passes instead of four, but their edges follow the 512 x 512 map rather than the exact silhouettes.

Shadow mapping the shelf: acne without bias (left), bias (middle), bias with 3 x 3 PCF (right)HTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1200" height="300"></canvas>
<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;
  },
  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);
  },
};
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);
}
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');
const depthProgram = program(`#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection, uModel;
void main() { gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0); }`, `#version 300 es
precision mediump float; void main() {}`);      // depth only: no colour is written
const litProgram = 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; out vec4 outColor;
uniform highp sampler2DShadow uShadowMap;
uniform mat4 uLightViewProjection;
uniform vec3 uColor, uLamp;
uniform float uBias, uSpread;
void main() {
  vec4 p = uLightViewProjection * vec4(vPosition, 1.0);
  vec3 s = p.xyz / p.w * 0.5 + 0.5;                   // shadow-map u, v and depth, all 0..1
  vec2 texel = uSpread / vec2(textureSize(uShadowMap, 0));
  float lit = 0.0;
  for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++)   // 3 x 3 PCF (all nine alike when uSpread = 0)
    lit += texture(uShadowMap, vec3(s.xy + vec2(x, y) * texel, s.z - uBias));
  vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition);
  outColor = vec4(uColor * (0.3 + 0.9 * max(dot(n, l), 0.0) * lit / 9.0), 1.0);
}`);
gl.bindVertexArray(gl.createVertexArray());
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 scene = [...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) })),
  { 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], size = 512;
function drawScene(p, viewProjection) {
  const u = (n) => gl.getUniformLocation(p, n);
  gl.uniformMatrix4fv(u('uViewProjection'), false, viewProjection);
  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);
  }
}
// The shadow map: a depth texture in comparison mode, on a framebuffer with no colour attachment.
const depthTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, size, size);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const shadowFBO = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);
gl.drawBuffers([gl.NONE]);
const light = m4.multiply(m4.perspective(90, 1, 0.5, 5), m4.lookAt(lamp, [0.1, -0.2, -0.3]));

gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.viewport(0, 0, size, size);                           // pass 1: depth from the lamp
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(depthProgram);
drawScene(depthProgram, light);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);                // pass 2: the camera's view
gl.useProgram(litProgram);
gl.uniformMatrix4fv(gl.getUniformLocation(litProgram, 'uLightViewProjection'), false, light);
gl.uniform3fv(gl.getUniformLocation(litProgram, 'uLamp'), lamp);
gl.enable(gl.SCISSOR_TEST);
gl.clearColor(0.93, 0.91, 0.87, 1);
const camera = m4.multiply(m4.perspective(40, 400 / 300, 0.1, 20), m4.lookAt(eye, [0.1, -0.2, 0]));
for (let pane = 0; pane < 3; pane++) {
  gl.viewport(pane * 400, 0, 400, 300); gl.scissor(pane * 400 + 3, 0, 394, 300);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.uniform1f(gl.getUniformLocation(litProgram, 'uBias'), [0, 0.002, 0.004][pane]);
  gl.uniform1f(gl.getUniformLocation(litProgram, 'uSpread'), [0, 0, 1.5][pane]);
  const filter = pane === 2 ? gl.LINEAR : gl.NEAREST;    // LINEAR compares 4 texels per lookup
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
  drawScene(litProgram, camera);
}
</script>