Three.js From Zero · Article s6-08

S6-08 Texture Atlasing

Season 6 · Article 08

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.