Normal map

노멀 맵

A texture that stores a different surface normal per pixel, faking bumpy detail without adding any geometry.

Also known as: Tangent-space normal mapNormal mapping
···
html
<canvas id="nm-canvas"></canvas><span class="nm-cap">procedural normal map · moving light</span>
css
#nm-canvas{display:block;height:88%;width:auto;max-width:94%;border-radius:6px;image-rendering:pixelated}
.nm-cap{position:absolute;left:0;right:0;bottom:4%;text-align:center;font-size:clamp(9px,2.4vmin,12px);color:var(--muted)}
js
const canvas = document.getElementById('nm-canvas');
const ctx = canvas.getContext('2d');
const W = 128, H = 86;
canvas.width = W; canvas.height = H;
const bumps = [];
for (let i = 0; i < 9; i++) bumps.push({ x: Math.random() * W, y: Math.random() * H, r: 8 + Math.random() * 10 });
function heightAt(x, y) {
  let h = 0;
  for (const b of bumps) {
    const d = Math.hypot(x - b.x, y - b.y) / b.r;
    if (d < 1) h += Math.pow(Math.cos(d * Math.PI * 0.5), 2);
  }
  return h;
}
const nx = new Float32Array(W * H), ny = new Float32Array(W * H), nz = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const hl = heightAt(x - 1, y), hr = heightAt(x + 1, y), hu = heightAt(x, y - 1), hd = heightAt(x, y + 1);
    const vx = -(hr - hl) * 6, vy = -(hd - hu) * 6, vz = 1;
    const len = Math.hypot(vx, vy, vz);
    const i = y * W + x;
    nx[i] = vx / len; ny[i] = vy / len; nz[i] = vz / len;
  }
}
const img = ctx.createImageData(W, H);
function render(t) {
  const ang = t * 0.0007;
  const lx = Math.cos(ang), ly = Math.sin(ang) * 0.6, lz = 0.7;
  const llen = Math.hypot(lx, ly, lz);
  const Lx = lx / llen, Ly = ly / llen, Lz = lz / llen;
  for (let i = 0; i < W * H; i++) {
    const d = Math.max(0, nx[i] * Lx + ny[i] * Ly + nz[i] * Lz);
    const base = 40 + d * 255 * 0.75;
    const p = i * 4;
    img.data[p] = base * 0.65 + 20;
    img.data[p + 1] = base * 0.55 + 30;
    img.data[p + 2] = base * 0.95 + 50;
    img.data[p + 3] = 255;
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

A face only has one flat normal — the direction it points — but lighting is computed per pixel, not per face. A normal map is a texture that encodes a different normal for every pixel in its RGB channels: R holds the X tilt, G the Y tilt, B the Z tilt, which is why a typical normal map looks like a soft lavender-blue haze.

When the renderer shades the surface, it reads a normal from this texture for every pixel and uses that in the lighting math instead of the face’s flat one. The actual geometry stays flat, but light appears to flow across bumps that aren’t really there — and, like bump and displacement maps, the silhouette never changes; that’s the shared limitation.

In Blender, Maya, or C4D the standard workflow is “baking” a high-poly sculpt down onto a low-poly mesh to produce this normal map (see texture-baking). This demo skips that pipeline and builds a small height field of procedural bumps directly, deriving normals from it and lighting them with a moving light.

When to use

Use it in real-time games or web 3D to add surface detail — wrinkles, rivets, fabric weave — without spending more polygons.