Tutorial 58: Normal Mapping

CNA Tutorials  ·  Advanced Shaders

What you’ll learn

  • What a normal map stores and why it is mostly blue.
  • Tangent space versus world space, and building the TBN matrix.
  • Applying the perturbed normal in the fragment shader.

Before you startTutorial 52: Writing Custom Shaders (ShaderEffect) (this is written as a custom shader) and Tutorial 51: Custom Vertex Types (the tangent has to come from your vertex layout). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the thirteen 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls.

CNA does not have a built-in NormalMapEffect. Normal mapping requires a custom GLSL shader compiled through ShaderEffect, whose constructor takes the vertex and fragment source text, with uniforms set by name via SetUniformXxx(). This tutorial needs a renderer that can execute a custom ShaderEffectOPENGLES3 and VULKAN both can. Note that BGFX accepts one and silently ignores it, and FNA3D cannot compile one at all.

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) * tangentHandedness where tangentHandedness is ±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 author the two GLSL shaders yourself and hand their source to ShaderEffect. There is no shader descriptor listing techniques, passes, or typed parameters — the constructor takes three arguments and the two strings are the shader source code itself, not file paths:

// Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp
CNAEXT ShaderEffect(GraphicsDevice& device,
                   const std::string& vertSrc,
                   const std::string& fragSrc);

The uniforms below are therefore not declared anywhere ahead of time. They are the ones the GLSL itself declares, and you push values into them by name with SetUniformMat4, SetUniformVec3, SetUniformFloat, and SetUniformInt after calling Apply(). Keeping the source in plain .glsl files and reading them with System::IO::File::ReadAllText, as the C++ section below does, is the simplest arrangement.

If you would rather have ContentManager do the reading, CNA does have a real .cnj Effect descriptor. It carries exactly two shader fields, each naming a file relative to the descriptor — no parameter list:

{
  "cnjVersion": 1,
  "type": "Effect",
  "vertex": "normal_map.vert.glsl",
  "fragment": "normal_map.frag.glsl"
}

Load that as a std::shared_ptr<Effect> and downcast to ShaderEffect*. See Tutorial 52 for the full treatment.

Complete GLSL Shaders

Vertex Shader (normal_map.vert.glsl)

#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;
// Declared mat4, not mat3: ShaderEffect has SetUniformMat4 but no mat3 setter,
// so the normal matrix is uploaded as a 4x4 and truncated here.
uniform mat4 u_normalMatrix;   // transpose(inverse(u_world))

out vec3 v_worldPos;
out vec2 v_texcoord;
out mat3 v_TBN;

void main() {
    mat3 normalMat = mat3(u_normalMatrix);

    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(normalMat * a_normal);
    vec3 T = normalize(normalMat * 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.glsl)

#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: Building and Binding the Normal Map Shader

This tutorial deliberately uses ShaderEffect. Alpha.1 can load XNA/FNA D3D9 Effect Framework bytecode on FNA3D and explicitly enabled EasyGL-family, SDL_GPU or Vulkan builds, but that renderer-qualified format is not the GLSL source shown here. ShaderEffect takes vertex and fragment shader source and sets uniforms with SetUniformXxx(). See Tutorial 52 and Tutorial 128.

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

class NormalMapGame final : public Game {
    std::unique_ptr<ShaderEffect> nmEffect_;
    Texture2D     diffuseTex_;
    Texture2D     normalMapTex_;
    std::unique_ptr<VertexBuffer> meshVB_;
    std::unique_ptr<IndexBuffer>  meshIB_;
    int   indexCount_  = 0;
    int   vertexCount_ = 0;
    Camera camera_;
    Vector3 lightDir_ = Vector3::Normalize(Vector3(1.0f, -2.0f, -1.0f));

    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();

        diffuseTex_   = getContentProperty().Load<Texture2D>("textures/stone_albedo");
        normalMapTex_ = getContentProperty().Load<Texture2D>("textures/stone_normal");

        // Three arguments: device, vertex source, fragment source. Not file paths.
        nmEffect_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/shaders/normal_map.vert.glsl"),
            System::IO::File::ReadAllText("Content/shaders/normal_map.frag.glsl"));

        // The constructor does not throw on a compile failure.
        if (!nmEffect_->IsEffectValid()) {
            // The GLSL did not compile. Do not draw with it.
        }

        // Apply() binds the program; the uniform setters below write into it.
        nmEffect_->Apply();
        nmEffect_->SetUniformInt("u_diffuse",   0);   // sampler unit assignments
        nmEffect_->SetUniformInt("u_normalMap", 1);
        nmEffect_->SetUniformVec3("u_lightColor",    1.0f, 0.95f, 0.85f);
        nmEffect_->SetUniformVec3("u_specularColor", 0.6f, 0.6f,  0.6f);
        nmEffect_->SetUniformFloat("u_shininess", 64.0f);

        LoadMesh();
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(30, 30, 40, 255));

        Matrix world        = Matrix::getIdentityProperty();
        Matrix normalMatrix = Matrix::Transpose(Matrix::Invert(world));

        float worldCM[16], viewCM[16], projCM[16], normalCM[16];
        world.ToColumnMajor(worldCM);
        camera_.View().ToColumnMajor(viewCM);
        camera_.Projection().ToColumnMajor(projCM);
        normalMatrix.ToColumnMajor(normalCM);

        const Vector3 toLight = -lightDir_;      // negate: points to light
        const Vector3 eye     = camera_.Position();

        // Bind the program first, then push this effect's uniforms and textures.
        nmEffect_->Apply();
        nmEffect_->SetUniformMat4("u_world",        worldCM);
        nmEffect_->SetUniformMat4("u_view",         viewCM);
        nmEffect_->SetUniformMat4("u_projection",   projCM);
        nmEffect_->SetUniformMat4("u_normalMatrix", normalCM);
        nmEffect_->SetUniformVec3("u_lightDir",  toLight.X, toLight.Y, toLight.Z);
        nmEffect_->SetUniformVec3("u_cameraPos", eye.X, eye.Y, eye.Z);
        nmEffect_->SetTexture(0, diffuseTex_);
        nmEffect_->SetTexture(1, normalMapTex_);

        gd.SetVertexBuffer(meshVB_.get());
        gd.setIndicesProperty(meshIB_.get());
        gd.DrawIndexedPrimitives(
            PrimitiveType::TriangleList,
            0,               // baseVertex
            0,               // minVertexIndex
            vertexCount_,    // numVertices
            0,               // startIndex
            indexCount_ / 3);
        gd.Present();
    }
};

Note that u_world/u_view/u_projection are pushed by hand here because of their names. Name them World, View and Projection instead and you can set them through ShaderEffect's IEffectMatrices properties — setWorldProperty() and friends — which CNA forwards to the renderer automatically.

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. CNA’s model loading generates tangents during 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.