Three.js From Zero · Article 04
Textures & Color Management
Textures & Color Management
Article 03 gave you PBR materials. The other half of PBR is the maps — color, normal, roughness, metalness, AO. This article covers how to load them, how to place them on geometry with UVs, how to tile and offset them, the anisotropy setting that sharpens oblique angles, and — the big one — the color-space pipeline that 90% of Three.js tutorials silently get wrong.
The demo below uses procedurally-generated textures so nothing has to be downloaded. Flip color space from "sRGB (correct)" to "linear (wrong)" and watch the colors go muddy. That's the bug you've seen in almost every hobby WebGL demo.
UV coordinates: the other vertex attribute
Every vertex in a geometry can carry a uv attribute — a 2D coordinate between
0 and 1 that says where on the texture this vertex samples. The GPU interpolates UV
across the triangle and samples the texture per-pixel.
All Three.js primitives ship with sensible default UVs. The moment you build a custom geometry you'll need to set them yourself:
geom.setAttribute('uv', new THREE.BufferAttribute(
new Float32Array([
0, 0, // vertex 0
1, 0, // vertex 1
0.5, 1, // vertex 2
]), 2
));
Two floats per vertex, stride 2. Same API as any other attribute.
Loading a texture
const loader = new THREE.TextureLoader();
const map = loader.load('/textures/albedo.jpg');
map.colorSpace = THREE.SRGBColorSpace; // ← color maps are sRGB
map.wrapS = map.wrapT = THREE.RepeatWrapping;
map.repeat.set(2, 2);
map.anisotropy = renderer.capabilities.getMaxAnisotropy();
For async / error handling:
loader.load(
url,
(tex) => { tex.colorSpace = THREE.SRGBColorSpace; material.map = tex; material.needsUpdate = true; },
undefined,
(err) => console.error('load failed', err),
);
Or the promise form:
const tex = await loader.loadAsync('/t.jpg');
tex.colorSpace = THREE.SRGBColorSpace;
The color-space rule (memorize this)
This is the single most important thing in the article, and the source of more washed-out screenshots than anything else in Three.js:
Color maps are sRGB. Data maps are not.
Which means:
| Texture slot | colorSpace |
|---|---|
map (albedo / base color) | SRGBColorSpace |
emissiveMap | SRGBColorSpace |
envMap / scene.environment | Handled by PMREM / RGBELoader automatically |
normalMap | NoColorSpace (linear) |
roughnessMap | NoColorSpace |
metalnessMap | NoColorSpace |
aoMap | NoColorSpace |
displacementMap | NoColorSpace |
The reason: sRGB is a perceptual curve designed to store color for display. Data maps — normal, roughness, metalness — store numbers, not colors. Applying the sRGB curve to a number is nonsense, and it produces visibly wrong results: muted metallic sheen, incorrect normal vectors, overblown roughness.
Flip the color space dropdown in the demo to see the difference on the color map. The correct setting (sRGB) gives saturated, accurate colors. The wrong setting (linear) makes everything muted — exactly the "milky" look you've seen in amateur Three.js projects.
The other half: the renderer output
renderer.outputColorSpace = THREE.SRGBColorSpace;
You set this in Article 01 and we haven't touched it since. It tells the renderer to apply the sRGB encoding curve to its final output so the monitor displays it correctly. Without both pieces — textures declared sRGB and renderer output in sRGB — your color pipeline is broken.
UV wrap modes
When repeat.x or repeat.y is greater than 1 (or the UV goes
outside 0..1 for any other reason), the wrap mode decides what happens.
| Constant | Behavior |
|---|---|
RepeatWrapping | Tiles the texture. The usual choice for materials. |
ClampToEdgeWrapping | Stretches the edge pixel. Default. |
MirroredRepeatWrapping | Flips every other tile. Hides seams on non-tileable textures. |
Important: the texture's width and height must both be power-of-two (128,
256, 512, 1024, 2048…) for RepeatWrapping to generate mipmaps and avoid rendering
artifacts. WebGL2 relaxes this somewhat but it's still safer to keep power-of-two sizes.
Anisotropy — the setting that sharpens everything
At grazing angles, a texture gets severely compressed per-pixel on screen — think of a ground plane stretching into the distance. Linear mipmap sampling averages too many texels into too few pixels, giving you a blurry ground that looks like nothing.
Anisotropic filtering asks the GPU to take multiple samples along the anisotropic axis. It costs almost nothing. Set it to max:
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
Most hardware returns 16. Drag the anisotropy slider in the demo down to 1 and tilt to a grazing angle — you'll see the checker pattern dissolve into mush. Back up to 16 and it stays crisp.
Mipmaps
A mipmap is a pre-scaled copy of the texture at every power-of-two smaller size. The GPU picks the closest mipmap for the on-screen size automatically. Without mipmaps, distant textures shimmer (aliasing).
Three.js auto-generates mipmaps for any POT texture. For non-POT or data textures:
texture.generateMipmaps = true; // default
texture.minFilter = THREE.LinearMipmapLinearFilter; // default; trilinear
Flip mipmaps off in the demo and watch the sphere shimmer as it rotates — that's what your texture looks like with pure bilinear sampling.
Environment maps & the PMREM pipeline
Article 03 used RoomEnvironment for reflections. For real HDR environments:
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
const hdr = await new RGBELoader().loadAsync('/env/studio.hdr');
hdr.mapping = THREE.EquirectangularReflectionMapping;
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromEquirectangular(hdr).texture;
scene.environment = envMap; // all PBR materials reflect this
scene.background = envMap; // and you see it as the skybox
hdr.dispose();
pmrem.dispose(); // dispose the processor, not the envMap
Why PMREM? A raw HDR equirect has sharp pixels. For PBR you need pre-filtered
versions at varying roughness levels so rough materials look like rough reflections instead
of a blurry skybox. PMREMGenerator does that filtering on the GPU in milliseconds.
Free HDR sources: Poly Haven, ambientCG, and HDRI Haven. Poly Haven is the gold standard — CC0 license, multiple resolutions.
Scene background vs scene environment vs material envMap
Three things, often confused:
| Where | What it does |
|---|---|
scene.background | What the camera sees behind objects. Can be a color, texture, cubemap. |
scene.environment | The env map used for reflections on every PBR material in the scene. |
material.envMap | Per-material override. Unusual — most scenes use scene.environment instead. |
Common pattern: set both scene.background and scene.environment
to the same PMREM'd envMap, so reflections and the visible sky match.
Compressed textures: KTX2
JPG/PNG are CPU-decoded and then re-uploaded as raw RGBA to the GPU. On a mobile device a 1024×1024 texture is 4 MB of GPU memory. Multiply by a few hundred textures in a scene and you're out of memory.
GPU-compressed formats (BC/ETC/ASTC) stay compressed in GPU memory — typically 4-8× smaller.
Three.js supports them via KTX2Loader:
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/') // basis_transcoder.js + wasm
.detectSupport(renderer);
const texture = await ktx2.loadAsync('/t.ktx2');
Author content to KTX2 with the basisu CLI or tools like
glTF-Transform. Critical for large
scenes, optional for small ones.
Common first-time pitfalls
- Colors look washed out / milky. You forgot
texture.colorSpace = SRGBColorSpaceon the color map. - Normals map is weirdly tinted. You set
colorSpace = SRGBColorSpaceon a non-color map. It should be left as the defaultNoColorSpace. - Texture renders as a solid black square. Load failed silently — wrong path, CORS, or the image isn't loaded yet. Use the error callback or
loadAsync. - Ground plane looks blurry at distance. Anisotropy is 1. Set to
renderer.capabilities.getMaxAnisotropy(). - Texture "shimmers" as it moves. Mipmaps aren't generating — your texture isn't power-of-two, or
generateMipmapsis off. - Repeat wrapping does nothing.
repeat.set(2,2)butwrapS/wrapTstill defaults toClampToEdgeWrapping.
The disposal checklist
Textures hold GPU memory. When you swap textures or unload a scene:
texture.dispose(); // frees the GPU buffer
material.dispose(); // frees the compiled shader program
geometry.dispose(); // frees vertex buffers
renderTarget.dispose(); // frees framebuffer
pmremGenerator.dispose();// frees the PMREM processor (not the envMap)
Exercises
- Load an HDR from Poly Haven (pick any 1K .hdr) and set it as both
scene.backgroundandscene.environment. Watch the reflections match the sky. - Procedurally build a normal map from a Canvas2D, mark its
colorSpace = NoColorSpace, and apply to a PBR sphere. It should add surface bumps without modifying the geometry. - Animate
texture.offset.xin the loop — instant scrolling effect. Useful for waterfalls, conveyor belts.
What's next
Article 05 — Loading 3D Models. glTF, Draco compression, KTX2 on real
assets, AnimationMixer, and clean model disposal. The format you'll actually
ship.