浅析游戏同步技术:帧同步和状态同步
0 前言
一年多以前,当我从上一家公司离职时,我对“帧同步”这三个字几乎一无所知,仅有的了解只是八股文里那些冰冷的理论。而加入新公司的这一年多,我几乎一半的时间都在和项目的帧同步框架打交道,因为这段经历,我对帧同步以及其他的同步技术有了更多的理解,以此文章作为记录,仅作学习使用。
一个之前项目主程推荐的帧同步框架:Github地址
1 同步机制
同步机制的目标是让所有客户端看到一致的世界状态(渲染一致+数据一致),同步机制主要分两大类:客户端模拟型和服务器权威型,典型的例子就是帧同步和状态同步。
2 帧同步
帧同步作为客户端模拟型机制,其核心就是所有的逻辑计算放在客户端,服务器不做任何逻辑处理,只做转发操作。
但商用实现中,服务器也会运行逻辑代码,在纯帧同步中,服务器运行逻辑主要用于哈希校验;在混合变体中,则作为权威仲裁。
2.1 实现
帧同步的一般流程是:
- 每个玩家都会在每固定逻辑Tick里产生输入命令(如移动角色、释放技能等),这些命令通常很小(几字节),客户端会将这些输入命令发送给服务器
- 服务器收集所有客户端在本次逻辑Tick中的输入,而后广播给所有客户端
- 所有客户端在同一逻辑Tick,根据收到的输入命令,执行游戏逻辑
- 重复上述过程
帧同步的核心假设是:游戏逻辑是确定性的,也就是说,给定相同的初始状态和相同的输入序列,所有客户端运行相同的代码会得到完全相同的游戏状态,这也就实现了多端同步。
2.2 常见变体
2.2.1 纯帧同步
纯帧同步的核心是玩家的游戏状态完全依赖于客户端本地逻辑代码计算,服务器只做辅助校验(作弊、不同步校验等),不作为权威强制覆盖状态。即整个系统不依赖任何中央权威状态。
锁定步进算法(LockStep)
所有客户端必须等待服务器收到所有玩家的输入后才能推进帧。如果某个玩家延迟高,整个游戏会“锁步”等待,从而保证严格一致。
主要问题很明显,就是高延迟玩家会拖慢整个游戏,影响其他玩家的游玩体验。
延迟锁步
延迟锁步是帧同步机制的一种优化变体,主要优化了纯锁步中高延迟玩家导致的卡顿问题。
延迟锁步在纯锁步基础上引入输入缓冲延迟:
- 客户端维护一个固定延迟帧数K
- 在第N帧,客户端收集本地玩家输入,这个输入被标记为属于“N+K”帧的命令,然后发送给服务器
- 服务器收集所有玩家输入,广播完整输入包
- 客户端维护一个输入缓冲队列,如Dictionary<帧号,输入包>,当客户端推进到第M帧时,从输入缓冲中找到key为M-K的输入包并执行
- 由于存在固定延迟帧数,导致玩家操作感觉有固定延迟,因此客户端需要做本地预测(玩家操作后,立即在渲染层应用输入,当权威逻辑追上时(K 帧后),如果预测正确,就无缝衔接,如果有细微差异,进行校正
优化:
将延迟帧数从固定改为动态调整,监控每个玩家的RTT(往返时延),每隔几秒重新计算并广播新的K值。
RTT:客户端发送一个"ping包",服务器立即回复,客户端收到时间-发送时间=RTT。
理想K值:最大玩家RTT/帧间隔时间+安全裕度。
回滚-Rollback Netcode
Rollback Netcode专为高实时性、高精度对战游戏设计(如格斗游戏),它解决了传统帧同步(锁步/延迟锁步)在高延迟下的卡顿和滞后问题,让玩家即使跨洲匹配,也能感受到“本地单机般丝滑”的操作响应。
其经典和核心实现是 P2P(点对点,无专用服务器):
- 每帧,A客户端收集本地输入,发送给B(帧N)
- 未及时收到B的输入,则预测B输入,并立即执行
后续校正
- 收到B的真实输入,进行比对,不一致,进行校正
- 校正:加载帧N的历史快照,根据历史快照回退到帧N时刻,用真实输入替换预测输入,然后从帧N开始,追帧,重新执行完整游戏逻辑到当前帧
- 继续推进
2.2.2 混合帧同步
乐观帧
乐观帧同步和回滚帧同步在预测部分和比对部分的思路是基本一致的,核心区别是校正部分不同。乐观帧同步本质上是帧同步和状态同步的混合模式,校正部分依赖于服务器权威(这部分是也是其状态同步的部分)。
实现过程:
服务器以固定Tick定时广播当前收集到的输入包
如果某个玩家的输入没及时到达,服务器不等待,而是用预测输入填充(常见方式:复制该玩家上帧输入,或假设“无操作”)
所有客户端收到广播后,立即执行逻辑,继续前进
后续校正
当迟到的真实输入(假设为帧N发送的输入)终于到达服务器,服务器会取出帧N缓存的预测输入,与真实输入进行比对。
如果发现不一致,进行局部重算:将真实输入插入到缓存中,重算帧N到当前帧受迟到输入影响部分的逻辑(局部重算,区别于Rollback),得到校正后的状态,进行广播。
客户端收到校正状态后,覆盖本地状态。
2.3 确定性保证(帧同步最难的部分)
帧同步框架下写逻辑代码时,必须保证不同客户端运行相同的逻辑代码时,结果是一致的,这就要求程序员自身在写逻辑代码时要时刻注意,对程序员自身素质要求比较高。
结合之前的项目经验,总结常见的不一致问题有以下几个。
2.3.1 浮点数运算结果不一致
1 不一致的原因
浮点数在不同平台上计算的结果不一致的本质原因是:不同平台在浮点数的舍入问题上处理方式不一样。
具体不一致的点:
- 硬件寄存器的分配策略不同,导致舍入发生的时机不同
- 平台1 (PC 端的 Intel CPU): 寄存器多,编译器优化激进,让数据在 80 位寄存器里多待了 5 个步骤,最后才舍入成 32 位
- 平台2(移动端的 ARM 或不同的编译器):寄存器分配逻辑不同,可能每算一步就写回内存
- 同样的代码,平台 1 经历的是“高精度连算 \(\rightarrow\) 一次性舍入”,而平台 2 经历的是“算一步 \(\rightarrow\) 舍入 \(\rightarrow\) 算一步 \(\rightarrow\) 舍入”,因此最终结果可能存在差异
- 浮点数指令集实现不同,导致舍入发生的次数有波动
- 比如有的平台支持FMA指令,它把乘法和加法合在一起只做1次舍入,而老平台必须先算乘法(舍入1次)、再算加法(再舍入1次)
2 定点数(Fixed-Point)
解决方案:完全避免使用浮点数,使用整数来模拟小数(定点数)。在计算机底层,整数运算(加减乘除)在任何符合标准的 CPU 上结果都是绝对一致的。定点数利用了这一点,人为规定:整数的最后 \(n\) 位代表小数部分。
为什么定点数可以解决1中不同平台上浮点数的舍入问题?
我们在讨论浮点数时说,本质原因是“舍入时机和方式不一致”。定点数完美规避了这点:
- 舍入时机可控: 舍入(截断)只发生在乘除法后我们手动移位的那一刻,代码怎么写,所有平台就怎么跑
- 不依赖 FPU: 它完全使用 CPU 的整数运算单元(ALU),不涉及 80 位浮点寄存器或 FMA 指令
- 确定性: 整数运算的溢出和截断规则在 CPU 指令集里是严格定义的,全球统一
具体做法
- 首先,我们需要决定用多少位来存储数据。最常用的是 64位整数
(
long),因为它范围够大,不容易溢出 - 其次,约定一个位移量,比如16
- 整数占高48位
- 小数占低16位
- 基础运算:手动处理小数点
- 加法:直接算,因为小数点位置相同,所以直接加减即可
- \(1.2 + 2.3 \rightarrow 1200 + 2300 = 3500\)
- 乘法:先乘再移位
- \((a \times 2^{16}) \times (b \times 2^{16}) = (a \times b) \times 2^{32}\)
- 为了回到我们的 Q16 格式,必须右移 16 位
- 在做乘法时,
a.RawValue * b.RawValue可能会瞬间超过long的最大值。技巧:内部计算时可以使用__int128(C++)或者先将部分数值拆分处理,确保中间结果不丢失
- 除法:先移位再除,如果直接除,缩放因子会被抵消掉,变成普通整数。所以要先左移
- \((a \times 2^{16} \times 2^{16}) / (b \times 2^{16}) = (a / b) \times 2^{16}\)
- 加法:直接算,因为小数点位置相同,所以直接加减即可
- 复杂函数:必须重写所有的数学函数(如
Sin,Cos,Sqrt,Atan2)。通用做法是查表法- 预计算:在开发阶段,用高精度浮点数算出 \(0^\circ\) 到 \(360^\circ\)(比如每隔 \(0.1^\circ\) 一个点)的
Sin值 - 将这些值转成定点数,存在一个巨大的
readonly int[]数组里 - 运行过程中,根据角度直接去数组里取值
- 如果输入的角度在两个点之间,用简单的线性插值算出一个近似值
- 预计算:在开发阶段,用高精度浮点数算出 \(0^\circ\) 到 \(360^\circ\)(比如每隔 \(0.1^\circ\) 一个点)的
1 | public struct FP { |
3 软件模拟浮点数(软浮点数 SoftFloat)
软浮点数放弃调用CPU硬件的浮点运算单元(FPU),改用纯软件逻辑(位运算和整数运算)来模拟IEEE 754浮点数标准。
核心思想:拆解浮点数结构
要模拟浮点数,首先要按照 IEEE 754 标准把一个 32
位浮点数拆成三部分(以 float 为例):
- 符号位 (Sign): 1 位,决定正负
- 指数位 (Exponent): 8 位,决定范围
- 尾数位 (Mantissa): 23 位,决定精度
实现逻辑: 软件模拟库会定义一个结构体,把这 32 位数据当成一个普通整数读进来,然后通过位掩码(Mask)和位移(Shift)把这三个部分拆开
具体运算流程(以加法为例)
软件模拟加法的过程,实际上就是手动复现 FPU(浮点运算单元)的工作原理:
- 解包: 提取两个数的符号、指数和尾数
- 对阶:比较两个数的指数。如果指数不同,就将指数较小的那个数的尾数向右移位,直到两者的指数对齐
- 求和: 根据符号位,对尾数进行整数加法或减法
- 规格化: 检查结果的尾数。如果超过了 23 位,就右移并增加指数;如果首位不是 1,就左移并减少指数
- 舍入: 这是最关键的一步!代码里写死固定的舍入规则(比如“舍入到最近,一样近时向偶数舍入”)。无论 CPU 硬件想怎么算,代码只按这一套规则执行
- 封包: 将处理完的符号、指数、尾数重新拼接成一个 32 位整数
4 方案对比
| 特性 | 软件模拟浮点 (Soft-Float) | 定点数 (Fixed-Point) |
|---|---|---|
| 数值行为 | 与原生 float 几乎一致,范围极大 |
行为像整数,范围有限,易溢出 |
| 性能 | 极慢(模拟一个加法可能需要几十条整数指令) | 极快(基本就是整数运算) |
| 实现难度 | 极高(需精通 IEEE 754 规范) | 中等(主要是处理好位移) |
| 典型代表 | Berkeley SoftFloat 库 |
绝大多数帧同步手游(如《王者荣耀》) |
2.3.2 随机数不一致
当不同客户端使用相同的随机函数执行相同的随机逻辑,它们的结果在大多数情况下仍会不一致的。这有很多方面的原因:
- 我们经常使用的随机函数(如UnityEngine.Random或System.Random),虽然底层实现上是伪随机的,但其初始化或内部状态往往依赖系统时间、线程、平台差异等不可控因素,导致跨客户端序列不一致
- 即使序列生成本身是确定性的,若随机函数被渲染层(如粒子效果、UI 动画)或其他非权威逻辑调用并消耗随机值,也会造成不同客户端的状态机推进步数不同步,从而在核心游戏逻辑(如伤害计算、掉落判定)中产生偏差
- ...
总之,为确保帧同步的一致性,我们必须使用专为逻辑层设计的确定性伪随机生成器:它的底层实现不依赖系统时间或平台,实现纯算法驱动,并通过服务器下发随机种子和严格控制调用顺序(仅限逻辑层使用)来保证所有客户端产生完全相同的随机序列。
一个随机数实现方案:
1 | using System; |
2.3.3 其他容易产生不一致的地方
- 在逻辑层使用无序遍历容器,如直接遍历 Dictionary 或
HashSet
- 原因:这些容器的遍历顺序依赖内部哈希码实现,而哈希码在不同平台(PC/iOS/Android)、运行时(Mono/IL2CPP)、.NET 版本或甚至同一平台的不同构建中可能不一致,导致相同数据产生不同遍历顺序,从而引发逻辑分支或计算结果差异
- 使用系统自带的排序方法,如 Array.Sort、List.Sort 等
- 原因:这些方法底层通常采用快速排序(QuickSort)或其变体,而快速排序本身是不稳定的(相同元素的相对顺序可能改变,可能会间接影响结果)。更关键的是,pivot选择策略在不同 .NET 实现或平台优化下可能略有差异,导致相同输入数组在不同客户端产生不同的排序结果
- 尽量使用稳定排序方法,如归并排序、插入排序等
- ...
2.4 追帧
2.4.1 描述
在帧同步中,客户端的逻辑进度完全依赖于服务器下发的指令。如果你的网络抖了一下,或者手机卡了几秒,你本地的逻辑帧号(Local Tick)就会落后于服务器当前广播的帧号(Server Tick)。为了回到实时的战斗状态,你必须进行追帧。
核心原理:逻辑和表现的彻底分离。
追帧能够实现的前提是,你在写代码的时候严格遵守了逻辑层与表现层的分离。
- 逻辑层:只负责计算数值(坐标、血量、技能状态),它必须是确定的、纯算术的
- 表现层:负责渲染模型、播放动画、特效和音效
追帧的操作本质是:在一帧的时间内,利用CPU循环执行多次逻辑层计算,而跳过所有的表现层渲染。
2.4.2 具体流程
当客户端发现
ServerTick - LocalTick > 阈值(通常阈值为 1 或
2)时,就会触发追帧逻辑:
逻辑补算阶段
这是追帧的核心,在一个渲染帧的时间内,CPU会通过一个while或for循环强制执行逻辑:
1 | // 伪代码示例 |
- 静默执行:在
LogicUpdate里,只计算碰撞、位移、血量、技能逻辑 - 时间切片控制
(
MAX_CATCHUP_PER_FRAME):这是为了防止“追帧死循环”。如果电脑性能太差,追 10 帧的时间比 10 帧正常运行的时间还长,就会陷入无限追帧。通常限制每帧最多追 10~20 帧
表现层拦截
在循环执行 LogicUpdate
时,系统必须拦截掉所有视觉和听觉的反馈:
- 动画跳过:
Animator不更新,或者直接强制 SetTime 到最新状态,不进行插值 - 音效静音:追帧期间触发的
PlaySound请求必须被静默或丢弃,否则追完的一瞬间你会听到几十个爆炸声重叠在一起 - 特效降级:不产生粒子,或者只在最后一帧产生必要的特效
状态恢复与平滑
当循环结束(即追上了服务器进度,或达到了单帧上限)后,需要让玩家看到的画面跟上逻辑。
- 瞬移对齐:表现层物体(View Object)直接将自己的坐标设置为逻辑层(Logic Object)计算出的最新坐标
- 高速插值:如果追帧距离较短,表现层可以在接下来的几个渲染帧内加速移动,而不是瞬间跳过去,以减少视觉上的“闪烁感”
2.4.3 客观性能开销与挑战
追帧虽然看起来只是“快进”,但在工程实践中有几个非常棘手的问题:
CPU 瞬时峰值
- 平时一帧算 1 次碰撞检测,追帧时一帧算 20 次。这会导致 CPU 占用率瞬间飙升,产生发热。如果逻辑层代码写得烂(比如在循环里写了 \(O(N^2)\) 的算法),追帧可能导致游戏直接崩了
GC(垃圾回收)压力
- 如果你在逻辑更新里写了
new操作或者使用了产生 GC 的字符串操作,追帧时的 GC 量会是平时的数十倍,这会导致追帧结束后紧接着一个大卡顿
- 如果你在逻辑更新里写了
断线重连(长距离追帧)
如果玩家掉线 1 分钟,可能落后了 3600 帧(按 60fps 算)
硬追:从第 0 帧重算到 3600 帧。优点是逻辑绝对准确,缺点是耗时极长
快照(Snapshot):从服务器拉取最新的“状态快照”,直接覆盖本地数据。优点是瞬间追上,缺点是快照数据的序列化非常复杂
3 状态同步
状态同步作为服务器权威型机制,其核心是服务器运行完整的游戏逻辑,计算权威状态并广播给客户端,客户端主要负责预测和渲染。服务器是“真相”的唯一来源,客户端的状态始终向服务器校正靠拢。这与帧同步的分布式计算相反,状态同步更注重稳定性和防作弊。
商用实现中,客户端会进行本地预测以隐藏延迟,但最终以服务器广播的状态为准。
3.1 实现
状态同步的一般流程是:
- 客户端每帧(或固定间隔)收集本地玩家输入(如移动、射击),发送给服务器
- 服务器作为权威,收集所有输入,运行完整游戏逻辑(移动、碰撞、伤害、技能等),计算新一帧的世界状态(所有对象的位置、血量、动画状态等)
- 服务器广播状态快照或增量变化给所有客户端
- 客户端收到状态后:
- 对远程对象:直接应用或插值平滑显示
- 对本地玩家:比对预测状态 vs 权威状态,如果偏差,进行校正(常见平滑插值或 rubber banding)
- 重复上述过程
状态同步的核心假设是:服务器完全可信,客户端只需“追上”服务器状态。
3.2 常见变体
3.2.1 纯状态同步
纯状态同步不做客户端预测,客户端完全依赖服务器广播状态显示:
- 服务器定期发送完整或增量状态
- 客户端接收后直接应用
主要问题:带宽消耗大(状态数据远多于输入命令),延迟感强。
3.2.2 客户端预测 + 服务器校正
- 客户端对本地玩家进行预测:输入后立即本地执行(移动、射击动画),感觉无延迟
- 同时发送输入到服务器
- 服务器权威计算后,广播权威状态
- 客户端收到后:
- 远程对象:插值/外推平滑
- 本地玩家:比对预测 vs 权威
- 一致 → 无操作
- 不一致 → 校正
4 怎么判断一个游戏用的是帧同步还是状态同步?
预测后校正的方式不一样
- 帧同步
- 帧同步在发生结果不一致时,会进行回滚,它会回退到上一个确定的逻辑帧(画面会发生闪回或者跳变),重新执行所有指令序列。所以如果看到时间倒流又极速快进的表现,一般是乐观帧同步
- 状态同步
- 会进行直接进行校正,服务器直接发来“正确的位置”,客户端强制拉回或平滑过渡
断线重连的加载方式
- 帧同步
- 通常需要“追帧”,即游戏会极快地重新跑一遍之前的战斗逻辑(录像回放式加载) 。如果一局游戏打了很久,你重连时可能需要加载较长时间
- 状态同步
- 通常是“全量快照”。服务器直接发给你当前地图上所有物体的坐标、血量等最终状态,你瞬间就能进入游戏,无需关心之前发生了什么
录像文件的体积
- 帧同步
- 录像文件体积较小,因为它只需要记录每个玩家在每一帧的操作
- 状态同步
- 录像文件较大,因为它必须记录关键时间点上所有实体的坐标、属性等状态信息
抓包分析
- 帧同步
- 数据包频率固定,且每个包的内容通常很简短,主要是所有玩家的操作指令序列
- 状态同步
- 数据包大小波动强烈。平时可能很小,但当视野内出现大量单位或爆发战斗时,数据包会因为需要下发大量位置和属性状态而激增
5 为什么FPS游戏要采用状态同步?
极致的实时反馈(解决“操作粘滞感”)
FPS 是对“输入-响应”要求最严苛的游戏类型。
- 原因: 状态同步允许客户端预测(Client-side Prediction)。玩家按下移动或开火时,本地立即渲染结果,不需要等待网络往返
- 对比: 帧同步如果做预测,在高延迟下会导致频繁的逻辑回退(Rollback),造成画面反复跳变(瞬移);如果不做预测,玩家就会感觉到明显的按键延迟
复杂的物理与确定性挑战(解决“逻辑分歧”)
FPS 涉及大量的 3D 物理模拟(如复杂的碰撞体、布娃娃系统、手雷抛物线)。
- 原因: 状态同步只需要服务器运行权威逻辑,客户端只负责表现。即便不同硬件(PC/手机)计算出的物理位置有 0.01 厘米的偏差,也会被服务器发来的状态强制修正,不会导致游戏崩溃
- 对比: 帧同步要求所有客户端计算结果绝对一致。在 3D 物理引擎下实现跨平台、跨硬件的“位对位一致”开发成本极高,一旦有一帧算错,全场直接不同步(Out of Sync)
核心判定:延迟补偿(解决“射击公平性”)
这是 FPS 的命脉——“所见即所得”。
- 原因: 状态同步支持延迟补偿(Lag Compensation)。服务器可以“回溯时空”,检查 100ms 前敌人的位置。这保证了网络稍差的玩家只要准星对准了目标,就能判定击中
- 对比: 帧同步下,由于所有人的逻辑帧必须严格对齐,很难在不破坏整体同步的前提下,为某个高延迟玩家单独进行“时间回溯”判定
带宽与单位数量的权衡(解决“效能比”)
- 原因: FPS 战局中活跃单位(玩家)通常较少(10-100人),同步这些人的坐标、旋转、状态所占用的带宽在现代网络下完全可以接受。
- 对比: 只有像 MOBA/RTS 这种有成百上千个小兵/单位需要同步的游戏,才会为了节省流量而被迫忍受帧同步带来的开发难度。
6 丢包严重的情况下,怎么保证在操作瞬间不会感到卡顿?
1.客户端预测+本地先行
当玩家进行操作,客户端不等服务端确认,而是直接在本地执行操作。
2.指令冗余发送
客户端发给服务器的每一个数据包,不仅包含当前的指令 \(N\),还会附带前 \(K\) 个历史指令(如 \(N-1, N-2, N-3\))。这样即便当前包丢了,只要后续包能够到达,服务器就能通过后续包的冗余数据找回指令。
3.确定性回滚与平滑处理
当客户端预测错了,就必须进行回滚。另外,客户端不会硬生生地把模型闪现过去,而是在几帧内通过插值(Lerp)将本地表现模型快速平移到逻辑坐标。对于玩家来说,看到的只是球员动作稍微“晃动”了一下,而不是消失再出现。