Appearance
同步模型
帧同步为什么要求确定性?
因为帧同步通常只同步输入,不同步完整结果。
也就是说,服务器不会每一帧把所有角色的位置、血量、Buff、子弹、技能状态都发给每个客户端,而是只广播:
第 100 帧,玩家 A 按了移动,玩家 B 释放了技能然后每个客户端自己在本地计算这一帧的结果。
核心公式
帧同步成立的前提是:
相同初始状态 + 相同输入 + 相同逻辑代码 + 相同执行顺序 = 相同结果如果所有客户端都从同一个初始状态开始,并且每一帧拿到完全一样的输入,那么只要逻辑是确定性的,大家就会算出完全一样的游戏状态。
比如:
第 100 帧:玩家 A 攻击玩家 B所有客户端都应该算出:
玩家 B 扣 30 血
玩家 B 剩余 HP = 70
技能 CD = 5 秒这样画面表现可以不同,但逻辑结果必须一样。
为什么不确定就会出大问题?
如果逻辑不确定,就会出现分叉。
比如同样一帧攻击:
- A 客户端算:命中,敌人 HP = 70
- B 客户端算:没命中,敌人 HP = 100
从这一刻开始,两个客户端的世界已经不是同一个世界了。
后面再来一个输入:
玩家 B 释放技能A 客户端认为 B 只有 70 血。 B 客户端认为自己还有 100 血。 后续伤害、死亡、Buff、AI、掉落都会越来越不一样。
这就叫不同步,也叫 desync。
为什么状态同步没这么怕?
状态同步是服务器直接下发结果。
比如:
玩家 B 当前 HP = 70
玩家 B 当前坐标 = 10, 5客户端本地算错了也没关系,服务器状态会覆盖它。
但帧同步不一样。 帧同步为了省流量,只发输入,结果靠客户端本地模拟。 所以它对确定性要求非常高。
哪些东西会破坏确定性?
常见危险点有:
- 浮点数误差
- 不同平台浮点计算差异
Random没有固定种子- 使用
Time.deltaTime - 遍历
Dictionary、HashSet这类无固定顺序容器 - 多线程执行顺序不固定
- Unity 物理引擎结果不完全确定
- 不同机器帧率不同
- 对象创建顺序不同
- 网络输入到达顺序不同
- 逻辑里混入表现层状态
尤其是 Unity 项目里,不能随便把 Rigidbody、Physics.Simulate、浮点位移直接拿来做强帧同步核心逻辑。
怎么保证确定性?
常见做法:
- 使用固定逻辑帧,比如每秒 15、20、30 个逻辑 Tick。
- 每帧只处理这一帧确认过的输入。
- 不用
Time.deltaTime驱动核心逻辑。 - 使用固定随机种子。
- 随机数由逻辑系统统一生成。
- 核心战斗逻辑使用定点数或确定性数学库。
- 固定对象遍历顺序。
- 避免依赖线程调度顺序。
- 不把 Unity 物理直接作为帧同步核心结果。
- 定期计算状态 Hash 校验是否一致。
- 发现不同步时可以回滚、重算或请求权威状态修正。
Unity 项目里怎么说更专业?
可以这样说:
Unity 本身的 Transform、Rigidbody、Animator、Particle 更多是表现层。 如果做严格帧同步,核心战斗逻辑最好独立出来。
比如:
- 逻辑层用定点数计算位置、技能、碰撞、伤害。
- 表现层再根据逻辑结果驱动动画和特效。
- Unity 物理只做表现或非核心辅助,不决定最终战斗结果。
- 所有客户端每个逻辑帧跑同一份输入和同一份逻辑。
面试高分回答
NOTE
帧同步要求确定性,是因为它同步的是玩家输入,而不是完整游戏状态。所有客户端拿到同一帧的相同输入后,会在本地执行同一套逻辑来推导结果。如果逻辑不是确定性的,即使输入相同,不同客户端也可能算出不同的位置、血量、技能命中和随机结果,状态一旦分叉,后续每一帧都会继续放大差异,最终产生不同步。所以帧同步必须保证固定逻辑帧、固定随机、固定执行顺序,并避免浮点误差、非确定性物理和平台差异影响核心逻辑。
一句话记忆:
帧同步同步的是输入,不是结果;所以所有客户端必须确定性地从相同输入算出相同结果。
浮点数为什么可能破坏确定性?
因为浮点数不是数学里的“精确小数”,它只是一个二进制近似值。 在帧同步里,同样输入如果算出哪怕一点点不同,也可能让不同客户端进入不同分支,最后状态不同步。
为什么 float 不精确?
计算机用二进制表示小数。 很多十进制小数无法被二进制精确表示。
比如经典例子:
0.1 + 0.2 可能不是精确的 0.3它可能接近:
0.30000004这个误差很小,普通单机游戏里可能看不出来。 但帧同步里,小误差很容易变成大问题。
为什么小误差会导致不同步?
比如两个客户端同一帧都计算角色位置:
客户端 A:pos = 10.00001
客户端 B:pos = 9.99999然后逻辑判断:
pos >= 10 是否进入攻击范围结果就不同了:
客户端 A:进入攻击范围,攻击命中
客户端 B:没进入攻击范围,攻击失败从这一帧开始,两个客户端的血量、技能 CD、Buff、随机数调用次数都可能不同。 后续每一帧都会继续放大差异,这就是 desync。
浮点不确定性的来源
常见来源有:
- 小数二进制表示误差。
- 每次浮点运算都有舍入。
- 不同平台浮点精度可能不同。
- CPU 指令集可能不同。
- 编译器优化可能改变运算顺序。
- FMA、SIMD 等指令可能改变结果。
sin、cos、sqrt这类数学库跨平台结果可能不完全一致。- 浮点加法不满足严格结合律。
- Unity 物理引擎跨平台不保证强确定性。
- 多线程执行顺序不同也可能影响结果。
比如:
(a + b) + c和:
a + (b + c)在浮点里不一定完全一样。
为什么物理更危险?
物理系统通常会大量使用浮点数,还涉及:
- 碰撞检测
- 接触点求解
- 迭代约束
- 摩擦
- 速度积分
- 碰撞顺序
- 多线程调度
一点误差就可能导致接触点不同。 接触点不同,受力不同。 受力不同,下一帧位置更不同。
所以严格帧同步一般不会直接把 Unity 的 Rigidbody 和 Physics 作为核心逻辑结果。
怎么避免?
常见做法:
- 核心战斗逻辑使用定点数。
- 使用确定性数学库。
- 使用固定逻辑帧。
- 不用
Time.deltaTime驱动核心同步逻辑。 - 固定随机种子。
- 随机数统一由逻辑层管理。
- 固定对象遍历顺序。
- 不依赖
Dictionary、HashSet的无序遍历结果。 - 核心逻辑和 Unity 表现层分离。
- 定期做状态 Hash 校验。
- 发现不同步后回滚、重算或请求权威状态修正。
面试高分回答
TIP
浮点数可能破坏确定性,是因为浮点数本身是二进制近似表示,很多小数无法精确存储,每次运算还会产生舍入误差。不同 CPU、编译器、指令集、数学库和运算顺序都可能让同一段浮点逻辑得到极小差异。帧同步只同步输入,不同步完整状态,所以这种微小差异一旦影响碰撞、命中、范围判断或随机数调用,就会让不同客户端进入不同分支,最终产生状态不同步。
一句话记忆:
浮点误差本身很小,但在帧同步里,一旦影响分支判断,就会从小误差变成大分叉。
随机数如何保证同步?
帧同步里,随机数不能让每个客户端“自己随便随机”。 正确做法是:
同一个种子、同一个随机算法、同一个调用顺序,才能得到同一个随机结果。
为什么随机数会导致不同步?
比如同一帧攻击需要判断是否暴击:
暴击概率 30%如果客户端 A 随机到 20,认为暴击。 客户端 B 随机到 80,认为没暴击。
那两个客户端的伤害、血量、死亡状态都会不同。
所以帧同步里随机数必须变成“伪随机”: 看起来随机,但只要种子和调用顺序一样,结果就完全一样。
正确方案
开局时服务器下发一个随机种子:
matchSeed = 9527所有客户端用这个种子初始化同一个确定性随机数生成器。
然后第 1 次调用、第 2 次调用、第 3 次调用……所有客户端都必须得到一样的结果。
核心公式:
同 seed + 同 PRNG 算法 + 同调用顺序 = 同随机结果最容易踩的坑
表现层不能消耗战斗随机数。
比如客户端 A 播放了一个粒子特效,用了逻辑随机数随机粒子方向。 客户端 B 因为画质低没播这个粒子,所以没有调用随机数。
从这里开始,两个客户端的随机序列错位了。
下一次战斗随机:
A 用的是第 43 个随机数
B 用的是第 42 个随机数结果就不同步。
所以要分开:
- 战斗逻辑随机流
- AI 随机流
- 掉落随机流
- 表现随机流
- 音效随机流
- 特效随机流
核心同步逻辑只使用核心随机流。
C# 简单确定性随机数示例
c
public struct DeterministicRandom // 定义一个结构体随机数生成器,方便复制状态和回放
{ // 结构体开始
private uint state; // 保存当前随机状态,所有随机结果都由这个状态推导出来
public DeterministicRandom(uint seed) // 定义构造函数,用固定种子初始化随机状态
{ // 构造函数开始
state = seed == 0 ? 1u : seed; // 如果种子是 0,就改成 1,避免某些算法卡在全 0 状态
} // 构造函数结束
public uint NextUInt() // 生成一个无符号整数随机数
{ // NextUInt 函数开始
state ^= state << 13; // 使用 XorShift 第一步,把状态左移后异或回去
state ^= state >> 17; // 使用 XorShift 第二步,把状态右移后异或回去
state ^= state << 5; // 使用 XorShift 第三步,再次左移并异或回去
return state; // 返回更新后的随机状态作为随机结果
} // NextUInt 函数结束
public int Range(int minInclusive, int maxExclusive) // 生成一个整数范围内的随机数,包含最小值,不包含最大值
{ // Range 函数开始
uint value = NextUInt(); // 先生成一个无符号随机数
uint range = (uint)(maxExclusive - minInclusive); // 计算随机范围大小
return minInclusive + (int)(value % range); // 用取模把随机值限制到范围内并加回最小值
} // Range 函数结束
} // 结构体结束项目里怎么用
战斗开始:
服务器下发 seed
所有客户端用 seed 初始化逻辑随机数生成器战斗中:
所有客户端同一帧按同一顺序调用随机数比如固定顺序:
先处理玩家 1
再处理玩家 2
再处理怪物 1
再处理怪物 2不要用无序容器直接遍历,比如 Dictionary、HashSet,否则不同客户端遍历顺序可能不同。
面试高分回答
IMPORTANT
帧同步中随机数要保证同步,本质是保证随机序列确定。通常由服务器在开局下发统一 seed,所有客户端使用同一个确定性 PRNG,并且在同一逻辑帧按照完全相同的顺序和次数消费随机数。核心战斗随机不能和表现层随机混用,粒子、音效、摄像机抖动要使用独立随机流,否则某个客户端多调用一次随机数,就会导致后续随机序列错位,引发不同步。项目里还会记录随机 seed、调用次数和状态 Hash,用来排查 desync。
一句话记忆:
随机数同步不是让随机真的随机,而是让所有客户端“随机得一模一样”。
状态同步为什么更常用于 MMO/动作游戏?
因为 MMO 和动作游戏更需要:
服务器权威、反作弊、支持大量玩家、支持中途加入,并且不依赖所有客户端完全确定性。
先对比帧同步和状态同步
帧同步同步的是输入:
第 100 帧,玩家 A 按了攻击然后所有客户端自己算结果。
状态同步同步的是结果:
玩家 A 坐标 = 10, 5
玩家 B 血量 = 70
技能 CD = 3.2 秒也就是说,状态同步里服务器是权威的,客户端主要负责显示、插值、预测和校正。
为什么 MMO 更适合状态同步?
MMO 玩家很多,世界也很大。
如果用帧同步,所有客户端理论上要知道大量玩家、怪物、NPC、掉落、技能、场景机关的输入或逻辑变化,并且本地算出一致结果。这个成本和复杂度非常高。
状态同步更适合:
- 世界对象多。
- 玩家可随时进入离开。
- 客户端不需要模拟整个世界。
- 服务器可以只同步玩家附近区域。
- 服务器可以管理 AOI,也就是兴趣范围。
比如你在主城,只需要收到附近玩家和 NPC 的状态。 远处另一个地图的怪物,不需要同步给你。
为什么动作游戏也常用状态同步?
动作游戏有很多不稳定因素:
- 高频移动
- 碰撞
- 击退
- 翻滚
- 跳跃
- 物理
- 动画状态
- 技能命中
- 客户端平台差异
- 网络延迟
这些东西如果全部用帧同步强行要求确定性,会很难。
状态同步可以让服务器决定最终结果:
你有没有命中
敌人有没有受伤
你的位置是否合法
你是否被击退
技能有没有释放成功客户端可以先预测自己的移动,让手感更顺;服务器状态回来后,如果客户端预测错了,再做校正。
为什么反作弊更好?
状态同步一般是服务器权威。
客户端不能直接说:
我命中了
我没受伤
我瞬移到了目标身边
我金币加了 999999客户端只能发送操作意图:
我想移动
我想攻击
我想释放技能最终是否成功,由服务器判断。
这对 MMO 和动作游戏非常重要,因为它们通常有长期账号、装备、经济系统、PVP、公会、交易,作弊风险更高。
状态同步的典型流程
客户端发送操作意图
服务器验证输入
服务器运行权威逻辑
服务器生成状态快照
服务器广播给相关客户端
客户端插值、预测、校正比如:
客户端:我要向前移动
服务器:检查是否合法
服务器:计算新位置
服务器:广播新位置
客户端:平滑插值到服务器位置它有什么代价?
状态同步不是完美的。
它的问题是:
- 网络量比帧同步大。
- 服务器压力更高。
- 要做状态压缩。
- 要做差量同步。
- 要做 AOI。
- 要做插值。
- 要做客户端预测。
- 要做服务器校正。
- 延迟高时可能出现拉扯感。
所以状态同步工程量不低,但它更适合大规模、实时、强交互、强反作弊的项目。
面试高分回答
CAUTION
状态同步更常用于 MMO 和动作游戏,是因为这类游戏通常需要服务器权威来保证反作弊和数据安全,同时场景对象多、玩家数量多、玩家可以中途加入,客户端不适合也没必要模拟整个世界。服务器可以按 AOI 只下发玩家关心范围内的状态,并通过快照、差量同步、插值和客户端预测来保证表现流畅。动作游戏里还有物理、碰撞、动画和平台差异,强行做帧同步确定性成本很高,因此常用状态同步由服务器计算最终结果,客户端负责表现和预测。
一句话记忆:
帧同步适合少量玩家、强确定性、低带宽;状态同步更适合 MMO 和动作游戏这种大世界、强交互、强反作弊的场景。
快照同步是什么?
快照同步是状态同步的一种。
它的核心是: 服务器定期把某一刻的权威游戏状态打包成快照发给客户端,客户端根据这些快照做插值、预测和校正。
什么叫快照?
快照可以理解成服务器给游戏世界拍了一张“状态照片”。
比如第 120 个服务器 Tick,快照里可能有:
对象 ID
位置
旋转
速度
血量
动作状态
技能状态
Buff 状态
服务器 Tick客户端收到后,就知道服务器眼里的世界是什么样。
它怎么工作?
流程一般是:
客户端发送操作意图
服务器运行权威逻辑
服务器生成状态快照
服务器把快照发给客户端
客户端把快照放入缓冲区
客户端在快照之间插值显示比如服务器每秒只发 20 次快照,但客户端画面是 60 FPS。
那客户端不能只在收到快照时才移动角色,否则画面会一跳一跳。
所以客户端会在两个快照之间插值:
快照 A:角色在 x = 10
快照 B:角色在 x = 20
中间渲染帧:角色显示在 x = 12、15、18这样画面就顺了。
为什么要有快照缓冲?
网络不是稳定的。
有时候包来得快,有时候包来得慢。 如果客户端一收到快照就立刻显示,画面会抖。
所以客户端通常会故意慢一点显示,比如延迟 100ms 左右,留一个插值缓冲区。
这样它手里能同时拿到两个快照:
快照 118
快照 119
快照 120然后稳定地在旧快照之间插值,而不是追着最新网络包乱跳。
快照同步和帧同步区别
帧同步同步输入:
第 100 帧玩家按了攻击客户端自己算结果。
快照同步同步状态:
第 100 帧玩家位置是 10,5,血量是 70客户端主要根据服务器结果表现。
所以快照同步不要求客户端完整确定性模拟世界,服务器才是权威。
为什么还需要预测?
如果客户端每次移动都等服务器确认,操作会有明显延迟。
所以动作游戏常做本地预测:
玩家按下移动
客户端先本地移动
服务器稍后返回权威位置
如果预测差不多,就平滑修正
如果差很多,就校正或回滚重放这样既保证手感,又保留服务器权威。
怎么节省带宽?
快照如果每次都发全世界状态,带宽会爆。
所以项目里一般会做:
AOI:只同步玩家附近对象。- 差量同步:只发变化过的字段。
- 量化压缩:坐标不用完整 float,压成整数或短字段。
- 优先级同步:重要对象高频,远处对象低频。
- 事件同步:技能释放、死亡、拾取这种用事件补充。
- 丢包容忍:下一帧快照可以覆盖旧状态。
- 基线快照:基于某个旧快照发增量。
快照同步的优点
它的优点是:
- 服务器权威,反作弊好。
- 适合 MMO、射击、动作游戏。
- 支持中途加入。
- 不要求所有客户端完全确定性。
- 客户端只关心自己附近区域。
- 可以通过插值让远端对象平滑移动。
- 可以用预测改善本地操作手感。
快照同步的缺点
缺点也很明显:
- 比帧同步更耗带宽。
- 服务器压力更大。
- 要处理延迟、丢包、乱序。
- 要做插值缓冲。
- 要做预测和校正。
- 校正不好会出现拉扯、瞬移、回弹。
- 快照频率低会卡,频率高会占带宽。
面试高分回答
TIP
快照同步是状态同步的一种实现方式。服务器作为权威端,按固定 Tick 模拟游戏逻辑,并定期把某一时刻的对象状态打包成快照发送给客户端。客户端收到快照后不会简单硬切,而是放入快照缓冲区,在两个快照之间做插值,让远端对象平滑显示。对于本地玩家,为了降低操作延迟,通常还会做客户端预测,等服务器权威快照回来后再校正。为了降低带宽,快照同步一般会配合 AOI、差量同步、量化压缩和优先级同步。
一句话记忆:
快照同步 = 服务器定期发权威状态照片,客户端用缓存和插值把照片连成连续动画。
插值和外推区别是什么?
一句话:
插值是在两个已知状态之间补中间值;外推是根据已有状态预测未来值。
插值是什么?
插值 Interpolation 是:
已知 A 点
已知 B 点
求 A 和 B 中间的点比如服务器发了两个快照:
快照 A:角色位置 x = 10
快照 B:角色位置 x = 20客户端当前渲染时间刚好在 A 和 B 中间,那就显示:
x = 15公式可以理解成:
显示位置 = Lerp(A位置, B位置, t)插值的特点是: 两个端点都是真实收到的服务器状态,所以结果稳定、不容易错。
缺点是: 你必须等到 B 快照到了,才能在 A 和 B 之间插值,所以画面会故意慢一点。
这就是插值缓冲。
外推是什么?
外推 Extrapolation 是:
已知 A 点
不知道未来 B 点
根据速度和方向猜未来位置比如现在只知道:
角色位置 x = 10
速度 velocity = 5那客户端猜测一段时间后:
预测位置 = 10 + velocity * dt外推的特点是: 不用等未来快照,响应更快。
缺点是: 它是在猜。
如果角色突然转向、停下、被击退、撞墙、释放位移技能,外推就可能错。服务器状态回来后,就需要校正,可能出现拉扯、瞬移、回弹。
在网络同步里怎么用?
远端玩家、怪物、NPC 通常用插值。
因为你不直接控制它们,稍微延迟一点没关系,重点是看起来平滑稳定。
本地玩家通常会用预测,也就是一种外推思路。
因为你按下移动键,如果必须等服务器回包再动,手感会很差。 所以客户端会先本地预测移动,然后等服务器权威状态回来再校正。
为什么远端对象更适合插值?
远端对象你没有输入控制权。 你只是在看别人移动。
所以宁愿让它慢一点显示,也要平滑。
比如:
服务器真实时间:第 105 帧
客户端渲染远端玩家:第 102 帧到第 103 帧之间这叫插值延迟。 画面晚一点,但更稳。
为什么本地玩家更适合外推/预测?
本地玩家需要手感。
如果你按下方向键,角色 100ms 后才动,会很难受。
所以客户端会先预测:
我按了向前
我先本地向前移动
服务器稍后确认
如果对了,就继续
如果错了,就校正这就是客户端预测和服务器校正。
面试高分回答
CAUTION
插值是在两个已经收到的服务器快照之间计算中间状态,比如用 Lerp 在快照 A 和快照 B 之间平滑位置,因此结果稳定,不容易出错,但需要等待后一个快照,会引入一定显示延迟。外推则是在没有未来快照的情况下,根据当前状态、速度或输入预测未来状态,响应更快,但如果目标突然转向、停止、碰撞或被服务器修正,就会预测错误,需要校正,可能产生拉扯或瞬移。网络同步里通常远端对象用插值保证平滑,本地玩家用预测外推降低输入延迟。
一句话记忆:
插值是“已知过去和现在,补中间”;外推是“只知道现在,猜未来”。
客户端预测如何提升手感?
核心解释
客户端预测就是:玩家一按键,客户端先本地模拟角色移动,不等服务器回包。这样玩家看到的角色会立刻动起来,手感就不会被网络延迟拖慢。
为什么能提升手感
如果没有客户端预测:
玩家按 W → 输入发给服务器 → 服务器计算位置 → 回包 → 客户端显示移动。
假设延迟是 80ms,玩家可能要等几十毫秒甚至更久才看到角色动,感觉就是“粘”“慢半拍”。
有客户端预测:
玩家按 W → 客户端立刻移动角色 → 同时把输入发给服务器。
所以视觉反馈几乎是当前帧发生的,玩家会觉得角色很跟手。
但客户端不能说了算
客户端预测不是让客户端决定最终结果。真正权威结果还是服务器算的。
常见流程是:
- 客户端给每次输入编号,比如
inputId = 101。 - 客户端立刻用这个输入模拟一次移动。
- 客户端把输入发给服务器。
- 服务器收到后验证并计算真实位置。
- 服务器回包:告诉客户端“我确认到了 inputId = 101,你的权威位置是这里”。
- 客户端如果发现本地预测位置和服务器位置不一致,就校正。
- 如果客户端已经提前模拟了后续输入,就从服务器位置开始,把未确认输入重新模拟一遍。
面试高分说法
TIP
客户端预测主要解决的是本地玩家的输入延迟问题。它让客户端收到输入后先执行本地模拟,让移动、攻击、闪避等操作立即反馈;同时服务器仍然保持权威,负责验证输入和同步最终状态。客户端收到服务器快照后,会根据输入序号做 reconciliation,也就是把本地状态校正到服务器状态,再重放还没被服务器确认的输入。这样既保证手感,又尽量保证同步正确。
注意点
NOTE
预测错了会出现拉回、抖动、瞬移感,所以一般要做平滑校正。
本地玩家适合用客户端预测,其他玩家通常用插值显示。
客户端预测不能用于完全信任客户端的伤害、掉落、结算,否则容易被作弊。
一句话记忆
客户端预测就是:本地先动,服务器裁决,错了再修正。
服务端回滚校正是什么?
核心解释
服务端回滚校正就是:服务器保存最近一段时间的历史状态,当一个“发生在过去”的输入或攻击请求晚到时,服务器临时回到那个历史 Tick 验算结果,然后再恢复到当前 Tick,把权威结果同步给客户端。
为什么需要它
网络游戏里,客户端看到的世界通常比服务器有延迟。
比如 FPS 里:
玩家 A 在自己屏幕上看到敌人在准星里,于是开枪。
但开枪消息到服务器时,敌人可能已经跑开了。
如果服务器只用“当前时刻”的位置判断,玩家会觉得:“我明明打中了,为什么没中?”
所以服务器可以根据玩家开枪时的时间戳或 Tick,回到当时的历史状态,重新判断那一枪是否命中。
底层怎么做
服务器每一帧或每个 Tick 保存一份历史快照,例如:
Tick 100:玩家位置、怪物位置、碰撞体状态
Tick 101:玩家位置、怪物位置、碰撞体状态
Tick 102:玩家 A 开枪时看到的世界
Tick 103
Tick 104
Tick 105:服务器当前状态当服务器在 Tick 105 收到一个请求:
玩家 A:我在 Tick 102 开枪了服务器会做:
1. 找到 Tick 102 的历史状态
2. 临时把相关对象位置还原到 Tick 102
3. 用 Tick 102 的位置做射线检测或命中判断
4. 得到权威结果,比如“命中”或“未命中”
5. 恢复到 Tick 105 当前状态
6. 把最终结果同步给所有客户端和客户端预测的关系
客户端预测解决的是“我按键后马上有反馈”。
服务端回滚校正解决的是“服务器如何公平判断过去发生的动作”。
两者经常配合使用:
客户端先预测移动
服务器权威模拟
服务器回包
客户端发现预测不一致
客户端回滚到服务器确认状态
客户端重放还没确认的输入这叫 reconciliation,也就是客户端校正。
它不是让客户端说了算
面试里一定要强调:
服务器回滚不是相信客户端说“我打中了”。
服务器只是相信客户端提交的输入时间或 Tick,然后用服务器保存的历史状态重新验算。
真正命中结果还是服务器算出来的。
适用场景
FPS 命中验证很常见,比如延迟补偿。
动作游戏也可能用,用来修正移动、闪避、技能判定。
格斗游戏的 Rollback Netcode 也类似,会回滚到过去帧,补上迟到输入,再重新模拟。
代价和风险
服务端要保存历史快照,会占内存。
回滚重算会消耗 CPU。
回滚期间不能随便重复播放特效、音效、掉落奖励这些副作用。
还要限制最大回滚时间,比如只允许回滚 100ms 到 200ms,防止高延迟玩家获得过大优势。
面试高分回答
IMPORTANT
服务端回滚校正本质上是一种延迟补偿。服务器会保存最近若干 Tick 的历史状态,当客户端带着输入序号或时间戳请求过来时,服务器可以临时回到对应 Tick,用当时的角色位置、碰撞体和场景状态重新验证结果。验证完成后服务器恢复到当前状态,并把权威结果同步给客户端。这样可以减少“客户端看着命中了,但服务器判未命中”的问题,同时服务器仍然保持权威,避免客户端作弊。
一句话记忆
服务端回滚校正就是:服务器回到过去验算,再回到现在同步权威结果。
Lockstep 是什么?
核心解释
Lockstep 叫“锁步同步”。它的核心不是同步角色位置、血量、怪物状态这些结果,而是只同步玩家输入,然后让所有客户端在同一个 Tick 上执行同一套逻辑。
怎么理解
比如有两个玩家:
玩家 A 在第 120 帧输入:向右移动。
玩家 B 在第 120 帧输入:攻击。
Lockstep 不会说:
A 的位置是哪里
B 的血量是多少
怪物现在在哪里而是同步:
Tick 120:
A = MoveRight
B = Attack然后所有客户端都拿到这批输入,在本地运行同一套战斗逻辑、移动逻辑、AI 逻辑,最后应该算出完全一样的结果。
为什么叫锁步
因为大家像排队一样,一帧一帧一起走。
Tick 120 输入没收齐
不能安全进入 Tick 121
Tick 120 输入收齐
所有人一起模拟 Tick 120
模拟结束
再进入 Tick 121所以它叫 Lockstep,意思就是“锁住步伐,同步前进”。
底层关键点
Lockstep 最重要的是确定性。
也就是说:
相同初始状态 + 相同输入顺序 + 相同逻辑 = 相同结果。
如果客户端 A 算出来怪物死了,客户端 B 算出来怪物还活着,那就不同步了,这叫 desync。
所以 Lockstep 里这些东西都要特别小心:
随机数必须统一种子
浮点数要谨慎
集合遍历顺序要固定
物理模拟要确定
所有客户端逻辑版本要一致
每个 Tick 的输入顺序要一致优点
最大的优点是省带宽。
因为它不需要同步大量单位状态,只需要同步玩家输入。
这对 RTS、战棋、模拟经营这类“单位很多、状态很多”的游戏很有用。
比如一场 RTS 里有 1000 个单位,如果每帧同步所有单位的位置、血量、目标,带宽会很大;但如果只同步玩家命令,比如“选中这些兵攻击某个点”,网络数据就小很多。
缺点
最大缺点是怕延迟。
因为当前 Tick 要等所有玩家输入都到齐才能模拟。如果一个玩家网络很差,其他人也可能被拖住。
所以 Lockstep 的手感通常不如客户端预测加状态同步那种方案,尤其不适合强实时动作游戏。
和状态同步的区别
Lockstep 是:
同步输入
每个客户端自己算结果
要求确定性
带宽低
怕延迟
常见于 RTS、战棋状态同步是:
服务器同步结果
客户端主要负责表现
不强依赖客户端确定性
带宽更高
更适合 MMO、动作游戏面试高分回答
CAUTION
Lockstep 是一种输入同步方案。它不会频繁同步完整游戏状态,而是把每个玩家在每个 Tick 的输入收集起来,然后广播给所有客户端。所有客户端拿到同一批输入后,用完全相同的逻辑从相同状态开始模拟,因此理论上会得到相同结果。它的优点是带宽低,适合 RTS 这类单位数量多的游戏;缺点是对确定性要求非常高,而且需要等待输入,网络延迟会影响响应速度。
一句话记忆
Lockstep 就是:只同步操作,不同步结果;所有客户端锁住帧号,一起算同一帧。
Replay 回放系统如何做?
核心解释
Replay 回放系统不是“录屏”,而是记录一份可以把战斗重新跑出来的数据。真正项目里通常记录:初始状态、随机种子、每个 Tick 的输入、关键事件、关键帧快照、版本信息。
两种常见方案
第一种是输入回放。
也就是只记录玩家输入:
Tick 100:玩家 A 向右移动
Tick 101:玩家 A 普攻
Tick 102:玩家 B 释放技能播放时重新初始化战斗,然后把这些输入按 Tick 重新喂给战斗逻辑。优点是文件很小,缺点是要求逻辑必须确定性。
第二种是快照回放。
也就是定期记录角色位置、血量、状态、技能 CD 等数据:
Tick 100:完整状态快照
Tick 110:完整状态快照
Tick 120:完整状态快照播放时按快照恢复,或者用快照做关键帧插值。优点是稳定、方便拖进度,缺点是文件更大。
真实项目里常用混合方案:输入流 + 关键帧快照 + 校验码。
录制阶段怎么做
录制时通常在固定逻辑帧里做,不建议跟渲染帧走。
流程是:
1. 战斗开始时记录初始信息
2. 每个 Tick 收集玩家输入
3. 记录重要事件,比如击杀、掉落、技能命中
4. 每隔一段时间保存关键帧快照
5. 保存 checksum,用来检查回放是否跑偏
6. 写入 replay 文件必须记录的信息一般有:
游戏版本
配置表版本
地图 ID
随机种子
初始角色属性
初始怪物状态
每帧输入
关键事件
关键帧快照
校验码播放阶段怎么做
播放时要把真实输入、网络消息关掉,让 Replay 系统接管战斗驱动。
流程是:
1. 读取 replay 文件
2. 检查版本和配置是否匹配
3. 初始化同一张地图和同一批角色
4. 设置相同随机种子
5. 从 Tick 0 开始推进
6. 每个 Tick 读取录制好的输入
7. 把输入喂给战斗逻辑
8. 战斗逻辑重新模拟
9. 表现层播放动画、特效、UI
10. 支持暂停、倍速、拖进度简单 C# 思路
c
using System; // 引入基础类型。
using System.Collections.Generic; // 引入 List 和 Dictionary。
public struct ReplayCommand // 定义一条回放指令。
{ // 结构体开始。
public int Tick; // 这条指令发生在哪个逻辑帧。
public int PlayerId; // 是哪个玩家产生的输入。
public int SkillId; // 玩家释放的技能 ID。
public int TargetId; // 技能目标 ID,没有目标时可以填 0。
} // 结构体结束。
public class ReplayRecorder // 定义回放录制器。
{ // 类开始。
private readonly List<ReplayCommand> commands = new List<ReplayCommand>(); // 保存所有录制到的输入指令。
public void Record(ReplayCommand command) // 录制一条输入指令。
{ // 方法开始。
commands.Add(command); // 把当前 Tick 的输入加入回放列表。
} // 方法结束。
public List<ReplayCommand> GetCommands() // 获取录制结果。
{ // 方法开始。
return commands; // 返回所有输入指令,真实项目里会序列化成二进制文件。
} // 方法结束。
} // 类结束。
public class ReplayPlayer // 定义回放播放器。
{ // 类开始。
private readonly List<ReplayCommand> commands; // 保存从回放文件读取出来的输入指令。
private int commandIndex; // 当前播放到第几条指令。
public ReplayPlayer(List<ReplayCommand> commands) // 构造回放播放器。
{ // 构造函数开始。
this.commands = commands; // 保存回放指令列表。
this.commandIndex = 0; // 从第一条指令开始播放。
} // 构造函数结束。
public void Tick(int currentTick) // 每个固定逻辑帧调用一次。
{ // 方法开始。
while (commandIndex < commands.Count && commands[commandIndex].Tick == currentTick) // 播放当前 Tick 的所有输入。
{ // 循环开始。
ApplyCommand(commands[commandIndex]); // 把录制好的输入喂给战斗逻辑。
commandIndex++; // 移动到下一条回放指令。
} // 循环结束。
} // 方法结束。
private void ApplyCommand(ReplayCommand command) // 应用一条回放指令。
{ // 方法开始。
Console.WriteLine($"Player {command.PlayerId} use skill {command.SkillId}"); // 示例:真实项目里这里会调用技能系统。
} // 方法结束。
} // 类结束。项目里要注意什么
Replay 系统最好只驱动逻辑层,不要直接依赖表现层。
随机数不能随便用 UnityEngine.Random 到处取,最好统一用战斗随机数管理器,并记录 seed。
不要用真实的 Time.deltaTime 决定战斗结果,应该使用固定 Tick。
配置表版本必须记录,否则今天的技能伤害是 100,明天改成 120,老回放就可能对不上。
如果是 PVP 或排行榜回放,最好由服务器保存权威回放数据,不能完全相信客户端上传的 replay。
面试高分回答
NOTE
Replay 回放系统本质是数据驱动的重演系统。录制时我不会录视频,而是按固定 Tick 记录初始状态、随机种子、玩家输入、关键事件和必要快照;播放时重建战斗环境,关闭真实输入和网络,把记录下来的输入重新喂给逻辑层。如果逻辑确定性足够好,可以用输入回放,文件小;如果需要稳定拖进度和容错,可以加关键帧快照。为了排查回放跑偏,还可以每隔一段 Tick 记录 checksum。
一句话记忆
Replay 不是录画面,而是录“能把战斗重新算出来的数据”。