Each pass reads the previous pass's texture and writes the next target. Two half-size targets suffice for any length of chain, used alternately (ping-pong), since no texture can be read and written in one pass:
// Section 3.16: a post-processing chain: render into post.scene (RGBA16F, so values may pass
// 1), then post.draw(settings) blurs the bright parts at half size, H V H V, and composites
// them with GLH.postFS into the framebuffer and viewport that were current
GLH.post = function (gl, width, height) {
const w = width >> 1, h = height >> 1, F = gl.FRAMEBUFFER;
const [scene, ...ping] = [[width, height], [w, h], [w, h]].map(([x, y]) =>
GLH.targets(gl, x, y, [gl.RGBA16F]));
const blur = GLH.screen(gl, GLH.blurFS), final = GLH.screen(gl, GLH.postFS);
const draw = ({ threshold = 1, strength = 0, gray = 0, contrast = 1, vignette = 0,
gain = [1, 1, 1] } = {}) => {
const target = gl.getParameter(gl.FRAMEBUFFER_BINDING);
const view = gl.getParameter(gl.VIEWPORT);
let image = scene.textures[0];
gl.disable(gl.DEPTH_TEST); gl.viewport(0, 0, w, h); gl.useProgram(blur.program);
for (let i = 0; i < 4; i++) { // two rounds of horizontal + vertical
gl.bindFramebuffer(F, ping[i % 2].fbo);
blur.uniforms.uThreshold(i === 0 ? threshold : 0); // the bright pass comes first
blur.uniforms.uStep(i % 2 ? [0, 1 / h] : [1 / w, 0]);
blur.draw(image); image = ping[i % 2].textures[0];
}
gl.bindFramebuffer(F, target); gl.viewport(...view); gl.useProgram(final.program);
const u = final.uniforms;
u.uBloom(1); u.uStrength(strength); u.uGray(gray); u.uContrast(contrast);
u.uVignette(vignette); u.uGain(gain); final.draw(scene.textures[0], image);
};
return { scene, draw };
};The page draws a dim scene once into post.scene, with a bulb of color (40, 32, 20), then applies four settings: untouched (top left), grayscale with vignette (top right), a warm grade (bottom left) and bloom:
<canvas id="c" width="1240" height="360" style="width:620px;height:180px"></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 = 620, H = 180, eye = [0.7, 0.7, 2.4], { mat4 } = glMatrix;
const post = GLH.post(gl, W, H); // before GLH.lit: both use programs
const { attributes, uniforms: u } = GLH.lit(gl, GLH.shelfFS);
const shelf = GLH.shelf(gl, attributes);
const ball = GLH.sphere(), lamp = [-0.35, 0.45, 0.75], bulb = GLH.vao(gl, attributes,
ball.vertices, [['aPosition', 3], ['aNormal', 3], ['aUV', 2]], ball.indices);
u.uAmbient([0.16, 0.17, 0.22]); u.uEye(eye); u.uShininess(40); u.uFoil(4); u.uAlpha(1);
u['uLights[0].position'](lamp); u['uLights[0].color']([0.55, 0.45, 0.3]);
u['uLights[1].position']([1.6, 0.4, 1.2]); u['uLights[1].color']([0.5, 0.6, 1.2]);
gl.bindFramebuffer(gl.FRAMEBUFFER, post.scene.fbo); // 1: the scene, offscreen
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);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
u.uViewProjection(shelf.camera(W / H, eye)); shelf.draw(u);
gl.bindVertexArray(bulb); u.uColor([40, 32, 20]); // the lamp's bulb, far above 1.0
u.uModel(mat4.scale([], mat4.fromTranslation([], lamp), [0.06, 0.06, 0.06]));
gl.drawElements(gl.TRIANGLES, ball.indices.length, gl.UNSIGNED_SHORT, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 2: four treatments of it
[[0, H, {}], [W, H, { gray: 1, contrast: 1.2, vignette: 0.8 }],
[0, 0, { gain: [1.25, 1.05, 0.8], contrast: 1.1, vignette: 0.6 }],
[W, 0, { strength: 1.5, threshold: 1 }]].forEach(([x, y, settings]) => {
gl.viewport(x, y, W, H); post.draw(settings);
});
window.__done = true;
</script>
Only the bloom tile glows: the bulb and foil bands exceed 1.0 and bleed over the covers. FXAA (fast approximate antialiasing, by Timothy Lottes at NVIDIA, 2009) is one more such pass: it blends across high-contrast edges of the final image, the usual antialiasing for deferred renderers.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="360"></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 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);
}
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 };
}
const W = 620, H = 180, eye = [0.7, 1.0, 2.6];
const post = makePost(W, H);
gl.bindFramebuffer(gl.FRAMEBUFFER, post.scene.fbo); // 1: the scene, offscreen
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(m4.multiply(m4.perspective(40, W / H, 0.1, 20), m4.lookAt(eye, [0, 0.4, 0])), eye);
// 2: four treatments of it.
[[0, H, {}], [W, H, { gray: 1, contrast: 1.2, vignette: 0.8 }],
[0, 0, { gain: [1.25, 1.05, 0.8], contrast: 1.1, vignette: 0.6 }],
[W, 0, { strength: 1.5, threshold: 1 }]].forEach(([x, y, settings]) => post.draw(settings, x, y));
</script>