Skip to content

同步模型

帧同步为什么要求确定性?

因为帧同步通常只同步输入,不同步完整结果

也就是说,服务器不会每一帧把所有角色的位置、血量、Buff、子弹、技能状态都发给每个客户端,而是只广播:

第 100 帧,玩家 A 按了移动,玩家 B 释放了技能

然后每个客户端自己在本地计算这一帧的结果。

network-frame-sync-determinism

核心公式

帧同步成立的前提是:

相同初始状态 + 相同输入 + 相同逻辑代码 + 相同执行顺序 = 相同结果

如果所有客户端都从同一个初始状态开始,并且每一帧拿到完全一样的输入,那么只要逻辑是确定性的,大家就会算出完全一样的游戏状态。

比如:

第 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
  • 遍历 DictionaryHashSet 这类无固定顺序容器
  • 多线程执行顺序不固定
  • Unity 物理引擎结果不完全确定
  • 不同机器帧率不同
  • 对象创建顺序不同
  • 网络输入到达顺序不同
  • 逻辑里混入表现层状态

尤其是 Unity 项目里,不能随便把 RigidbodyPhysics.Simulate、浮点位移直接拿来做强帧同步核心逻辑。

怎么保证确定性?

常见做法:

  • 使用固定逻辑帧,比如每秒 15、20、30 个逻辑 Tick。
  • 每帧只处理这一帧确认过的输入。
  • 不用 Time.deltaTime 驱动核心逻辑。
  • 使用固定随机种子。
  • 随机数由逻辑系统统一生成。
  • 核心战斗逻辑使用定点数或确定性数学库。
  • 固定对象遍历顺序。
  • 避免依赖线程调度顺序。
  • 不把 Unity 物理直接作为帧同步核心结果。
  • 定期计算状态 Hash 校验是否一致。
  • 发现不同步时可以回滚、重算或请求权威状态修正。

Unity 项目里怎么说更专业?

可以这样说:

Unity 本身的 Transform、Rigidbody、Animator、Particle 更多是表现层。 如果做严格帧同步,核心战斗逻辑最好独立出来。

比如:

  • 逻辑层用定点数计算位置、技能、碰撞、伤害。
  • 表现层再根据逻辑结果驱动动画和特效。
  • Unity 物理只做表现或非核心辅助,不决定最终战斗结果。
  • 所有客户端每个逻辑帧跑同一份输入和同一份逻辑。

面试高分回答

NOTE

帧同步要求确定性,是因为它同步的是玩家输入,而不是完整游戏状态。所有客户端拿到同一帧的相同输入后,会在本地执行同一套逻辑来推导结果。如果逻辑不是确定性的,即使输入相同,不同客户端也可能算出不同的位置、血量、技能命中和随机结果,状态一旦分叉,后续每一帧都会继续放大差异,最终产生不同步。所以帧同步必须保证固定逻辑帧、固定随机、固定执行顺序,并避免浮点误差、非确定性物理和平台差异影响核心逻辑。

一句话记忆:

帧同步同步的是输入,不是结果;所以所有客户端必须确定性地从相同输入算出相同结果。

浮点数为什么可能破坏确定性?

因为浮点数不是数学里的“精确小数”,它只是一个二进制近似值。 在帧同步里,同样输入如果算出哪怕一点点不同,也可能让不同客户端进入不同分支,最后状态不同步。

network-floating-point-determinism

为什么 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 等指令可能改变结果。
  • sincossqrt 这类数学库跨平台结果可能不完全一致。
  • 浮点加法不满足严格结合律。
  • Unity 物理引擎跨平台不保证强确定性。
  • 多线程执行顺序不同也可能影响结果。

比如:

(a + b) + c

和:

a + (b + c)

在浮点里不一定完全一样。

为什么物理更危险?

物理系统通常会大量使用浮点数,还涉及:

  • 碰撞检测
  • 接触点求解
  • 迭代约束
  • 摩擦
  • 速度积分
  • 碰撞顺序
  • 多线程调度

一点误差就可能导致接触点不同。 接触点不同,受力不同。 受力不同,下一帧位置更不同。

所以严格帧同步一般不会直接把 Unity 的 RigidbodyPhysics 作为核心逻辑结果。

怎么避免?

常见做法:

  • 核心战斗逻辑使用定点数。
  • 使用确定性数学库。
  • 使用固定逻辑帧。
  • 不用 Time.deltaTime 驱动核心同步逻辑。
  • 固定随机种子。
  • 随机数统一由逻辑层管理。
  • 固定对象遍历顺序。
  • 不依赖 DictionaryHashSet 的无序遍历结果。
  • 核心逻辑和 Unity 表现层分离。
  • 定期做状态 Hash 校验。
  • 发现不同步后回滚、重算或请求权威状态修正。

面试高分回答

TIP

浮点数可能破坏确定性,是因为浮点数本身是二进制近似表示,很多小数无法精确存储,每次运算还会产生舍入误差。不同 CPU、编译器、指令集、数学库和运算顺序都可能让同一段浮点逻辑得到极小差异。帧同步只同步输入,不同步完整状态,所以这种微小差异一旦影响碰撞、命中、范围判断或随机数调用,就会让不同客户端进入不同分支,最终产生状态不同步。

一句话记忆:

浮点误差本身很小,但在帧同步里,一旦影响分支判断,就会从小误差变成大分叉。

随机数如何保证同步?

帧同步里,随机数不能让每个客户端“自己随便随机”。 正确做法是:

同一个种子、同一个随机算法、同一个调用顺序,才能得到同一个随机结果。

network-random-sync-deterministic

为什么随机数会导致不同步?

比如同一帧攻击需要判断是否暴击:

暴击概率 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

不要用无序容器直接遍历,比如 DictionaryHashSet,否则不同客户端遍历顺序可能不同。

面试高分回答

IMPORTANT

帧同步中随机数要保证同步,本质是保证随机序列确定。通常由服务器在开局下发统一 seed,所有客户端使用同一个确定性 PRNG,并且在同一逻辑帧按照完全相同的顺序和次数消费随机数。核心战斗随机不能和表现层随机混用,粒子、音效、摄像机抖动要使用独立随机流,否则某个客户端多调用一次随机数,就会导致后续随机序列错位,引发不同步。项目里还会记录随机 seed、调用次数和状态 Hash,用来排查 desync。

一句话记忆:

随机数同步不是让随机真的随机,而是让所有客户端“随机得一模一样”。

状态同步为什么更常用于 MMO/动作游戏?

因为 MMO 和动作游戏更需要:

服务器权威、反作弊、支持大量玩家、支持中途加入,并且不依赖所有客户端完全确定性。

network-state-sync-mmo-action

先对比帧同步和状态同步

帧同步同步的是输入:

第 100 帧,玩家 A 按了攻击

然后所有客户端自己算结果。

状态同步同步的是结果:

玩家 A 坐标 = 10, 5
玩家 B 血量 = 70
技能 CD = 3.2 秒

也就是说,状态同步里服务器是权威的,客户端主要负责显示、插值、预测和校正。

为什么 MMO 更适合状态同步?

MMO 玩家很多,世界也很大。

如果用帧同步,所有客户端理论上要知道大量玩家、怪物、NPC、掉落、技能、场景机关的输入或逻辑变化,并且本地算出一致结果。这个成本和复杂度非常高。

状态同步更适合:

  • 世界对象多。
  • 玩家可随时进入离开。
  • 客户端不需要模拟整个世界。
  • 服务器可以只同步玩家附近区域。
  • 服务器可以管理 AOI,也就是兴趣范围。

比如你在主城,只需要收到附近玩家和 NPC 的状态。 远处另一个地图的怪物,不需要同步给你。

为什么动作游戏也常用状态同步?

动作游戏有很多不稳定因素:

  • 高频移动
  • 碰撞
  • 击退
  • 翻滚
  • 跳跃
  • 物理
  • 动画状态
  • 技能命中
  • 客户端平台差异
  • 网络延迟

这些东西如果全部用帧同步强行要求确定性,会很难。

状态同步可以让服务器决定最终结果:

你有没有命中
敌人有没有受伤
你的位置是否合法
你是否被击退
技能有没有释放成功

客户端可以先预测自己的移动,让手感更顺;服务器状态回来后,如果客户端预测错了,再做校正。

为什么反作弊更好?

状态同步一般是服务器权威。

客户端不能直接说:

我命中了
我没受伤
我瞬移到了目标身边
我金币加了 999999

客户端只能发送操作意图:

我想移动
我想攻击
我想释放技能

最终是否成功,由服务器判断。

这对 MMO 和动作游戏非常重要,因为它们通常有长期账号、装备、经济系统、PVP、公会、交易,作弊风险更高。

状态同步的典型流程

客户端发送操作意图
服务器验证输入
服务器运行权威逻辑
服务器生成状态快照
服务器广播给相关客户端
客户端插值、预测、校正

比如:

客户端:我要向前移动
服务器:检查是否合法
服务器:计算新位置
服务器:广播新位置
客户端:平滑插值到服务器位置

它有什么代价?

状态同步不是完美的。

它的问题是:

  • 网络量比帧同步大。
  • 服务器压力更高。
  • 要做状态压缩。
  • 要做差量同步。
  • 要做 AOI。
  • 要做插值。
  • 要做客户端预测。
  • 要做服务器校正。
  • 延迟高时可能出现拉扯感。

所以状态同步工程量不低,但它更适合大规模、实时、强交互、强反作弊的项目。

面试高分回答

CAUTION

状态同步更常用于 MMO 和动作游戏,是因为这类游戏通常需要服务器权威来保证反作弊和数据安全,同时场景对象多、玩家数量多、玩家可以中途加入,客户端不适合也没必要模拟整个世界。服务器可以按 AOI 只下发玩家关心范围内的状态,并通过快照、差量同步、插值和客户端预测来保证表现流畅。动作游戏里还有物理、碰撞、动画和平台差异,强行做帧同步确定性成本很高,因此常用状态同步由服务器计算最终结果,客户端负责表现和预测。

一句话记忆:

帧同步适合少量玩家、强确定性、低带宽;状态同步更适合 MMO 和动作游戏这种大世界、强交互、强反作弊的场景。

快照同步是什么?

快照同步是状态同步的一种。

它的核心是: 服务器定期把某一刻的权威游戏状态打包成快照发给客户端,客户端根据这些快照做插值、预测和校正。

network-snapshot-sync-explained

什么叫快照?

快照可以理解成服务器给游戏世界拍了一张“状态照片”。

比如第 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、差量同步、量化压缩和优先级同步。

一句话记忆:

快照同步 = 服务器定期发权威状态照片,客户端用缓存和插值把照片连成连续动画。

插值和外推区别是什么?

一句话:

插值是在两个已知状态之间补中间值;外推是根据已有状态预测未来值。

network-interpolation-vs-extrapolation

插值是什么?

插值 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 之间平滑位置,因此结果稳定,不容易出错,但需要等待后一个快照,会引入一定显示延迟。外推则是在没有未来快照的情况下,根据当前状态、速度或输入预测未来状态,响应更快,但如果目标突然转向、停止、碰撞或被服务器修正,就会预测错误,需要校正,可能产生拉扯或瞬移。网络同步里通常远端对象用插值保证平滑,本地玩家用预测外推降低输入延迟。

一句话记忆:

插值是“已知过去和现在,补中间”;外推是“只知道现在,猜未来”。

客户端预测如何提升手感?

核心解释

客户端预测就是:玩家一按键,客户端先本地模拟角色移动,不等服务器回包。这样玩家看到的角色会立刻动起来,手感就不会被网络延迟拖慢。

network-client-prediction-feel

为什么能提升手感

如果没有客户端预测:

玩家按 W → 输入发给服务器 → 服务器计算位置 → 回包 → 客户端显示移动。

假设延迟是 80ms,玩家可能要等几十毫秒甚至更久才看到角色动,感觉就是“粘”“慢半拍”。

有客户端预测:

玩家按 W → 客户端立刻移动角色 → 同时把输入发给服务器。

所以视觉反馈几乎是当前帧发生的,玩家会觉得角色很跟手。

但客户端不能说了算

客户端预测不是让客户端决定最终结果。真正权威结果还是服务器算的。

常见流程是:

  1. 客户端给每次输入编号,比如 inputId = 101
  2. 客户端立刻用这个输入模拟一次移动。
  3. 客户端把输入发给服务器。
  4. 服务器收到后验证并计算真实位置。
  5. 服务器回包:告诉客户端“我确认到了 inputId = 101,你的权威位置是这里”。
  6. 客户端如果发现本地预测位置和服务器位置不一致,就校正。
  7. 如果客户端已经提前模拟了后续输入,就从服务器位置开始,把未确认输入重新模拟一遍。

面试高分说法

TIP

客户端预测主要解决的是本地玩家的输入延迟问题。它让客户端收到输入后先执行本地模拟,让移动、攻击、闪避等操作立即反馈;同时服务器仍然保持权威,负责验证输入和同步最终状态。客户端收到服务器快照后,会根据输入序号做 reconciliation,也就是把本地状态校正到服务器状态,再重放还没被服务器确认的输入。这样既保证手感,又尽量保证同步正确。

注意点

NOTE

预测错了会出现拉回、抖动、瞬移感,所以一般要做平滑校正。

本地玩家适合用客户端预测,其他玩家通常用插值显示。

客户端预测不能用于完全信任客户端的伤害、掉落、结算,否则容易被作弊。

一句话记忆

客户端预测就是:本地先动,服务器裁决,错了再修正。

服务端回滚校正是什么?

核心解释

服务端回滚校正就是:服务器保存最近一段时间的历史状态,当一个“发生在过去”的输入或攻击请求晚到时,服务器临时回到那个历史 Tick 验算结果,然后再恢复到当前 Tick,把权威结果同步给客户端。

network-server-rollback-correction

为什么需要它

网络游戏里,客户端看到的世界通常比服务器有延迟。

比如 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 上执行同一套逻辑。

network-lockstep-explained

怎么理解

比如有两个玩家:

玩家 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 的输入、关键事件、关键帧快照、版本信息。

network-replay-system

两种常见方案

第一种是输入回放。

也就是只记录玩家输入:

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 不是录画面,而是录“能把战斗重新算出来的数据”。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0