Cover Embossing

A Normal-Mapped BookNest Cover Embossing Effect

Each cover gets a normal map baked from its own art, lit with the PBR BRDF (paper: dielectric, roughness 0.45) by a lamp low on the left:

BookNest covers flat (left) and embossed with baked normal maps (right)HTML
<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 type="module">
const gl = GLH.context('c'), { mat4, glMatrix: { toRadian } } = glMatrix;
const { attributes, uniforms: u } = GLH.lit(gl, `#version 300 es
  precision highp float;
  in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
  uniform sampler2D uCover, uBumps;                   // texture units 0 and 1
  uniform mat3 uNormalMatrix;
  uniform vec3 uColor, uLamp, uEye;
  uniform float uEmboss;
  ${GLH.tangentFrame}
  ${GLH.brdf}
  void main() {
    vec3 n = normalize(vNormal), v = normalize(uEye - vPosition), toLamp = uLamp - vPosition;
    float front = step(0.99, dot(n, normalize(uNormalMatrix * vec3(0, 0, 1))));
    mat3 tbn = tangentFrame(n, vPosition, vUV);       // outside any branch: uses dFdx/dFdy
    vec3 bump = texture(uBumps, vUV).xyz * 2.0 - 1.0; // unpack 0..1 to -1..1
    n = normalize(mix(n, tbn * bump, front * uEmboss));
    vec3 albedo = pow(mix(uColor, texture(uCover, vUV).rgb, front), vec3(2.2));
    vec3 color = brdf(n, v, normalize(toLamp), albedo, 0.0, 0.45) * 16.0 / dot(toLamp, toLamp);
    color += 0.15 * albedo;                           // a little ambient light
    color = color / (1.0 + color);
    outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
  }`);
const shelf = GLH.shelf(gl, attributes), eye = [-0.2, 0.35, 1.9];
const images = await Promise.all([1, 2, 3, 4, 5, 6].map((n) =>
  GLH.image(`booknest-cover-${n}.png`)));
const covers = images.map((image) => GLH.texture(gl, image));
const bumps = images.map((image) => GLH.normalMap(gl, image, 6));
u.uBumps(1); u.uLamp([-2.2, 0.6, 0.9]); u.uEye(eye);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
GLH.panes(gl, 2, (pane, aspect) => {
  u.uEmboss(pane);                                     // left flat, right embossed
  u.uViewProjection(mat4.multiply([], mat4.perspective([], toRadian(40), aspect, 0.1, 20),
    mat4.lookAt([], eye, [-0.75, 0, 0], [0, 1, 0])));
  shelf.items.forEach((item, i) => {
    gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, bumps[i] ?? null);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, covers[i] ?? null);
    shelf.draw(u, [item]);
  });
});
window.__done = true;
</script>
Browser output of Listing 3.110
Browser output of 110

On the right, titles, authors and logo rise from the paper: edges facing the lamp catch light, far edges fall into shade. Each book is still 12 triangles; the relief costs a texture read and the tangent frame per pixel.

BookNest covers flat (left) and embossed with normal maps baked from their own art (right), lit with the PBR BRDFHTMLLive
<!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) {
    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
  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);
}
// Cover art on 2D canvases, and a normal map baked from each one's luminance.
const books = [['#1f5f8b', 'The Quiet', 'Harbor'], ['#5b3f99', "Clockmaker's", 'Paradox'], ['#b5452f', 'Salt and', 'Saffron'],
               ['#3f7d3a', 'Small Steps', 'Big Summits'], ['#e09a10', 'Paper', 'Lanterns'], ['#2a9d8f', 'Tide', 'Tables']];
function coverArt([color, line1, line2]) {
  const c = Object.assign(document.createElement('canvas'), { width: 192, height: 288 });
  const g = c.getContext('2d');
  g.fillStyle = color; g.fillRect(0, 0, 192, 288);
  g.strokeStyle = '#f2ead8'; g.lineWidth = 5; g.strokeRect(14, 14, 164, 260);
  g.fillStyle = '#f2ead8'; g.beginPath(); g.arc(96, 100, 34, 0, Math.PI * 2); g.fill();
  g.fillStyle = color; g.beginPath(); g.arc(96, 100, 22, 0, Math.PI * 2); g.fill();
  g.fillStyle = '#fff'; g.textAlign = 'center'; g.font = 'bold 24px Georgia';
  g.fillText(line1, 96, 200); g.fillText(line2, 96, 232);
  return c;
}
function bakeNormalMap(source, strength) {
  const { width: w, height: h } = source, rgba = source.getContext('2d').getImageData(0, 0, w, h).data;
  const out = new ImageData(w, h);
  const height = (x, y) => { const i = 4 * (Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x)));
    return (rgba[i] + rgba[i + 1] + rgba[i + 2]) / 765; };
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
    const n = [(height(x - 1, y) - height(x + 1, y)) * strength, (height(x, y + 1) - height(x, y - 1)) * strength, 1];
    const l = Math.hypot(...n);
    out.data.set([...n.map((k) => (k / l * 0.5 + 0.5) * 255), 255], 4 * (y * w + x));
  }
  return out;
}
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#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; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
uniform sampler2D uCover, uBumps;                   // texture units 0 and 1
uniform mat3 uNormalMatrix; uniform vec3 uColor, uLamp, uEye; uniform float uEmboss;
const float PI = 3.14159265;
mat3 tangentFrame(vec3 n, vec3 position, vec2 uv) {
  vec3 dp1 = dFdx(position), dp2 = dFdy(position); vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
  vec3 t = cross(dp2, n) * duv1.x + cross(n, dp1) * duv2.x, b = cross(dp2, n) * duv1.y + cross(n, dp1) * duv2.y;
  float scale = inversesqrt(max(dot(t, t), dot(b, b)));
  return mat3(t * scale, b * scale, n);
}
vec3 brdf(vec3 n, vec3 v, vec3 l, vec3 albedo, float metallic, 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 f0 = mix(vec3(0.04), albedo, metallic), f = f0 + (1.0 - f0) * pow(1.0 - max(dot(h, v), 0.0), 5.0);
  return ((1.0 - f) * (1.0 - metallic) * albedo / PI + d * g * f / (4.0 * nv * max(nl, 1e-4))) * nl;
}
void main() {
  vec3 n = normalize(vNormal), v = normalize(uEye - vPosition), toLamp = uLamp - vPosition;
  float front = step(0.99, dot(n, normalize(uNormalMatrix * vec3(0, 0, 1))));
  mat3 tbn = tangentFrame(n, vPosition, vUV);       // outside any branch: uses dFdx/dFdy
  vec3 bump = texture(uBumps, vUV).xyz * 2.0 - 1.0; // unpack 0..1 to -1..1
  n = normalize(mix(n, tbn * bump, front * uEmboss));
  vec3 albedo = pow(mix(uColor, texture(uCover, vUV).rgb, front), vec3(2.2));
  vec3 color = brdf(n, v, normalize(toLamp), albedo, 0.0, 0.45) * 16.0 / dot(toLamp, toLamp);   // paper
  color += 0.15 * albedo;                           // a little ambient light
  color = color / (1.0 + color);
  outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}`]]) {
  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(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
const u = (n) => gl.getUniformLocation(program, n);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, cube(), 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);
function texture(source) {
  const t = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, t);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);             // v runs up the cover
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source);
  gl.generateMipmap(gl.TEXTURE_2D);
  return t;
}
const arts = books.map(coverArt);
const covers = arts.map(texture), bumps = arts.map((a) => texture(bakeNormalMap(a, 12)));
const items = books.map(([color], i) => ({ color: [1, 3, 5].map(k => parseInt(color.slice(k, k + 2), 16) / 255),
  model: m4.model(-1.3 + i * 0.52, 0.5, 0, 0.42, 0.98, 0.12, (30 - i * 12) * Math.PI / 180) }));
items.push({ color: [0.54, 0.38, 0.25], model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) });
const eye = [-0.2, 0.75, 1.9];
gl.uniform1i(u('uBumps'), 1);
gl.uniform3f(u('uLamp'), -2.4, 0.9, 0.45);                  // low on the left: grazing light shows relief
gl.uniform3fv(u('uEye'), eye);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 620 / 360, 0.1, 20), m4.lookAt(eye, [-0.75, 0.45, 0])));
for (let pane = 0; pane < 2; pane++) {
  gl.viewport(pane * 620, 0, 620, 360); gl.scissor(pane * 620 + 3, 0, 614, 360);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.uniform1f(u('uEmboss'), pane);                        // left flat, right embossed
  items.forEach(({ color, model }, i) => {
    gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, bumps[i] ?? null);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, covers[i] ?? null);
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  });
}
</script>