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 浮点数运算结果不一致

大多数现代计算机系统使用IEEE 754标准来表示浮点数,然而,不同硬件和编辑器可能对浮点数的处理方式略有不同,尤其是在舍入和精度方面,导致了计算结果可能有微小差异,因此我们不能直接使用浮点数进行逻辑运算。

解决方案:完全避免使用浮点数,使用定点数进行计算。

从github的FixedMath.Net扒的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
using System;

/// 简化版 Fix64:64位定点数(Q31.32 格式)
/// 仅保留核心运算、转换和比较
/// 底层:long rawValue,缩放因子 1 << 32
/// 适合帧同步确定性计算
/// </summary>
public struct Fix64 : IEquatable<Fix64>, IComparable<Fix64>
{
private long rawValue;

private const int FRACTIONAL_BITS = 32;
private const long SCALE = 1L << FRACTIONAL_BITS;

public static readonly Fix64 Zero = new Fix64(0L);
public static readonly Fix64 One = new Fix64(SCALE);

private Fix64(long raw)
{
rawValue = raw;
}

public Fix64(int value)
{
rawValue = (long)value << FRACTIONAL_BITS;
}

public Fix64(float value)
{
// 四舍五入确保一致性
rawValue = (long)Math.Round(value * SCALE);
}

public Fix64(double value)
{
rawValue = (long)Math.Round(value * SCALE);
}

// 隐式:int → Fix64
public static implicit operator Fix64(int value) => new Fix64(value);

// 显式:float → Fix64
public static explicit operator Fix64(float value) => new Fix64(value);

// 显式:Fix64 → float(渲染用)
public static explicit operator float(Fix64 f) => (float)f.rawValue / SCALE;

// 显式:Fix64 → int(向零截断)
public static explicit operator int(Fix64 f) => (int)(f.rawValue >> FRACTIONAL_BITS);

public static Fix64 operator +(Fix64 a, Fix64 b) => new Fix64(a.rawValue + b.rawValue);
public static Fix64 operator -(Fix64 a, Fix64 b) => new Fix64(a.rawValue - b.rawValue);
public static Fix64 operator -(Fix64 a) => new Fix64(-a.rawValue);

public static Fix64 operator *(Fix64 a, Fix64 b)
{
// 高低32位拆分防溢出
long aHigh = a.rawValue >> 32;
long aLow = a.rawValue & 0xFFFFFFFFL;
long bHigh = b.rawValue >> 32;
long bLow = b.rawValue & 0xFFFFFFFFL;

long ac = aHigh * bHigh;
long ad = aHigh * bLow;
long bc = aLow * bHigh;
long bd = aLow * bLow;

long mid = (ad + bc) >> 32;
return new Fix64(ac + mid + (bd >> 32));
}

public static Fix64 operator /(Fix64 a, Fix64 b)
{
if (b.rawValue == 0) throw new DivideByZeroException();

// 简单高精度除法(左移提升精度)
long dividend = a.rawValue << FRACTIONAL_BITS;
long result = dividend / b.rawValue;

// 当为负数并且有余数,向下取整,而不是向0取整
if ((a.rawValue ^ b.rawValue) < 0 && result * b.rawValue != dividend)
{
result -= 1; // 调整舍入
}

return new Fix64(result);
}

public static bool operator ==(Fix64 a, Fix64 b) => a.rawValue == b.rawValue;
public static bool operator !=(Fix64 a, Fix64 b) => a.rawValue != b.rawValue;
public static bool operator >(Fix64 a, Fix64 b) => a.rawValue > b.rawValue;
public static bool operator <(Fix64 a, Fix64 b) => a.rawValue < b.rawValue;
public static bool operator >=(Fix64 a, Fix64 b) => a.rawValue >= b.rawValue;
public static bool operator <=(Fix64 a, Fix64 b) => a.rawValue <= b.rawValue;

public bool Equals(Fix64 other) => rawValue == other.rawValue;

public int CompareTo(Fix64 other) => rawValue.CompareTo(other.rawValue);

public override bool Equals(object obj) => obj is Fix64 other && Equals(other);

public override int GetHashCode() => rawValue.GetHashCode();

public override string ToString() => ((float)this).ToString("F6");

}

2.3.2 随机数不一致

当不同客户端使用相同的随机函数执行相同的随机逻辑,它们的结果在大多数情况下仍会不一致的。这有很多方面的原因:

  • 我们经常使用的随机函数(如UnityEngine.Random或System.Random),虽然底层实现上是伪随机的,但其初始化或内部状态往往依赖系统时间、线程、平台差异等不可控因素,导致跨客户端序列不一致
  • 即使序列生成本身是确定性的,若随机函数被渲染层(如粒子效果、UI 动画)或其他非权威逻辑调用并消耗随机值,也会造成不同客户端的状态机推进步数不同步,从而在核心游戏逻辑(如伤害计算、掉落判定)中产生偏差
  • ...

总之,为确保帧同步的一致性,我们必须使用专为逻辑层设计的确定性伪随机生成器:它的底层实现不依赖系统时间或平台,实现纯算法驱动,并通过服务器下发随机种子和严格控制调用顺序(仅限逻辑层使用)来保证所有客户端产生完全相同的随机序列。

一个随机数实现方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
using System;

public class MRandom
{
private uint _state;
public uint CurState => _state;

private int randomCount = 0;
public int RandomCount => randomCount;

/// <summary>
/// 由服务器统一下发随机种子
/// </summary>
public MRandom(uint seed)
{
Init(seed);
}

public void Init(uint seed)
{
_state = seed == 0 ? 123456789u : seed;
randomCount = 0;
}

// 用于重连时恢复状态(广播 CurrentState + RandomCount)
public void RestoreState(uint state, int counter)
{
_state = state;
randomCount = counter;
}

// 核心 Xorshift 生成 uint
private uint NextUInt()
{
randomCount++;
uint x = _state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
return _state = x;
}

/// <summary>
/// 包含上下限
/// </summary>
public int Next(int min, int max)
{
if (min > max) throw new ArgumentException("min > max");

long longRange = (long)max - min + 1;
if (longRange <= 0) throw new ArgumentException("范围无效");

uint range = (uint)longRange;

// 极端情况:范围正好 2^32(int 全范围)
if (range == 0)
{
return (int)NextUInt();
}

// 解决普通 % 模运算在范围随机时的经典问题
//当使用普通模运算 NextUInt() % range 生成[0, range - 1] 的随机数时,如果 range 不是 2 的幂(如 10、7、100 等常见范围),低值的出现概率会稍高(多1次)。
uint threshold = (uint.MaxValue - range + 1) % range; //计算低值
uint r;
do
{
r = NextUInt();
} while (r < threshold); //避开,减少低值出现概率

return min + (int)(r % range);
}
}

2.3.3 其他容易产生不一致的地方

  • 在逻辑层使用无序遍历容器,如直接遍历 Dictionary 或 HashSet
    • 原因:这些容器的遍历顺序依赖内部哈希码实现,而哈希码在不同平台(PC/iOS/Android)、运行时(Mono/IL2CPP)、.NET 版本或甚至同一平台的不同构建中可能不一致,导致相同数据产生不同遍历顺序,从而引发逻辑分支或计算结果差异
  • 使用系统自带的排序方法,如 Array.Sort、List.Sort 等
    • 原因:这些方法底层通常采用快速排序(QuickSort)或其变体,而快速排序本身是不稳定的(相同元素的相对顺序可能改变,可能会间接影响结果)。更关键的是,pivot选择策略在不同 .NET 实现或平台优化下可能略有差异,导致相同输入数组在不同客户端产生不同的排序结果
    • 尽量使用稳定排序方法,如归并排序、插入排序等
  • ...

3 状态同步

状态同步作为服务器权威型机制,其核心是服务器运行完整的游戏逻辑,计算权威状态并广播给客户端,客户端主要负责预测和渲染。服务器是“真相”的唯一来源,客户端的状态始终向服务器校正靠拢。这与帧同步的分布式计算相反,状态同步更注重稳定性和防作弊。

商用实现中,客户端会进行本地预测以隐藏延迟,但最终以服务器广播的状态为准。

3.1 实现

状态同步的一般流程是:

  • 客户端每帧(或固定间隔)收集本地玩家输入(如移动、射击),发送给服务器
  • 服务器作为权威,收集所有输入,运行完整游戏逻辑(移动、碰撞、伤害、技能等),计算新一帧的世界状态(所有对象的位置、血量、动画状态等)
  • 服务器广播状态快照或增量变化给所有客户端
  • 客户端收到状态后:
    • 对远程对象:直接应用或插值平滑显示
    • 对本地玩家:比对预测状态 vs 权威状态,如果偏差,进行校正(常见平滑插值或 rubber banding)
  • 重复上述过程

状态同步的核心假设是:服务器完全可信,客户端只需“追上”服务器状态。

3.2 常见变体

3.2.1 纯状态同步

纯状态同步不做客户端预测,客户端完全依赖服务器广播状态显示:

  • 服务器定期发送完整或增量状态
  • 客户端接收后直接应用

主要问题:带宽消耗大(状态数据远多于输入命令),延迟感强。

3.2.2 客户端预测 + 服务器校正

  • 客户端对本地玩家进行预测:输入后立即本地执行(移动、射击动画),感觉无延迟
  • 同时发送输入到服务器
  • 服务器权威计算后,广播权威状态
  • 客户端收到后:
    • 远程对象:插值/外推平滑
    • 本地玩家:比对预测 vs 权威
      • 一致 → 无操作
      • 不一致 → 校正

4 帧同步VS状态同步

方面 帧同步 状态同步
带宽消耗 极低(只传几字节输入/玩家/帧) 较高(需传位置、血量等状态数据,多对象时带宽大)
操作响应延迟 (本地预测/回滚变体感觉丝滑,即使高延迟) 较高(需等服务器确认 + 广播,预测错时有延迟感)
一致性(理论上) 完美(确定性逻辑 + 输入一致 → 状态自然一致) (服务器权威,但客户端预测可能短暂不一致,需校正)
防作弊能力 (分布式校验哈希,输入可见) 极强(服务器权威,客户端无法伪造状态)
实现难度 (需严格确定性:定点数、随机、排序等;不同步 调试难) (服务器主导,客户端简单预测 + 插值)
服务器负载 (服务器只中继/校验) (服务器跑完整逻辑,玩家多时 CPU/带宽压力大)
客户端负载 较高(本地跑完整逻辑,回滚需重模拟) (主要渲染 + 预测本地玩家)
适用游戏类型 高实时性对战(如 RTS、格斗、MOBA 部分机制) 大规模开放世界、FPS、MMORPG(防作弊/稳定优先)
典型问题 desync 调试难、确定性陷阱(浮点/随机/排序) rubber banding(橡皮筋拉回)、带宽优化复杂