Many-Light Shelf

A Deferred BookNest Shelf with Many Lights

The finished page hangs two strings of 24 colored lights around the textured shelf, on the wall and along the plank's front edge. It fills the G-buffer once, then shows albedo (top left), normals (top right), positions (bottom left) and the image lit by all 48 lights (bottom right):

A deferred shelf: the G-buffer's three targets and the image lit by 48 lightsHTML
<canvas id="c" width="1240" height="440" style="width:620px;height:220px"></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 type="module">
const gl = GLH.context('c'), { mat4, mat3 } = glMatrix;
const W = 620, H = 220, eye = [0.5, 0.6, 2.6];              // one tile, in device pixels
const geometry = GLH.lit(gl, GLH.gbufferFS), u = geometry.uniforms;
const shelf = GLH.shelf(gl, geometry.attributes), wall = { color: GLH.rgb('#cdbfa8') };
wall.model = mat4.scale([], mat4.fromTranslation([], [0, 0.13, -0.45]), [3.5, 1.3, 0.04]);
wall.normal = mat3.normalFromMat4([], wall.model);
const ball = GLH.sphere(6, 8), bulb = GLH.vao(gl, geometry.attributes, ball.vertices,
  [['aPosition', 3], ['aNormal', 3], ['aUV', 2]], ball.indices);
const lights = [], colors = [...GLH.covers, '#ffd27a'].map(GLH.rgb);
for (let i = 0; i < 48; i++) {                  // 24 lights on the wall, 24 on the plank
  const t = (i % 24) / 23, color = colors[(i * 5) % 7], sag = 0.25 * Math.sin(Math.PI * t);
  lights.push({ color: color.map((c) => c / Math.max(...color)), position: i < 24
    ? [-1.45 + 2.9 * t, 0.7 - sag, -0.36] : [-1.6 + 3.2 * t, -0.4, 0.5] });
}
const gbuffer = GLH.targets(gl, W, H, [gl.RGBA8, gl.RGBA16F, gl.RGBA16F]);
const covers = await Promise.all([1, 2, 3, 4, 5, 6].map((n) =>
  GLH.image(`booknest-cover-${n}.png`).then((image) => GLH.texture(gl, image))));
gl.bindFramebuffer(gl.FRAMEBUFFER, gbuffer.fbo); gl.viewport(0, 0, W, H);   // geometry pass
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
u.uViewProjection(mat4.multiply([], mat4.perspective([], 0.6, W / H, 0.1, 20),
  mat4.lookAt([], eye, [0, 0.05, 0], [0, 1, 0]))); u.uEmissive(0);
[...shelf.items, wall].forEach((item, i) => {
  gl.bindTexture(gl.TEXTURE_2D, covers[i] ?? null);
  u.uTextured(i < 6 ? 1 : 0); u.uRoughness(i < 6 ? 0.45 : 0.8);
  shelf.draw(u, [item]);
});
gl.bindVertexArray(bulb); u.uEmissive(1); u.uTextured(0); u.uNormalMatrix(mat3.create());
for (const { position, color } of lights) {     // the bulbs: small unlit spheres
  u.uModel(mat4.scale([], mat4.fromTranslation([], position), [0.025, 0.025, 0.025]));
  u.uColor(color); gl.drawElements(gl.TRIANGLES, ball.indices.length, gl.UNSIGNED_SHORT, 0);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.disable(gl.DEPTH_TEST);   // lighting, per tile
const lighting = GLH.screen(gl, GLH.deferredFS), l = lighting.uniforms;
l.uNormal(1); l.uPosition(2); l.uEye(eye); l.uAmbient([0.06, 0.06, 0.07]); l.uRadius(1.2);
l.uLightCount(lights.length); l.uLightPosition(lights.flatMap((x) => x.position));
l.uLightColor(lights.flatMap((x) => x.color.map((c) => c * 0.8)));
[[0, H, 1], [W, H, 2], [0, 0, 3], [W, 0, 0]].forEach(([x, y, view]) => {
  gl.viewport(x, y, W, H); l.uView(view);
  lighting.draw(...gbuffer.textures);
});
window.__done = true;
</script>
Browser output of Listing 3.115
Browser output of 115

The tiles are the real textures: flat albedo, normals that change color per face, and smoothly shaded positions. The lit tile pays for 48 lights once per visible pixel, never for the wall behind the books.

A deferred shelf: the G-buffer's albedo, normals and positions, and the image lit by 48 coloured lightsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="440"></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() {                                 // position, normal, uv: 36 vertices
  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, (a + 1) / 2, (b + 1) / 2);
  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');
gl.getExtension('EXT_color_buffer_float');              // RGBA16F render targets
const u = (p, n) => gl.getUniformLocation(p, n);

// The geometry pass: no lighting, three outputs, one per render target.
const geometry = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aUV;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix; out vec3 vPosition, vNormal; out vec2 vUV;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal;
  vUV = vec2(aUV.x, 1.0 - aUV.y); gl_Position = uViewProjection * w; }`, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV;
layout(location = 0) out vec4 gAlbedo;     // rgb albedo (sRGB), a roughness
layout(location = 1) out vec4 gNormal;     // xyz world normal, w 1 for emissive surfaces
layout(location = 2) out vec4 gPosition;   // xyz world position, w 1 where geometry is
uniform sampler2D uCover; uniform mat3 uNormalMatrix; uniform vec3 uColor;
uniform float uTextured, uRoughness, uEmissive;
void main() {
  vec3 n = normalize(vNormal);
  float front = uTextured * step(0.99, dot(n, normalize(uNormalMatrix * vec3(0, 0, 1))));
  gAlbedo = vec4(mix(uColor, texture(uCover, vUV).rgb, front), uRoughness);
  gNormal = vec4(n, uEmissive);
  gPosition = vec4(vPosition, 1.0);
}`);
// The lighting pass: one oversized triangle, the BRDF for up to 64 point lights per pixel.
const lighting = 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;
in vec2 vUV; out vec4 outColor;
uniform sampler2D uAlbedo, uNormal, uPosition;
uniform vec3 uLightPosition[64], uLightColor[64];
uniform int uLightCount, uView;
uniform vec3 uEye, uAmbient; uniform float uRadius;
const float PI = 3.14159265;
vec3 brdf(vec3 n, vec3 v, vec3 l, vec3 albedo, float roughness) {
  vec3 h = normalize(v + l);
  float nl = max(dot(n, l), 0.0), nv = max(dot(n, v), 1e-4), nh = max(dot(n, h), 0.0), a2 = pow(roughness, 4.0);
  float d = a2 / (PI * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0)), k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
  float g = nl / (nl * (1.0 - k) + k) * nv / (nv * (1.0 - k) + k);
  vec3 f = vec3(0.04) + 0.96 * pow(1.0 - max(dot(h, v), 0.0), 5.0);
  return ((1.0 - f) * albedo / PI + d * g * f / (4.0 * nv * max(nl, 1e-4))) * nl;
}
void main() {
  vec4 a = texture(uAlbedo, vUV), n = texture(uNormal, vUV), p = texture(uPosition, vUV);
  if (p.w < 0.5) discard;                            // nothing drawn here
  vec3 debug[4] = vec3[](a.rgb, a.rgb, n.xyz * 0.5 + 0.5, p.xyz * 0.3 + 0.5);
  if (uView > 0 || n.w > 0.5) { outColor = vec4(debug[uView], 1.0); return; }   // a G-buffer view, or emissive
  vec3 albedo = pow(a.rgb, vec3(2.2)), color = uAmbient * albedo;
  vec3 v = normalize(uEye - p.xyz), normal = normalize(n.xyz);
  for (int i = 0; i < uLightCount; i++) {
    vec3 toLight = uLightPosition[i] - p.xyz;
    float d = length(toLight);
    if (d > uRadius) continue;                       // out of this light's reach: skipped
    float fade = pow(1.0 - pow(d / uRadius, 4.0), 2.0) / (d * d + 0.01);
    color += brdf(normal, v, toLight / d, albedo, a.a) * fade * uLightColor[i];
  }
  color = color / (1.0 + color);                     // Reinhard tone mapping
  outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}`);

// Geometry: a cube (books, plank, wall) and a low-poly sphere (bulbs), in one buffer.
const verts = [...cube()];
const ring = (i, j) => { const t = i / 6 * Math.PI, p = j / 8 * 2 * Math.PI; return [Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p)]; };
for (let i = 0; i < 6; i++) for (let j = 0; j < 8; j++)
  for (const [di, dj] of [[0, 0], [1, 0], [0, 1], [0, 1], [1, 0], [1, 1]]) { const q = ring(i + di, j + dj); verts.push(...q, ...q, 0, 0); }
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); gl.enableVertexAttribArray(2);
const BULB_FIRST = 36, BULB_COUNT = 6 * 8 * 6;

// Covers drawn on 2D canvases.
const titles = [['#1f5f8b', 'The Quiet', 'Harbor'], ['#5b3f99', "Clockmaker's", 'Paradox'], ['#e09a10', 'Salt and', 'Saffron'],
                ['#3f7d3a', 'Small Steps', 'Big Summits'], ['#b5452f', 'Paper', 'Lanterns'], ['#2a9d8f', 'Tide', 'Tables']];
const covers = titles.map(([color, a, b]) => {
  const c = Object.assign(document.createElement('canvas'), { width: 128, height: 192 }), g = c.getContext('2d');
  g.fillStyle = color; g.fillRect(0, 0, 128, 192);
  g.fillStyle = '#f2ead8'; g.font = 'bold 17px Georgia'; g.textAlign = 'center'; g.fillText(a, 64, 120); g.fillText(b, 64, 142);
  g.strokeStyle = '#f2ead8'; g.lineWidth = 3; g.strokeRect(8, 8, 112, 176);
  const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, c); gl.generateMipmap(gl.TEXTURE_2D);
  return t;
});
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const scene = [...titles.map(([hex], i) => ({ color: rgb(hex), texture: covers[i], roughness: 0.45,
    model: m4.model(-1.3 + i * 0.52, 0.5, 0, 0.42, 0.98, 0.12, (30 - i * 12) * Math.PI / 180) })),
  { color: rgb('#8a6240'), roughness: 0.8, model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) },
  { color: rgb('#cdbfa8'), roughness: 0.8, model: m4.model(0, 0.55, -0.45, 3.5, 1.3, 0.04) }];

// Two strings of 24 coloured lights: on the wall, and along the plank's front edge.
const lights = [], palette = [...titles.map(([hex]) => hex), '#ffd27a'].map(rgb);
for (let i = 0; i < 48; i++) {
  const t = (i % 24) / 23, color = palette[(i * 5) % 7], sag = 0.25 * Math.sin(Math.PI * t);
  lights.push({ color: color.map((c) => c / Math.max(...color)),
    position: i < 24 ? [-1.45 + 2.9 * t, 1.1 - sag, -0.36] : [-1.6 + 3.2 * t, 0.02, 0.5] });
}

// The G-buffer: three colour targets (attached in order) and a depth texture.
function makeGBuffer(w, h) {
  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  const textures = [gl.RGBA8, gl.RGBA16F, gl.RGBA16F].map((format, i) => {
    const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
    gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t, 0);
    return t;
  });
  const depth = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, depth);
  gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, w, h);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depth, 0);
  gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2]);   // output i -> attachment i
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  return { fbo, textures, depth };
}
function geometryPass(gbuffer, w, h, viewProjection, bulbs = true) {
  gl.bindFramebuffer(gl.FRAMEBUFFER, gbuffer.fbo);
  gl.viewport(0, 0, w, h);
  gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);   // w = 0 marks the background
  gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
  gl.useProgram(geometry);
  gl.uniformMatrix4fv(u(geometry, 'uViewProjection'), false, viewProjection);
  gl.uniform1f(u(geometry, 'uEmissive'), 0);
  gl.activeTexture(gl.TEXTURE0);
  for (const { color, texture, roughness, model } of scene) {
    gl.bindTexture(gl.TEXTURE_2D, texture || null);
    gl.uniform1f(u(geometry, 'uTextured'), texture ? 1 : 0);
    gl.uniform1f(u(geometry, 'uRoughness'), roughness);
    gl.uniformMatrix4fv(u(geometry, 'uModel'), false, model);
    gl.uniformMatrix3fv(u(geometry, 'uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u(geometry, 'uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
  if (bulbs) {                                          // the bulbs: small unlit (emissive) spheres
    gl.uniform1f(u(geometry, 'uEmissive'), 1); gl.uniform1f(u(geometry, 'uTextured'), 0);
    for (const { position, color } of lights) {
      const model = m4.model(...position, 0.025, 0.025, 0.025);
      gl.uniformMatrix4fv(u(geometry, 'uModel'), false, model);
      gl.uniformMatrix3fv(u(geometry, 'uNormalMatrix'), false, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
      gl.uniform3fv(u(geometry, 'uColor'), color);
      gl.drawArrays(gl.TRIANGLES, BULB_FIRST, BULB_COUNT);
    }
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
function lightingPass(gbuffer, view, eye, count = lights.length) {
  gl.disable(gl.DEPTH_TEST);
  gl.useProgram(lighting);
  gbuffer.textures.forEach((t, i) => { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, t); });
  gl.activeTexture(gl.TEXTURE0);
  ['uAlbedo', 'uNormal', 'uPosition'].forEach((name, i) => gl.uniform1i(u(lighting, name), i));
  gl.uniform3fv(u(lighting, 'uEye'), eye); gl.uniform3f(u(lighting, 'uAmbient'), 0.06, 0.06, 0.07);
  gl.uniform1f(u(lighting, 'uRadius'), 1.2); gl.uniform1i(u(lighting, 'uLightCount'), count);
  gl.uniform3fv(u(lighting, 'uLightPosition'), lights.flatMap((x) => x.position));
  gl.uniform3fv(u(lighting, 'uLightColor'), lights.flatMap((x) => x.color.map((c) => c * 0.8)));
  gl.uniform1i(u(lighting, 'uView'), view);
  gl.drawArrays(gl.TRIANGLES, 0, 3);                    // no attributes are read: gl_VertexID only
}

const W = 620, H = 220, eye = [0.5, 1.0, 2.6];
const gbuffer = makeGBuffer(W, H);
geometryPass(gbuffer, W, H, m4.multiply(m4.perspective(36, W / H, 0.1, 20), m4.lookAt(eye, [0, 0.45, 0])));
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
[[0, H, 1], [W, H, 2], [0, 0, 3], [W, 0, 0]].forEach(([x, y, view]) => {   // albedo, normals, positions, lit
  gl.viewport(x, y, W, H);
  lightingPass(gbuffer, view, eye);
});
</script>