Advanced Mapping Concepts

Using texture maps to control every aspect of a PBR material

The Anatomy of a PBR Material

In the previous article, we used single values for roughness, metalness, and color. The entire surface had the same properties. Real objects aren't uniform. A rusted iron bar is shiny where the metal is exposed and rough where rust has formed. A wooden floor is smooth where it's polished and matte where it's scuffed.

To create this kind of variation, PBR materials use texture maps. These are 2D images that control a specific property at every point on the surface. Each pixel in the map corresponds to a point on the mesh, and its value overrides the scalar property. Four primary maps form the foundation of any realistic PBR material.

1. Color Map (Albedo / Diffuse)

The color map, also called albedo or diffuse map, defines the base color pattern of the surface. It tells the GPU "this part is brick red, this part is gray mortar." In PBR, the color map should contain only the raw surface color. This means no baked-in shadows, no lighting, no ambient occlusion. The lighting system handles all of that.

The left sphere uses a flat color. The right sphere uses a procedural color map. It's the same geometry, same lighting, but the texture gives it a pattern. The map is applied to the map property.

typescript
// Load a color (albedo) texture
const textureLoader = new THREE.TextureLoader();
const colorMap = textureLoader.load('/textures/brick_color.jpg');

const material = new THREE.MeshStandardMaterial({
  map: colorMap  // Replaces the flat 'color' property
});

// Pro tip: your color map should NEVER contain
// baked shadows. The PBR lighting handles that.
// Keep it as the pure, flat surface color.

2. Roughness Map

The roughness map is a grayscale image that controls how rough or smooth each point on the surface is. White pixels (1.0) mean fully rough, i.e. light scatters in all directions, like chalk or matte paint. Black pixels (0.0) mean perfectly smooth. Light reflects crisply, like a mirror. Gray values are in between.

A "scratched metal" texture uses a roughness map where the scratches are white (rough, light scatters) and the polished metal between scratches is black (smooth, sharp reflections).

The left sphere has uniform roughness. The right sphere uses a roughness map. Notice how some areas reflect sharply while others scatter light diffusely, all on the same object.

typescript
const roughnessMap = textureLoader.load('/textures/brick_roughness.jpg');

const material = new THREE.MeshStandardMaterial({
  color: 0xcccccc,
  roughnessMap: roughnessMap, // Per-pixel roughness
  roughness: 1.0              // Scales the map values
});

// roughness acts as a multiplier:
// final roughness = roughnessMap pixel * roughness value
// So roughness: 0.5 with a white pixel (1.0) = 0.5

3. Metalness Map

The metalness map tells Three.js which parts of the object are metal and which are dielectric (plastic, wood, stone). This is usually a high-contrast image like a gold ring on a wooden table would have white (metal) for the ring and black (non-metal) for the wood. In-between values are rare in physically accurate materials since real surfaces are either metal or not.

Why does this matter? Metals reflect light fundamentally differently. They tint the color of their reflections and have no diffuse component. Getting this map right is critical for realism.

The left sphere is uniformly metallic. The right sphere uses a metalness map, with some regions behave as metal (tinted reflections, no diffuse) while others behave as dielectric (white reflections, colored diffuse).

typescript
const metalnessMap = textureLoader.load('/textures/rust_metalness.jpg');

const material = new THREE.MeshStandardMaterial({
  color: 0xcc8844,
  metalnessMap: metalnessMap, // Per-pixel metalness
  metalness: 1.0              // Scales the map values
});

// White pixels = metal (tinted reflections, no diffuse)
// Black pixels = dielectric (white reflections, colored diffuse)
// For physically accurate results, avoid gray values

4. Normal Map (The Illusion of Detail)

The normal map is the most powerful map in the toolkit. It's the strange-looking purple and blue image that tricks the lighting into thinking the surface has bumps, dents, cracks, and grooves without adding a single extra vertex. Each pixel encodes a direction (the surface normal at that point), and the shader uses it to calculate how light bounces off that pixel.

This lets you make a flat plane look like a rugged stone wall, or a smooth sphere look like a golf ball, all by changing how light interacts with the surface, not the actual geometry.

The left sphere is geometrically smooth with no normal map. The right sphere uses a procedural normal map. The geometry is identical, but the lighting creates the illusion of a textured surface. The normalScale vector controls the intensity of the effect.

typescript
const normalMap = textureLoader.load('/textures/brick_normal.jpg');

const material = new THREE.MeshStandardMaterial({
  map: colorMap,
  normalMap: normalMap,
  normalScale: new THREE.Vector2(1.0, 1.0) // Strength of the effect
});

// normalScale controls intensity:
// (1, 1) = full strength
// (0.5, 0.5) = half strength (subtler bumps)
// (2, 2) = exaggerated bumps
// Negative values invert the bump direction

The Layering Logic

These four maps work together to build a complete material. Each map controls one independent aspect of the surface, and the shader combines them per-pixel to produce the final result. For example, to create a realistic wet cobblestone street:

  • Color Map: The gray stone texture with mortar lines between blocks.
  • Normal Map: Creates the bumps between and across the stones without extra geometry.
  • Roughness Map: Puddles are black (perfectly smooth, mirror-like reflections) and dry stone is white (rough, scattered light).
  • Metalness Map: Entirely black, since stone isn't metal.

The demo above shows a sphere with all four maps applied simultaneously. The color map provides the pattern, the normal map adds surface detail, the roughness map creates variation in shininess, and the metalness map defines which regions are metallic. Together they produce a surface that looks far more complex than the underlying geometry.

typescript
// A complete PBR material with all four maps
const material = new THREE.MeshStandardMaterial({
  map: colorMap,              // Base color pattern
  normalMap: normalMap,        // Surface detail / bumps
  normalScale: new THREE.Vector2(1.0, 1.0),
  roughnessMap: roughnessMap,  // Per-pixel roughness
  roughness: 1.0,
  metalnessMap: metalnessMap,  // Metal vs non-metal regions
  metalness: 1.0
});

// The scalar roughness/metalness values act as multipliers
// for their respective maps. Set them to 1.0 when using
// maps so the map has full control.

Additional Map Types

Beyond the four primary maps, Three.js supports several more that add further realism:

  • aoMap: Ambient Occlusion: darkens crevices and corners where ambient light can't reach. Requires a second UV set (uv2).
  • displacementMap: Actually moves vertices based on a heightmap, creating real geometry changes (unlike normal maps which only fake it). Requires a high-poly mesh to work well.
  • emissiveMap: Controls which parts of the surface glow. Combined with the emissive color.
  • alphaMap: Controls transparency per-pixel. Requires transparent: true on the material.
  • envMap: An environment map for reflections, as covered in the PBR article.

Displacement vs Normal Maps

Normal maps and displacement maps solve the same problem, adding surface detail, but in fundamentally different ways. A normal map fakes the detail by changing how light calculates on a flat surface. A displacement map actually moves the vertices, creating real geometric changes. The trade-off: displacement requires a highly subdivided mesh (many vertices to move), while normal maps work on any geometry.

Left: a smooth sphere with only a normal map. The silhouette is perfectly round, but the surface looks bumpy. Right: the same sphere with a displacement map. The silhouette itself is deformed, and the bumps are real geometry. Displacement is more convincing at edges and in silhouette, but costs more vertices.

typescript
// Normal map only, fake bumps, smooth silhouette
const normalOnly = new THREE.MeshStandardMaterial({
  normalMap: normalMap,
  normalScale: new THREE.Vector2(1.5, 1.5)
});

// Displacement map, real geometry deformation
const displaced = new THREE.MeshStandardMaterial({
  displacementMap: heightMap,
  displacementScale: 0.3,  // How far vertices move
  displacementBias: -0.15, // Offset (center the displacement)
  normalMap: normalMap       // Often used together
});

// Displacement needs a high-poly mesh to look good
const geometry = new THREE.SphereGeometry(1, 128, 128);

UV Mapping and Texture Coordinates

For texture maps to work, every vertex in a mesh needs UV coordinates, a 2D position (u, v) that maps it to a point on the texture image. All built-in Three.js geometries come with UVs pre-computed. For custom BufferGeometry, you need to set the uv attribute yourself.

You can control how textures tile and offset using the texture's repeat, offset, and wrapS/wrapT properties:

The demo above shows the same texture at different tiling scales. Increasing repeat tiles the pattern more densely across the surface.

typescript
// Tile a texture 4x4 across the surface
const texture = textureLoader.load('/textures/pattern.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(4, 4);

// Offset the tiling origin
texture.offset.set(0.25, 0.0);

// For custom BufferGeometry, set UVs manually
const uvs = new Float32Array([
  0, 0,  // vertex 0: bottom-left of texture
  1, 0,  // vertex 1: bottom-right
  1, 1,  // vertex 2: top-right
  0, 1   // vertex 3: top-left
]);
geometry.setAttribute('uv',
  new THREE.BufferAttribute(uvs, 2));
←   Physically Based RenderingTypes of Lighting   →