同一个种子,四种宽高比,四局不同的游戏
CandyRush 是一个确定性模拟:一局游戏就是一个种子加上玩家的输入,回放同一份录像必须逐 tick 地产生同一局游戏。4 月时,这个承诺有一个漏洞。敌人在哪里生成,取决于玩家的屏幕形状和 帧率,还可能取决于 CPU,所以同一个种子在 16:9 显示器和 4:3 显示器上并不会产生同一局游戏。 只要有一次生成不同,之后的每一次生成也都会不同。
4 月 25 日,拉取请求 #240 修复了它:
| 之前 | #240 之后 | |
|---|---|---|
| 生成时选边的阈值 | 来自实时相机的 aspect / (aspect + 1) | 来自固定参考宽高比的一个常量 |
| 每次生成尝试循环的随机数抽取次数 | 不固定,在第一个合法位置处停止 | 固定,始终尝试 10 次 |
| 生成调用 | Unity Timeline,每个渲染帧一次 | 进入队列,在固定的模拟 tick 上处理 |
| 屏幕外传送的子集选择 | Time.frameCount % n | tick % n |
| 对角线与旋转计算 | Mathf.Sqrt、Quaternion.Euler | Fixed64.Sqrt、FixedMath.Rotate |
这个拉取请求修改了 21 个文件(+1,115 / -74),新增 29 个 EditMode 测试。两天后,#251 把 屏幕宽高比变成了一个按会话锁定、并记录在回放头中的输入,修复了 #240 当时接受的唯一一个取舍。
代码由 AI 编程代理根据该 issue 编写;工作被拆成三个层级,我审查了每一层并把关了合并。
问题是怎么浮现的
它是意外发现的。4 月 11 日,我合并了流场寻路的工作(#218),让每个敌人从一张共享网格中查找 方向,而不是直线追逐玩家。那次提交的说明估计其开销约为每秒 5 ms CPU,并新增了 74 个 EditMode 测试。在阅读它的逐敌人生成诊断日志时,日志显示了另一件事:一批敌人中的第一个每次都在 同一位置生成,而它之后的每个敌人都出现了分歧。issue #227 在同一天被创建。
根本原因:屏幕渗入模拟的三条途径
1. 生成分支读取了相机的宽高比
敌人在可见区域外侧生成。为了选择哪条边,相机代码抽取一个随机数,并与一个由实时相机推导出的 阈值比较:
if (rng.Value(RngScopes.Spawning) > mainCamera.aspect / (mainCamera.aspect + 1))
这个阈值取决于玩家的屏幕:
| 屏幕 | 阈值 |
|---|---|
| 1:1 | 0.500 |
| 4:3 | 0.571 |
| 16:9 | 0.640 |
| 21:9 | 0.700 |
随机数 0.6 在 4:3 屏幕上会选择左右边,在 16:9 屏幕上会选择上下边。同一个种子、同一次抽取, 敌人的位置却不同。用来放置这个点的边界同样依赖宽高比,因为可见宽度就是正交尺寸乘以宽高比。
2. 重试循环让分歧扩散
一个位置可能落在障碍物上,所以生成器最多重试 10 次,并在第一个合法位置处停止:
while (triesCount < maxTriesCount) // up to 10
{
triesCount++;
float variation = rng.Value(RngScopes.Spawning) * 0.2f;
position = cameraManager.GetRandomPointOutsideCamera(0.5f + variation);
if (stageFieldManager.ValidatePosition(position, Vector2.zero))
{
foundPosition = true;
break; // early exit: the RNG offset now depends on the retry count
}
}
每次尝试消耗四个随机数。如果第一个敌人在一块屏幕上需要两次尝试、在另一块上需要三次,随机数流 就错开了四次抽取,之后的每次生成读到的数字都不同。这与日志中的模式一致:一批中的第一个敌人 一致,之后的都不一致。单个错误的值只是一个小 bug;错位的随机数流则会把它变成另一局游戏。
3. 所有按相机尺寸确定自身范围的东西
同样的实时相机边界还输入给另外两个系统。流场网格覆盖相机区域加上一段缓冲,所以更宽的屏幕会 登记一组不同的障碍物,敌人的寻路和碰撞也随之不同。空的测试关卡掩盖了这一点,因为没有道具, 也就没有可以产生分歧的东西。无尽关卡也通过与实时边界比较,来决定何时生成和移除场景区块以及 区块上的道具。
除了宽高比问题,同一条路径上还有两处与帧率的耦合。波次行为在 Unity Timeline 的
ProcessFrame 中调用 Spawn,它每个渲染帧运行一次,而不是每个模拟 tick 一次,并且读取的是
逐帧插值的相机位置。屏幕外传送检查(把掉队的敌人移回玩家前方)用 Time.frameCount 来挑选
敌人子集。在 30 fps 和 144 fps 下,这些会产生不同的结果。
修复,分三个层级
这个拉取请求以三次提交落地,每次都带有自己的测试。
第 1 层,与宽高比无关。 一个新的纯函数辅助类 DeterministicSpawnBounds,以固定的 16:9
参考宽高比完成所有生成计算。相机仍然显示玩家真实的屏幕;只是模拟不再看它。无论走哪个分支,
这个辅助类都恰好抽取三个随机数。重试循环改为固定的 10 次迭代:它保留第一个合法候选位置,但会
继续抽取直到用完预算,因此每次生成消耗的随机数数量是恒定的。流场网格、区块激活检查和传送距离
全部改用同样的确定性边界。
第 2 层,与帧率无关。 波次行为现在调用 EnqueueSpawn。生成器在 Simulate(tick, dt)
内部处理这个队列,所以无论帧渲染得多快,随机数抽取和位置读取都发生在固定的 tick 上。传送检查
也移到了 Simulate 中,并按 tick % n 步进。
第 3 层,与 CPU 无关。 浮点平方根和三角函数在 x87、SSE2 和 ARM NEON 之间,或在以不同
方式融合乘加的 JIT 之间,并不保证逐位一致。一个 ULP 的差异就足以翻转一次严格的小于比较,让
敌人在一台机器上被传送而在另一台上不被传送。对角线现在使用 Fixed64.Sqrt,传送旋转使用
FixedMath.Rotate,而我的共享库 UTools 中配套的一次改动(7079b05f)把 DeterministicRng
的单位圆辅助函数改到定点数的正弦、余弦和平方根上,同时保持抽取的次数和顺序不变。
第 3 层的测试断言的是精确的 Fixed64.RawValue 结果,而不是容差。拉取请求中的理由是:对确定性
契约使用容差,会悄悄接受一种回退,即退回到在每个平台上只是“大致正确”的浮点运算。完整的划分是
DeterministicSpawnBoundsTests 中 24 个测试、WaveBehaviorEnqueueTests 中 5 个,其中包括一个
检查两个生成分支消耗相同抽取次数的测试。拉取请求报告 2,602 个 EditMode 测试中 2,600 个通过,
另外两个失败在 dev 上已经存在;issue 的状态更新记录了一次手动检查:在 16:9、4:3、1:1 和
一个超宽分辨率下,敌人的世界坐标逐位一致。
同一天,另一次提交(8d519867e)堵上了同一类型的两处较小泄漏:生成抖动改用独立的随机数流,
使其无法错开主生成序列;敌人碰撞半径不再读取 Collider2D.bounds,这是一个物理引擎给出的值,
在不同平台上最低位可能不同。它又新增了 26 个测试。
取舍与跟进
固定的 16:9 参考有一个已知代价,写在这个辅助类自己的注释里:在更宽的屏幕上,敌人可能在左右边缘 的可见区域内生成,场景区块也会激活得较晚。
4 月 27 日的跟进 #251 在不放弃确定性的前提下修复了这一点。参考宽高比不再是常量。它按会话从 玩家设置中选定,在一个关卡期间锁定,并写入新版本的回放头。回放时从回放头读取它,所以无论在 什么屏幕上观看,回放都使用录制时的宽高比。没有这个字段的旧回放按 16:9 处理,这正是它们录制时 所用的宽高比。它新增了 31 个测试,包括每个回放头版本的往返测试。
这就是这一整类 bug 的通用修法:任何可能改变模拟的东西,要么从模拟中移除,要么作为输入记录下来。
为什么这对可验证的分数很重要
这一年晚些时候,CandyRush 的每周排行榜开始只在服务器用已发布的游戏构建重新运行录像、并比较 模拟状态的滚动摘要之后,才接受一个分数。The Verified Score 介绍了这个系统。它只有在录像包含所有影响对局的东西时才成立。在这些修复之前,一份在超宽显示器上 录制、在另一种分辨率的无头浏览器中验证的录像,会是两局不同的游戏。诚实的玩家会被拒绝,而且仅凭 分数没有人能看出原因。
同一天,#239 把测试用作弊功能从生产构建中移除:作弊方法在编辑器和开发构建之外会被编译掉, 作弊面板在加载时会自我销毁。如果产生回放的构建带着无敌开关发布,那么经过验证的回放也没什么意义。
可以推广的经验
确定性泄漏藏在第一次成功的背后。 在发现这个问题的日志里,每批的第一个敌人都是一致的。 要测试第二次、第十次和第一百次生成,而不只是第一次。
修复随机数流,而不只是修复值。 依赖宽高比的分支产生了一个错误的位置。提前的 break 把它
变成了错位的随机数流和另一局游戏。每个操作消耗恒定数量的随机数,是一个值得直接测试的契约。
玩家的机器要么被记录,要么被忽略。 屏幕形状、帧率和 CPU 都是玩家的机器决定的。每一项要么 不进入模拟,要么进入回放。
确定性测试应当是精确的。 确定性测试里的容差,恰恰接受了它本该捕捉的那种漂移。