Three.js Normal Maps: Simulate Surface Detail in 3D

This article explains what a normal map is and how it simulates complex surface details in 3D graphics without adding extra geometry. You will learn how normal maps manipulate light calculations and discover the step-by-step process of implementing them on a material in Three.js to create highly realistic web-based 3D scenes.

What is a Normal Map?

A normal map is a specialized 2D texture used in 3D modeling to simulate fine surface details like bumps, scratches, and crevices. Instead of storing color data, a normal map stores directional data. Each pixel’s Red, Green, and Blue (RGB) values represent the X, Y, and Z coordinates of a “normal vector”—the direction perpendicular to the surface at that point. Because the vector pointing straight out from the surface (Z-axis) maps to the blue channel, normal maps typically have a distinctive light-blue or purple appearance.

How It Simulates Detail

In 3D rendering, the way light reflects off a surface depends heavily on the angle of the surface normals. Normally, a flat polygon has a single, uniform normal vector, meaning light reflects off it uniformly, making it look completely flat.

When you apply a normal map, the rendering engine reads the vector data from the texture for every individual pixel. It uses these vectors instead of the actual geometry’s normals to calculate how light bounces off the surface. As light moves across the object, the changing reflection angles create highlights and shadows that trick the human eye into perceiving depth and texture, even though the underlying 3D mesh remains flat. This drastically improves performance by saving computational power that would otherwise be spent rendering millions of polygons.

Applying a Normal Map in Three.js

Implementing a normal map in Three.js is a straightforward process. You need a compatible material, such as MeshStandardMaterial or MeshPhongMaterial, and a texture loader to import your normal map image.

Here is the step-by-step code implementation:

import * as THREE from 'three';

// 1. Initialize the TextureLoader
const textureLoader = new THREE.TextureLoader();

// 2. Load the normal map texture
const normalMapTexture = textureLoader.load('path/to/normal-map.png');

// 3. Create a material and assign the normal map
const material = new THREE.MeshStandardMaterial({
    color: 0x808080,
    roughness: 0.5,
    metalness: 0.2,
    normalMap: normalMapTexture
});

// 4. Adjust the depth of the effect (Optional)
// Default scale is (1, 1). Higher values increase perceived depth.
material.normalScale.set(1.5, 1.5); 

// 5. Apply the material to a mesh
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);

Key Considerations for Three.js

To make normal maps work effectively in Three.js, ensure you have active light sources in your scene, such as a DirectionalLight or PointLight. Because normal mapping relies entirely on light calculations to simulate depth, the effect will be invisible in ambient-only lighting or when using unlit materials like MeshBasicMaterial. Finally, you can use the normalScale property, which takes a THREE.Vector2, to scale the intensity of the bumpiness along the X and Y axes to fine-tune your visual results.