← Blog
·8 min read·I Love Sprites Team·Three.js

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.

Why your Three.js scene is slow: draw calls, not polygons Every material + texture switch is a state change the CPU pays for — atlases make them stop Without an atlas: 24 sprites, 24 textures 24 draw calls bind texture, draw, repeat × 24 … Each mesh has its own material → the renderer can't batch anything With an atlas: 24 sprites, 1 texture 1 draw call (instanced) one packed atlas UVs remapped One InstancedMesh per-instance UV offset attribute GPU draws once Same texture + same material for every sprite → the renderer batches or instances freely The rule of thumb Draw calls scale with materials, not meshes — pack everything that shares a shader into one atlas ilovesprites.com — MaxRects atlas packing with JSON coordinates, free in your browser

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.
  • Measurerenderer.info.render.calls tells 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.