帧同步(Lockstep):原理、实现与踩坑
帧同步(Lockstep):原理、实现与踩坑
帧同步是多人游戏里同步世界状态的一种思路:不同步「结果」,只同步「输入」,每个客户端拿到所有人的输入后各自跑同一套确定性模拟,算出来的世界必然一致。RTS、MOBA、格斗这类对象多、状态复杂的游戏常用它,因为带宽只和输入量挂钩,跟场上有多少单位无关。
什么样的游戏适合帧同步
- 玩家数在开局时固定,中途不允许新玩家加入。
- 对局有明确的结束点。沙盒、MMO 这类没有终点、随时进出的玩法不适合。
优缺点
优点
- 后端几乎不用写 gameplay 逻辑,省掉一大块服务器开发量。
- 带宽正比于「输入命令大小 × 玩家数」,与模拟对象数量无关——同步一百万个对象和同步一个对象花的带宽一样。
- 回放系统实现简单,回放文件也小:存下每帧输入就能完整重演。
缺点
- 对延迟极其敏感。哪怕只有一秒收不到消息,玩家也能明显感觉到卡顿。
- 反作弊困难。比如全图视野(map hack)这类客户端作弊,服务器看不到模拟内容,基本无能为力。
- 断线重连恢复慢。重进的客户端要从第 1 帧开始把整个世界重新模拟一遍才能追上进度。
实现
客户端:确定性计算
帧同步的命门是所有客户端的模拟结果必须逐位一致,所以要把一切不确定来源掐死。
用定点数(Fixed-point)代替浮点。 不同 CPU 架构、操作系统、第三方库都可能让浮点运算结果有微小差异,时间一长就 desync。逻辑模拟里禁止用
float,全部走Fixed64之类的定点实现。统一随机种子。 由服务器决定随机种子,所有客户端在开局前设成同一个值。
固定步长模拟。 模拟只能在一个函数里推进,且用固定的 delta time:
void LockstepManager::FixedUpdate(Fixed64 deltaTime) { PhysicsManager.Instance.FixedUpdate(deltaTime); LevelManager.Instance.FixedUpdate(deltaTime); SceneObjectManager.Instance.FixedUpdate(deltaTime); // ... }这个函数之外的任何逻辑都不许碰世界状态(比如引擎自带的
FixedUpdate回调就不能用)。步长固定,例如逻辑帧 15 FPS:deltaTime = Fixed64(1) / Fixed64(15); // 每秒 15 个逻辑帧模拟只在收到输入数据时触发。 没有网络输入就不推进:
while (true) { if (HasInputData()) { ConvertInputDataToCommands(); LockstepManager::FixedUpdate(FixedDeltaTime); } yield return null; }所有实体在数组里用同一套索引。 各端的对象布局一致,命令才能对得上号。
客户端:渲染与模拟解耦
模拟由网络输入触发,跑在较低但固定的帧率(如 15 FPS),更新内存里所有实体的逻辑状态。渲染则跑在 60 FPS 或更高,只根据实体的逻辑状态去画——渲染绝不修改实体状态。两者之间用插值衔接(见下文优化)。
服务器
服务器在帧同步里很「轻」:
- 收集各客户端下一逻辑帧的输入消息。
- 以固定频率派发输入(例如每 66ms 一次),即使这一帧没有任何玩家输入也照常派发——靠这个节拍驱动所有客户端同步推进。
- 撮合相同 build 版本的玩家(版本不一致会因代码差异直接 desync)。
可靠 UDP
逻辑帧 15 FPS 时,客户端每 66ms 才被一次网络消息驱动一次逻辑更新,所以网络延迟就是体验的主要瓶颈。TCP 还是 UDP?
- TCP 丢包会自动重传,UDP 不会。
- TCP 下丢失包之后到达的包会被对端丢弃、要求发送方重发;UDP 会把所有收到的包都交给应用层。
- TCP 发现丢包时会把 RTO(重传超时)翻倍,也就是说重传间隔越来越长;UDP 则完全按你的节奏发包。
结论是:自己在 UDP 上实现一套可靠传输更高效——既能控制重传策略,又不被 TCP 的拥塞退避拖慢。
调试 desync
不同步问题堪称「平行宇宙生成器」:一旦某帧算岔了,各客户端的世界会越走越远,彻底变成两个世界。两个实用的排查手段:
- 客户端上报世界状态快照哈希。 把所有实体的关键信息(transform、血量、等级、装备等)打成一个 hash,每隔固定帧数(如每 10 帧)发给服务器。服务器一比对就知道哪个客户端先 desync 了,定位到具体帧。
- 服务器存全程输入流。 把每帧输入数据存成文件,发给不同客户端反复重放,稳定复现 bug——因为帧同步是确定性的,同一份输入必然重演出同样的错误。
优化
- 插值。 渲染要尽量高帧率,但逻辑数据是低帧率产出的,所以每个渲染帧都要在前后两个逻辑状态间做插值,否则画面会一跳一跳。
- 快照加速重连。 断线/崩溃重进时,从第 1 帧重新模拟整个世界会非常久。定期把世界快照存盘,重连时直接从最近快照恢复,再补跑少量帧,能省掉大段重算时间。