帧同步省下的带宽,会花在你买不到的那个晚上
确定性锁帧只看输入,带宽与实体数量无关;代价是"每个字节都必须位级相同"。浮点、编译器和 late game lag 都从这里长出来。
帧同步(deterministic lockstep)大概是网络编程里最"便宜"也最贵的方案。
便宜在于它的模型:不同步状态,只同步输入。每个 tick 所有人报告自己的输入,收齐之后所有人用同一起始状态 + 同一输入序列跑同一个固定步长的模拟。 Glenn Fiedler 那篇 Deterministic Lockstep 把优点讲得很清楚:带宽和场上物体数量无关 —— 一百万个单位和一个人头的开销是一样的。而一条输入小到只有几个 bit(他举的量级是 6 bit 左右),小到不值得为它做 TCP 那种可靠重传,直接用 UDP 冗余重发几份过去的输入更划算。
贵在于:这个方案要求每个参与者的每一次运算结果完全一致。 位级一致。
你的浮点数不是全局函数
绝大多数人第一次理解这件事,是从 sin 开始。Glenn Fiedler 的 Floating Point Determinism 列的坑位我逐条抄下来,因为它们都不是理论问题,是"上线前一晚会炸"的问题:
- 超越函数不是标准规定的。
sin/cos这类 libm 实现,AMD 和 Intel 的 CPU 结果可以不同。 - FMA 会少一次中间舍入。 fused multiply-add 把乘和加合成一条指令,中间结果不落回 32 位精度 —— 于是开了
-mfma的机器和没开的机器算出的轨道不一样。 - x87 和 SSE 语义不同。x87 是 80 位内部精度,SSE 是严格的 IEEE 单/双精度;而且(Fiedler 的原话要点)SSE 的浮点行为在规范里被写得 underspecified。
- debug 和 release 不一样,
-ffast-math更是直接分裂:它允许重排结合律,而浮点加法本来就不满足结合律。 - 跨平台连
==的传递性都不能信。
所以"我们四个都用同样代码"这个前提是假的。真正的前提是一整套构建矩阵:同一编译器、同一版本、同一 flags、同一 ISA。
能锁就锁死。锁不住的时候有几条退路,Fiedler 都点了:把精度钉在 24 位(Windows/x87 上 _controlfp(_PC_24, _MCW_PC),让不同 CPU 走到同一条舍入路径)、用定点数、或者干脆把位置吸附到网格 —— 位置量化到格点之后,浮点误差就不再累积,因为它每次都被抹平了。最后这条我印象最深,因为它同时解决了两个问题:确定性和带宽。
late game lag 常常不是网卡
帧同步圈子里有个经典误会:一局打得越久越卡,玩家就怪网络。
真实原因通常是 CPU。所有端都要跑完整模拟,而模拟的状态量是单调增长的:单位多了、建筑多了、每次 tick 要遍历的东西多了。这是算力瓶颈,跟带宽无关 —— 而带宽恰恰是你当初选它的理由。
那到底该不该用?
我读到的最实用的反方意见是 Treeform(《铁锈战争》作者)的 Don't use Lockstep in RTS games。立场很直接:
- 帧同步省下的带宽,在今天已经不值那个价了。
- "必须位级相同"这个隐性成本会随机引爆 —— 一个编译器升级、一个新平台、一次
-ffast-math,然后你花几周做 desync 诊断工具。 - 有战争迷雾的游戏里,状态同步反而更抗作弊:客户端根本不持有全图状态,透挂无从下手;帧同步则天然把整张地图都在每个客户端算了一遍。
我现在倾向于这样划:帧同步适合输入流极小、状态更新极密、且玩家设备同质的场景(手游 RTS / MOBA 那一类,机型可控是它真正的先决条件);一旦你要跨 PC/Mac/手机/主机,或者要做观战和回放服务端的重放校验,状态同步的运维成本反而低。
换句话说,选帧同步前该问的问题不是"我们的带宽够不够",而是:
我能保证所有客户端在第 47 分钟还算出同一个浮点数吗?我出问题时有没有 desync 上报和快照比对工具?
这两个问题答不上来,省下的带宽就是预支的加班。
参考来源
- Deterministic Lockstep — Glenn Fiedler, Gaffer on Games(2014)
- Floating Point Determinism — Glenn Fiedler, Gaffer on Games
- Don't use Lockstep in RTS games — Treeform(2016)
- Snapshot Compression — Glenn Fiedler, Gaffer on Games(网格量化同时救确定性与带宽,下一篇展开)