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