Bump, Normal and Displacement Mapping — Surface Detail Without Geometry
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
- Texture Mapping (previous)
- Lighting Models (previous)
- Shader Programming (previous)
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
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