Skip to content

Bump, Normal and Displacement Mapping — Surface Detail Without Geometry

DodaTech Updated 2026-06-23 8 min read

In this tutorial, you'll learn about Bump, Normal and Displacement Mapping. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Bump and normal mapping are texture-based techniques that simulate surface detail by perturbing surface normals, creating the illusion of bumps, scratches, and crevices without modifying the underlying geometry.

What You'll Learn & Why It Matters

In this tutorial, you will learn three levels of surface detail simulation: bump mapping (grayscale height perturbation), normal mapping (RGB direction perturbation), and displacement mapping (actual vertex movement). You will implement tangent space normal mapping and parallax occlusion mapping.

Real-world use: Game Development assets rely on normal maps to add detail without increasing polygon counts. A 20,000-poly character with normal maps looks as detailed as a 2-million-poly version. DodaZIP uses normal-mapped UI elements for 3D button effects.

Prerequisites

Learning Path

flowchart LR
  A[Texture Mapping] --> B[Bump, Normal & Displacement]
  B --> C[Procedural Textures]
  B --> D[Lighting Models]
  B --> E[PBR Rendering]
  C --> F[BRDF and Materials]
  B:::current

  classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px

Bump Mapping: Height-Based Normals

Bump mapping uses a grayscale height map to perturb normals by computing height derivatives:

import numpy as np
from PIL import Image

def bump_to_normal(height_map, strength=1.0):
    """Convert a grayscale height map to a normal map."""
    h, w = height_map.shape[:2]
    normal_map = np.zeros((h, w, 3), dtype=np.float32)

    for y in range(1, h - 1):
        for x in range(1, w - 1):
            # Sobel gradients
            dx = (height_map[y-1, x+1] + 2*height_map[y, x+1] + height_map[y+1, x+1] -
                  height_map[y-1, x-1] - 2*height_map[y, x-1] - height_map[y+1, x-1])
            dy = (height_map[y+1, x-1] + 2*height_map[y+1, x] + height_map[y+1, x+1] -
                  height_map[y-1, x-1] - 2*height_map[y-1, x] - height_map[y-1, x+1])

            normal = np.array([-dx * strength, -dy * strength, 1.0])
            normal = normal / np.linalg.norm(normal)
            normal_map[y, x] = normal * 0.5 + 0.5

    return (normal_map * 255).astype(np.uint8)

height = np.random.rand(256, 256).astype(np.float32)
normal = bump_to_normal(height, 2.0)
Image.fromarray(normal).save("normal_from_height.png")
print(f"Generated normal map from height field: {normal.shape}")

Tangent Space Normal Mapping

Normal maps are stored in tangent space, which must be transformed to world space using the TBN matrix:

// Vertex shader: compute TBN matrix
#version 460 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;

out vec2 TexCoord;
out vec3 FragPos;
out mat3 TBN;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    TexCoord = aTexCoord;
    FragPos = vec3(model * vec4(aPos, 1.0));

    vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
    vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
    vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
    TBN = mat3(T, B, N);

    gl_Position = projection * view * model * vec4(aPos, 1.0);
}
// Fragment shader: sample normal map and transform to world space
#version 460 core

in vec2 TexCoord;
in vec3 FragPos;
in mat3 TBN;

uniform sampler2D normalMap;
uniform vec3 lightPos;
uniform vec3 viewPos;

out vec4 FragColor;

void main()
{
    // Sample and expand normal from [0,1] to [-1,1]
    vec3 tangentNormal = texture(normalMap, TexCoord).rgb;
    tangentNormal = normalize(tangentNormal * 2.0 - 1.0);

    // Transform to world space
    vec3 worldNormal = normalize(TBN * tangentNormal);

    // Lighting
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(worldNormal, lightDir), 0.0);

    vec3 viewDir = normalize(viewPos - FragPos);
    vec3 halfDir = normalize(lightDir + viewDir);
    float spec = pow(max(dot(worldNormal, halfDir), 0.0), 32.0);

    vec3 color = (0.1 + 0.7 * diff + 0.5 * spec) * vec3(0.8, 0.2, 0.2);
    FragColor = vec4(color, 1.0);
}
flowchart TD
  A[Tangent Space Normal Map
RGB = perturbed normal] -->|Sample texture| B[Tangent Normal] B -->|TBN Matrix multiply| C[World Space Normal] D[Vertex Tangent] -->|Build TBN| C E[Vertex Bitangent] -->|Build TBN| C F[Vertex Normal] -->|Build TBN| C C -->|Lighting calculation| G[Detailed surface]

Computing Tangents for Arbitrary Meshes

#include <glm/glm.hpp>
#include <vector>

struct Vertex {
    glm::vec3 position;
    glm::vec3 normal;
    glm::vec2 uv;
    glm::vec3 tangent;
    glm::vec3 bitangent;
};

void computeTangents(std::vector<Vertex>& vertices, const std::vector<unsigned int>& indices) {
    for (size_t i = 0; i < indices.size(); i += 3) {
        Vertex& v0 = vertices[indices[i + 0]];
        Vertex& v1 = vertices[indices[i + 1]];
        Vertex& v2 = vertices[indices[i + 2]];

        glm::vec3 edge1 = v1.position - v0.position;
        glm::vec3 edge2 = v2.position - v0.position;

        glm::vec2 duv1 = v1.uv - v0.uv;
        glm::vec2 duv2 = v2.uv - v0.uv;

        float f = 1.0f / (duv1.x * duv2.y - duv2.x * duv1.y);

        glm::vec3 tangent;
        tangent.x = f * (duv2.y * edge1.x - duv1.y * edge2.x);
        tangent.y = f * (duv2.y * edge1.y - duv1.y * edge2.y);
        tangent.z = f * (duv2.y * edge1.z - duv1.y * edge2.z);
        tangent = glm::normalize(tangent);

        glm::vec3 bitangent;
        bitangent.x = f * (-duv2.x * edge1.x + duv1.x * edge2.x);
        bitangent.y = f * (-duv2.x * edge1.y + duv1.x * edge2.y);
        bitangent.z = f * (-duv2.x * edge1.z + duv1.x * edge2.z);
        bitangent = glm::normalize(bitangent);

        v0.tangent += tangent;
        v1.tangent += tangent;
        v2.tangent += tangent;
        v0.bitangent += bitangent;
        v1.bitangent += bitangent;
        v2.bitangent += bitangent;
    }

    for (auto& v : vertices) {
        v.tangent = glm::normalize(v.tangent);
        v.bitangent = glm::normalize(v.bitangent);
    }
}

int main() {
    std::vector<Vertex> vertices = {
        {{-1, -1, 0}, {0, 0, 1}, {0, 0}, {}, {}},
        {{ 1, -1, 0}, {0, 0, 1}, {1, 0}, {}, {}},
        {{ 1,  1, 0}, {0, 0, 1}, {1, 1}, {}, {}},
        {{-1,  1, 0}, {0, 0, 1}, {0, 1}, {}, {}}
    };
    std::vector<unsigned int> indices = {0, 1, 2, 2, 3, 0};

    computeTangents(vertices, indices);
    std::cout << "Tangents computed for " << vertices.size() << " vertices" << std::endl;
    std::cout << "v0 tangent: (" << vertices[0].tangent.x << ", "
              << vertices[0].tangent.y << ", " << vertices[0].tangent.z << ")" << std::endl;
    return 0;
}

Parallax Occlusion Mapping

Parallax occlusion mapping (POM) simulates 3D depth by ray-marching through a height map:

vec2 parallaxMapping(sampler2D heightMap, vec2 texCoord, vec3 viewDirTS, float heightScale) {
    const float minLayers = 8.0;
    const float maxLayers = 32.0;
    float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0,0,1), viewDirTS)));

    float layerDepth = 1.0 / numLayers;
    float currentLayerDepth = 0.0;
    vec2 deltaTexCoord = viewDirTS.xy / viewDirTS.z * heightScale / numLayers;

    vec2 currentTex = texCoord;
    float currentHeight = texture(heightMap, currentTex).r;

    while (currentLayerDepth < currentHeight) {
        currentTex -= deltaTexCoord;
        currentHeight = texture(heightMap, currentTex).r;
        currentLayerDepth += layerDepth;
    }

    // Interpolate between last two samples
    vec2 prevTex = currentTex + deltaTexCoord;
    float afterDepth = currentHeight - currentLayerDepth;
    float beforeDepth = texture(heightMap, prevTex).r - currentLayerDepth + layerDepth;
    float weight = afterDepth / (afterDepth - beforeDepth);
    vec2 finalTex = mix(currentTex, prevTex, weight);

    return finalTex;
}

void main() {
    vec3 viewDirTS = normalize(TBN * (viewPos - FragPos));  // tangent-space view
    vec2 displacedUV = parallaxMapping(heightMap, TexCoord, viewDirTS, 0.05);

    vec3 tangentNormal = texture(normalMap, displacedUV).rgb * 2.0 - 1.0;
    vec3 worldNormal = normalize(TBN * tangentNormal);
    // ... standard lighting with displaced UV
}

Common Errors & Mistakes

1. Incorrect TBN Orthogonality

Mistake: The tangent and bitangent are not orthogonal to the normal, causing lighting artifacts that shift with viewing angle.

Fix: Use the Gram-Schmidt Process to re-orthogonalize the TBN matrix in the fragment shader. Average tangents per-vertex and normalize in the shader.

2. Normal Map Colors Look Wrong

Mistake: Using an sRGB texture for normal maps, causing incorrect normals because sRGB conversion distorts the direction values.

Fix: Load normal maps as linear textures (GL_SRGB8 for albedo, GL_RGB8 for normals). Normal maps store direction data, not color information.

3. Self-Shadowing with Parallax Occlusion

Mistake: The parallax effect looks correct at shallow angles but shows texture sliding at steep angles.

Fix: Increase the number of layers for shallow viewing angles (use more samples when viewDir is nearly perpendicular to the surface).

4. Seams at UV Boundaries

Mistake: Visible seams where UV shells meet because tangent computation differs across the boundary.

Fix: Use hardware-based normal map compression (BC5/BC7) that handles boundaries better. Ensure tangents are computed consistently across shared vertices.

5. Height Scale Too Large

Mistake: Setting the height scale too high causes the parallax effect to break, showing gaps between the geometry and the apparent height.

Fix: Keep height scale between 0.02 and 0.1 for most surfaces. Use smaller values for subtle detail, larger values for deep grooves.

Practice Questions

Question 1

What is the difference between bump mapping and normal mapping?

Show answer Bump mapping uses a single-channel height map and computes derivatives to perturb normals. Normal mapping uses a three-channel RGB texture that directly stores perturbed normal directions, providing more detail and requiring fewer calculations at runtime.

Question 2

What is tangent space and why are normal maps stored in it?

Show answer Tangent space is a local coordinate system aligned to each surface's UV orientation (tangent = U, bitangent = V, normal = perpendicular). Normal maps are stored in tangent space so they can be reused on different meshes, rotated, and deformed without recalculating normals.

Question 3

How does parallax occlusion mapping differ from simple normal mapping?

Show answer Normal mapping only perturbs the surface normal, creating a lighting illusion of depth. Parallax occlusion mapping ray-marches through a height map to displace UV coordinates, creating actual parallax where closer parts of bumps move more than distant parts when the view changes.

Challenge

Build a material viewer that supports all three techniques (bump, normal, parallax occlusion) with a toggle. Render a brick wall quad with each technique and compare the visual quality and performance impact using GPU timestamps.

FAQ

Can normal maps replace high-poly geometry entirely?

Normal maps approximate high-poly detail well for smooth surfaces but miss silhouette details. The object outline remains low-poly. Displacement mapping with tessellation is needed to change the actual silhouette.

What is the difference between object-space and tangent-space normal maps?

Object-space normal maps encode directions in the model's coordinate system (unique per model) and cannot tile. Tangent-space normal maps encode directions relative to each surface (portable) and can be tiled and reused across different models.

What format should I use for normal map compression?

BC5 (RGTC) stores two channels (red, green) to reconstruct the normal z-component, providing the best quality for normal maps. BC7 is also suitable but has higher decode cost. Avoid BC1/BC3 for normal maps as they lack precision.


Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Author: DodaTech | Last updated: June 23, 2026

DodaTech tutorials are built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — security tools used by millions worldwide.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro