Introduction

Open any modern video game and the characters look richly detailed — rough skin pores, dented metal, carved stone — yet your GPU is drawing flat triangles. The secret is normal mapping: a technique that stores, for every pixel on a surface, the direction it is pretending to face, and hands that direction to the lighting equation instead of the true flat-polygon normal.

The result is striking. A wall that is literally two triangles can absorb and reflect light as though it were covered in thousands of tiny bumps. Artists sculpt an extremely detailed model, bake the surface normals of that detail into a texture, and then apply the texture to the low-polygon version that ships in the game.

To understand why this works, you need to understand what a surface normal is and how light uses it.

Try It: Move the Light

The quad below is geometrically flat — four corners, two triangles. But its pixels carry a normal map that encodes the orientation of an imaginary bumpy surface. Move the light (click or drag) and watch the apparent bumps catch and lose the light just like real geometry would.

<!-- canvas + controls — no external imports -->
<div class="controls">
  <label class="toggle">
    <input type="checkbox" id="flatToggle">
    <span>Flat shading</span>
  </label>
  <span class="hint-text">Click or drag to move the light</span>
</div>
<canvas id="c" width="380" height="280" title="Normal mapping interactive demo — move the light"></canvas>
<p class="caption" id="caption">Normal map active — each pixel claims its own surface direction</p>
/* Demo styles — dark sci-fi background, crosshair cursor on canvas */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; display: flex;
       flex-direction: column; align-items: center; padding: 1rem; gap: .6rem; }
.controls { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
label.toggle { display: flex; align-items: center; gap: .4rem; color: #ccd6f6;
               font-size: .9rem; cursor: pointer; user-select: none; }
input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; }
.hint-text { color: #8892b0; font-size: .82rem; }
canvas { border-radius: 8px; cursor: crosshair; touch-action: none; }
.caption { color: #8892b0; font-size: .8rem; text-align: center; min-height: 1.2em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Normal-mapping demo — per-pixel Phong lighting driven by a procedural normal map. */

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const flatToggle = document.getElementById('flatToggle');
const captionEl = document.getElementById('caption');

/* Light position in canvas coordinates — starts top-center */
let lightX = canvas.width / 2;
let lightY = 60;
let isDragging = false;

/* We simulate a normal map procedurally using sine waves so the */
/* demo works without loading an image file. */
const W = canvas.width;
const H = canvas.height;
const QUAD_X = 40, QUAD_Y = 40, QUAD_W = W - 80, QUAD_H = H - 80;

/* Return the perturbed unit normal at canvas pixel (px, py). */
/* Normals are derived from the gradient of a 2D sine-wave height field. */
function bumpNormal(px, py) {
  const u = (px - QUAD_X) / QUAD_W;
  const v = (py - QUAD_Y) / QUAD_H;
  /* Height field value at (u,v) — blends two sine waves on U and V */
  const freq = 6.0;
  const amp = 0.45;
  const wu = Math.sin(u * freq * Math.PI * 2) * amp;
  const wv = Math.cos(v * freq * Math.PI * 2) * amp;
  /* Finite-difference gradient of the height field in U and V */
  const eps = 0.005;
  const du = Math.sin((u + eps) * freq * Math.PI * 2) * amp - wu;
  const dv = Math.cos((v + eps) * freq * Math.PI * 2) * amp - wv;
  /* Build tangent-space normal from gradient and normalize */
  const nx = -du / eps;
  const ny = -dv / eps;
  const nz = 1.0;
  const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
  return [nx / len, ny / len, nz / len];
}

/* Full-screen render — redraws every frame when the light moves */
function render() {
  ctx.clearRect(0, 0, W, H);

  const isFlat = flatToggle.checked;

  /* Write directly to pixel buffer for per-pixel lighting */
  const imgData = ctx.createImageData(QUAD_W, QUAD_H);
  const data = imgData.data;

  /* Approximate light depth above the surface (in canvas pixels) */
  const lz = 120;

  for (let j = 0; j < QUAD_H; j++) {
    for (let i = 0; i < QUAD_W; i++) {
      const px = QUAD_X + i;
      const py = QUAD_Y + j;

      /* Choose normal source: flat polygon normal or bumped normal */
      let nx, ny, nz;
      if (isFlat) {
        nx = 0; ny = 0; nz = 1;
      } else {
        [nx, ny, nz] = bumpNormal(px, py);
      }

      /* Light direction vector from surface point to light (unnormalized then normalized) */
      const lx = lightX - px;
      const ly = lightY - py;
      const ll = Math.sqrt(lx * lx + ly * ly + lz * lz);
      const llx = lx / ll, lly = ly / ll, llz = lz / ll;

      /* Lambertian diffuse term */
      const diff = Math.max(0, nx * llx + ny * lly + nz * llz);

      /* Blinn-Phong specular: reflect L around N, raise to shininess power */
      const rx = 2 * diff * nx - llx;
      const ry = 2 * diff * ny - lly;
      const rz = 2 * diff * nz - llz;
      const spec = Math.pow(Math.max(0, rz), 32) * 0.6;

      /* Blend ambient + diffuse + specular onto base color */
      const baseR = 80, baseG = 110, baseB = 160;
      const ambient = 0.12;

      const r = Math.min(255, (ambient + diff) * baseR + spec * 255);
      const g = Math.min(255, (ambient + diff) * baseG + spec * 255);
      const b = Math.min(255, (ambient + diff) * baseB + spec * 255);

      const idx = (j * QUAD_W + i) * 4;
      data[idx]     = r;
      data[idx + 1] = g;
      data[idx + 2] = b;
      data[idx + 3] = 255;
    }
  }

  ctx.putImageData(imgData, QUAD_X, QUAD_Y);

  /* Draw the draggable light indicator */
  ctx.beginPath();
  ctx.arc(lightX, lightY, 10, 0, Math.PI * 2);
  ctx.fillStyle = '#ffe066';
  ctx.fill();
  ctx.strokeStyle = '#fff8';
  ctx.lineWidth = 1.5;
  ctx.stroke();

  /* Update the caption below the canvas */
  captionEl.textContent = isFlat ? 'Flat shading — every pixel uses the polygon’s true normal' : 'Normal map active — each pixel claims its own surface direction';
}

/* Pointer events — works for mouse and touch */
function setLight(e) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  const src = e.touches ? e.touches[0] : e;
  lightX = (src.clientX - rect.left) * scaleX;
  lightY = (src.clientY - rect.top) * scaleY;
  render();
}

canvas.addEventListener('mousedown', e => { isDragging = true; setLight(e); });
canvas.addEventListener('mousemove', e => { if (isDragging) setLight(e); });
canvas.addEventListener('mouseup', () => { isDragging = false; });
canvas.addEventListener('mouseleave', () => { isDragging = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); setLight(e); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); setLight(e); }, { passive: false });

flatToggle.addEventListener('change', render);

/* Initial render */
render();
Result
Console

Toggle Flat shading to see the same quad with only the polygon's true normal — every pixel shaded identically — then switch back to Normal map to feel the difference. The geometry never changed: only the direction each pixel claims to face.

The Math Behind It

A surface normal is a unit vector n^\hat{n} perpendicular to the surface at a given point. The standard diffuse lighting model (Lambertian) computes brightness as:

L=max⁡(0,  n^⋅l^)L = \max(0,\; \hat{n} \cdot \hat{l})

where l^\hat{l} is the unit vector toward the light. When n^\hat{n} points straight at the light the surface is brightest; when n^\hat{n} is perpendicular to l^\hat{l} the surface is dark. Normal mapping works by substituting a per-pixel n^\hat{n} read from a texture.

Encoding normals in RGB. A unit vector has components in [−1,1][-1, 1]. Colors live in [0,1][0, 1]. The mapping is:

texel=n^+12\text{texel} = \frac{\hat{n} + 1}{2}

That is why normal maps are predominantly blue-purple: a surface mostly facing straight up encodes as (0.5, 0.5, 1.0)(0.5,\, 0.5,\, 1.0), which maps to a lilac-blue color.

Tangent space. A normal map stores vectors relative to the local surface, not the world. This "tangent space" is defined by three vectors at each vertex: the face normal N^\hat{N}, a tangent T^\hat{T} (along the U texture axis), and a bitangent B^\hat{B} (along V). At runtime the GPU transforms the stored tangent-space normal into world space using the matrix [T^  B^  N^][\hat{T}\; \hat{B}\; \hat{N}], then feeds it to the lighting equation. The upside: the same normal-map texture works on any orientation of the surface.

Normal mapping is a pure lighting trick with zero extra geometry. Its cost is one extra texture lookup per fragment — a tiny price for the visual richness it delivers.

Where It Matters

Normal mapping is one of the most widely deployed techniques in real-time graphics because it is cheap, effective, and fits naturally into the modern rendering pipeline:

  • Video games: every AAA title uses normal maps for characters, environments and props. A high-resolution sculpt baked into a normal map can represent millions of virtual polygons on a mesh of a few thousand.
  • Physically based rendering (PBR): modern PBR pipelines extend normal mapping with roughness and metalness maps. The three together define how a surface interacts with light at every pixel.
  • Film and VFX: even in offline rendering, normal maps accelerate the shading of assets without the overhead of subdivision surfaces everywhere.
  • Product visualization and AR: furniture, shoes, and electronics shown in configurators or augmented reality use normal maps to give materials a realistic tactile feel without expensive scans or meshes.
  • Terrain and architecture: large-scale surfaces like ground or concrete walls use normal maps (often combined with heightmaps) to break up the flat appearance that would otherwise expose the polygon grid.

The technique is not without limits — silhouettes still reveal the true flat edges, and extreme viewing angles break the illusion. Variants like parallax mapping and relief mapping push the illusion further by also shifting texture coordinates to simulate depth.

Conclusion

Normal mapping is a beautiful lie: you hand the lighting equation a direction that the surface does not actually have, and the light cooperates so convincingly that the illusion holds up in motion, under moving lights, from almost any angle. The geometry stays cheap; the apparent detail stays rich.

The trick works because light does not care about geometry — it only cares about the direction it is being told the surface faces. Change that direction per pixel, store it in an image, and you get a free sculpture out of two triangles.

That same principle — encoding direction rather than position — underlies many computer graphics ideas beyond normal maps. Once you see the pattern, the surfaces around you in games and films look very different.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/normal-mapping/Content licensed under CC BY-NC 4.0.