When a reader opens Salt and Saffron, 400 pages and golden sparks burst from the book, flutter up, turn and fall away. The page renders shelf and particles into post.scene and blooms them, at three moments:
<canvas id="c" width="1236" height="300" style="width:618px;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'), W = 412, H = 300, eye = [0.2, 0.7, 3.0], { mat4 } = glMatrix;
const camera = mat4.multiply([], mat4.perspective([], 0.7, W / H, 0.1, 20),
mat4.lookAt([], eye, [-0.3, 0.45, 0], [0, 1, 0]));
const post = GLH.post(gl, W, H), pages = GLH.particles(gl, 400), p = pages.uniforms;
p.uOrigin([-0.26, 0.45, 0.1]); p.uGravity([0, -1.5, 0]); p.uSpeed(1.1); // over book 3
p.uSize(0.05); p.uLife(1.6); p.uTint(GLH.rgb('#e09a10')); p.uViewProjection(camera);
const { program, attributes, uniforms: u } = GLH.lit(gl, GLH.shelfFS);
const shelf = GLH.shelf(gl, attributes);
u.uAmbient([0.16, 0.17, 0.22]); u.uEye(eye); u.uShininess(40); u.uFoil(2); u.uAlpha(1);
u['uLights[0].position']([-0.3, 0.9, 1.2]); u['uLights[0].color']([1.0, 0.85, 0.6]);
u['uLights[1].position']([1.6, 0.4, 1.2]); u['uLights[1].color']([0.5, 0.6, 1.2]);
u.uViewProjection(camera); gl.clearColor(0.1, 0.11, 0.15, 1);
[0.35, 0.8, 1.4].forEach((time, pane) => { // three moments of the burst
gl.bindFramebuffer(gl.FRAMEBUFFER, post.scene.fbo); gl.viewport(0, 0, W, H);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.useProgram(program);
shelf.draw(u);
gl.disable(gl.CULL_FACE); pages.draw(time); // pages show both sides
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(pane * W, 0, W, H);
post.draw({ strength: 1.5 });
});
window.__done = true;
</script>
The sparks take the book's color, #e09a10, times four, so only they bloom. Live, requestAnimationFrame() passes the elapsed time to pages.draw(): one call per frame.
<!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="1236" height="300"></canvas>
<div class="names"><div>t</div><div>t + 0.45 s</div><div>t + 1.05 s</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 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 scene: the lit shelf with glossy foil bands and a lamp bulb far brighter than 1.0 ---
const lit = 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 = aUV;
gl_Position = uViewProjection * w; }`, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
uniform vec3 uColor, uEye, uLamp, uLampColor, uScreen, uScreenColor; uniform bool uEmissive;
void main() {
if (uEmissive) { outColor = vec4(uColor, 1.0); return; } // values above 1.0 survive in RGBA16F
vec3 n = normalize(vNormal), v = normalize(uEye - vPosition), light = vec3(0.16, 0.17, 0.22), spec = vec3(0.0);
for (int i = 0; i < 2; i++) {
vec3 toL = (i == 0 ? uLamp : uScreen) - vPosition, c = i == 0 ? uLampColor : uScreenColor;
float d = length(toL); vec3 l = toL / d; float fade = 1.0 / (d * d), nl = max(dot(n, l), 0.0);
light += c * nl * fade;
if (nl > 0.0) spec += c * pow(max(dot(n, normalize(l + v)), 0.0), 40.0) * fade;
}
float band = step(abs(vUV.y - 0.78), 0.05); // foil title bands
outColor = vec4(uColor * light + spec * 4.0 * band, 1.0);
}`);
// A cube (36 vertices) and a small sphere for the bulb, in one buffer: position, normal, uv.
const verts = [];
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]]];
for (const [n, a1, b1] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
verts.push(...[0, 1, 2].map(i => (n[i] + a * a1[i] + b * b1[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);
const ring = (i, j) => { const t = i / 8 * Math.PI, p = j / 12 * 2 * Math.PI; return [Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p)]; };
for (let i = 0; i < 8; i++) for (let j = 0; j < 12; 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); }
const sceneVAO = gl.createVertexArray();
gl.bindVertexArray(sceneVAO);
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);
gl.bindVertexArray(null);
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const shelf = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].map((hex, i) => ({ color: rgb(hex),
model: m4.model(-1.3 + i * 0.52, 0.5, 0, 0.42, 0.98, 0.12, (30 - i * 12) * Math.PI / 180) }));
shelf.push({ color: rgb('#8a6240'), model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) });
const lamp = [-0.35, 0.95, 0.75];
function drawScene(viewProjection, eye, bulbColor = [40, 32, 20]) {
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.useProgram(lit);
gl.bindVertexArray(sceneVAO);
gl.uniformMatrix4fv(u(lit, 'uViewProjection'), false, viewProjection);
gl.uniform3fv(u(lit, 'uEye'), eye);
gl.uniform3fv(u(lit, 'uLamp'), lamp); gl.uniform3f(u(lit, 'uLampColor'), 0.55, 0.45, 0.3);
gl.uniform3f(u(lit, 'uScreen'), 1.6, 0.9, 1.2); gl.uniform3f(u(lit, 'uScreenColor'), 0.5, 0.6, 1.2);
gl.uniform1i(u(lit, 'uEmissive'), 0);
for (const { color, model } of shelf) {
gl.uniformMatrix4fv(u(lit, 'uModel'), false, model);
gl.uniformMatrix3fv(u(lit, 'uNormalMatrix'), false, m4.normal(model));
gl.uniform3fv(u(lit, 'uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
if (!bulbColor) { gl.bindVertexArray(null); return; } // no bulb in this scene
gl.uniform1i(u(lit, 'uEmissive'), 1); // the lamp's bulb
gl.uniform3fv(u(lit, 'uColor'), bulbColor);
gl.uniformMatrix4fv(u(lit, 'uModel'), false, m4.model(...lamp, 0.06, 0.06, 0.06));
gl.drawArrays(gl.TRIANGLES, 36, verts.length / 8 - 36);
gl.bindVertexArray(null);
}
// --- Render targets and the post-processing passes ---
function target(w, h, format = gl.RGBA16F) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
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 depth = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, w, h);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { fbo, texture, w, h };
}
const screenVS = `#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); }`;
// A separable 9-tap Gaussian (sigma 2 texels); uThreshold > 0 keeps only what is brighter (the bright pass).
const blur = program(screenVS, `#version 300 es
precision highp float;
in vec2 vUV; out vec4 outColor;
uniform sampler2D uImage; uniform vec2 uStep; uniform float uThreshold;
const float weight[5] = float[](0.2042, 0.1802, 0.1238, 0.0663, 0.0276);
vec3 bright(vec2 uv) { return max(texture(uImage, uv).rgb - uThreshold, 0.0); }
void main() {
vec3 sum = bright(vUV) * weight[0];
for (int i = 1; i < 5; i++) sum += (bright(vUV + float(i) * uStep) + bright(vUV - float(i) * uStep)) * weight[i];
outColor = vec4(sum, 1.0);
}`);
// The final pass: add bloom, then grayscale, grading (gain and contrast) and a vignette.
const final = program(screenVS, `#version 300 es
precision highp float;
in vec2 vUV; out vec4 outColor;
uniform sampler2D uScene, uBloom; // texture units 0 and 1
uniform float uStrength, uGray, uContrast, uVignette; uniform vec3 uGain;
void main() {
vec3 c = texture(uScene, vUV).rgb + uStrength * texture(uBloom, vUV).rgb;
c = mix(c, vec3(dot(c, vec3(0.2126, 0.7152, 0.0722))), uGray); // Rec. 709 luma
c = (c * uGain - 0.5) * uContrast + 0.5;
c *= 1.0 - uVignette * smoothstep(0.2, 0.75, length(vUV - 0.5));
outColor = vec4(clamp(c, 0.0, 1.0), 1.0);
}`);
function makePost(width, height) {
const scene = target(width, height), ping = [target(width >> 1, height >> 1), target(width >> 1, height >> 1)];
function draw({ threshold = 1, strength = 0, gray = 0, contrast = 1, vignette = 0, gain = [1, 1, 1] } = {}, x = 0, y = 0) {
gl.disable(gl.DEPTH_TEST);
let image = scene.texture;
gl.useProgram(blur);
gl.viewport(0, 0, ping[0].w, ping[0].h);
for (let i = 0; i < 4; i++) { // H V H V, ping-ponging between two half-size targets
gl.bindFramebuffer(gl.FRAMEBUFFER, ping[i % 2].fbo);
gl.uniform1f(u(blur, 'uThreshold'), i === 0 ? threshold : 0); // the bright pass comes first
gl.uniform2fv(u(blur, 'uStep'), i % 2 ? [0, 1 / ping[0].h] : [1 / ping[0].w, 0]);
gl.bindTexture(gl.TEXTURE_2D, image);
gl.drawArrays(gl.TRIANGLES, 0, 3);
image = ping[i % 2].texture;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(x, y, width, height);
gl.useProgram(final);
gl.uniform1i(u(final, 'uBloom'), 1);
gl.uniform1f(u(final, 'uStrength'), strength); gl.uniform1f(u(final, 'uGray'), gray);
gl.uniform1f(u(final, 'uContrast'), contrast); gl.uniform1f(u(final, 'uVignette'), vignette);
gl.uniform3fv(u(final, 'uGain'), gain);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, image);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, scene.texture);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
return { scene, draw };
}
// A stateless GPU particle system: 400 instances of a 4-vertex strip, each with random aSeed (divisor 1).
const particles = program(`#version 300 es
layout(location = 0) in vec4 aSeed; // four random numbers per particle
uniform mat4 uViewProjection;
uniform vec3 uOrigin, uGravity, uTint;
uniform float uTime, uSpeed, uSize, uLife;
out vec3 vColor;
mat2 turn(float a) { return mat2(cos(a), sin(a), -sin(a), cos(a)); }
void main() {
float t = uTime - 0.4 * aSeed.w; // staggered start times
bool spark = aSeed.w > 0.8; // one in five is a glowing spark
vec2 corner = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0 - 1.0;
float size = uSize * (spark ? 0.3 : 1.0) * step(0.0, t);
size *= smoothstep(uLife, 0.6 * uLife, t); // shrink away at the end
vec3 local = vec3(corner * size * vec2(0.75, 1.0), 0.0); // a page-shaped quad
float flip = t * (5.0 + 8.0 * aSeed.z); // turning like a page
local.xz = turn(flip) * local.xz;
local.xy = turn(6.2832 * aSeed.x) * local.xy;
vec3 velocity = uSpeed * (vec3(1.6, 0.6, 0.8) * aSeed.xyz - vec3(0.8, -0.7, 0.4));
vec3 p = uOrigin + velocity * t + 0.5 * uGravity * t * t + local;
p.x += 0.06 * sin(7.0 * t + 20.0 * aSeed.y); // flutter
vColor = spark ? uTint * 4.0 : vec3(0.95, 0.93, 0.86) * (0.55 + 0.45 * abs(cos(flip)));
gl_Position = uViewProjection * vec4(p, 1.0);
}`, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 outColor; void main() { outColor = vec4(vColor, 1.0); }`);
let seed = 7;
const random = () => (seed = seed * 16807 % 2147483647) / 2147483647; // repeatable
const particleVAO = gl.createVertexArray();
gl.bindVertexArray(particleVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, Float32Array.from({ length: 400 * 4 }, random), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribDivisor(0, 1); // advance once per instance
gl.bindVertexArray(null);
const W = 412, H = 300, eye = [0.2, 1.0, 3.0];
const camera = m4.multiply(m4.perspective(40, W / H, 0.1, 20), m4.lookAt(eye, [-0.3, 0.6, 0]));
gl.useProgram(particles);
gl.uniformMatrix4fv(u(particles, 'uViewProjection'), false, camera);
gl.uniform3f(u(particles, 'uOrigin'), -0.26, 0.95, 0.1); // over book 3, Salt and Saffron
gl.uniform3f(u(particles, 'uGravity'), 0, -1.5, 0);
gl.uniform1f(u(particles, 'uSpeed'), 1.1); gl.uniform1f(u(particles, 'uSize'), 0.05); gl.uniform1f(u(particles, 'uLife'), 1.6);
gl.uniform3f(u(particles, 'uTint'), 0.88, 0.6, 0.06);
const post = makePost(W, H);
function frame(ms) {
const t0 = (ms / 1000) % 2.2;
[t0, t0 + 0.45, t0 + 1.05].forEach((time, pane) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, post.scene.fbo); gl.viewport(0, 0, W, H);
gl.clearColor(0.1, 0.11, 0.15, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawScene(camera, eye, null);
gl.disable(gl.CULL_FACE); // pages show both sides
gl.useProgram(particles);
gl.uniform1f(u(particles, 'uTime'), time % 2.2);
gl.bindVertexArray(particleVAO);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, 400); // 4 vertices, 400 times, one call
gl.bindVertexArray(null);
post.draw({ strength: 1.5 }, pane * W, 0);
});
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>