做联网游戏绕不开一个问题:选状态同步还是帧同步。帧同步(lockstep)只传输入,各端各自跑模拟,好处是带宽几乎为零、天然支持回放;前提是模拟必须确定——同样的初始状态加同样的输入序列,第 N 步后每个物体的位姿逐比特一致。物理引擎恰恰是确定性最脆弱的环节。Jolt 官方文档对确定性的表述很克制:"在相同平台、相同编译产物、相同输入的前提下",这给了一堆定语。这篇逐个拆这些定语,看它们在 Godot 的接入层还剩多少成立。
确定性的第一道裂缝:浮点不结合
多数人对浮点误差的印象停留在"0.1 + 0.2 ≠ 0.3",但破坏模拟确定性的不是舍入误差本身,而是运算顺序。IEEE 754 规定了单条运算的舍入规则,却不保证结合律:
$$(a \oplus b) \oplus c \neq a \oplus (b \oplus c)$$
一个具体例子(双精度):$a = 10^{16}$、$b = 1$、$c = -10^{16}$。按 $(a+b)+c$ 的顺序,$a+b$ 舍入回 $10^{16}$,结果得 $0$;按 $a+(b+c)$ 的顺序,$b+c = 1$,结果得 $1$。两边各自都"符合 IEEE 标准",但结果差 1——而 1 在物理模拟里就是一次碰撞判定的翻转,翻转之后两个世界的分歧只会放大。
这一步出现的位置比想象更早:编译器。开启 -ffast-math 后,编译器有权重排浮点表达式、把 $a \times b + c$ 融合成 FMA(融合乘加,少一次中间舍入)。同一份 C++ 源码,开不开 fast-math 是两个不同的数学机器。Jolt 自己的构建对此很小心——它要求关闭浮点收缩(JPH 文档明确要求不要使用 fp-contract 做跨表达式融合),否则 a*b+c 和 fma(a,b,c) 的结果可以差一个 ulp,迭代求解器里差一个 ulp 就意味着约束是否收敛的分歧。Godot 官方模板的编译配置不做 fast-math,这条在官方构建下是守住的;但如果你自己裁剪编译引擎(见 SCons 那篇),改优化选项就是在改模拟的数学定义。
第二道裂缝:求解器是迭代式的
Jolt 的约束求解器是 Gauss-Seidel 家族的迭代法(Sequential Impulse):每个约束在当前速度场上施加一次冲量,然后轮到下一个约束。第 $k$ 次迭代更新的速度是
$$v^{(k+1)} = v^{(k)} + M^{-1} J^\top \lambda^{(k)}, \qquad \lambda^{(k)} = \mathrm{clamp}\!\big(C - J\,v^{(k)}\big)$$
关键在于 $J v^{(k)}$ 依赖当前速度场,而当前速度场依赖上一个约束刚写完的值。约束按什么顺序求解,结果就是什么——这不是实现瑕疵,是算法的固有性质。顺序固定 + 迭代次数固定 + 初值固定,结果才固定;任何一个变了,两个世界从第一个接触对开始岔开。
顺序固定这一条,在单线程下成立。问题出在多线程:Jolt 的 JobSystem 把 island(互相接触的物体簇)分发给工作线程并行求解。island 之间互不依赖,怎么分片取决于任务队列当时的空闲状态——而空闲状态受操作系统调度影响,本质上是随机的。同一台机器跑两遍,第二次某个 island 可能就换了个线程、换了个求解时刻。线程内计算结果一样,但跨 island 的速度场写入顺序可以不同……严格说,只要 island 之间无共享约束,并行求解的终值理论上一致;但只要有一个宽相或窄相阶段的归约(比如求解前的预处理和 island 划分的负载均衡)引入顺序依赖,确定性就破了。Jolt 文档的实际口径是:多线程模式下不保证确定性,要做确定性模拟得跑单线程,或者保证 job 以固定顺序、固定并发度执行。
在 Godot 里这意味什么
把上面两条落到 Godot 4.4 的接入层(modules/jolt_physics/),确定性回放的条件清单是:
- 同一物理引擎:跨引擎对比(Godot Physics 对 Jolt)没有确定性可言,连数学模型都不同;
- 同一二进制:自己导出 release 包 + 编辑器跑 debug,SSE 指令集和优化等级都可能不同,必须同产物;
- 单线程物理:Godot 的项目设置里物理默认走主线程,这个默认恰好是确定性需要的;开了服务器线程模式就出局;
- 固定步长:
physics_ticks_per_second固定,不用自动步长补偿; - 禁止时间相关输入:
randf()、Time.get_ticks_usec()这类调用是隐性输入,必须换成从输入队列派生的种子; - 脚本侧也要确定性:GDScript 的浮点行为本身确定,但你写在
_physics_process里的lerp、clamp顺序得和回放端逐行一致。
满足全部条件,Jolt 在同一台机器上可以做到逐比特回放。跨平台(Windows 对 macOS、x86 对 ARM)不行——超出 IEEE 754 的超越函数(sin、exp)各平台实现不同,差在 ulp 级,同样会被迭代求解器放大。这也是为什么商业帧同步游戏(格斗、RTS)往往自研定点数物理,而不是用任何浮点引擎。
工程上的务实答案
既然逐比特只在一台机器上成立,跨端帧同步怎么办?商业上的成熟做法是不追求逐比特,追求可检测:每端照常跑浮点模拟,每隔固定步数对关键状态算一个校验和(CRC 或哈希),随输入包广播。两端校验和一致就当没漂移;不一致说明某端的世界已经分叉——浮点累加的误差漂移是真实存在的,长跑几十分钟后总会出现——这时用最近一次一致快照回滚重放。状态校验 + 快照回滚,这套组合拳把"确定性"从硬要求降成了软要求,也就绕开了上面全部裂缝。
所以回到标题的问题:帧同步游戏能用 Jolt 吗?同机回放(观战、死亡回放、本地重放验证)可以,把上面清单钉死就行;跨端 lockstep 不建议,不是 Jolt 的锅——任何浮点迭代求解器都过不了跨平台逐比特这关,这是浮点算术的物理限制,不是工程的偷懒。真要做跨端帧同步,选项只有两个:定点数物理自研,或者接受快照回滚的 lockstep。
本文的求解器形式化描述参考 Jolt Physics 官方文档的 Determinism 一节(jrouwe/JoltPhysics),Godot 侧集成基于 4.4 的 modules/jolt_physics/。