Concept: Normal Mapping


What Is Normal Mapping?

  • Technique to add surface detail without adding geometry
  • A texture stores per-pixel surface normals (in tangent space)
  • The shading normal is perturbed based on the texture
  • Result: the surface appears to have bumps and grooves at no geometry cost

Tangent Space

  • A local coordinate system defined per surface point
  • Axes: T (tangent), B (bitangent), N (normal)
  • N — geometric surface normal
  • T — tangent direction, aligned with UV u axis
  • B — bitangent, aligned with UV v axis, B = cross(N, T)
  • Normal map stores normals in this local space
    • Flat surface: (0, 0, 1) in tangent space → encoded as (0.5, 0.5, 1.0) in texture
    • Bump pointing right: (1, 0, 0) → encoded as (1.0, 0.5, 0.5)

TBN Matrix

  • Transforms from tangent space to world space
  • TBN = mat3(T, B, N) — columns are the tangent space axes in world space
  • world_normal = normalize(TBN * tangent_space_normal)
  • Computing T and B from mesh data
    • Stored per-vertex in the mesh (precomputed from UVs)
    • Or computed in shader from UV derivatives: dFdx(uv), dFdy(uv)
  • Mikktspace (Mikkelsen 2008)
    • Standard algorithm for computing tangents
    • Used by Blender, Godot, Unreal, Unity
    • Ensures consistent tangents across different tools

Normal Map Encoding

  • Tangent-space normal maps: blue-ish (most normals point up = (0,0,1))
  • Decode: N = normalize(texture(normalMap, uv).rgb * 2.0 - 1.0)
  • BC5 compression: stores only RG channels (Z reconstructed as sqrt(1 - R² - G²))
  • OpenGL vs DirectX convention: Y axis is flipped
    • OpenGL: +Y = up in tangent space
    • DirectX: -Y = up in tangent space
    • Fix: N.y = -N.y when loading DirectX normal maps in OpenGL

In a Path Tracer

  • Use the perturbed normal for BRDF evaluation and sampling
// In closest-hit shader
vec3 geo_normal = normalize(interpolate_normal(bary));  // geometric normal
vec3 tangent    = normalize(interpolate_tangent(bary));
vec3 bitangent  = cross(geo_normal, tangent) * tangent_sign;
mat3 TBN        = mat3(tangent, bitangent, geo_normal);
 
vec3 map_normal = texture(normalMap, uv).rgb * 2.0 - 1.0;
vec3 shading_normal = normalize(TBN * map_normal);
 
// Use shading_normal for BRDF, not geo_normal
  • Important: use geometric normal for ray offset (avoid self-intersection)
  • Use shading normal for BRDF evaluation

Bump Mapping vs Normal Mapping vs Displacement

  • Bump mapping: height texture → compute normal from height gradient
    • N = normalize(N + dh/du * T + dh/dv * B)
  • Normal mapping: directly store normals in texture (more control)
  • Displacement mapping: actually move vertices based on height texture
    • Requires tessellation or ray marching
    • True geometric detail — correct silhouettes and self-shadowing

Geometric vs Shading Normal Consistency

  • Problem: shading normal can point away from the ray direction
    • Happens at silhouette edges where normal interpolation creates inconsistencies
    • dot(ray_dir, shading_normal) > 0 — ray hits “back” of shading normal
  • Fix: flip shading normal if inconsistent with geometric normal
    if (dot(shading_normal, -ray_dir) < 0.0) {
        shading_normal = reflect(shading_normal, geo_normal);
    }
  • Or: use geometric normal for ray offset, shading normal for BRDF only