Normal Maps

Storing Surface Detail in a Normal Map

A normal map stores unit vectors, packed from -1..1 into RGB as n * 0.5 + 0.5. In tangent space, relative to the surface, a flat texel is (0, 0, 1), the typical lilac (128, 128, 255). Artists bake them from a high-polygon sculpt or derive them from a height map, as the eighteenth part of webgl-helper.js does:

webgl-helper.js, eighteenth part: baking a normal map from a height imageJavaScript
// Section 3.14: a tangent-space normal map baked from a height source (image or canvas;
// brighter = higher) by central differences, packed from -1..1 into 0..255 and uploaded
GLH.normalMap = function (gl, source, strength = 2) {
  const { width: w, height: h } = source, g = new OffscreenCanvas(w, h).getContext('2d');
  g.drawImage(source, 0, 0);
  const rgba = g.getImageData(0, 0, w, h).data, out = new ImageData(w, h);
  const height = (x, y) => {                              // luminance, clamped at the edges
    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++) {                         // image y runs down, v runs up
      const n = [(height(x - 1, y) - height(x + 1, y)) * strength,
        (height(x, y + 1) - height(x, y - 1)) * strength, 1];
      const length = Math.hypot(...n);
      out.data.set([...n.map((k) => (k / length * 0.5 + 0.5) * 255), 255], 4 * (y * w + x));
    }
  }
  return GLH.texture(gl, out);
};

BookNest uses each cover's luminance as height, so the light titles and logo rise from the darker cover. Normal maps are data, never sRGB; renormalize after sampling, since mipmapped (averaged) normals are shorter.

A cover's luminance used as height, baked into a tangent-space normal map, and lit by a circling lampHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font-family: monospace; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="600" height="280"></canvas>
<div class="names"><div>height source (brighter = higher)</div><div>baked normal map: n * 0.5 + 0.5</div><div>lit with the normal map</div></div>
<script>
// The height source: a cover drawn on a 2D canvas.
const art = Object.assign(document.createElement('canvas'), { width: 192, height: 288 });
const g = art.getContext('2d');
g.fillStyle = '#26303a'; g.fillRect(0, 0, 192, 288);
g.strokeStyle = '#8a8a8a'; g.lineWidth = 6; g.strokeRect(14, 14, 164, 260);
g.fillStyle = '#d0d0d0'; g.beginPath(); g.arc(96, 100, 42, 0, Math.PI * 2); g.fill();
g.fillStyle = '#26303a'; g.beginPath(); g.arc(96, 100, 26, 0, Math.PI * 2); g.fill();
g.fillStyle = '#f0f0f0'; g.font = 'bold 30px Georgia'; g.textAlign = 'center';
g.fillText('The Quiet', 96, 200); g.fillText('Harbor', 96, 236);

// Bake: central differences of luminance give the slope; pack the unit normal into RGB.
function bakeNormalMap(source, strength = 3) {
  const { width: w, height: h } = source;
  const rgba = source.getContext('2d').getImageData(0, 0, w, h).data;
  const out = new ImageData(w, h);
  const height = (x, y) => {                               // luminance, clamped at the edges
    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++) {  // image y runs down, v runs up
    const n = [(height(x - 1, y) - height(x + 1, y)) * strength, (height(x, y + 1) - height(x, y - 1)) * strength, 1];
    const length = Math.hypot(...n);
    out.data.set([...n.map((k) => (k / length * 0.5 + 0.5) * 255), 255], 4 * (y * w + x));
  }
  return out;
}
const normals = bakeNormalMap(art);

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 vec2 aPos; out vec2 vUV;
void main() { vUV = aPos; gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float; in vec2 vUV; uniform sampler2D uHeight, uNormals; uniform int uShow; uniform vec3 uLight; out vec4 c;
void main() {
  if (uShow == 0) { c = texture(uHeight, vUV); return; }
  vec4 packed = texture(uNormals, vUV);
  if (uShow == 1) { c = packed; return; }              // flat texels are lilac (128, 128, 255)
  vec3 n = normalize(packed.xyz * 2.0 - 1.0);          // unpack and renormalize
  // The quad faces +z, so tangent space equals world space here: T = +x, B = +y, N = +z.
  vec3 p = vec3(vUV * 2.0 - 1.0, 0.0), l = normalize(uLight - p);
  float diffuse = max(dot(n, l), 0.0);
  c = vec4(vec3(0.12, 0.37, 0.55) * (0.25 + 1.1 * diffuse) + pow(max(dot(n, normalize(l + vec3(0, 0, 1))), 0.0), 40.0) * 0.4, 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);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
[art, normals].forEach((source, unit) => {
  gl.activeTexture(gl.TEXTURE0 + unit);
  gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);         // top row first in images: flip so v runs up
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source);   // data: not sRGB
  gl.generateMipmap(gl.TEXTURE_2D);
});
gl.uniform1i(gl.getUniformLocation(program, 'uHeight'), 0);
gl.uniform1i(gl.getUniformLocation(program, 'uNormals'), 1);
gl.clearColor(0.97, 0.96, 0.93, 1);
function frame(ms) {
  const a = ms * 0.0012;
  gl.uniform3f(gl.getUniformLocation(program, 'uLight'), Math.cos(a) * 1.5, Math.sin(a) * 1.5, 0.8);
  gl.clear(gl.COLOR_BUFFER_BIT);
  for (let i = 0; i < 3; i++) {
    gl.uniform1i(gl.getUniformLocation(program, 'uShow'), i);
    gl.viewport(i * 200 + 12, 5, 176, 270);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>