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.
<!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>