Forward Rendering Limits

Forward Rendering's Limits with Many Lights

The shelf shaders so far are forward renderers: every fragment drawn runs the whole lighting loop. Fragments that a nearer object later covers were lit for nothing (overdraw), and every fragment loops over every light, even lights far out of reach. At 1920 x 1080 with an overdraw of 2.5 and 48 lights, that is 5.2 million fragments x 48 = 249 million light evaluations per frame; lighting each visible pixel once cuts it to 100 million before any light is culled. Uniform arrays also cap the lights one shader sees (WebGL 2 guarantees only 224 fragment uniform vectors). Deferred rendering lights only the visible surface of each pixel and skips every light whose radius does not reach it.

Forward overdraw counted per pixel (left: brighter = shaded more often) against deferred lighting of visible pixels only (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; }
  p { max-width: 600px; margin: 4px 0; font: 11px monospace; color: #333; }
</style>
<canvas id="c" width="1240" height="300"></canvas>
<div class="names"><div>forward, back to front: fragments shaded per pixel</div><div>deferred: each visible pixel lit once</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() {                                 // 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 = 300, eye = [0.5, 1.0, 2.6];
const viewProjection = m4.multiply(m4.perspective(40, W / H, 0.1, 20), m4.lookAt(eye, [0, 0.45, 0]));
// Counting overdraw: each fragment that survives the depth test adds 1/255 to an offscreen target.
const counter = 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; uniform vec4 uAdd; out vec4 c; void main() { c = uAdd; }`);
const countTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, countTexture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, W, H);
const countDepth = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, countDepth);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, W, H);
const countFBO = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, countFBO);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, countTexture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, countDepth);
function drawBackToFront(add) {                          // the wall first, the books last: worst-case order
  gl.useProgram(counter);
  gl.uniformMatrix4fv(u(counter, 'uViewProjection'), false, viewProjection);
  gl.uniform4fv(u(counter, 'uAdd'), add);
  gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
  for (const { model } of [...scene].reverse()) {
    gl.uniformMatrix4fv(u(counter, 'uModel'), false, model);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
  gl.disable(gl.BLEND);
}
gl.viewport(0, 0, W, H);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawBackToFront([1 / 255, 0, 0, 0]);
const counts = new Uint8Array(W * H * 4);
gl.readPixels(0, 0, W, H, gl.RGBA, gl.UNSIGNED_BYTE, counts);
let shaded = 0, visible = 0;
for (let i = 0; i < counts.length; i += 4) { shaded += counts[i]; if (counts[i]) visible++; }

// Deferred: fill the G-buffer, read the positions back and count the lights that reach each pixel.
const gbuffer = makeGBuffer(W, H);
geometryPass(gbuffer, W, H, viewProjection, false);
gl.bindFramebuffer(gl.FRAMEBUFFER, gbuffer.fbo);
gl.readBuffer(gl.COLOR_ATTACHMENT2);
const positions = new Float32Array(W * H * 4);
gl.readPixels(0, 0, W, H, gl.RGBA, gl.FLOAT, positions);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
let inReach = 0;
for (let i = 0; i < positions.length; i += 4) {
  if (positions[i + 3] < 0.5) continue;
  for (const { position: [x, y, z] } of lights)
    if (Math.hypot(positions[i] - x, positions[i + 1] - y, positions[i + 2] - z) < 1.2) inReach++;
}

gl.clearColor(0.93, 0.91, 0.87, 1);
gl.enable(gl.SCISSOR_TEST);
gl.viewport(0, 0, W, H); gl.scissor(0, 0, W - 3, H);
gl.clearColor(0.08, 0.08, 0.1, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawBackToFront([0.45, 0.2, 0.05, 1]);                   // on screen: 1 layer dim, 2 bright, 3 brighter
gl.viewport(W, 0, W, H); gl.scissor(W + 3, 0, W - 3, H);
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
lightingPass(gbuffer, 0, eye);
gl.disable(gl.SCISSOR_TEST);
const n = lights.length, fmt = (v) => v.toLocaleString('en-US');
document.body.insertAdjacentHTML('beforeend', `<p>forward: ${fmt(shaded)} fragments x ${n} lights = ${fmt(shaded * n)} light evaluations<br>` +
  `deferred: ${fmt(visible)} visible pixels x ${n} lights = ${fmt(visible * n)}; skipping lights out of reach: ${fmt(inReach)}</p>`);
</script>