The eight player free-for-all is where this game stops being comfortable, and the moment it stops being comfortable is the moment you zoom all the way out to see the whole board — which, with eight players, is the only way to read a match at all. The camera went heavy exactly there.

A live capture of that view measured 2,109 draw calls against 1,392 units on screen. A draw call is a single request from the processor to the graphics card: here is a shape, here is a material, draw it. The cost of one lives almost entirely in the asking rather than in the drawing — the card is fast, but each request has to be assembled, validated and handed over, and doing that two thousand times per frame is how a processor spends a frame achieving very little. Roughly one per unit here, because every unit was its own separate object as far as the graphics card was concerned.
The fix is instancing, which is the card's own answer to this exact problem: instead of a thousand requests to draw one dwarf, you make one request to draw the same dwarf a thousand times, handing over a list of positions. One shape, one material, one submission, a thousand copies. The catch — and most of this post — is that every copy must genuinely be the same shape and the same material, and a surprising number of things quietly break that.
In that same frame, the breakdown of detail levels was 0 units near, 54 at medium distance and 1,338 far away. Essentially the whole army was already past the distance at which you can see it move at all.
Where the requests were, and where they were not
Draw calls matter here because on this game they are the thing that actually predicts frame time. Measurements from real sessions correlate rendering time with draw calls strongly and with triangle count slightly negatively, which is why the rendering budget counts requests and not triangles.
Nearby units genuinely cannot be grouped. Each one carries its own animation playback and its own posed skeleton, and a group of instances shares one shape between all of them. Those are left exactly as they were.
The distant ones are a different case. The game already poses each low-detail version once, on the template, by playing the idle animation for zero seconds and then dropping the animation player entirely, because nothing ever advances an animation at the distances those are drawn at. Every copy therefore holds the same pose. Identical shapes drawn many times at different positions is the textbook case for instancing, and in this capture that was 1,338 of the 1,392 units on screen.
The claim that would have shipped a field of T-poses
The phrase in my own notes was "the far versions are static-posed", and it was wrong in a way that matters. Static-posed is a statement about the bones, not about the vertex data.
A distant unit is still a skinned character. The vertex data holds the pose it was modelled in, arms out, and the idle pose exists only as bone positions that the graphics card applies as it draws. Merging that vertex data the way the game merges never-animated building geometry would have produced a field of units standing to attention with their arms out across half the map. That is the sort of bug a player finds before a test does.
So the pose is applied on the processor, once per template, and written into fresh vertex data. It reproduces exactly what three.js does on the graphics card, then divides the object's own world position back out, so that each copy can be placed by its own matrix and get heading, scale, hover, bob, tilt and ground height for free.
It is written in the fully general form rather than the shortcut that this particular kind of rig happens to collapse to, so a future model with an unusual rig cannot silently bake the wrong pose. A test checks the result against three.js's own processor-side version of the same maths, and comes back with a maximum error of 0.000000266 world units, on a test case the same test separately proves is non-trivial by confirming the pose moves vertices well away from where they started.
1,392 to 96, and where 96 comes from
The same test replays the captured scene against the real batching code: 24 unit types spread across the two distant detail levels, 54 units at medium distance and 1,338 far away.
| Measure | One at a time | Grouped |
|---|---|---|
| Units drawn | 1,392 | 1,392 |
| Draw calls | 1,392 | 96 |
| Occupied groups | n/a | 48 |
Ninety six is 48 occupied groups times two, because the comic-book outline around each unit is grouped alongside the body. The outline shares the body's placement data by reference, so one update covers both. Outlines are a separate request per unit on the old path, so leaving them out would have halved the win, and the outline would have vanished from exactly the units that get handed to the low detail versions.
One gotcha will bite anyone doing this in three.js, and the fix is two lines because the failure looks like something else entirely.
// three.js tests an InstancedMesh against the bounding sphere of its geometry,
// not against where the copies actually are. With the shape sitting at the
// origin and every copy placed by its own matrix, the whole group blinks out
// the moment the origin leaves the view. Decide visibility yourself instead.
mesh.frustumCulled = false;Leave it on and your army vanishes as one when the camera pans away from the middle of the map, which reads as a rendering bug rather than a culling one.
Work out where grouping stops paying before you build it
Grouping is not free and it is not always a win, so the number to establish first is where it stops being one. Grouped requests are the number of groups multiplied by the cost of each group. Ungrouped requests are the number of objects multiplied by the cost of each object. Grouping wins while the first is smaller, which rearranges into a single readable rule: the average number of copies per group must be higher than the ratio between what a group costs and what a lone object costs.
For this game: 1,392 units at one request each is 1,392. Each group costs two requests, body and outline. Each unit type occupies two groups, because it can sit at either of the two distant detail levels. So 1,392 divided by four gives 348 different unit types on screen at once before grouping stops paying. The whole game defines 229 unit types, so the break-even cannot be reached even by a match containing one of everything.
The part people get wrong is what counts as a group. A group is not a model. It is every distinct combination the batch cannot merge across: shape, material, and any detail level you keep separately. Two detail levels and two materials is four groups per type, not one, and that multiplies straight into the answer. Count the combinations you will actually submit, not the models you own.
The refusal list is the design
The batching code answers "can you draw this?" with a plain no whenever it cannot, and the caller then draws that unit the old way at its old cost. Nothing is drawn twice and nothing is dropped: a unit is either grouped, or drawn individually, never both.
There are six ways to get a no, and each one is a different way of writing per-object state that a shared material cannot hold. A unit part-way through a cross-fade between two detail levels, which deliberately draws both at once with tweened transparency. A cloaked unit, which covers stealth, submerged submarines and the ghostly hero in the spirit world. A summoned unit with its spectral tint. A unit part-way through dying. And a catch-all: any unit that has taken its own private copy of the material for any reason at all, which is the interesting one, because it removed two entries from the list rather than adding one. Fading corpses and dimmed buildings under fog both take a private copy on their way past, so neither needs a rule of its own.
Holding a cross-fading unit on the individual path also happens to be what stops the handoff being visible. A unit only joins a group once it has settled at one detail level, and the grouped version is the same shape at the same position, so there is nothing left to pop.
Two things are deliberately not on the refusal list. Team colour is not, because units carry no per-unit tint in this game at all: who owns what is read from the minimap, the selection ring and the health bar. Fog dimming is not, because it is computed from world position inside the shared material, which grouping carries through unchanged.
Your refusal list will have most of these on it
The test for every candidate is one question: can this state be expressed as a per-copy attribute, or does it need a different material? Position, colour and any single number you are willing to add an attribute for are the first kind. Everything below is the second kind, and it is the same list in every project I have seen attempt this, so write it down before you discover it as a bug report.
- Transparency, in every disguise. Cross-fades, death fades, cloaks, stealth shimmer, spawn-in effects. Blending is material state.
- Any object that copied its material. Whatever the reason. This one entry removes several others, because a per-object tweak almost always begins by copying the material.
- A different shape from the rest of its group. A shatter, a procedural death, a damaged variant swapped in at runtime.
- A different draw order or depth behaviour. Outlines, decals, anything with custom
depthWrite,renderOrderor polygon offset. - Anything mid-transition. Not because it cannot be expressed, but because a handover that happens during a visible change is the handover a player sees.
The rule that keeps it safe is the same in all of them. The group answers honestly, and every refusal falls back to the path that already worked. The alternative, making one shared material express every case, is how a draw call saving turns into a renderer rewrite.

Two more batches, from the same measurement
Selection rings and the little bars above units were worse per unit than the units themselves.
Every selected unit used to get a freshly built ring shape and a freshly built material, each as its own object hung straight off the scene. Box-selecting a 382 unit doomstack in a 400 versus 400 capture pushed the count of live shapes from 152 to 525 and the draw calls from 690 to 1,251. A ring is 96 triangles, so about 37,000 for the whole army, which is nothing at all. The cost was creating and destroying graphics resources per unit, plus one request per ring. It is now a single ring, scaled per copy, with own, enemy and neutral colours carried as per-copy data.
The bars were the bigger number. Every health, mana, summon timer and production bar was two sprites, one black backing and one coloured fill, each with its own material and each hung directly off the scene. About 1,700 of them, which is 1,700 draw calls and also 1,700 transforms to walk and 1,700 bounding boxes to test, every frame, before a single triangle was drawn. Grouped, that is two draw calls.
Bars can be grouped when sprites in general cannot for one reason: every bar shares a single one pixel white image, and the colour lives in the material rather than in a picture. There is no texture to switch between, so the only per-bar state is position, size, centre and colour, which is four small attributes. Transparency is not per bar and does not need to be, because the only input to it is camera distance, which is identical for every unit in a frame. Rank badges stayed as individual sprites, because there is one badge image per rank per faction and they share nothing.
What the group costs to fill
Filling the groups is not instant, and the number that governs it trades directly against what the player sees. At most two detail levels are prepared per frame, because applying a pose on the processor is the one genuinely expensive step here, and doing forty of them on the exact frame a player zooms out would be a visible hitch. The 48 groups in that capture therefore need at least 24 frames to fill, and during those frames the units are drawing themselves the way they always did.
The change rests on three things: a batch that answers honestly, a fallback that is always correct, and a fill rate chosen so the transition costs less than the thing it replaces. The same audit also found that every model in the game had been drawing both sides of every triangle, which was a free saving on a completely different axis.





