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.
  • Two ways to get it in CNA: PbrEffect's built-in normal map, or your own ShaderEffect.

Before you start — Tutorial 52: Writing Custom Shaders (ShaderEffect) (the custom-shader route is written as a ShaderEffect) 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 2D-only renderer (SDL_RENDERER) throws on 3D calls.

⚠

Three ways to normal-map in CNA. None of the XNA stock effects has a normal map, and CNA has no NormalMapEffect. You have three options: (1) PbrEffect / SkinnedPbrEffect (CNAEXT, always compiled) sample a tangent-space normal map through setNormalMapProperty() and setNormalScaleEXTProperty() — no shader to write, on DIRECTX9, DIRECTX11, the three EasyGL identities, METAL, SDL_GPU, VULKAN and WEBGPU (FNA3D refuses PBR draws); (2) a custom ShaderEffect whose constructor takes the vertex and fragment source text, with uniforms set by name via SetUniformXxx(), which is what most of this tutorial teaches; (3) a compiled XNA/FNA effect (Effect(device, bytes)) that already normal-maps, on the 11 renderer identities that support compiled effects. The custom-shader route needs a renderer that can execute a source ShaderEffect: the GLSL below runs on OPENGLES3/WEBGL2 as written; VULKAN and SDL_GPU need it pre-compiled to SPIR-V (a ShaderEffect there takes SPIR-V words, not GLSL text), WEBGPU needs WGSL and the Direct3D renderers HLSL. SOFTWARE and HEADLESS accept a ShaderEffect and silently ignore it, and FNA3D cannot run one at all. See Tutorial 52.

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 plain RGBA8 PNG, which every renderer can load. CNA has no BC5/ATI2 SurfaceFormat (the DDS reader keeps Color, Dxt1, Dxt3 and Dxt5, and SurfaceFormat adds Bc7EXT), so the two-channel BC5 trick is not loadable content. If you compress a normal map with DXT5 to save memory, remember that only renderers that classify DXT formats (DIRECTX11, VULKAN, SDL_GPU, WEBGPU with BC support, METAL, SOFTWARE, EasyGL, and FNA3D where its driver stores them) accept it; DIRECTX9 takes Color only. A two-channel encoding (RG only, with the third component reconstructed) works with any format as long as your shader reconstructs it:

// Two-channel normal map (RG only, reconstruct B) -- only if you author it that way
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 w component of the tangent 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.xyz);
// Re-orthogonalise T against N (Gram-Schmidt)
T = normalize(T - dot(T, N) * N);
vec3 B = cross(N, T) * a_tangent.w;  // a_tangent.w 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);

Option A: PbrEffect with a normal map (no shader to write)

If you do not need a bespoke look, PbrEffect does the whole job: assign the albedo with setTextureProperty() and the tangent-space normal map with setNormalMapProperty(), scale the effect of the map with setNormalScaleEXTProperty(), and give it a mesh in CNA's tangent layout. That layout is the shipped CNAEXT vertex type VertexPositionNormalTangentTexture: Position at offset 0, Normal at 12, a Vector4 Tangent at 24 (xyz plus the handedness in w) and TextureCoordinate at 40, stride 48 — exactly the layout the glTF importer produces.

#include "Microsoft/Xna/Framework/Graphics/PbrEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionNormalTangentTexture.hpp"

auto& gd = getGraphicsDeviceProperty();
// albedoTex_ and normalTex_ are members: the effect keeps pointers to them.
albedoTex_ = getContentProperty().Load<Texture2D>("textures/stone_albedo");
normalTex_ = getContentProperty().Load<Texture2D>("textures/stone_normal");

pbr_ = std::make_unique<PbrEffect>(gd);
pbr_->setTextureProperty(&albedoTex_);         // base colour map
pbr_->setNormalMapProperty(&normalTex_);       // tangent-space normal map
pbr_->setNormalScaleEXTProperty(1.0f);         // 1 = as authored
pbr_->setMetallicFactorProperty(0.0f);
pbr_->setRoughnessFactorProperty(0.8f);
pbr_->EnableDefaultLighting();

std::vector<VertexPositionNormalTangentTexture> verts;   // Position, Normal, Tangent (Vector4), UV
// ... fill verts ...
VertexBuffer vb(gd, VertexPositionNormalTangentTexture::getVertexDeclarationStatic(),
                static_cast<int>(verts.size()), BufferUsage::WriteOnly);
vb.SetData(verts.data(), static_cast<int>(verts.size()));

Then draw as with any effect: set World/View/Projection, Apply() a pass, and issue the draw. PbrEffect is CNAEXT (not XNA), and on Vulkan its vertex stride must be 48 or 60 bytes, which the shipped tangent type satisfies. The rest of this tutorial shows how the same shading works when you write it yourself.

Custom Shader Approach in CNA

If you want full control — a different lighting model, a custom material — 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 content root (not to the descriptor's own folder) — 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;

// Locations follow the element order of VertexPositionNormalTangentTexture
// (Position, Normal, Tangent, TextureCoordinate): a ShaderEffect on EasyGL
// sees each element at location = its index in the declaration.
layout(location = 0) in vec3  a_position;
layout(location = 1) in vec3  a_normal;
layout(location = 2) in vec4  a_tangent;    // xyz = tangent, w = handedness (+1 or -1)
layout(location = 3) in vec2  a_texcoord;

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.xyz);
    // Gram-Schmidt re-orthogonalisation
    T      = normalize(T - dot(T, N) * N);
    vec3 B = cross(N, T) * a_tangent.w;

    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 section deliberately uses ShaderEffect. CNA can also load XNA/FNA D3D9 Effect Framework bytecode on FNA3D and on 10 more identities behind default-off build options, 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 <cstdio>
#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;
    Matrix  view_, projection_;   // your own camera matrices (Tutorial 34)
    Vector3 eye_;                 // and the camera position
    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 (or this renderer does not run source shaders).
            std::fprintf(stderr, "normal_map shader: %s\n", nmEffect_->GetCompileErrorEXT().c_str());
        }

        // 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);
        view_.ToColumnMajor(viewCM);
        projection_.ToColumnMajor(projCM);
        normalMatrix.ToColumnMajor(normalCM);

        const Vector3 toLight = -lightDir_;      // negate: points to light
        const Vector3 eye     = eye_;

        // 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);
        // No gd.Present(): Game presents in EndDraw().
    }
};

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 a per-vertex tangent (a_tangent, with the handedness in w) in addition to position, normal, and UV — i.e. the VertexPositionNormalTangentTexture layout above, or your own declaration (Tutorial 51). glTF import computes a tangent when the file has none (a Lengyel-style per-vertex tangent for PbrEffect's normal mapping, not an exact MikkTSpace bake), reads legacy tangent/binormal attributes, and drops the tangent with a diagnostic if the chosen layout has no tangent slot. 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.