Texture Atlases in Three.js: One Draw Call for a Whole Scene of Sprites
Draw calls scale with materials, not meshes — that's why fifty textured quads crawl and one atlas flies. Packing with MaxRects, remapping UVs from the atlas JSON, and scaling to thousands of sprites with InstancedMesh and per-instance UV offsets.
Profile a slow Three.js scene and the culprit is almost never polygon count — modern GPUs chew through millions of triangles. It's draw calls: the per-object CPU cost of telling WebGL "bind this texture, use this material, draw this mesh," repeated hundreds of times a frame. Fifty sprites with fifty textures is fifty of those conversations. Pack the fifty images into one texture atlas and the renderer can have one conversation instead. This post covers how to build the atlas, remap UVs onto it, and scale up with instancing when the sprite count gets serious.
Why Draw Calls Dominate
Each mesh with a distinct material forces the renderer to flush state: switch shader uniforms, bind textures, then issue the draw. The GPU is idle during much of this — the cost is CPU-side driver work, which is exactly the resource a busy web page has least of. The fix follows directly from the cause: draw calls scale with materials, not meshes. Give every sprite the same material pointing at the same atlas texture, and you've removed the reason the renderer can't batch. This is the same discipline every 2D engine enforces internally; in Three.js you do it yourself, and the payoff on scenes with dozens-to-hundreds of textured quads is dramatic.
Building the Atlas
The Images to Atlas packer takes your individual sprite PNGs and produces two files: a packed PNG (MaxRects layout, so mixed sizes nest tightly) and a JSON file with each sprite's pixel rectangle. Three settings matter for Three.js use. Enable power-of-two output — safest for mipmapping and required if you later move to compressed GPU formats. Add 2 pixels of padding between sprites, because mipmaps average neighboring pixels and zero-padding lets sprites bleed into each other at smaller mip levels. And let the packer's duplicate detection fold identical images into one rect — repeated props cost nothing. The texture atlas guide covers the packing theory in more depth.
Remapping UVs Onto the Atlas
Each quad now needs UV coordinates that select its sprite's rectangle instead of the whole texture. Convert the JSON's pixel rect to UV space — remembering the bottom-left origin flip:
// rect = { x, y, w, h } in pixels from the atlas JSON
const u0 = rect.x / atlasW;
const u1 = (rect.x + rect.w) / atlasW;
const v1 = 1 - rect.y / atlasH; // top edge
const v0 = 1 - (rect.y + rect.h) / atlasH; // bottom edge
geometry.setAttribute("uv", new THREE.Float32BufferAttribute([
u0, v1, u1, v1, u0, v0, u1, v0
], 2));
That vertex order matches PlaneGeometry's default layout (top-left, top-right, bottom-left, bottom-right). With every quad remapped this way and sharing one MeshBasicMaterial, the renderer stops switching textures entirely. For static level geometry you can go further and merge the quads into a single BufferGeometry — one mesh, one call, done.
Scaling Up: One Draw Call for a Thousand Sprites
When sprites are numerous and dynamic — bullets, particles, a crowd — merging is wrong (you'd re-upload geometry every frame) and per-mesh quads bring back per-mesh overhead. InstancedMesh is the tool: one quad geometry, one material, one draw call, with per-instance transforms. Selecting a different atlas sprite per instance takes one extra step — an InstancedBufferAttribute holding each instance's UV offset and scale, plus a small shader patch via onBeforeCompile that applies it to the UV. It's about fifteen lines of shader math, and it's the difference between a scene that struggles at 200 sprites and one that idles at 10,000. One honest caveat: THREE.Sprite objects don't batch this way — each is its own draw call — so for bulk billboards, instanced quads that face the camera in the vertex shader are the scalable path.
The Checklist
- One atlas per shader family — everything drawn with the same material type can share one texture.
- Power-of-two, padded, deduplicated — set at pack time in the packer, impossible to fix after.
- Remap UVs from the JSON, flipping the Y axis once, in one utility function you write once and never think about again.
- Static geometry: merge. Dynamic sprites: instance. Both end at one draw call.
- Measure —
renderer.info.render.callstells you the actual draw call count per frame; watch it while you refactor.
The whole pipeline — packing, padding, dedup, JSON export — runs free in the browser with no uploads, and the atlas drops into Three.js with the twenty lines above. Your frame budget will notice.