forge-normal-maps

star 25

Add tangent-space normal mapping to an SDL GPU project. Extends the vertex layout with tangent vectors, constructs a TBN matrix in the vertex shader, and samples/decodes normal maps in the fragment shader for per-texel surface detail.

Nebulavenus By Nebulavenus schedule Updated 3/22/2026

name: forge-normal-maps description: Add tangent-space normal mapping to an SDL GPU project. Extends the vertex layout with tangent vectors, constructs a TBN matrix in the vertex shader, and samples/decodes normal maps in the fragment shader for per-texel surface detail.

Normal Maps Skill

Add tangent-space normal mapping to a 3D scene rendered with SDL's GPU API. Normal maps add surface detail (bumps, grooves, patterns) without increasing triangle count.

When to use

  • You have a textured 3D mesh and want to add surface detail
  • Your model has a normal map texture (tangent-space, glTF convention)
  • You need to compute tangent vectors for a mesh that doesn't supply them
  • You want to implement the TBN (tangent-bitangent-normal) pipeline

Prerequisites

  • Working 3D rendering with Blinn-Phong lighting (Lesson 10 / /forge-basic-lighting)
  • Texture sampling already set up (Lesson 04 / /forge-textures-and-samplers)
  • glTF or OBJ model loading (Lesson 09 / /forge-scene-loading)

Key concepts

Vertex layout

Extend your vertex struct with a vec4 tangent:

typedef struct SceneVertex {
    vec3 position;    /* TEXCOORD0 */
    vec3 normal;      /* TEXCOORD1 */
    vec2 uv;          /* TEXCOORD2 */
    vec4 tangent;     /* TEXCOORD3 — xyz = direction, w = handedness */
} SceneVertex;

Tangent computation (Lengyel's method)

For models without supplied tangents, compute them from triangle edges:

  1. For each triangle, solve for T and B from edge vectors and UV deltas
  2. Accumulate per-vertex (average across sharing triangles)
  3. Orthogonalize T against N using Gram-Schmidt: T' = normalize(T - N * dot(N, T))
  4. Compute handedness: sign(dot(cross(N, T), B))

Vertex shader — TBN construction

/* Normal: adjugate transpose for non-uniform scale */
float3x3 m = (float3x3)model;
float3x3 adj_t;
adj_t[0] = cross(m[1], m[2]);
adj_t[1] = cross(m[2], m[0]);
adj_t[2] = cross(m[0], m[1]);
float3 N = normalize(mul(adj_t, input.normal));

/* Tangent: transform by model matrix */
float3 T = normalize(mul(m, input.tangent.xyz));

/* Gram-Schmidt re-orthogonalization */
T = normalize(T - N * dot(N, T));

/* Bitangent with handedness */
float3 B = cross(N, T) * input.tangent.w;

Fragment shader — normal map sampling

/* Build TBN matrix (T, B, N as rows) */
float3x3 TBN = float3x3(
    normalize(input.world_tangent),
    normalize(input.world_bitan),
    normalize(input.world_normal));

/* Decode normal map: [0,1] → [-1,1] */
float3 map_normal = normal_tex.Sample(normal_smp, input.uv).rgb;
map_normal = map_normal * 2.0 - 1.0;

/* Transform to world space */
float3 N = normalize(mul(map_normal, TBN));

Pipeline setup

Fragment shader resources

The fragment shader needs 2 samplers (diffuse + normal map):

#define FS_NUM_SAMPLERS 2

Binding textures

SDL_GPUTextureSamplerBinding bindings[2];
bindings[0].texture = diffuse_texture;
bindings[0].sampler = sampler;
bindings[1].texture = normal_texture;
bindings[1].sampler = sampler;
SDL_BindGPUFragmentSamplers(pass, 0, bindings, 2);

Flat normal map placeholder

For materials without a normal map, use a 1x1 texture encoding (128, 128, 255) which decodes to tangent-space normal (0, 0, 1) — no perturbation.

Common mistakes

  1. Forgetting handedness — The tangent.w sign must be used when computing the bitangent. Without it, mirrored UVs produce inverted normal maps.

  2. Wrong normal map convention — glTF uses OpenGL convention (Y up / right-handed). DirectX-convention normal maps need Y flipped.

  3. Not re-orthogonalizing — After different transforms (adjugate for N, direct for T), the basis vectors may not be perpendicular. Gram-Schmidt fixes this.

  4. Normalizing in VS only — After rasterizer interpolation, the TBN vectors are no longer unit length. Always re-normalize in the fragment shader.

  5. mul order in HLSLmul(map_normal, TBN) with TBN rows = T, B, N gives the correct tangent-to-world transformation.

glTF parser extension

The forge-gpu glTF parser stores tangents in ForgeGltfPrimitive.tangents (a separate vec4 array) and normal map paths in ForgeGltfMaterial.normal_map_path.

Reference

  • Lesson 17 source
  • Eric Lengyel, "Foundations of Game Engine Development, Volume 2: Rendering"
  • glTF 2.0 specification: tangent vectors and normal textures
Install via CLI
npx skills add https://github.com/Nebulavenus/forge-gpu --skill forge-normal-maps
Repository Details
star Stars 25
call_split Forks 5
navigation Branch main
article Path SKILL.md
More from Creator