Three.js From Zero · Article s6-08
S6-08 Texture Atlasing
Texture Atlasing
Pack many small textures into one big one. Meshes share a material. One draw call for 50 objects. A 10× draw-call reduction.
1. Why draw calls matter
Each unique (mesh, material) combination = one draw call on CPU. 5000 draw calls/frame on a 60fps budget = 0.2ms each. Mobile chokes around 500.
2. The atlas idea
Instead of 50 separate 256×256 textures, pack them into one 2048×2048 atlas. All 50 meshes use this one texture + one material. One draw call (with instancing) or batched draw.
3. Packing algorithms
- Guillotine: recursively split rectangles. Simple, decent fill.
- MaxRects: keeps a list of free rectangles. Picks best-fit each insert. 90-98% fill ratio.
- Shelf: sort by height, pack in rows. Fast, 80% fill.
4. UV remapping
Original mesh had UV from (0,0) to (1,1). Now those UVs need to point at the atlas region.
// For each vertex UV (u, v) of mesh M:
// M's atlas region: origin (ox, oy), size (sx, sy)
// New UV:
u_new = ox + u * sx;
v_new = oy + v * sy;
5. Demo — 32 sprites in one atlas
32 colored number badges drawn with InstancedMesh sharing a single atlas. Draw call count in stats bar.
6. The bleed problem
Mipmap levels sample neighbors. If atlas tiles touch, neighbor pixels leak across cells.
Fix: pad each tile with 2-4 pixels of border, copy edge colors outward (bleeding). Most packers support this.
7. Wrap modes
Individual textures often use REPEAT wrap. Atlas tiles CAN'T repeat without math tricks.
- Option A: shader-side fract() to wrap within tile region.
- Option B: don't atlas tileable textures. Keep them separate.
8. Three.js InstancedMesh + atlas
// One geometry (quad), one material (atlas texture), N instances with per-instance UV offset
const geo = new THREE.PlaneGeometry(1, 1);
const mat = new THREE.MeshBasicMaterial({ map: atlasTexture });
const inst = new THREE.InstancedMesh(geo, mat, N);
// Per-instance UV offset via instance attribute
const offsets = new Float32Array(N * 4); // ox, oy, sx, sy
inst.geometry.setAttribute('uvOffset', new THREE.InstancedBufferAttribute(offsets, 4));
// Shader patch
mat.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'#include ',
`vMapUv = uv * uvOffset.zw + uvOffset.xy;`
);
};
9. Runtime atlas builders
three-atlas-generator— pack runtime, good for UI.texturepacker— offline tool, outputs JSON + atlas PNG.- Blender: UV → Pack Islands (manual, per-mesh).
10. When NOT to atlas
- Tiling textures (bricks, wood). Different wrap semantics.
- Huge textures (4K+). Atlas wastes space on a few giant tiles.
- Different filter modes needed per image. All atlas tiles share filter.
- Textures needing different compression (albedo ETC1S + normal UASTC).
11. Takeaways
- Atlas = pack small textures into one. Share material → share draw call.
- MaxRects packer: 95%+ fill, free online tools.
- Border padding or bleed to prevent mip seams.
- No REPEAT wrap inside an atlas unless you shader-wrap.
- InstancedMesh + per-instance UV offset = most flexible approach.