Light's-Eye View

Rendering the Scene from the Light's Point of View

The light needs its own view-projection matrix. A spotlight or a lamp shining at a small scene uses a perspective projection from its position; a sun uses an orthographic box around the scene (Orthographic Projection); a point light shining in every direction needs six maps in a cube (TEXTURE_CUBE_MAP). BookNest's lamp covers the shelf with perspective(90 degrees, 1, 0.5, 5) looking from the lamp toward the shelf's middle. The depth pass (pass 1 in Shelf Shadows) binds the depth-only framebuffer, sets a 512 x 512 viewport, clears depth, and draws the scene with any program that writes gl_Position; the chapter's tint program will do, since no color is written (drawBuffers([gl.NONE])). Casters outside the light's frustum cast nothing, so aim it with care.

Fit the near and far planes tightly around the casters, since depth precision spreads over that range. Large outdoor scenes use cascaded shadow maps, several maps along the view range, as Babylon.js 89,603 does (Babylon.js).

The lamp as a camera: its frustum seen from outside (left), what it sees (middle) and the depth it stores (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="1200" height="300"></canvas>
<div class="names"><div>perspective(90, 1, 0.5, 5) from the lamp</div><div>the lamp's view, in colour</div><div>its depth pass: the shadow map</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;
  },
  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);
}`);
const sceneVAO = gl.createVertexArray();
gl.bindVertexArray(sceneVAO);
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]));


// The depth pass: bind the depth-only framebuffer, a size x size viewport, clear, draw.
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
gl.viewport(0, 0, size, size); gl.scissor(0, 0, size, size);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(depthProgram);
drawScene(depthProgram, light);                           // any program that writes gl_Position will do
gl.bindFramebuffer(gl.FRAMEBUFFER, null);

// The light's frustum corners: the inverse of its view-projection, found by transforming NDC corners
// with a camera built the other way round (lamp position, looking at the same target).
const toWorld = (() => {
  const target = [0.1, -0.2, -0.3], z = lamp.map((c, i) => c - target[i]), zl = Math.hypot(...z);
  const zz = z.map(c => c / zl), xx = [zz[2], 0, -zz[0]].map(c => c / Math.hypot(zz[2], zz[0]));
  const yy = [zz[1] * xx[2] - zz[2] * xx[1], zz[2] * xx[0] - zz[0] * xx[2], zz[0] * xx[1] - zz[1] * xx[0]];
  return (x, y, d) => lamp.map((c, i) => c + (xx[i] * x + yy[i] * y) * d - zz[i] * d);   // 90 degrees: half-size = distance
})();
const corners = [0.5, 5].map(d => [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([x, y]) => toWorld(x, y, d)));
const lines = [];
for (let i = 0; i < 4; i++) {
  const j = (i + 1) % 4;
  lines.push(...corners[0][i], ...corners[0][j], ...corners[1][i], ...corners[1][j], ...lamp, ...corners[1][i]);
}
const lineProgram = program(`#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * vec4(aPosition, 1.0); gl_PointSize = 8.0; }`, `#version 300 es
precision mediump float; out vec4 c; void main() { c = vec4(0.85, 0.55, 0.05, 1.0); }`);
const lineVAO = gl.createVertexArray();
gl.bindVertexArray(lineVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([...lines, ...lamp]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.bindVertexArray(null);

const showDepth = program(`#version 300 es
out vec2 vUV;
void main() { vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0; gl_Position = vec4(vUV * 2.0 - 1.0, 0.0, 1.0); }`,
`#version 300 es
precision highp float; uniform highp sampler2D uDepth; in vec2 vUV; out vec4 c;
void main() {
  float d = texture(uDepth, vUV).r * 2.0 - 1.0, near = 0.5, far = 5.0;
  c = vec4(vec3((2.0 * near * far / (far + near - d * (far - near)) - near) / (far - near)), 1.0);
}`);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.useProgram(litProgram);
gl.uniformMatrix4fv(gl.getUniformLocation(litProgram, 'uLightViewProjection'), false, light);
gl.uniform3fv(gl.getUniformLocation(litProgram, 'uLamp'), lamp);
gl.uniform1f(gl.getUniformLocation(litProgram, 'uBias'), 1.0);   // shadows off: just the view
gl.bindTexture(gl.TEXTURE_2D, depthTexture);

// Left: an outside camera, with the frustum drawn as lines.
const outside = m4.multiply(m4.perspective(45, 400 / 300, 0.1, 30), m4.lookAt([3.2, 2.2, 4.0], [0, 0.2, -0.2]));
gl.viewport(0, 0, 400, 300); gl.scissor(3, 0, 394, 300);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(sceneVAO);
drawScene(litProgram, outside);
gl.useProgram(lineProgram);
gl.uniformMatrix4fv(gl.getUniformLocation(lineProgram, 'uViewProjection'), false, outside);
gl.bindVertexArray(lineVAO);
gl.drawArrays(gl.LINES, 0, lines.length / 3);
gl.drawArrays(gl.POINTS, lines.length / 3, 1);
gl.bindVertexArray(null);
// Middle: the scene through the lamp's own view-projection matrix (square, 90 degrees).
gl.useProgram(litProgram);
gl.bindVertexArray(sceneVAO);
gl.viewport(450, 0, 300, 300); gl.scissor(403, 0, 394, 300);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawScene(litProgram, light);
// Right: the depth it stored, as grey.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.NONE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.viewport(850, 0, 300, 300); gl.scissor(803, 0, 394, 300);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(showDepth);
gl.drawArrays(gl.TRIANGLES, 0, 3);
</script>