Ecru GamesDevlog › The freeze was not the shadows

The freeze was not the shadows

Players read the frame counter and reported it: 10 fps on a desktop machine, 20 to 30 on another, 36 on a third. The curve tracked world size, not hardware. Three obvious suspects turned out to be completely innocent, and only an ablation test could show it.

Ecru Games developer

Cotton Tycoon runs on three.js with an orthographic camera. It was designed for a farm of a few dozen fields. The save that reported 10 fps had 3,182 fields and 240 vehicles, so the first instinct was the correct one: something scales badly with world size. The second instinct, which suspects were to blame, was wrong three times in a row.

These are ablation runs on a frozen world, 1,169 fields visible, everything measured in the same session:

ExperimentfpsDraw calls
Baseline258,847
Quality tier 3 → 0 (shadows off, device pixel ratio 1)258,847
Disabling shadow casters class by class258,847
Removing the crops entirely375,221

Shadows did not move the frame rate. Resolution did not move it. Triangle count did not move it. The only number that moved with the frame rate was the draw call count, and the CPU profile agreed: 46% of the time went to scene traversal, world matrix updates and uniform uploads, with idle CPU at 1.7%. The cost was not in computing movement. It was in describing each body to the GPU separately.

Know what a flag actually skips. "Turning shadows off entirely gains 12 fps" was a true measurement and a misleading one: disabling the light's shadow casting skips the shadow pass completely, while disabling individual meshes does not. Those are different experiments that sound like the same one.

Instancing the crops

The plants were already instanced, but only within their own field: every planted field carried four instanced meshes of its own. Now there are four in the world, total. On each culling pass the plants of visible, planted fields are written into one shared buffer in sequence and the instance count is set to the number of visible plants, so an off-screen field never enters the buffer. Frustum culling did not disappear, it moved from per-object to per-instance.

Draw calls went from 8,847 to 5,637 and the frame rate from 25 to 37. Fully zoomed out with all 3,182 fields visible, the scene now costs 9,033 draw calls at 26 fps, where previously half as many fields cost 16,927.

The remaining obvious target was the field ground tiles, 3,182 of the 9,033. I measured that before building it, and the result killed the idea: draw calls fell from 8,090 to 5,355 and the frame time went from 40.4 ms to 39.7 ms. Two percent. Deleting a third of the draw calls did nothing, because after the crop work the bottleneck was no longer draw calls at all. My own diagnosis from two weeks earlier had expired.

The most expensive single item was an art decision

With the load now on the triangle side, the biggest line was one decorative element. Each cotton plant carried fifteen bolls, each a small sphere at 840 triangles per plant. Across 2,805 visible fields that is 23,454 plants and 19.8 million triangles, three quarters of the scene.

The wide-angle level of detail deliberately kept the bolls visible, which was the right call: at that distance the green-to-white transition is the only ripeness signal a player has. It just kept them at full price. The fix was not to remove them but to draw a cheap version at that distance: one icosahedron instead of three spheres per branch tip, 840 triangles down to 100. Triangles fell from 21.8 million to 4.0 million, and the frame time from 40.4 ms to 32.6 ms.

Two frames at wide angle are indistinguishable by eye, and the close-up view is untouched. The acceptance criterion here was not the frame rate, it was that the ripeness signal survived.

A one-way level of detail is a permanent visual regression that nobody notices. The regression test asserts three things: high geometry up close, low at wide angle, and back to high when the camera returns. Without the third assertion the optimisation quietly degrades the game forever.

What was actually eating the frame

On the largest save available, 3,511 fields, the ablation was blunt about where the money went:

The thing eating the frame was not the count of objects but the number of meshes per object. Eighty bale trains at 86 meshes each cost ten times what 642 warehouses at one mesh each cost. When picking a family to optimise, look at its share of draw calls, not at how many of them there are.

The largest win came from a small helper that merges a procedural model's parts by material. A box helper created its own material on every call, so ten parts of the same colour were ten draw calls. Applied to six families: 18,813 draw calls down to 10,420, with identical triangles and an identical picture. Clicking still works because hit testing goes through a separate collision box, and keeping the body as "just the picture" is exactly what made the merge safe.

A dead end worth publishing

Of the 33,371 objects in that scene, 26,349 recompute a matrix every frame and none of them ever move. Freezing all of them looks like free performance, and it is a very attractive idea.

It gained zero. Disabling automatic matrix updates only stops the local matrix computation. The scene traversal and the per-draw-call program and uniform work continue exactly as before. The cost was never in computing movement.

A related trap in the other direction: an earlier attempt to freeze the fields made things measurably worse, 42 fps down to 27, and I recorded the wrong reason for it. Freezing does not break culling. Freezing a matrix that was never computed once leaves it at identity, which puts the object's bounding sphere at the origin, which puts every field inside the frustum. Compute first, then freeze, and draw calls go down.

The median hides the thing players feel

A player found the next problem, not a measurement: "60 fps but it stutters sometimes". Every performance run until then had reported median frame time, and the median is precisely what hides an 800 ms pause once a second.

On a mid-size save, both stalls in a 30 second window were shader compilation: 11 new programs inside one 163 ms frame, 5 inside a 106 ms frame. The heap did not move, so it was not garbage collection. three.js compiles a material variant the first time it draws it, and the main thread waits.

Compiling up front on the loading screen fixed part of it, and the rest needed compiling again whenever a new model finishes streaming in, because the first pass can only cover what existed at the time.

MetricBeforeAfter
Stalls in 30 s112
Worst frame806 ms160 ms
Time lost to stalls8.3%0.7%
p99 frame time69.5 ms42.5 ms
p50 frame timeunchanged, and it should be

Anyone reviewing that change by looking at the average would conclude it did nothing and revert it. Also worth knowing: jank has to be measured while the game is running. The freeze hook that made the draw-call numbers reproducible is the wrong instrument here, because the sources of stutter, save writes, panel redraws, model streaming, garbage collection, are all switched off inside it.

"Every vehicle, every frame"

The last thing that night was a pattern rather than a bug, and it appeared in three unrelated places at once. A function that walked every field to answer one yes-or-no question was being called per vehicle per frame: 240 × 3,182 × 60 is 45 million iterations a second, 34.4% of the profile, for a single boolean whose answer can only change when a building is placed, moved or demolished. A 250 ms cache erased it.

Whenever you read the words "for every vehicle, every frame" in your own code, stop and ask how often the answer can actually change.

The measurement discipline that made all of this possible, and the five times my own tools lied about it, is in the previous post.

Play Cotton Tycoon