Textures are the data-rich half of a 3D material — colour, roughness, normal detail, AO, emission, displacement. Done right they make a low-poly model look fully detailed; done wrong they look washed-out, banded, or blurry. This post covers loading textures, the colour-space rules every modern Three.js project must follow, mipmaps + anisotropy, the full PBR map set, environment maps and image-based lighting (IBL), and KTX2 compressed textures for shipping mobile-friendly builds.

Series map: 00 Foundations → 01 Geometry & Materials → 02 Cameras & Controls → 03 Lighting & Shadows → 04 Textures & PBR (this post) → 05 Asset Pipeline → 06 Animation → 07 Instancing & Batching → 08 Low-End Perf → 09 Shaders → 10 Post-Processing → 11 Physics → 12 R3F → 13 AI 3D.


Learning Outcome

  • Load textures with TextureLoader (and async / Suspense in R3F).
  • Apply the correct colour space (sRGB for colour, Linear for data maps).
  • Configure wrap, repeat, offset, anisotropy, mipmaps.
  • Use the full PBR map set on MeshStandardMaterial / MeshPhysicalMaterial.
  • Add environment maps (HDR / EXR) for image-based lighting.
  • Ship KTX2 compressed textures with basis-universal for 10× GPU memory savings.

Chapter 1 — Loading a texture

1
2
3
4
5
6
7
import * as THREE from 'three';

const loader = new THREE.TextureLoader();
const tex = loader.load('/textures/wood/diffuse.jpg');

const mat = new THREE.MeshStandardMaterial({ map: tex });

Loading is asynchronous, but the texture object is returned synchronously — the renderer simply skips it until pixels arrive. To know when ready:

1
2
loader.loadAsync('/textures/wood/diffuse.jpg').then((tex) => { /* ... */ });

In R3F (Post 12), useLoader + Suspense wraps this pattern.


Chapter 2 — Colour space (the bug that ruins your scene)

PBR shading does its math in linear light space. Image files (JPG, PNG) are stored in sRGB (gamma-encoded for storage efficiency, perception-uniform on monitors).

If you load an sRGB image without telling Three.js, the shader treats the gamma-encoded values as linear → washed-out, oversaturated, wrong-coloured result.

The rule (since r152, default)

1
2
3
4
5
6
7
8
9
const colorMap = loader.load('/diffuse.jpg');
colorMap.colorSpace = THREE.SRGBColorSpace; // colour textures

const normalMap = loader.load('/normal.png');
normalMap.colorSpace = THREE.NoColorSpace; // data textures (default)

const roughnessMap = loader.load('/rough.png');
roughnessMap.colorSpace = THREE.NoColorSpace;

GLTFLoader sets colour spaces correctly for you. Hand-loaded TextureLoader does not — you must set them.

Renderer side:

1
2
renderer.outputColorSpace = THREE.SRGBColorSpace;    // default since r152

This converts the linear render back to sRGB for the monitor.

Colour textures are sRGB and data textures are linear; PBR shading runs in linear light and the output converts back to sRGB

Trick: if your scene looks washed-out (light grey instead of vibrant), the first suspect is colour space. Set colorSpace = SRGBColorSpace on every colour texture and the output back to sRGB. The fix is one line per file.


Chapter 3 — Wrap, repeat, offset

1
2
3
4
5
6
7
tex.wrapS = THREE.RepeatWrapping;       // U-axis
tex.wrapT = THREE.RepeatWrapping; // V-axis
tex.repeat.set(4, 4); // tile 4× in each direction
tex.offset.set(0.25, 0); // shift
tex.rotation = Math.PI / 4; // 45° rotation around (0.5, 0.5)
tex.center.set(0.5, 0.5); // rotation centre

Wrap modes:

Mode Effect outside 0-1
RepeatWrapping tile
ClampToEdgeWrapping clamp to last pixel (default)
MirroredRepeatWrapping tile with mirror reflection

For repeating textures across large surfaces (floor, terrain, brick walls), RepeatWrapping + repeat.set(N, N) is the standard pattern.

After changing repeat / offset / rotation on a texture already used, you may need tex.needsUpdate = true (rarely; usually automatic).


Chapter 4 — Mipmaps + anisotropy

A mipmap is a chain of pre-downscaled copies of the texture (1024 → 512 → 256 → 128 → ... → 1). The GPU samples the right level for the distance, avoiding flicker and improving cache use.

Mipmaps generate automatically as long as texture dimensions are powers of two (256, 512, 1024, 2048...) and the wrap mode is RepeatWrapping. Otherwise:

1
2
3
tex.minFilter = THREE.LinearFilter;     // no mipmaps
tex.generateMipmaps = false;

Anisotropic filtering

Default mipmapping looks blurry on surfaces seen at glancing angles (floors viewed from afar). Anisotropic filtering samples more along the view direction:

1
2
3
const max = renderer.capabilities.getMaxAnisotropy();   // typically 16
tex.anisotropy = Math.min(8, max);

Set anisotropy to 4-8 for ground textures, 1 (default) for most others. Cost is small but not free.

Trick: if your "muddy" texture on a distant floor turns sharp when you crank tex.anisotropy = 16, anisotropy was the problem — not the texture resolution.


Chapter 5 — The PBR map set

MeshStandardMaterial (and Physical) accept these maps:

Map Type sRGB? Purpose
map colour sRGB base albedo / diffuse colour
normalMap RGB linear per-pixel surface bumps
roughnessMap grayscale (G channel) linear how rough each pixel is
metalnessMap grayscale (B channel) linear metal vs dielectric per pixel
aoMap grayscale (R channel) linear ambient occlusion
emissiveMap colour sRGB self-illumination
displacementMap grayscale linear actual vertex displacement
bumpMap grayscale linear older alternative to normalMap
envMap cube / equirect inherits reflection environment
lightMap colour linear (or sRGB) baked diffuse lighting
alphaMap grayscale linear transparency

Packed maps (the modern convention)

Roughness, metalness, and AO are each single-channel. Packing them into one RGB image saves a texture slot:

  • R = AO
  • G = Roughness
  • B = Metalness

This is the glTF 2.0 convention. Both roughnessMap and metalnessMap can point at the same texture; Three.js reads the right channel.

1
2
3
4
5
6
7
const orm = loader.load('/wood/orm.jpg');     // ao + rough + metal
orm.colorSpace = THREE.NoColorSpace;

mat.aoMap = orm;
mat.roughnessMap = orm;
mat.metalnessMap = orm;

Trick: glTF authoring tools (Substance Painter, Blender, Mixer) export ORM packed by default. Save a draw call by packing.

aoMap requires a second UV set

aoMap reads from geometry.attributes.uv2 (second UV channel), not uv. Most authoring tools generate it; if not, set explicitly:

1
2
geometry.setAttribute('uv2', new THREE.BufferAttribute(geometry.attributes.uv.array, 2));

Chapter 6 — Normal maps (the detail multiplier)

A normal map encodes a surface normal per pixel as RGB. The shader fakes high-frequency detail without geometry.

1
2
3
4
5
6
const normal = loader.load('/normal.png');
normal.colorSpace = THREE.NoColorSpace;

mat.normalMap = normal;
mat.normalScale.set(1.0, 1.0); // y can be negated for some authoring tools

Two normal-map conventions

  • OpenGL convention: green channel is +Y up. (Three.js default.)
  • DirectX convention: green channel is +Y down.

If a normal map looks "inverted" (bumps look like dents), flip the green channel: mat.normalScale.set(1, -1).

Most glTF normal maps follow OpenGL. Substance exports either; check the export setting.


Chapter 7 — Environment maps + IBL

A MeshStandardMaterial reflects its surroundings via an environment map (a panoramic image of "what the object sees"). Without one, metals look black and dielectrics look flat.

Loading an HDR / EXR

1
2
3
4
5
6
7
8
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';

new RGBELoader().load('/env/studio.hdr', (tex) => {
tex.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = tex; // applied to every PBR material in scene
scene.background = tex; // optional — also render as the sky
});

scene.environment is the modern way — every PBR material picks it up automatically. No more setting mat.envMap on every material individually.

Pre-filtered environment (PMREM)

Sharp HDRs are aliased when used directly. The PMREM (Pre-filtered Mipmapped Radiance Environment) generator pre-blurs the env map at multiple roughness levels:

1
2
3
4
5
6
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromEquirectangular(hdr).texture;
hdr.dispose();
pmrem.dispose();
scene.environment = envMap;

GLTFLoader handles PMREM automatically when a glTF has an embedded env. For hand-loaded HDRs, do it explicitly.

Free HDR sources

  • polyhaven.com — CC0, hundreds of HDRIs at every resolution.
  • hdri-haven.com — similar.
  • Use 1K / 2K HDRIs for runtime; 8K is overkill for most scenes.

Chapter 8 — KTX2 / Basis Universal (the cost saver)

JPG / PNG textures decompress fully on the GPU. A 1024² PNG = 4 MB of GPU memory regardless of file size on disk. A scene with 50 textures can blow the GPU budget on phones.

KTX2 with Basis Universal stays compressed on the GPU — ~10× smaller in GPU memory. Two flavours:

Mode Use Notes
UASTC hero textures, normal maps higher quality, slightly larger
ETC1S secondary textures, background smaller, lower quality

Generate KTX2

gltf-transform CLI is the workflow:

1
2
3
4
5
npm install -g @gltf-transform/cli
gltf-transform uastc model.glb model.uastc.glb # normal maps, hero
gltf-transform etc1s model.glb model.etc1s.glb # everything else
gltf-transform optimize model.glb model.opt.glb # combined pipeline

Or with toktx directly:

1
2
3
toktx --bcmp out.ktx2 input.png       # ETC1S
toktx --uastc 4 out.ktx2 input.png # UASTC

Loading in Three.js

1
2
3
4
5
6
7
8
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';

const ktx2 = new KTX2Loader()
.setTranscoderPath('https://unpkg.com/three@0.184.0/examples/jsm/libs/basis/')
.detectSupport(renderer);

const tex = await ktx2.loadAsync('/textures/wood/diffuse.ktx2');

GLTFLoader chains in KTX2Loader automatically when a glTF references KTX2 textures:

1
2
gltfLoader.setKTX2Loader(ktx2);

Compression ratios (rule of thumb)

Texture (1024²) PNG on disk GPU memory uncompressed KTX2 ETC1S KTX2 GPU memory
Colour ~1 MB 4 MB ~100 KB ~700 KB
Normal (UASTC) ~2 MB 4 MB ~500 KB ~1 MB

GPU memory savings are the headline. Network savings are secondary (PNG is already compressed; KTX2 ETC1S beats it).


Chapter 9 — Texture atlases

Combining many small textures into one big "atlas" saves texture-binding overhead. With UV remapping per object, you can render many meshes that share the atlas in fewer draw calls.

Authoring tools (Blender → UV Editor, gltf-transform dedup, custom packers like MaxRectsBin) generate atlases. At runtime, every mesh references the same atlas texture with different UV ranges.

Pairs naturally with InstancedMesh (Post 07) and texture arrays (one texture sampler, N layers).


Practice Exercises

Exercise 1 — Wood plank

Download a wood material from polyhaven.com. Apply Color + Normal + Roughness + AO maps to a PlaneGeometry(4, 4). Set colour spaces correctly. Repeat 4× in each direction.

Exercise 2 — Colour-space comparison

Same mesh, two versions side by side:

  1. colorMap.colorSpace = THREE.SRGBColorSpace
  2. colorMap.colorSpace = THREE.NoColorSpace

Note how the second one washes out under PBR lighting.

Exercise 3 — IBL via HDR

Load a 1K HDRI from polyhaven. Set scene.environment. A MeshStandardMaterial chrome sphere should now reflect the environment realistically.

Exercise 4 — KTX2 round-trip

Convert one of your PNG textures to KTX2 with toktx. Load with KTX2Loader. Compare renderer.info.memory.textures before/after.

Exercise 5 — Tile a wall

A large wall with a brick texture set tiling 8×4. Add a normal map and roughness map. Tweak anisotropy for sharp viewing at grazing angles.


Cheat-sheet

What How
Load texture new THREE.TextureLoader().load(url)
Colour space (colour) tex.colorSpace = THREE.SRGBColorSpace
Colour space (data) tex.colorSpace = THREE.NoColorSpace
Tile tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(N, N)
Mipmaps auto if pow-2; otherwise tex.minFilter = LinearFilter
Anisotropy tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy())
PBR maps map / normalMap / roughnessMap / metalnessMap / aoMap
Packed ORM one image, all three maps point to it
aoMap UV needs uv2
Env map (scene-wide) scene.environment = pmremTexture
HDR loader RGBELoader
Pre-filter HDR PMREMGenerator.fromEquirectangular(hdr)
KTX2 loader KTX2Loader + transcoder path + detectSupport(renderer)
Free HDRIs polyhaven.com
Output renderer.outputColorSpace = THREE.SRGBColorSpace (default)

What's next

Post 05 covers the asset pipeline — loading glTF 2.0 models with Draco compression, Meshopt, KTX2 textures, gltf-transform CLI for optimisation, and the scene-graph traversal you need to wire up loaded models.


References

Note: this post is in progress. Planned additions: tri-planar mapping for terrain, virtual texturing intro, displacement vs parallax maps, anisotropy direction maps for fabric/brushed metal.