Computer Graphics

Normal Mapping: Faking Surface Detail With a Texture

A brick wall that looks like it has a million chiseled grooves might be one flat quad — two triangles, four vertices. The illusion is a single RGB texture that lies to the lighting equation about which way the surface faces. Normal mapping ships in essentially every real-time renderer built since DOOM 3 (2004), where id Software's Carmack popularized it, and it is why a modern game character can carry the visual weight of 10 million polygons while the GPU only rasterizes 40,000.

The trick is deceptively cheap: for every pixel, replace the geometry's true surface normal with one sampled from a texture, then feed that fake normal into Phong or PBR shading. The lighting responds as if the surface were bumpy, but the silhouette stays flat. Get the coordinate frame wrong by one transpose and your walls light up inside-out.

  • CostO(1) per fragment
  • StorageO(w·h) RGB texture
  • Core opTBN matrix transform
  • Best forHigh-freq detail, flat silhouette OK
  • PopularizedDOOM 3, id Software, 2004
  • Used inEvery real-time PBR engine

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The Core Idea and Its One Invariant

Lighting a surface reduces, at each shaded point, to a dot product: how aligned is the surface normal N with the direction to the light L? Lambert's cosine law gives diffuse intensity as max(0, N·L). If N is constant across a flat triangle, the triangle shades uniformly and looks flat. Normal mapping replaces that single geometric normal with a per-pixel normal fetched from a texture, so N·L varies texel-by-texel and the flat triangle appears to have relief.

The stored texture is a normal map: each RGB texel encodes a unit vector, not a color. The mapping is n = 2·(rgb) − 1, unpacking [0,1] channels back to [−1,1] components. Because most detail bulges toward the viewer, the dominant encoded direction is +Z ≈ (0,0,1), which packs to RGB (0.5, 0.5, 1.0) — the characteristic lavender-blue of a normal map.

The load-bearing invariant: the sampled normal and the light vector must live in the same coordinate space before you dot them. The map stores normals in tangent space (relative to the surface), so you either lift the normal into world space or push the light down into tangent space — but you must do exactly one of them, consistently, for every fragment.

Tangent Space and the TBN Matrix

A normal map is authored in tangent space: a local frame glued to the surface with basis vectors T (tangent, aligned with the U texture axis), B (bitangent, aligned with V), and N (the interpolated geometric normal). Storing detail relative to this frame is what makes a map reusable — the same brick tile works on a wall, a curved column, or an animated character, because the frame rotates with the geometry.

The change-of-basis is the TBN matrix, the 3×3 whose columns are T, B, N. It maps a tangent-space vector into world space; its transpose (an orthonormal matrix's inverse is its transpose) maps world→tangent. T is derived per-triangle from the UVs by solving the edge equations:

// Given edges E1,E2 and UV deltas (Δu1,Δv1),(Δu2,Δv2)
r = 1 / (Δu1·Δv2 − Δu2·Δv1)
T = r · (Δv2·E1 − Δv1·E2)
B = r · (Δu1·E2 − Δu2·E1)

Vertex tangents are accumulated across adjacent triangles and averaged, then Gram–Schmidt orthonormalized against N: T ← normalize(T − (N·T)·N). A fourth value, the sign w = ±1 of (N×T)·B, is stored per vertex so the bitangent can be reconstructed as B = w·(N×T) — this handles mirrored UVs, where the handedness flips.

The Algorithm, Step by Step

Two implementation strategies exist; the choice is a classic vertex-vs-fragment workload trade-off.

  • Light-to-tangent (cheaper). In the vertex shader, transform L (and the view vector V) into tangent space using TBNᵀ, then interpolate them across the triangle. The fragment shader samples the map and dots directly. Fewer per-fragment ops; the transform amortizes over the (usually fewer) vertices.
  • Normal-to-world (more common in PBR). Pass T, B, N to the fragment shader, rebuild the TBN there, and lift the sampled normal to world space. Slightly costlier per fragment but composes cleanly with world-space lighting, environment reflections, and deferred shading.

The fragment core in the world-space variant:

vec3 n = texture(normalMap, uv).rgb;   // [0,1]
n = normalize(n * 2.0 - 1.0);           // → [-1,1]
mat3 TBN = mat3(normalize(T),
                normalize(B),
                normalize(N));
vec3 Nw = normalize(TBN * n);           // world space
float diff = max(dot(Nw, Lw), 0.0);

Because the interpolated T, B, N drift from orthonormal across a large triangle, re-normalizing (and optionally re-orthogonalizing) in the fragment shader is standard defensive practice.

Complexity: Why It's Effectively Free

Time, per fragment: Θ(1). One texture fetch (a bilinear/trilinear sample, hardware-accelerated and cache-friendly), one scale-and-bias, and one 3×3 matrix–vector product (9 multiplies, 6 adds) plus a normalize. There are no loops and no data dependencies across fragments, so the whole pass is embarrassingly parallel across the GPU's shading cores.

Time, per frame: Θ(F) where F is the number of shaded fragments (pixels × overdraw × lights). Crucially this is decoupled from geometric complexity: F does not grow with the visual detail the map implies. That is the entire economic argument — a 10-million-triangle sculpt is baked once (an offline O(m log m)-ish ray-cast from high-poly to low-poly) into a texture, after which runtime cost tracks the low-poly mesh, not the sculpt.

Space: Θ(w·h) for the texture. Tangent-space maps compress well: with the +Z bulge assumption you can store only the X and Y channels (BC5/3Dc two-channel format) and reconstruct z = √(1 − x² − y²), halving storage. Per-vertex tangents add Θ(V) — typically one vec4 (T.xyz plus the handedness sign) per vertex.

Contrast with brute-force geometry, where matching the same detail needs Θ(m) triangles through the entire vertex-transform, clipping, and rasterization pipeline — orders of magnitude more work and memory bandwidth for the same lit appearance on flat-ish surfaces.

Where It Wins, Where It Loses

Normal mapping is the right tool when detail is high-frequency and shallow relative to viewing distance, and the silhouette can stay flat: pores, fabric weave, brick mortar, scratches, panel seams, bark. It composes with every downstream lighting model — Phong, Blinn–Phong, Cook–Torrance PBR — because it only changes the input N.

  • Wins vs. adding geometry: constant per-fragment cost, tiny memory, and the same map tiles/reuses across many meshes.
  • Wins vs. bump mapping: stores the full vector directly instead of a heightfield you must differentiate, so it captures asymmetric and steeply-tilted detail and needs no derivative in the shader.
  • Loses when depth matters: the surface is still geometrically flat. At grazing angles the illusion collapses — bumps don't occlude each other, don't cast contact shadows, and the outline is a straight edge. Parallax occlusion mapping or real tessellation/displacement is required for depth and silhouette.
  • Loses on hard edges: a normal map cannot invent a crease the low-poly mesh doesn't have; it can only reshade within the existing surface.

Real Systems and the Baking Pipeline

Every major engine implements it: Unreal Engine (its material graph exposes a Normal pin feeding the deferred G-buffer), Unity (unpacks DXT5nm/BC5 maps via UnpackNormal), Godot, Frostbite, CryEngine, and every WebGL/WebGPU stack including three.js (normalMap on MeshStandardMaterial). The historical inflection was DOOM 3, which shipped tangent-space normal mapping as a load-bearing feature and forced the pipeline into the mainstream.

The content pipeline is the other half of the technique. Artists sculpt a high-poly mesh in ZBrush/Blender/Mudbox, retopologize a low-poly game mesh, then bake: for each low-poly texel, cast a ray along the low-poly normal, hit the high-poly surface, and record that hit's normal — transformed into the texel's tangent frame. Tools like Substance 3D (Designer/Painter), xNormal, Marmoset Toolbag, and Blender's bake do this. A companion ambient occlusion and curvature bake usually rides along.

In deferred renderers, the world-space normal is written into the G-buffer during the geometry pass and consumed later during lighting, cleanly decoupling normal mapping from the number of lights. This is why the normal-to-world variant dominates modern PBR pipelines.

Pitfalls, Edge Cases, and Variants

Normal mapping is a minefield of coordinate-convention bugs. The failure modes are specific and recur:

  • Green-channel (Y) flip. DirectX and OpenGL disagree on whether +V points up or down, so the Y/green channel is inverted between them. A map baked for one lights concave-for-convex in the other — grooves pop out as ridges. Fix by negating n.y or re-exporting.
  • Handedness on mirrored UVs. Skip the stored sign w and mirrored parts of the mesh (e.g. a symmetric face) light inverted. The B = w·(N×T) reconstruction is mandatory.
  • Non-orthonormal interpolation. Interpolated TBN vectors are neither unit-length nor orthogonal; failing to re-normalize causes energy loss and dark seams. Use MikkTSpace, the de-facto standard tangent basis, so the baker and the shader agree exactly — mismatched tangent bases produce subtle, maddening lighting errors.
  • Mip-level normal aliasing. Naive mipmapping averages the vectors and shortens them, over-smoothing specular. Toksvig factors and LEAN/CLEAN mapping fold the lost variance into a widened roughness, preserving the glinty look at distance.

Variants: object-space normal maps store world-relative normals (rainbow-colored, no per-vertex tangents needed, but not reusable and awkward to animate); bump/height maps store scalar displacement and derive the normal; detail normal maps blend a tiled high-frequency map over a base using reoriented normal blending (RNM) to add pore-level micro-detail without extra texels.

Surface-detail techniques compared: cost, what they fake, and where they fail
TechniquePer-fragment costFakesSilhouette / occlusion
Bump mapping (Blinn 1978)O(1), derivative samplePerturbed normal from heightfieldNo — flat
Normal mappingO(1), one texel + TBN mulFull normal vector directlyNo — flat
Parallax mappingO(1) single stepNormal + UV shift for depthWeak, breaks at grazing
Parallax occlusionO(k) ray-march, k≈8–64Normal + depth + self-occlusionPartial, still no silhouette
Real geometry / tessellationO(triangles)Actual displaced surfaceYes — true silhouette

Frequently asked questions

Why is a normal map mostly blue?

Each RGB texel encodes a unit normal via n = 2·rgb − 1. In tangent space, most surface detail points outward along +Z, i.e. (0,0,1), which packs to RGB (0.5, 0.5, 1.0). That flat-facing majority dominates the image, giving normal maps their signature lavender-blue tint; red and green deviations mark where the surface tilts in U and V.

What's the difference between bump mapping and normal mapping?

Bump mapping (Blinn, 1978) stores a scalar heightfield and perturbs the normal from its derivatives at shade time. Normal mapping stores the full 3-component normal vector directly, so it needs no differentiation, captures asymmetric and steeply-tilted detail, and is a straight texture fetch. Both keep the geometry flat and cost O(1) per fragment; normal maps are strictly more expressive.

What is the TBN matrix and why do I need it?

TBN is the 3×3 change-of-basis matrix whose columns are the tangent, bitangent, and geometric normal. It converts a tangent-space vector to world space (its transpose does the reverse). You need it because the map stores normals in tangent space but lighting is computed in a shared space — TBN aligns the two so N·L is meaningful. Skip it and the lighting is arbitrary garbage.

Why does my mirrored/symmetric model light up inside-out?

Mirroring UVs flips the surface handedness, so the bitangent's sign inverts on the mirrored half. If you compute B = N×T without the stored handedness sign w, one side gets the wrong-chirality frame and reads convex detail as concave. Store w = sign((N×T)·B) per vertex and reconstruct B = w·(N×T).

When does normal mapping break down?

It fails whenever depth or silhouette matters, because the surface is still geometrically flat. At grazing angles bumps don't occlude each other or self-shadow, and the outline is a straight edge that betrays the trick. For that you need parallax occlusion mapping (an O(k) ray-march in texture space) or actual tessellation and displacement.

What's the runtime cost, and how does baking factor in?

Runtime is Θ(1) per fragment — one cached texture sample plus a 3×3 transform and a normalize — and Θ(F) per frame in the number of fragments, independent of the implied detail. The expensive step is offline: baking ray-casts from a multi-million-triangle sculpt onto the low-poly mesh's tangent frame once, so all that geometric complexity becomes a fixed-size texture the GPU reads for free thereafter.