500 个敌人:在确定性模拟中从 27 FPS 提升到 42 FPS
10 月 26 日,距离 CandyRush 在 Steam 发售还有五天,一次在 Unity 编辑器里对第 3 章(arc 3)深度 5 的试玩几乎没法玩:15 到 30 FPS。当天结束时,同一关卡的峰值测得 42.4 FPS,而且无论新代码运行在 1 个还是 23 个工作线程上,回放摘要(digest)都完全 相同。
下面所有数据都来自 arc 3 的 "Horde" 段,深度 5,敌人数达到 500 的上限时:
| 之前 | 之后 | |
|---|---|---|
| 峰值 FPS(中位数) | 27.4 | 42.4 |
| 帧时间 | 36.4 ms | 23.5 ms |
| 每帧模拟 tick 数 | 2.14 | 1.42 |
| 每 tick 敌人邻居计算 | 约 3.0 ms | 0.86 ms |
| 每 tick 危险道具 | 1.80 ms | 0.57 ms |
危险道具这一行来自对最终代码的一次后续采集,那次峰值为 41.4 FPS,与 42.4 的差距在 多次运行之间的噪声范围内。
这些都是 Unity 编辑器里的采集结果,机器是 Ryzen 9 7900X 加 RTX 4090,Game 视图 为 1599x894,"之后" 一行在编辑器里开启了 Burst 编译。按我的经验,部署出去的玩家构建 通常比编辑器高 2-3 倍 FPS;这是我的经验,不是本文测得的结果。同一场景多次运行之间的 噪声大约是每帧正负 1.5 ms(三次运行分别为 41.4、42.4 和 43.1 FPS)。
这项工作以两个在 10 月 26 日合并的拉取请求交付:先是 UTools #179,然后是 CandyRush #1079。我指挥一个 AI 编码代理完成了它:我设定约束和要回答的问题,并在每 一次改动之前审阅上一次的测量结果。本文讲的是这些决策是怎么做出来的。
约束
CandyRush 是一个确定性的固定时间步长模拟。每一个周排行榜成绩都要在服务器上重放玩家
录下的输入来验证,而这只有在同样的输入在 Windows、macOS、Linux 和网页上逐位产生
同样状态时才成立。正因为如此,模拟使用定点数学(Fixed64),而不是浮点数。
这就排除了常见的快速优化。浮点快速路径早就试过:敌人分离(separation)一直用浮点 数学,直到 8 月 9 日因为网页版和桌面版回放出现分歧而被关闭。我提出过一个选项:在每周 排行榜那一局之外放宽确定性。书面分析建议不要做双模式模拟,最终上线的方案保留一个 确定性模拟,只放宽表现层。
第一步:让问题在 90 秒内可复现
一次 15 分钟的试玩不是测量。第一个交付物是一个 PlayMode 采集测试
SegmentDensityCaptureTests:它用发售的关卡段数据,在指定深度构建一局真实的游戏,
并每秒记录一行:帧时间、模拟所占份额、每帧 tick 数、存活敌人数和人群重叠情况。它还
读取 Unity 自己的 profiler 标记来拆分引擎开销,并支持 A/B 变体(关闭物理、关闭
Animator、关闭音频等)。
测试复现了反馈的问题:深度 5 的 Horde 大约 35 秒就填满 500 个敌人的上限,然后一直
停在 25 到 29 FPS。一次运行大约 90 秒,而不是 15 分钟。它被标记为 [Explicit],
所以永远不会在 CI 中运行。
基线也指明了该看哪里:
| 峰值时,深度 5 | 敌人数 | FPS | 帧 ms |
|---|---|---|---|
| Horde | 500 | 27.4 | 36.4 |
| Combat | 309 | 60.9 | 16.4 |
| Horde,深度 0 | 254 | 74.9 | 13.3 |
在 500 个敌人时,模拟大约占一帧的 54%:每帧 2.14 个 tick,每个 9.1 ms。这是追赶 螺旋(catch-up spiral)的开端:慢的一帧意味着下一帧要补更多固定 tick,于是那一帧 更慢。降低每个 tick 的开销,也就减少了 tick 的数量。
采集还发现了值得清除的噪声。一个 8 月留下的临时调试探针,每局 arc 3 仍在写大约 6,400 行带堆栈的日志,记录敌人死亡、魔杖射击和进入关门的事件。它被删掉了。
第二步:保持确定性的 Burst 内核
敌人分离(柔和地避开邻居)和重叠推出(硬性解除穿透)是每个 tick 里最大的开销。 这次改动把两者都移进了 Burst 编译的内核:
EnemyNeighbourGrid是每个敌人位置、半径和 id 的快照,在 tick 开始时生成,并用 计数排序分桶。SeparationForceJob在一个并行 job 中计算整个人群的转向力。- 重叠推出以 Burst 直接调用的方式运行。
有两个细节让它保持确定性:
用 Jacobi,而不是 Gauss-Seidel。 每个敌人都从快照读取邻居,绝不读取其他敌人在
本 tick 已经更新过的位置。结果不再依赖处理顺序,所以 1 个工作线程和 23 个工作线程的
结果相同。第一版用移动后的自身去和快照中的邻居比较,结果两个完全重叠堆在一起的敌人
永远分不开。现有的 EnemySeparationIntegrationTests 抓住了这个问题,一个新的内核
测试把修复固定了下来。
Burst 不能依赖静态构造函数里的 double 运算。 Fixed64 用
FromDouble(Math.PI ...) 构造它的角度常量。UTools #179 把它们换成了那个表达式本来
就会产生的原始 64 位数值,并有一个测试断言每个字面量都等于原来的来源。这个改动没有
让任何摘要发生变化。
500 个敌人时每个 tick 的测量结果:
| 开销 | 之前(ms) | 之后(ms) |
|---|---|---|
| 分离 | 1.26 | 0.15 |
| 重叠 | 1.42 | 0.48 |
| 网格重建 | 0.34 | 0.23 |
有一个发现根本不是代码:开发机上的编辑器关闭了 Burst 编译,在这种设置下同样的内核 测得 30.9 FPS,而不是 42.4。采集必须记录这个设置。
第三步:一次错误的猜测,经过测量后撤回
profile 上的下一项是 HazardPropBehavior:危险区域这类范围道具每个 tick 花
1.80 ms。代码用线性查找在成员列表里找每个敌人,所以显而易见的猜测是查找本身。代理把
它换成了 HashSet。
没有任何变化。于是它没有再猜一次,而是加上临时计时器,覆盖了 135,045 次范围道具 tick:
| 每个道具、每个 tick | 耗时 |
|---|---|
| 整个成员扫描 | 19.0 µs |
| 遍历全部约 500 个敌人 | 17.2 µs |
| 形状重建 | 1.2 µs |
| 施加效果 | 0.45 µs |
区域里平均只有 0.2 个敌人。开销不在于查找成员,而在于每个道具、每个 tick 都要访问
全部 500 个敌人,结果几乎一个都找不到。HashSet 的改动连同计时器一起被撤回。
真正的修复复用了快照网格:范围道具现在向网格查询自己边界内的敌人。
HazardPropBehavior 从每 tick 1.80 ms 降到 0.57 ms,峰值时完整 tick 从 6.65 ms
降到 5.73 ms。那次运行的峰值 FPS 是 41.4,与之前的 42.4 相差在噪声范围内。
人群行为还正常吗?
改用 tick 开始时的快照会改变模拟结果。所以人群行为是通过测量检查的,而不是靠肉眼:
| Horde 深度 5,峰值时 | 每个敌人的重叠对数 | 最深重叠 |
|---|---|---|
| 新的快照路径 | 0.465 | 0.36 |
| 旧的实时读取路径 | 0.568 | 0.81 |
人群并没有挤得更紧;重叠对更少,最坏情况也更浅。对比行使用的是浮点数学选项,因为 旧的定点实时读取路径已经不存在,无法再运行。
确定性:重新认定基线,再加以证明
因为结果变了,回放摘要也随之改变。这不是一次 "什么都没变" 的优化,拉取请求里也明确 写了这一点。流程如下:
- 先确认这台机器能复现它自己的摘要(同操作系统的多次运行测试为绿)。
- 重新生成发生变化的两个 PlayMode 回放基线,并重新盖章两个 canary 回放。 EditMode、道具和回旋镖的基线没有变化。
- 全部重跑:PlayMode 摘要 18/18,EditMode 摘要 9/9,两个 canary,以及多段和六个 Boss 的矩阵测试。
- 证明新代码与顺序无关:无论 1 个还是 23 个 job 工作线程,无论 Burst 开或关, 摘要都完全相同。
只有 Windows 有回放测试数据,所以基线是在 Windows 上重新生成的。模拟版本保持为 1, 符合项目对发售前模拟改动的规定。
表现层:敌人不再走物理引擎
A/B 变体带来了两项表现层的改动:
- 敌人离开了 Box2D。 它们的
Rigidbody2D不再参与模拟。这首先需要审查每一个 trigger 和 collision 回调,确认每个回调要么因为确定性扫描已经处理了而提前返回, 要么只关心玩家。A/B:在上限时 Box2D 每帧约花 2.6 ms,开和关的击杀数完全相同。 - 屏幕外的普通敌人停止播放动画。 Boss 骨骼保留其设定的模式。
一次把敌人物理和 Animator 都完全关闭的 A/B 测得 61.1 FPS。最终上线的方案略有不同 (Animator 在屏幕外被剔除,而不是被移除),我没有重新测量这个确切组合,所以我认可的 数字是 42.4。
评审
两次独立的对抗性评审,一次针对确定性,一次针对原生/引擎代码,在合并前发现了七个 问题。其中包括:在快照之后被传送的敌人使用了过期的槽位;位于网格边缘之外的范围道具 可能漏掉敌人(现在会回退到旧的扫描);以及快照重建时如果抛出异常会让模拟冻结。七个 问题都在同一个拉取请求中修复了。
还剩什么
- 流场采样和障碍物推出,在 500 个敌人时约 0.7 ms,仍然是逐个敌人的托管代码。
- 多次运行之间的噪声约为正负 1.5 ms。有了采集测试,在一台安静的机器上重新测量只需 一行命令。
可推广的经验
先复现,再优化。 一个能在 90 秒内重现问题的测试,比任何单个修复都更有价值,因为 之后的每一个结论都要拿它来检验。
当猜测没有让数字变化时,去测量,而不是再猜一次。 HashSet 是一个合理的想法,
只是针对了错误的开销。计时器在一次运行中就找到了真正的开销。
确定性是要在其中设计的约束,而不是停下来的理由。 从快照做 Jacobi 式读取,既更 容易并行化,又与顺序无关。证据就是 1 个和 23 个工作线程下摘要完全一致。