Hundreds of Draw Calls Down to a Handful with BatchedMesh
· 7 min read · #threejs #game-dev

A level in Typo’s Voyage (opens in a new tab) is a long runway of tiles. Stones, logs, walls, spikes, traps, bridges, boats, cannons and treadmills, and floating above them, a 3D letter for every character the player has to type. A long level has hundreds of pieces on screen at once.
The obvious way to build that in three.js is one THREE.Mesh per tile and one per letter. It works. It also means hundreds of draw calls every frame, and on the school Chromebooks many students play on, that’s where the frame budget goes.
Today every tile and every letter goes through four BatchedMesh objects: one for opaque tiles, one for transparent ones, one for water ripples and one for text.
This post is how that works, and the handful of things that surprised us along the way.
Why hundreds of meshes hurt
A draw call isn’t expensive because the GPU minds drawing a small mesh. It’s expensive because of everything the CPU does around it. Every Object3D in the scene gets its world matrix recomputed, gets frustum-culled, gets sorted and pushed into a render list, and then three.js binds its program, uploads its uniforms and binds its buffers before issuing the draw. Multiply that by several hundred and you’ve spent a good chunk of your 16 milliseconds on bookkeeping before the GPU has done anything interesting.
The fix for “lots of the same object” in three.js has long been InstancedMesh. That’s not quite our situation, though.
BatchedMesh, not InstancedMesh
InstancedMesh draws one geometry many times. That’s perfect for a forest of identical trees. Our tiles are a stone, then a log, then a wall, then spikes: different shapes, in no particular pattern.
BatchedMesh, added to three.js at the end of 2023, is built for exactly that. You give it several geometries, it packs them into one shared vertex and index buffer, and each instance says which of those geometries it is. It’s all drawn with a single material.
The API is small. You size the buffers up front, add each geometry once, then add as many instances as you like:
const batch = new THREE.BatchedMesh(maxInstances, maxVertices, maxIndices, material);
const stone = batch.addGeometry(stoneGeometry);
const log = batch.addGeometry(logGeometry);
const id = batch.addInstance(stone);
batch.setMatrixAt(id, matrix);
batch.setColorAt(id, color);
Where the browser supports WEBGL_multi_draw, three.js renders the whole batch in one multi-draw call. Where it doesn’t, it falls back to looping over the instances, which is still a big win: there’s one object in the scene graph, one program bind and one set of buffers, instead of hundreds.
Two things we added on top made it pleasant to work with:
- A geometry registry. Each tile factory registers its meshes once, by name, and gets back the geometry id. Registering the same name twice returns the existing id instead of adding a duplicate.
- Instances as tiny wrappers. A tile doesn’t own a
Meshor even anObject3D. It owns an instance id, and moving it means onesetMatrixAt. No scene graph node, no matrix-world propagation.
addGeometry also takes optional reserved vertex and index counts. We reserve a bit of headroom for letters, so a glyph can be swapped for its outlined version in place with setGeometryAt instead of reallocating anything.
One material, every tile type
Batching only works if everything in the batch shares a material, but our tiles don’t look alike. Traps flash, fragile tiles crack, water-side tiles get a waterline, and so on.
So there’s one MeshStandardMaterial for all the opaque tiles, extended through onBeforeCompile. Each tile factory contributes its own chunk of GLSL, and the shared shader branches on a per-instance tile type ID. That means one shader program for every tile variant, which also keeps the shader warm-up story simple.
The type ID has to come from somewhere per instance, and this is the trick that makes the rest of the post possible. setColorAt doesn’t write into a vertex attribute. It writes into a floating-point RGBA texture with one texel per instance, which the vertex shader reads with the instance’s index. So an instance’s “color” is really four floats of per-instance data, and you’re free to use them for things that aren’t color at all.
Posing a treadmill on the GPU
The treadmill is where this paid off most. It’s a mini-game tile: a moving belt the character has to run along, built from step boards, chain sections and couplers that loop around the machine. Each slot on the belt is about 15 instances, and a default treadmill has around 257 of them.
Animating that the straightforward way means computing a matrix for every piece on the CPU and calling setMatrixAt about 257 times a frame, per treadmill, and then re-uploading the matrix texture.
Instead, every piece knows where it sits on the loop. Its loop position goes in the instance color’s green channel and the index of the machine it belongs to goes in blue. Both are written once, when the treadmill is built. The vertex shader reads them, reads that machine’s state from a small uniform array, and works out the piece’s position and rotation itself.
The per-frame cost of a whole treadmill is now writing one vec4 into that uniform array. However long you make the machine, it adds no draw calls, no material and no per-frame matrix writes.
What breaks when the shader moves things
Moving an instance in the vertex shader works immediately for the color pass. Then three other systems turn out to still believe the instance is sitting at its batching matrix, which for a GPU-posed piece is only its anchor.
Shadows. Shadow maps are rendered with a separate depth material, which doesn’t include your custom vertex code, so every belt piece casts its shadow from its anchor. The fix is a customDepthMaterial on the batch, built from the same injected vertex chunks as the main material, so shadows come from the posed position.
Lighting. If the shader rotates a piece but not its normal, it’s lit as if it had never turned. Rotate the normal in the same place, by injecting into the beginnormal_vertex chunk. The depth pass never compiles that chunk, which is fine, since shadows don’t need normals.
Culling. BatchedMesh culls each instance against its bounding sphere, positioned by its matrix. A piece whose matrix says “anchor” but whose shader says “far end of the belt” gets culled whenever the anchor leaves the screen. We pad each track geometry’s bounds to cover everywhere the shader can move it.
The costs
It isn’t free.
Fixed ceilings. A BatchedMesh is sized when you create it. Our opaque batch reserves room for 10,000 instances, and that number quietly becomes an authoring limit: the treadmill’s maximum length is a budget calculated against it. Allocate too much and you waste GPU memory; too little and level construction fails.
CPU-posed pieces need care too. A few instances still have to move on the CPU, like the boards of a fragile tile falling away. If your wrapper exposes position, rotation and scale setters, each one can trigger its own setMatrixAt. Compose the matrix once from reusable scratch objects and write it with a single call.
Off-screen work. A mini-game far down the runway doesn’t need to animate at all. The mini-game tiles check whether they’re near the character first, and skip their update entirely when they aren’t.
Manual cleanup. Instances aren’t scene graph objects, so nothing tears them down for you. When a level ends, each tile’s reference to its instance has to be cleared by hand.
Shader glue. A stock material is easy to reason about. An onBeforeCompile material with contributions from a dozen tile factories, a depth variant and generated GLSL for the treadmill is not. We keep a written note in the codebase explaining where each injection point is and why.
When I’d reach for it
- Many objects, a few different shapes, one material:
BatchedMesh. - Many copies of exactly one shape:
InstancedMeshis simpler. - A few hundred things moving every frame: pose them in the vertex shader from a small uniform, and put their static per-instance data in the instance color.
- And if you do: fix shadows, normals and culling bounds at the same time, before you forget which of them you already did.
The frame rate stopped depending on how long the level is, and level designers stopped having to think about it. Both of those were the point.
Stay in touch
Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.
Follow @zkmake