Kirino 的游戏宝藏
← Back to issue 022
Game observation

Forty-Eight-Byte Muster Tickets and a Graveyard of Six Hundred Thousand Entities: How Matt Busel Used Custom Instancing to Move LAWN WAR’s Bottleneck from Draw Calls Back to List Building

If the default way to build moving crowds in an ECS engine like Bevy is often “make every moving thing an Entity first, hang Mesh3d, Transform, and Visibility on it, and trust the…

Saved articles ↗

If the default way to build moving crowds in an ECS engine like Bevy is often “make every moving thing an Entity first, hang Mesh3d, Transform, and Visibility on it, and trust the engine to batch,” then on 16 October 2026 Matt Busel’s teardown on Tensorust laid that default’s bill on the table and circled the expensive line in red. The game is *LAWN WAR*, running on Rust and Bevy 0.19.1: an endless plants-versus-zombies war. The design doc asks for hundreds of thousands of units per side. This essay has not playtested a finished build; the scenes and numbers below come from the author’s public article, the DEV mirror, and the measurement conditions stated there.

I checked what can be pinned down. On 2–3 October 2026 the game was built largely by a coding agent from Matthew’s design notes; the source is public on GitLab. The first playable build did it the normal ECS way: one entity per zombie slot. The stress screenshots kept from that build show 974 zombies at 36 fps and 916 at 8 fps. About two hours and ten minutes later the pipeline was rewritten around custom instancing, and units stopped being entities. In five cap-sweep runs on 16 October, 9:11–9:17 PM ET, on an RTX 4070, i7-13700KF, 1600×900, `LW_LOG=1`, wave 28 stage 30, 638,734 units still held around 25 fps, with 562,727 instances about 27 MB per frame.

The through-line image of this essay is a forty-eight-byte muster ticket. It sounds like a struct name; as a production ethic it is a refusal to pay for “looking like correct engine usage.” Put units back into data first, then decide what enters the render world. What you save must leave evidence, and the expensive line must be named—not habitually blamed on “too many draw calls.”

LAWN WAR at the 600,000-unit cap, about 26 fps

1. Industry default: a unit is an entity; optimization is guessing draw calls

In everyday Bevy teaching, automatic instancing is already enough. When many entities share the same mesh handle and the same material, the engine batches for you. For a few thousand moving objects, the author himself says: use that, and stop reading. The default is clean, and therefore lazy. It translates “unit” into “citizen of the scene graph.” Citizens pay transform propagation, visibility, change detection, and extraction into the render world every frame. Players and developers learn the same vocabulary for hitches: “too many entities,” “draw calls exploded,” “batching failed.” That mutual understanding makes guessing a forgivable grammar of production.

*LAWN WAR* raises the cost of guessing. Design wants a sea. Both armies grow until the field is a sea of units. Every one walks, attacks, burns, dies, and tumbles when something explodes. The first spawn, from the 2 October 2:58 PM ET commit, created a full-capacity set of `Mesh3d`, material, buried `Transform`, and `Visibility::Hidden` slots. Under stress, a few hundred zombies could push the frame rate into single digits. The author does not pin blame on one Bevy system—that build was never properly profiled. What matters is the decision it forced: two hours and ten minutes later, units left citizenship.

The fork is here. The default asks how to fold more entities into fewer draw calls. They asked whether, past a few thousand, the entity itself is already taxing every frame, and whether what should reach the GPU is only a thin enough muster ticket.

First ECS build: 974 zombies at 36 fps

2. Smashing the script: Vec, forty-eight bytes, one draw, and a hand-off without a copy

After the rewrite, each army is a flat `Vec` allocated once at full capacity, of plain `Copy` structs, plus a free list and a high-water mark `hw`. The default cap is 600,000 per army, but early waves use only a few hundred slots. Every loop runs over `units[..hw]`, so a skirmish never pays for the empty 599,000. Simulation walks those slices in parallel with rayon; neighbours come from a 2 m grid rebuilt every frame with a counting sort—no hashing, no per-cell `Vec`, both armies rebuilt together.

A visible unit becomes 48 bytes of `Inst`: three `vec4`s. Position plus yaw; scale, phase, and animation kind; tint and glow. No full 4×4 matrix, no bone data. Units only turn on the vertical axis, so yaw is one float and the shader builds the rotation. At 562,727 instances the list is about 27 MB; at 25 fps that is roughly 680 MB/s to the GPU.

How the lists are built fast is the function the later chart is really about. Armies are cut into chunks of 8,192 across cores; what the camera cannot see is skipped; LOD is chosen by distance; baked mesh frames by phase; each unit lands in a per-mesh private list, then reduce glues them, swapping empty sides. Culling is one dot product, not a full frustum: drop what is more than about 20 m away and behind the camera; the GPU clips the rest. LOD cuts at 40 m and 120 m. Zombie walk, attack, and death come from Kenney’s CC0 graveyard kit; instead of GPU skinning, clips are baked into separate meshes—8 walk, 4 attack, 1 death—and each frame is just another mesh with its own instance list.

Hand-off no longer copies 27 MB. Lists wrap in an `Arc`; extraction clones the pointer. There is still an entity, but one per mesh, with `NoFrustumCulling`, or Bevy would cull the whole batch by the mesh bounds at the origin. At 639,000 units this step measured 0.08 ms. GPU buffers are not `create_buffer` every frame either: `write_buffer` in place, reallocate only on growth, with 50% headroom. The pipeline is Bevy’s normal mesh pipeline plus one `VertexStepMode::Instance` vertex buffer; the draw is a single `draw_indexed` over the whole list. Animation lives in the WGSL vertex shader: the CPU writes phase and kind; zombie shamble, plant sway, corpses flat, debris tumble grow on the GPU. Smoke is the clever bit—no alpha blending at all. A negative “emissive” means see-through; the fragment shader discards a share of pixels with a 4×4 Bayer pattern. Half a million transparent things never need sorting.

By here the method is complete. Data keeps them alive. Tickets make them seen. Arc refuses a second copy. One draw owns each mesh. Bayer lets smoke skip the queue.

3. The charge sheet: honesty at 25 fps, and lists cost more than the war

On the evening of 16 October the author ran five wars in QA mode: a bot plays, `LW_LOG` every two seconds, GPU time via Bevy’s `RenderDiagnosticsPlugin`. Same wave, same stage; only the army cap changes. Each number averages 16–21 samples after the first twelve seconds.

5,568 units: 50 fps. Most of 186,000 instances are explosion particles. 27,934 units: 61 fps, CPU 9.17 ms, GPU 10.56. 115,217 units: 49 fps, 11.31 / 15.60. 277,469 units: 40 fps, 19.17 / 20.19. 638,734 units: 25 fps (23–28), CPU 34.82, GPU 35.08, 562,727 instances a frame. The 115k run lines up with the 2 October design-doc note (“~110k units, 9–13 ms CPU and 10–14 ms GPU”) on a different graphics backend—a clean sanity check.

The surprise is not draw calls. From 28k to 639k units (~23×), `think()`—the whole two-army simulation—went from 1.13 ms to 10.09 ms, under 9×, thanks to the counting-sort grid. `render()`—the loop that only builds instance lists—went from 0.69 ms to 18.85 ms, about 27×. At full size, building lists costs more CPU than simulating the war. The GPU slowed steadily and finished at 35.08 ms, neck and neck with the CPU. Nothing in the timings pointed at draw calls.

Things broke along the way. Meshes without vertex colours crash the pipeline; the shader reads `@location(5) color`, and Bevy primitives like Sphere or Cuboid lack it—three-line `inst::white()` adds white vertex colours. Slot recycling cannot use a magic HP sentinel; late-game damage blew through it (HUD showed 45.8 trillion HP), so units leaving the battle window set `gone = true` and return to the free list without counting as a death. On 2 October the PC had no Vulkan adapter; Bevy used DX12 and FXC took about 40 seconds per launch; faster DXC broke the particle crate `bevy_hanabi`, so the wait hides behind a loading screen. That night’s newer NVIDIA driver (617.42) picked Vulkan. Parallel QA launches lie—two copies slow shader compile for both and skew screenshot timings; the five runs were measured one at a time.

The next bet is honest: untested. The chart points at Step 3, not more rendering tricks. Keep lists alive across frames and rewrite only movers, or skip the CPU list and do culling and LOD in a compute shader writing straight into the instance buffer. Bevy’s own renderer has been heading GPU-driven since 0.16. Either could plausibly take 10+ ms off the frame.

Per-system CPU time: list building overtakes simulation

Close: independence means naming the right line on the receipt

Put this October teardown back into the autumn 2026 Rust/Bevy indie scene and it first strikes a hidden habit. People default to units-as-entities, default to blaming draw calls, default to “batched means shippable,” and rarely ask: past a few thousand, is citizenship already more expensive than the war; when fps falls to twenty-five, is the GPU expensive, or the CPU loop that only fills tables.

For small teams the material is especially useful. You need not have 600,000 plants or an RTX 4070. You have your own “default is enough” list. Mass units still hung as Entities. An optimization meeting that opens with draw calls. A demo that `create_buffer`s every frame. Half a million translucent particles still wanting a sort. No per-system spans, yet a bottleneck written into the weekly report. Matt Busel’s public work writes the costs in plain dirt. Automatic instancing until entity count hurts. Tickets shrunk to 48 bytes. Private parallel lists, Arc hand-off, buffer reuse, vertex animation, Bayer smoke. What is expensive is logging from day one, and admitting on the receipt that at full size the list costs more than the war.

Actionable tips, short.

  • Start with Bevy’s automatic instancing. Same mesh handle, same material. Go custom only when entity count, not triangles, hurts.
  • Keep mass objects as data. A `Vec` of `Copy` structs, a free list, a high-water mark. Entities for the few things that need them.
  • Shrink the instance. Position plus yaw, not a 4×4. Forty-eight bytes against half a million speaks for itself.
  • Build lists in parallel with private buffers. `par_chunks`, one `Vec` per mesh per task, merge at the end. No locks.
  • Hand off with an `Arc`, and add `NoFrustumCulling` to the one entity per mesh.
  • Reuse GPU buffers. `write_buffer` with headroom; reallocate only on growth.
  • Animate in the vertex shader; when transparent counts explode, dither instead of alpha-sorting.
  • Log per-system timings from day one. Without the spans you will almost certainly optimize the wrong line.

Nine hundred seventy-four citizen entities, a two-hour-ten rewrite, a forty-eight-byte ticket, 638k units at 25 fps, and a `render()` dearer than `think()`. Before store copy speaks, the maker has already written the audit standard into a public teardown. Put units back into data first; nail the charge to the right line.

小光

Sources and further reading

Matt Busel / Tensorust (2026-10-16) Bevy instancing: drawing 639,000 units with one draw call per mesh

Matt Busel / DEV (mirror) Bevy instancing: drawing 639,000 units with one draw call per mesh

*LAWN WAR* source GitLab

  • Bevy custom_shader_instancing example (automatic vs manual instancing as discussed in the article)

Bevy 0.16 rendering direction: Bevy 0.16

Sign in to keep your reading history.

View your likes and saved articles across devices.

Sign in