Normal Mapping
Purpose
A normal map is a texture that lies to the lighting system about which way the surface is facing — the geometry stays flat, but every lit pixel behaves as if it were bumpy. That's how modern rendering fakes surface detail without modeling it.
Key insight
Every 3D lighting lesson so far has asked dot(normal, light). That normal came from the mesh — one per vertex, smoothly interpolated across triangles. Normal mapping replaces that interpolated normal with one read from an image: red channel is the X tilt, green is the Y tilt, blue is the "out" component. A small matrix called TBN rotates the stored tilt into world space so the lighting math works unchanged.
The geometry is not bumped — the silhouette stays smooth — but every lighting model downstream treats each pixel as if it had its own micro-orientation. This is the most important trick in real-time rendering because it decouples visual detail from polygon count.
This is where your material vocabulary becomes concrete: "feels worn," "feels smooth," "feels detailed," "feels scratched" all resolve to normal-map choices. depth is the continuous dial — 0 is flat, 1 is the authored relief, 2 is an exaggerated comic-book version of it.
Break it
Depth to 2 and watch the silhouette (wait for the sphere to rotate). The outer edge of the sphere stays perfectly smooth no matter what the normal map claims. Teaches: normal mapping is a lighting lie, not a geometry change. When silhouette matters, you need tessellation or displacement instead.
Drag depth negative (toward -2). Bumps become pits and pits become bumps — every detail reverses because the stored tilts now flip. Teaches: a normal map is a sign convention, and the same file reads as a different surface if the convention flips. This is the most common bug in mixed-tool pipelines (OpenGL vs. DirectX flip Y).
Direct Claude
// Generate a normal map on a 2D canvas at load time.
// 1) Build a height field (here: a lattice of rounded bumps).
// 2) For each pixel, sample the heights of its neighbors and
// compute the surface tilt via finite differences.
// 3) Pack (tiltX, tiltY, 1) into RGB. Blue = 255 means "straight out."
function buildNormalMap(size) {
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
const img = ctx.createImageData(size, size);
// height field — sum of two frequencies so bumps have variation
const h = new Float32Array(size * size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const u = (x / size) * Math.PI * 16;
const v = (y / size) * Math.PI * 16;
h[y * size + x] = Math.sin(u) * Math.sin(v)
+ 0.4 * Math.sin(u * 2.3 + 1.0) * Math.sin(v * 2.3);
}
}
// normal from finite differences
const strength = 3.0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const l = h[y * size + ((x + size - 1) % size)];
const r = h[y * size + ((x + 1) % size)];
const u = h[((y + size - 1) % size) * size + x];
const d = h[((y + 1) % size) * size + x];
const nx = (l - r) * strength;
const ny = (u - d) * strength;
const nz = 1.0;
const m = 1 / Math.hypot(nx, ny, nz);
const i = (y * size + x) * 4;
img.data[i ] = (nx * m * 0.5 + 0.5) * 255;
img.data[i + 1] = (ny * m * 0.5 + 0.5) * 255;
img.data[i + 2] = (nz * m * 0.5 + 0.5) * 255;
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return new THREE.CanvasTexture(c);
}
// Apply it to a standard material. normalScale is the depth dial.
const material = new THREE.MeshStandardMaterial({
color: 0xd0c4b0,
metalness: 0.6,
roughness: 0.35,
normalMap: buildNormalMap(512),
});
material.normalScale.set(1.0, 1.0); // depth slider writes both