如果说独立游戏在 Bevy 一类 ECS 引擎里的默认写法,常常是「每个会动的东西都先做成 Entity,Mesh3d、Transform、Visibility 挂齐,再靠引擎自动合批」,那么 2026 年十月十六日,Matt Busel 在 Tensorust 公开的那篇拆解,等于把这套默认的账单摊开,并在收据最底下用红笔圈出真正贵的那一行。游戏叫《LAWN WAR》,跑在 Rust 与 Bevy 0.19.1 上,题材是无尽的植物大战僵尸。设计文档要的是「每侧数十万单位」。本稿未试玩成品,以下场面与数字均来自作者公开文章、DEV 同步稿与文中给出的测量条件。
我把能钉死的事实对了一遍。十月二日至三日,作品大体由编码代理按 Matthew 的设计笔记搭出,源码公开在 GitLab。第一版可玩构建走正规 ECS:每个僵尸槽位一个实体。留下的压力截图是九百七十四个僵尸三十六帧,以及九百一十六个僵尸八帧。大约两小时十分钟后,整条管线改成自定义实例化,单位不再是实体。十月十六日晚九点十一分至十七分(美东),RTX 4070、i7-13700KF、一六〇〇×九〇〇、`LW_LOG=1`、wave28 stage30 的五次封顶对照里,六十三万八千七百三十四个单位仍能跑在二十五帧左右,五百六十二万七百二十七个实例每帧约二十七兆字节。
贯穿本稿的意象,是一张四十八字节的兵票。它听起来像数据结构名,落到制作伦理上,却是一组拒绝为「看起来像引擎用法」付账的承诺。先让单位回到数据,再谈谁该进渲染世界。省下来的必须留下证据,贵的那一行也必须被点名,而不是被习惯性归因成「Draw Call 太多」。

一、行业默认,单位等于实体,优化等于猜 Draw Call
在 Bevy 的日常教学里,自动实例化已经够用。许多实体共享同一 `Handle<Mesh>` 与同一材质时,引擎会替你合批。几千个会动的东西,作者自己也说:用自动那套,然后停下来。这套默认很干净,也因此很偷懒。它把「单位」翻译成「场景图里的公民」。公民要有变换传播、可见性、变更检测,以及每帧向渲染世界的提取。玩家侧与开发侧都学会了用同一套语汇阅读卡顿。他们会说「实体太多」「Draw Call 爆了」「合批没生效」。这种默契让「猜」变成可被原谅的制作语法。
《LAWN WAR》偏偏把猜测成本抬得很高。设计要的是海。植物与僵尸两侧一起长,直到战场变成单位的海。每一个都会走、打、烧、死,爆炸时还要在空中翻滚。第一版 spawn 来自十月二日下午两点五十八分的提交:容量内每个槽位 spawn 一组 `Mesh3d`、材质、藏在地下的 `Transform`、`Visibility::Hidden`。压力一上来,九百多个僵尸就能把帧率摁到个位数。作者没有把罪名钉死在某一个 Bevy 系统上,因为那一版根本没认真 profiling。重要的是它逼出的决定:两小时十分钟后,单位退出实体公民籍。
分叉点就在这里。默认问的是,怎样让引擎把更多实体合进更少的 Draw Call。他们改问的是,当数量冲到数十万,实体本身是不是已经在每帧收税,而真正该进 GPU 的,只是一张足够瘦的兵票。

二、砸碎脚本,Vec、四十八字节、一次 Draw,以及不拷贝的交接
改写之后,每支军队是一块按满容量一次性分配的扁平 `Vec`,元素是可 `Copy` 的普通结构,外加空闲链表与高水位标记 `hw`。默认封顶每军六十万,但早期波次只用几百个槽。所有循环只跑 `units[..hw]`,小规模遭遇战不必为空着的五十九万九千付账。模拟用 rayon 并行扫这些切片;邻域靠每帧重建的两米网格,计数排序,没有哈希,没有每格一个 `Vec`,两军同时重建。
可见单位落到 GPU 上,是四十八字节的 `Inst`:三个 `vec4`。位置加偏航,缩放、相位与动画种类,染色与发光。没有完整的四乘四矩阵,也没有骨骼数据。单位只绕竖直轴转向,偏航一个浮点,旋转由着色器拼出来。在五十六万二千七百二十七个实例的规模上,列表大约二十七兆字节;二十五帧时,每秒大约六百八十兆字节往 GPU 送。
列表怎么造得够快,是后文图表真正在谈的函数。军队按八千一百九十二一块并行切开,跳过相机背后的东西,按距离选 LOD,按相位选烘焙网格帧,再丢进「每个网格一份」的私有列表,最后 reduce 粘合,空侧直接 swap。剔除是一个点积,不是完整视锥:大约二十米外且在相机背后的丢掉,其余交给 GPU 裁。LOD ��在四十米与一百二十米。僵尸的走、打、死来自 Kenney 的 CC0 墓园套件;不做 GPU 蒙皮,而是把片段烤成独立网格——八帧走、四帧打、一帧死——每一帧只是另一张带自己实例列表的网格。
交接也不再拷贝二十七兆。列表裹进 `Arc`,提取只克隆指针。仍有实体,但是每个网格一个,并挂上 `NoFrustumCulling`,否则 Bevy 会按原点处网格包围盒把整批裁掉。六十三万九千单位时,这一步测得零点零八毫秒。GPU 缓冲也不再每帧 `create_buffer`:`write_buffer` 原地写,容量不够才扩,并留百分之五十余量。管线是 Bevy 普通网格管线外加一块 `VertexStepMode::Instance` 的顶点缓冲;绘制是一次 `draw_indexed`,实例范围覆盖整份列表。动画在 WGSL 顶点着色器里完成:CPU 只写相位与种类,僵尸蹒跚、植物晃动、尸体躺平、碎片翻滚都在 GPU 上长出来。烟雾更绝——完全不做半透明混合。负的「emissive」表示可透视,片元用四乘四 Bayer 抖动丢掉一部分像素。半百万透明物永远不必排序。
到这里,方法已经齐了。数据负责活着。兵票负责被看见。Arc 负责不付第二次拷贝。一次 Draw 负责每个网格。Bayer 负责烟雾不必排队。
三、罪名表,二十五帧的诚实,以及列表比战争更贵
十月十六日晚,作者用自带 QA 模式连开五场:bot 自己打,每两秒打一次 `LW_LOG`,GPU 时间走 Bevy 的 `RenderDiagnosticsPlugin`。同一波次、同一阶段,只改军队封顶。每个数字是前十二秒之后十六到二十一个样本的均值。
五千五百六十八单位:五十帧。十八万六千个实例里,爆炸粒子占大头。二万七千九百三十四单位:六十一帧,CPU 九点一七毫秒,GPU 十点五六。十一万五千二百一十七单位:四十九帧,十一点三一 / 十五点六零。二十七万七千四百六十九单位:四十帧,十九点一七 / 二十点一九。六十三万八千七百三十四单位:二十五帧(二十三到二十八),CPU 三十四点八二,GPU 三十五点零八,每帧五十六万二千七百二十七个实例。十一万那一组,与十月二日设计文档记下的「约十一万单位,CPU 九到十三、GPU 十到十四」对得上,只是图形后端不同,算一次干净的 sanity check。
惊喜不在 Draw Call。从二万八千到六十三万九千,单位大约二十三倍,`think()`——整场两军模拟——从一点一三毫秒到十点零九,不到九倍,计数排序网格扛住了。`render()`——只负责造实例列表的那一圈——从零点六九到十八点八五,大约二十七倍。满编时,造列表比打这场战争更贵。GPU 随战争变慢,但是稳,最后停在三十五点零八毫秒,与 CPU 并排。计时里没有任何一行指向「Draw Call 太多」。
沿途还摔过几跤。没有顶点色的网格会弄垮管线,着色器读 `@location(5) color`,Bevy 的球与方块没有这属性;三行 `inst::white()` 给图元补白色顶点色。槽位回收不能靠魔法血量——后期伤害大到能直接碾过那个哨兵值,HUD 上出现过四十五点八万亿 HP——于是离开战场窗口的单位改设 `gone = true`,回空闲链表却不算死亡。十月二日这台机器没有 Vulkan,Bevy 走 DX12,FXC 编译大约四十秒一次;更快的 DXC 又弄坏粒子 crate `bevy_hanabi`,于是等待被藏进加载画面。当晚的新驱动(六一七.四二)改走 Vulkan。并行开两份 QA 会一起拖慢着色器编译、弄歪截图计时;五次对照是一份一份跑的。
下一步的赌注,作者写得很老实:还没试。图表指向 Step 3,不是更多渲染花活。要么让列表跨帧存活、只改动过的单位;要么把剔除与 LOD 丢进 compute,直接写实例缓冲。Bevy 自带渲染器从零点十六起也在往 GPU-driven 走。任一方向都有可能再抠十毫秒以上。

收束,独立等于愿意把罪名钉在正确的一行
把这篇十月拆解放回 2026 年秋天的 Rust / Bevy 独立现场,它首先打击的是一种隐蔽的制作惯性。大家默认单位该是实体,默认卡顿该怪 Draw Call,默认「合批了就能玩」就够了,很少有人追问:当数量冲过几千,公民税是不是已经贵过战争本身;当帧率掉到二十五,贵的究竟是 GPU,还是那条只负责填表的 CPU 循环。
对小团队,这组材料尤其有用。你未必要六十万株植物,也未必有 RTX 4070。你一定有自己的「默认已经够用」清单。某次海量单位仍按 Entity 全挂。某次优化会议开口就说 Draw Call。某次 demo 每帧 `create_buffer`。某次半透明粒子在五十万级仍想做排序。某次没有 per-system span,就敢把瓶颈写进周报。Matt Busel 的公开作业把成本写得很土。自动实例化先撑到「实体数」开始疼。兵票瘦到四十八字节。私有列表并行、Arc 交接、缓冲复用、顶点动画、Bayer 烟雾。贵的是愿意从第一天就打日志,以及愿意在收据上承认:满编时,列表比战争更贵。
可执行的提示很短。
- 先用 Bevy 自动实例化。同一网格句柄、同一材质。只有实体数而不是三角形数在疼时,再上自定义。
- 海量对象留在数据里。`Copy` 结构的 `Vec`、空闲链表、高水位。实体留给真正需要的少数东西。
- 瘦实例。位置加偏航,不要整张四乘四。四十八字节对上五十万,会自己说话。
- 并行造表用私有缓冲。`par_chunks`,每任务每网格一份,末尾合并,不要锁。
- 用 `Arc` 交接,每个网格一个实体并加 `NoFrustumCulling`。
- 复用 GPU 缓冲。`write_buffer` 加余量,只在增长时重分配。
- 动画放进顶点着色器;透明物一多,用抖动,不要半透明排序。
- 从第一天记 per-system 耗时。没有 span,你几乎一定会去优化错误的那一行。
九百七十四个实体公民、两小时十分钟的改写、四十八字节的兵票、二十五帧上的六十三万八千单位、以及那条比 `think()` 更贵的 `render()`。在商店话术尚未开口之前,制作人已经把审计标准写进公开拆解。先让单位回到数据,再让罪名钉在正确的一行。

来源与延伸阅读
Matt Busel / Tensorust(2026-10-16)Bevy instancing: drawing 639,000 units with one draw call per mesh
Matt Busel / DEV(同步稿)Bevy instancing: drawing 639,000 units with one draw call per mesh