Bevy 實例化:每個網格一次 draw call 渲染 639,000 個單位
Bevy instancing: drawing 639,000 units with one draw call per mesh
作者在 Bevy 0.19.1 的 LAWN WAR 中以自訂實例化渲染 638,734 個單位,在 RTX 4070 上達到 25 fps,每個網格只需一次 draw call。
Originally published at tensorust.com.
TL;DR: Bevy already instances meshes for you when entities share a mesh and a material. Past a few thousand moving units, we stopped making them entities at all: units live in a plain Vec, each frame builds one 48-byte instance per unit, and a custom render command draws each mesh once. In LAWN WAR that holds 639,000 live units at 25 fps on an RTX 4070, and the thing that finally slows it down is not the GPU or the draw calls. It's the CPU loop that builds the instance lists.
Short answer first, since that's what you searched for. Bevy has two kinds of instancing. The automatic kind: if many entities use the same Handle<Mesh> and the same material, Bevy batches them into instanced draws on its own (the custom_shader_instancing example says so in its first lines). For a few thousand objects, use that and stop reading.
The manual kind is that same example: you skip entities per object, hand the GPU a buffer of per-instance data, and issue one draw call that renders the mesh N times. That's what you want when you have hundreds of thousands of things that move every frame. Below is the version that runs LAWN WAR, with the real code, the numbers we measured on October 16, 2026, and the traps.

One frame of LAWN WAR with 600,000 plants and about 38,700 zombies alive. Timings from LW_LOG, Oct 16 2026, 9:11 PM ET, RTX 4070.
What is LAWN WAR, and why did it need this?
LAWN WAR is a Rust game on Bevy 0.19.1: an endless plants-versus-zombies war in first and third person, where both armies keep growing until the field is a sea of units. It was built on October 2 and 3, 2026, mostly by a coding agent working from Matthew's design notes, and the source is public on GitLab. The design doc asks for "hundreds of thousands of units per side". Every one of them walks, attacks, burns, dies and tumbles through the air when something explodes.
The first playable build did it the normal ECS way. Each zombie slot was an entity with a Mesh3d, a material, a Transform and a Visibility, and a system copied positions from the simulation into those transforms every frame. Here's the spawn, from the first commit (src/horde.rs, Oct 2, 2:58 PM):
h.entities = (0..h.cap)
.map(|i| {
commands
.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(fx.vertex.clone()),
Transform::from_xyz(0.0, -50.0, 0.0),
Visibility::Hidden,
NotShadowCaster,
ZombieSlot(i as u32),
))
.id()
})
.collect();
The two stress screenshots we kept from that build show 974 zombies on the field at 36 fps and 916 at 8 fps. We never profiled that version properly, so I won't pin the blame on one Bevy system. What matters is the decision it forced: 2 hours and 10 minutes later the whole thing was rewritten around custom instancing, and units stopped being entities.

The first build, one ECS entity per zombie: 974 zombies on the field at 36 fps (QA screenshot, Oct 2 2026, 3:37 PM ET).
Step 1: why aren't the units entities?
An entity is a great home for a player, a camera or a boss. For 600,000 identical soldiers it's overhead you pay on every frame: change detection, transform propagation, visibility checks, extraction to the render world. So each army is just a flat Vec of plain Copy structs, allocated once at full capacity, with a free list for recycled slots (src/war.rs):
#[derive(Clone, Copy, Default)]
pub struct Unit {
pub pos: Vec2,
pub vel: Vec2,
pub y: f32,
pub hp: f32,
// ... 12 more f32 status fields (burn, chill, tox, corpse timer...)
pub kind: u8,
pub strain: u8,
// ... mark, elite, jumped, died_now ...
pub alive: bool,
/// Left the battle window: free the slot without a death.
pub gone: bool,
}
pub struct Army {
pub units: Vec<Unit>,
pub free: Vec<u32>,
/// One past the highest slot ever used: every loop stops here, not at capacity.
pub hw: usize,
// ...
}
That hw field (a high-water mark) is a small thing that matters a lot. The default cap is 600,000 per army, but early waves only use a few hundred slots. Every loop runs over units[..hw], so a skirmish never pays for the empty 599,000.
The simulation runs over those slices in parallel with rayon, and finds neighbours through a grid rebuilt every frame with a counting sort: count units per 2 m cell, turn the counts into start offsets, then drop each unit index into its slot. No hashing, no per-cell Vec, two flat arrays. Both armies rebuild at once:
rayon::join(
|| a0.grid.rebuild(origin, size, &a0.units[..a0.hw]),
|| a1.grid.rebuild(origin, size, &a1.units[..a1.hw]),
);
Step 2: what goes into one instance?
Each visible unit becomes 48 bytes: three vec4s the vertex shader reads as extra vertex attributes. Position and facing, then scale, animation phase and kind, then tint and glow. bytemuck's Pod lets us cast the whole list to bytes for free (src/inst.rs):
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default, Debug)]
pub struct Inst {
/// world xyz, yaw
pub a: [f32; 4],
/// scale, phase, anim kind, extra
pub b: [f32; 4],
/// tint rgb, emissive (0 = lit)
pub c: [f32; 4],
}
Notice what's missing: a full 4x4 transform matrix (64 bytes on its own) and any bone data. Units only turn around the vertical axis, so yaw is one float, and the shader builds the rotation. Keeping the instance this small matters at scale. At 562,727 instances a frame, the list is about 27 MB, and at 25 fps that's roughly 680 MB a second going to the GPU.
Step 3: how do you build 600,000 instances fast?
This is the function the chart further down is about. It walks each army in chunks of 8,192 on all cores, skips what the camera can't see, picks a level of detail (LOD) by distance, picks an animation frame, and sorts each unit into a per-mesh list (src/war.rs, render(), trimmed):
let lists: Vec<Vec<Inst>> = a.units[..a.hw]
.par_chunks(8192)
.map(|chunk| {
let mut local: Vec<Vec<Inst>> = vec![Vec::new(); nb];
for u in chunk {
if !u.alive && u.corpse <= 0.0 {
continue;
}
let p = Vec3::new(u.pos.x, u.y, u.pos.y);
let to = p - cp;
let d2 = to.length_squared();
// Behind the camera: skip (with a margin for big things nearby).
if d2 > 400.0 && to.dot(cf) < -0.15 * d2.sqrt() {
continue;
}
let lod = if d2 < 40.0 * 40.0 { 0 } else if d2 < 120.0 * 120.0 { 1 } else { 2 };
// ... tint for burning/frozen/toxic, elite scale, animation frame ...
local[idx].push(Inst::new(p, yaw, scale, phase, an, extra, tint, glow));
}
local
})
.reduce(|| vec![Vec::new(); nb], |mut a, mut b| {
for (x, y) in a.iter_mut().zip(b.iter_mut()) {
if x.is_empty() { std::mem::swap(x, y); } else { x.append(y); }
}
a
});
A few choices in there are worth stealing. The culling is one dot product, not a full frustum test: anything more than about 20 m away and behind the camera is dropped, and the GPU clips the rest. The LOD cut-offs are 40 m and 120 m. And each rayon task fills its own lists, so there are no locks; the reduce step just glues them together, swapping instead of copying when one side is empty.
The zombies' walk, attack and death animations come from Kenney's CC0 graveyard kit. Instead of skinning on the GPU, each clip is baked into separate meshes (8 walk frames, 4 attack frames, 1 death pose), and a unit picks its frame from its own phase. Every frame is just another mesh with its own instance list.
Step 4: how does the list reach the render world without a copy?
Bevy renders in a separate "render world" and copies (extracts) what it needs from the main world every frame. Copying 27 MB twice a frame would be silly, so the lists are wrapped in an Arc. Extraction clones the pointer, not the data (src/inst.rs):
#[derive(Component, Clone)]
pub struct InstanceData(pub Arc<Vec<Inst>>);
fn flush(mut commands: Commands, mut b: ResMut<Batches>, mut q: Query<&mut InstanceData>) {
for (id, (mesh, list)) in b.lists.iter_mut() {
let data = Arc::new(std::mem::replace(list, Vec::with_capacity(list.capacity())));
match b.entities.get(id).copied() {
Some(e) => { if let Ok(mut d) = q.get_mut(e) { d.0 = data; } }
None => { /* first time this mesh appears: spawn ONE entity for it,
with NoFrustumCulling, holding InstanceData(data) */ }
}
}
}
So there's still an entity, but one per mesh, not one per unit. That entity gets NoFrustumCulling, because Bevy would otherwise cull the whole batch based on the mesh's own bounds at the origin. The measured cost of this step at 639,000 units: 0.08 ms a frame.
Step 5: why not make a new GPU buffer every frame?
Bevy's example creates a fresh buffer with create_buffer_with_data for every batch, every frame. That's fine for a demo. For lists that are millions of instances long, it's a GPU allocation per mesh per frame. LAWN WAR keeps each buffer and rewrites it in place with Queue::write_buffer, and only reallocates when the list outgrows it, with 50% headroom:
match existing {
Some(mut b) if b.cap >= n => {
if n > 0 {
queue.write_buffer(&b.buffer, 0, bytes);
}
b.len = n;
}
_ => {
let cap = (n * 3 / 2).max(1024);
let buffer = device.create_buffer(&BufferDescriptor {
label: Some("lawnwar instances"),
size: (cap * size_of::<Inst>()) as u64,
usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&buffer, 0, bytes);
commands.entity(entity).insert(InstanceBuffer { buffer, cap, len: n });
}
}
Step 6: what does the draw call look like?
The pipeline is Bevy's normal mesh pipeline with one extra vertex buffer bolted on. Its step mode is VertexStepMode::Instance, which means the GPU advances through it once per instance instead of once per vertex:
d.vertex.buffers.push(VertexBufferLayout {
array_stride: size_of::<Inst>() as u64,
step_mode: VertexStepMode::Instance,
attributes: vec![
VertexAttribute { format: VertexFormat::Float32x4, offset: 0, shader_location: 8 },
VertexAttribute { format: VertexFormat::Float32x4, offset: f4, shader_location: 9 },
VertexAttribute { format: VertexFormat::Float32x4, offset: f4 * 2, shader_location: 10 },
],
});
And the draw is a single draw_indexed whose instance range is the whole list: pass.draw_indexed(islice.range.start..(islice.range.start + count), vslice.range.start as i32, 0..ib.len as u32). One call, however many units use that mesh. If you want the concept explained from scratch in raw wgpu, the Learn Wgpu instancing chapter is the clearest walkthrough I know.
Step 7: who animates 600,000 zombies?
The GPU does. The CPU writes a phase and an animation kind; the WGSL vertex shader turns that into motion. Here's the zombie shamble from src/inst.wgsl:
if anim == 1u {
// Zombie shamble: lean forward, rock side to side, lunge when attacking.
let gait = sin(t * 4.5 + ph * 7.0);
let lean = -0.12 - extra * 0.6;
p = rz(rx(p, lean), gait * 0.12);
n = rz(rx(n, lean), gait * 0.12);
p.y = p.y + abs(gait) * 0.05;
}
Plants sway harder toward the top and recoil when they fire, corpses lie flat, debris tumbles, and lightning bolts are a unit cube stretched between two points. Smoke is the clever bit: there's no alpha blending at all. A negative "emissive" value means see-through, and the fragment shader throws away a share of pixels using a 4x4 Bayer pattern. No sorting of half a million transparent things, ever.

The 600,000 cap run, Oct 16 2026, 9:12 PM ET: 600K plants and 38.4K zombies alive, 26 fps in the corner. Bot-driven QA screenshot, unedited.
So what does it cost at 639,000 units?
We measured it tonight. The game has a QA mode that launches straight into a war, plays itself with a bot and prints timings every 2 seconds (LW_LOG=1, using Bevy's RenderDiagnosticsPlugin for GPU time). Five runs on Oct 16, 2026, 9:11 to 9:17 PM ET, same wave (28), same stage (30), only the army cap changing. The shipped build, 1600x900 window, RTX 4070, i7-13700KF. Each number is the average of 16 to 21 samples after the first 12 seconds.
- 5,568 units: 50 fps. Most of the 186,000 instances are explosion particles, not units.
- 27,934 units: 61 fps, CPU 9.17 ms, GPU 10.56 ms.
- 115,217 units: 49 fps, CPU 11.31 ms, GPU 15.60 ms.
- 277,469 units: 40 fps, CPU 19.17 ms, GPU 20.19 ms.
- 638,734 units: 25 fps (23 to 28), CPU 34.82 ms, GPU 35.08 ms, 562,727 instances a frame.
The 115,000 run lines up with what the design doc recorded on Oct 2 ("~110k units 9-13 ms CPU and 10-14 ms GPU"), on a different graphics backend, which is a nice sanity check.

Per-system CPU time per frame, five runs with different army caps, Oct 16 2026, 9:11 to 9:17 PM ET. Source: LW_LOG spans, averaged over 16 to 21 samples per run.
The surprise is in the per-system breakdown. Going from 28,000 to 639,000 units (23 times more), think(), the whole two-army simulation, went from 1.13 ms to 10.09 ms. That's under 9 times the cost, thanks to the counting-sort grid. render(), the loop from Step 3 that only builds instance lists, went from 0.69 ms to 18.85 ms, 27 times the cost. At full size, building the lists costs more CPU time than simulating the war.
Nothing in the timings pointed at the draw calls. The GPU did get slower as the war grew, but steadily, and it finished at 35.08 ms, neck and neck with the CPU.

Whole-frame CPU vs GPU time, same five runs. The smallest run has the most GPU time per unit because 173,000 explosion particles dominate it.
What broke along the way?
-
Meshes without vertex colours crash the pipeline. The shader reads
@location(5) color. A Bevy primitive likeSphereorCuboidhas no colour attribute, so the pipeline fails validation at location 5. The fix is a three-line helper,inst::white(), that adds white vertex colours to every primitive before it's instanced. -
Slot recycling needs its own flag. An early version marked "free this slot" with a magic hit-point value. Late-game damage numbers got big enough to blow straight through it (the HUD in the screenshot above shows 45.8 trillion HP). Now units that leave the battle window set
gone = trueand go back on the free list without counting as a death. - Shader compile time on DirectX 12. On Oct 2 this PC had no Vulkan adapter, Bevy used DX12, and the shader compiler (FXC) took about 40 seconds per launch. The faster DXC compiler broke the particle crate, bevy_hanabi, so the game hides the wait behind a loading screen. Tonight's runs, on a newer NVIDIA driver (617.42), picked Vulkan instead. Check which backend your log line says before you blame your code.
- Parallel QA launches lie. Running two copies at once slowed shader compile for both and skewed the screenshots' timing. Measure one at a time, which is how the five runs above were done.
What would I try next?
The chart says the next win is in Step 3, not in rendering tricks. My bet, untested so far: keep the instance lists alive between frames and only rewrite units that moved, or skip the CPU list entirely and do the culling and LOD pick in a compute shader that writes straight into the instance buffer. That's the "GPU-driven" direction, and Bevy's own renderer has been heading there for its built-in meshes since 0.16. Either could plausibly take 10+ ms off the frame. If someone has done the compute-shader version in Bevy, I'd genuinely like to see the numbers.
Do this in your project
- Start with Bevy's automatic instancing. Same mesh handle, same material handle. Only go custom when entity count, not triangles, is what hurts.
-
Keep mass objects as data. A
VecofCopystructs, a free list and a high-water mark. Entities for the few things that need them. - Shrink the instance. Pack position plus yaw instead of a 4x4 matrix. 48 bytes instead of 64+ adds up at half a million.
-
Build lists in parallel with private buffers.
par_chunks, oneVecper mesh per task, merge at the end. No locks. -
Hand off with an
Arc, and addNoFrustumCullingto the one entity per mesh. -
Reuse GPU buffers.
write_bufferinto a buffer with headroom; reallocate only on growth. - Animate in the vertex shader, and dither instead of alpha-blending when you have huge counts of transparent things.
- Log per-system timings from day one. Without the spans, we'd have guessed "too many draw calls" and optimized the wrong thing.
If you're building in Rust, the write-up on rendering Chinese, Japanese and Korean text in our own Rust game engine covers the other engine, and the geodesic live wallpaper is another GPU-in-Rust side quest. LAWN WAR itself was written with coding agents; what broke running 20 of them on one PC is the story of how that works without melting the machine.
Our other Rust game, O'BLOCK, a sneaker-flipping sim where you haggle from a $300 shoebox to a billion, is free on Steam, out October 20. Wishlist it here.
What's the biggest unit count you've pushed through Bevy, and what fell over first: the CPU, the GPU, or you?
來源:DEV Community · dev.to