Tutorial 58: Normal Mapping
CNA does not have a built-in NormalMapEffect. Normal mapping requires a custom GLSL shader loaded via a .shader.json descriptor and bound through Effect + EffectParameter. This tutorial requires the EASYGL or VULKAN backend.
Normal mapping is a per-pixel shading technique that replaces the interpolated geometric normal at each fragment with a normal sampled from a texture. Because the normal governs how the surface responds to lighting, a flat quad can appear to have bumps, grooves, and rivets without any additional geometry. The key to its visual richness is that it works at the full resolution of the screen — every pixel computes its own lighting response — while the geometry cost stays constant.
What is Normal Mapping?
A standard lighting calculation at a fragment uses the surface normal vector — perpendicular to the surface — to determine how strongly the light hits that point. A perfectly smooth surface has a constant normal across its face, producing flat, featureless shading. Normal mapping breaks this uniformity: a normal map texture stores a different normal direction for each texel, encoding high-frequency surface detail (the bumpiness of brick, the scratches in metal, the stitches in leather) without the polygon count that would otherwise be required to represent those details geometrically.
Visually, normal mapping does not change the silhouette of an object — it is purely a per-pixel lighting trick. From directly in front, a normal-mapped brick wall looks three-dimensional. From a shallow grazing angle, it still appears perfectly flat. This is the fundamental limitation of the technique: for parallax and silhouette effects you need parallax occlusion mapping or tessellation-based displacement mapping. Within its scope, however, normal mapping delivers an exceptional quality-to-performance ratio and is standard practice in every modern game engine.
Normal Map Texture Format
A tangent-space normal map encodes the surface normal as an RGB colour. The mapping is:
- R channel = X component of the normal (tangent direction)
- G channel = Y component of the normal (bitangent direction)
- B channel = Z component of the normal (surface-outward direction)
Since RGB values are in [0, 1] and normal components span [-1, 1], the values are remapped: normal = rgb * 2.0 - 1.0. A perfectly flat surface (normal pointing straight outward) encodes as R=0.5, G=0.5, B=1.0, which appears as the characteristic "flat normal" blue-violet colour of normal maps in image editors. Deviations from this encode tilted normals.
Normal maps are typically authored in a tool like Substance Painter, Marmoset Toolbag, or generated from a high-poly mesh bake in Blender. Export them as PNG or DDS (BC5 / ATI2 compressed format, which stores only the RG channels and reconstructs B at runtime as B = sqrt(1 - R*R - G*G), halving texture memory). The DDS/BC5 approach is recommended for shipping but requires a shader modification to reconstruct B:
// BC5/ATI2 normal map (RG only, reconstruct B)
vec2 rg = texture(u_normalMap, v_texcoord).rg * 2.0 - 1.0;
vec3 tsNormal = vec3(rg, sqrt(max(0.0, 1.0 - dot(rg, rg))));
Tangent Space vs World Space
Normal maps come in two flavours:
- Tangent-space normal maps (by far the most common) — the encoded normals are relative to the surface's local coordinate frame. They are UV-dependent: the X direction aligns with the texture U axis and the Y direction aligns with the V axis. Because the map is relative to the surface, the same normal map can tile across repeated surfaces regardless of their world-space orientation. This is the format produced by Blender, Substance, and virtually all DCC tools by default.
- World-space (or object-space) normal maps — normals are encoded in world/object space. They look distinctly colourful (each face displays a different colour corresponding to its world-space facing direction). They are simpler to shade (no TBN matrix needed) but cannot be reused across surfaces that face different directions, and they break when the object rotates.
This tutorial uses tangent-space maps, which require a TBN matrix in the shader to transform the sampled normal back into world space for lighting.
Computing the TBN Matrix
The TBN (Tangent, Bitangent, Normal) matrix is a 3×3 orthonormal basis that maps from tangent space to world space. Each column is a world-space axis:
- T (tangent) — points in the direction of increasing U in texture space, lying in the surface plane.
- B (bitangent) — points in the direction of increasing V, also in the surface plane. Computed as
B = cross(N, T) * tangentHandednesswheretangentHandednessis ±1 stored in the vertex data to preserve UV mirroring. - N (normal) — the geometric surface normal.
All three vectors must be in the same coordinate space (world space is recommended for multi-light setups). T and N are read from vertex attributes and transformed by the normal matrix (the transpose inverse of the world matrix) to get world-space versions. B is reconstructed from T and N in the vertex shader:
vec3 N = normalize(u_normalMatrix * a_normal);
vec3 T = normalize(u_normalMatrix * a_tangent);
// Re-orthogonalise T against N (Gram-Schmidt)
T = normalize(T - dot(T, N) * N);
vec3 B = cross(N, T) * a_tangentW; // a_tangentW is handedness (+1 or -1)
mat3 TBN = mat3(T, B, N);
The Gram-Schmidt step (T = normalize(T - dot(T,N)*N)) corrects for any non-orthogonality introduced by the normal matrix transformation, ensuring T is perpendicular to N.
Applying in the Fragment Shader
In the fragment shader, the TBN matrix (passed as a varying from the vertex shader) is used to transform the decoded tangent-space normal into world space, where it can be used in any standard lighting equation:
// Decode tangent-space normal from texture
vec3 rawNormal = texture(u_normalMap, v_texcoord).rgb;
vec3 tsNormal = rawNormal * 2.0 - 1.0; // remap [0,1] -> [-1,1]
// Transform to world space using the TBN matrix
vec3 N = normalize(v_TBN * tsNormal);
// Use N in standard diffuse + specular lighting
vec3 L = normalize(-u_lightDir); // world-space light direction
float diff = max(dot(N, L), 0.0);
vec3 V = normalize(u_cameraPos - v_worldPos);
vec3 H = normalize(L + V); // half-vector (Blinn-Phong)
float spec = pow(max(dot(N, H), 0.0), u_shininess);
vec4 albedo = texture(u_diffuse, v_texcoord);
vec3 color = albedo.rgb * u_lightColor * diff
+ u_specularColor * spec;
fragColor = vec4(color, albedo.a);
Custom Shader Approach in CNA
Since CNA does not include a built-in NormalMapEffect, you must author a custom shader and a .shader.json descriptor. A minimal descriptor for a normal-mapped surface looks like this:
// shaders/normal_map.shader.json
{
"technique": "NormalMap",
"passes": [
{
"name": "P0",
"vertex": "shaders/normal_map.vert",
"fragment": "shaders/normal_map.frag"
}
],
"parameters": [
{ "name": "u_world", "type": "Matrix" },
{ "name": "u_view", "type": "Matrix" },
{ "name": "u_projection", "type": "Matrix" },
{ "name": "u_normalMatrix", "type": "Matrix3" },
{ "name": "u_diffuse", "type": "Texture2D" },
{ "name": "u_normalMap", "type": "Texture2D" },
{ "name": "u_lightDir", "type": "Vector3" },
{ "name": "u_lightColor", "type": "Vector3" },
{ "name": "u_specularColor","type": "Vector3" },
{ "name": "u_shininess", "type": "Single" },
{ "name": "u_cameraPos", "type": "Vector3" }
]
}
Complete GLSL Shaders
Vertex Shader (normal_map.vert)
#version 300 es
precision highp float;
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texcoord;
layout(location = 3) in vec3 a_tangent;
layout(location = 4) in float a_tangentW; // handedness: +1 or -1
uniform mat4 u_world;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat3 u_normalMatrix; // transpose(inverse(mat3(u_world)))
out vec3 v_worldPos;
out vec2 v_texcoord;
out mat3 v_TBN;
void main() {
vec4 worldPos = u_world * vec4(a_position, 1.0);
v_worldPos = worldPos.xyz;
v_texcoord = a_texcoord;
// Build TBN in world space
vec3 N = normalize(u_normalMatrix * a_normal);
vec3 T = normalize(u_normalMatrix * a_tangent);
// Gram-Schmidt re-orthogonalisation
T = normalize(T - dot(T, N) * N);
vec3 B = cross(N, T) * a_tangentW;
v_TBN = mat3(T, B, N);
gl_Position = u_projection * u_view * worldPos;
}
Fragment Shader (normal_map.frag)
#version 300 es
precision highp float;
in vec3 v_worldPos;
in vec2 v_texcoord;
in mat3 v_TBN;
uniform sampler2D u_diffuse;
uniform sampler2D u_normalMap;
uniform vec3 u_lightDir; // world-space, normalised, points TO light source
uniform vec3 u_lightColor;
uniform vec3 u_specularColor;
uniform float u_shininess;
uniform vec3 u_cameraPos;
out vec4 fragColor;
void main() {
// Decode tangent-space normal
vec3 rawNormal = texture(u_normalMap, v_texcoord).rgb;
vec3 tsNormal = rawNormal * 2.0 - 1.0;
vec3 N = normalize(v_TBN * tsNormal);
// Lighting vectors
vec3 L = normalize(u_lightDir); // towards light
vec3 V = normalize(u_cameraPos - v_worldPos);
vec3 H = normalize(L + V); // Blinn-Phong half-vector
float diff = max(dot(N, L), 0.0);
float spec = pow(max(dot(N, H), 0.0), u_shininess);
vec4 albedo = texture(u_diffuse, v_texcoord);
vec3 color = albedo.rgb * u_lightColor * diff
+ u_specularColor * spec;
fragColor = vec4(color, albedo.a);
}
C++ Setup: Loading and Binding the Normal Map Shader
class NormalMapGame final : public Game {
std::unique_ptr<Effect> nmEffect_;
std::unique_ptr<Texture2D> diffuseTex_;
std::unique_ptr<Texture2D> normalMapTex_;
std::unique_ptr<VertexBuffer> meshVB_;
std::unique_ptr<IndexBuffer> meshIB_;
int indexCount_ = 0;
Camera camera_;
Vector3 lightDir_ = Vector3::Normalize(Vector3(1.0f, -2.0f, -1.0f));
void LoadContent() override {
diffuseTex_ = Content.Load<Texture2D>("textures/stone_albedo");
normalMapTex_ = Content.Load<Texture2D>("textures/stone_normal");
nmEffect_ = Content.Load<Effect>("shaders/normal_map");
// Bind static parameters once
nmEffect_->Parameters["u_diffuse" ].SetValue(diffuseTex_.get());
nmEffect_->Parameters["u_normalMap" ].SetValue(normalMapTex_.get());
nmEffect_->Parameters["u_lightColor" ].SetValue(Vector3(1.0f, 0.95f, 0.85f));
nmEffect_->Parameters["u_specularColor"].SetValue(Vector3(0.6f, 0.6f, 0.6f));
nmEffect_->Parameters["u_shininess" ].SetValue(64.0f);
LoadMesh();
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color(30, 30, 40, 255));
Matrix world = Matrix::Identity;
Matrix3 normalMatrix = Matrix3::Transpose(Matrix3::Invert(Matrix3(world)));
nmEffect_->Parameters["u_world" ].SetValue(world);
nmEffect_->Parameters["u_view" ].SetValue(camera_.View());
nmEffect_->Parameters["u_projection" ].SetValue(camera_.Projection());
nmEffect_->Parameters["u_normalMatrix"].SetValue(normalMatrix);
nmEffect_->Parameters["u_lightDir" ].SetValue(-lightDir_); // negate: points to light
nmEffect_->Parameters["u_cameraPos" ].SetValue(camera_.Position());
gd.setVertexBuffer(*meshVB_);
gd.setIndexBuffer(*meshIB_);
for (auto& pass : nmEffect_->getCurrentTechnique().Passes) {
pass.Apply();
gd.DrawIndexedPrimitives(
PrimitiveType::TriangleList, 0, 0, indexCount_ / 3);
}
gd.Present();
}
};
Tangent data in vertex buffers: The vertex buffer must contain per-vertex tangent vectors (a_tangent and a_tangentW) in addition to position, normal, and UV. The content pipeline automatically generates tangents during model import when the mesh has UV coordinates. If you build geometry procedurally, use a tangent-generation algorithm (Mikkelsen's MikkTSpace is the industry standard, implemented by Blender and Substance Painter) before uploading vertex data.