游戏内状态同步的一些心得与实现要点
作者基于自己 Unity 多人在线项目(KCP + ECS + Fix64 + 权威服务器架构)的实战经验整理。
本文聚焦"状态同步"流派,不展开纯帧同步(Lockstep),但会做必要对比。
---成果展示


一、写在前面:为什么状态同步比想象中难
第一次做联网游戏的时候,我以为同步就是"客户端把位置发给服务器,服务器再广播给所有人"。真正动手之后,会被各种问题反复教育:
- 玩家网络从 30ms 到 300ms 都有,怎么让 300ms 的玩家也"不卡"?
- 按下方向键的那一刻角色不动一下,手感就废了------但又不能让客户端说了算(外挂飞天)。
- 不同客户端浮点数算出来的结果不完全一样,断线重连/录像回放就对不上。
- 服务端 20Hz 下发快照,客户端 60fps 渲染,中间那 50ms 的"空隙"怎么补?
- 输入丢一帧,服务器没收到,玩家眼里就是"我明明按了 J 但没冲刺"。
这些问题不是写代码时一次性想清楚的,而是一边踩坑一边补出来的。下面把我认为最关键的几个点系统地总结一下。
二、先选型:帧同步 vs 状态同步
| 维度 | 帧同步 (Lockstep) | 状态同步 (State Sync) |
|---|---|---|
| 服务端职责 | 只转发输入 | 跑完整逻辑,下发权威状态 |
| 带宽 | 小(只发输入) | 较大(要发状态快照) |
| 反作弊 | 弱(客户端跑完整逻辑) | 强(一切以服务器为准) |
| 容错性 | 差(一个客户端不同步全局崩) | 好(坏帧扔了就行) |
| 断线重连 | 难(必须追帧) | 容易(直接发最新快照) |
| 典型场景 | MOBA、RTS、格斗 | MMO、FPS、大世界 |
经验 :如果你做的是房间制、强对抗、需要回放,倾向帧同步;如果你做的是大世界、PVE、PVP 混合、玩家多、容错要求高,老老实实做状态同步。我这个项目是以大世界为主,所以选了权威服务器 + 客户端预测的状态同步方案。
三、整体架构(我的实现)
┌─────────────┐ 输入(seq, MoveX, MoveY, IsSprint) ┌──────────────┐
│ Client A │ ───────────────────────────────────────► │ │
│ ECS World │ │ HallServer │
│ 预测/插值 │ ◄─────── 权威状态快照(tick, players) ──── │ 权威逻辑 │
└─────────────┘ │ 20Hz Tick │
│ │
┌─────────────┐ │ │
│ Client B │ ◄────────────────────────────────────► │ │
└─────────────┘ └──────────────┘
传输层:KCP (可靠UDP) + Protobuf
确定性:Fix64 定点数(位级一致)
关键模块拆分:
- 网络层 :
KcpBattleTransport(客户端)、KcpBattleServer(服务端)------ 战斗内独立 UDP/KCP 通道,与大厅 TCP 通道解耦。 - 逻辑层(ECS) :客户端和服务端跑同一套逻辑系统(移动、冲刺、碰撞等),用 Fix64 保证位级一致。
- 表现层 :
WorldViewBinder渲染帧插值/外推,不参与任何业务逻辑。 - 预测层 :
LocalPredictSystem+PredictionSnapshot[128]环形缓冲,用于服务端校正后的"重放"。 - 协议层 :Protobuf 定义
BattlePlayerState,所有 Fix64 字段用sfixed64直接传RawValue,零精度损失。
四、实现要点详解
1. 用定点数(Fix64)替代 float
这是状态同步项目里最容易被低估的一件事。
csharp
// 错误示范:客户端和服务端都用 float
position.x += velocity.x * deltaTime; // 不同 CPU/不同编译器结果可能不同
// 正确做法:Fix64
Position.X += Velocity.X * BattleConfig.DefaultTickIntervalFx; // 任何平台位级一致
为什么必须定点数?
- 客户端做预测、服务器算权威,两边必须算出完全一致的结果,否则每一帧都要校正,画面抖动。
- Fix64 用
long存储(高 32 位整数 + 低 32 位小数),加减乘除都是整数运算,跨平台位级一致。 - 协议传输时直接发
sfixed64(即RawValue),不走 float→string→float 这种损失通路。
踩坑 :项目里我曾经混用过 float 和 Fix64,结果"两个客户端跑出来的本地预测位置相差 0.0001",看着没事,但几秒之后会累积到几个像素,远端玩家会出现微小抖动。最后统一改成"逻辑层全 Fix64,表现层才转 float"。
2. 客户端预测(Client-Side Prediction)
如果本地玩家按 W 之后等服务器回包才能动,那就是 100~300ms 延迟的"棉花手感"。必须做预测:
csharp
// 渲染帧采集输入
input.MoveX = Input.GetAxisRaw("Horizontal");
input.MoveY = Input.GetAxisRaw("Vertical");
// 逻辑帧 Tick:
// 1) 把本帧输入立即应用到本地玩家("我先动起来")
// 2) 把输入连同 seq 上行给服务器
// 3) 把"这一帧执行后的状态"存进 PredictionSnapshot[seq]
_predictionBuffer[seq % 128] = new PredictionSnapshot { Seq = seq, PosX = ..., InputX = ... };
SendInput(seq, intent);
要点:
- 每个输入都有自增 seq 号 ,服务器回包里带
lastProcessedSeq告诉你"我处理到哪儿了"。 - 预测历史要用环形缓冲(我用 128 帧 = 6.4 秒 @20Hz,对一般网络足够)。
3. 服务器校正与重放(Reconciliation)
服务器下发 S2C_BattleStateNtf 时,本地玩家需要做对账:
csharp
// 服务器告诉我:seq=42 处理完后你的位置应该是 (10, 5)
var serverSeq = state.LastProcessedSeq;
var serverPos = new Vector2(state.PosX, state.PosZ);
// 我的预测里 seq=42 处理完是 (10.05, 5)
var predicted = _predictionBuffer[serverSeq % 128];
if (Fix64.Distance(predicted.Pos, serverPos) > THRESHOLD) {
// 偏差超出阈值:用服务器权威状态覆盖,然后把 seq+1 ~ 当前 seq 的输入全部"重放"一遍
SetLocalPosition(serverPos);
for (int s = serverSeq + 1; s <= _lastSentSeq; s++) {
Replay(_predictionBuffer[s % 128].Input);
}
}
要点:
- 小偏差直接忽略 (设个
0.01的阈值),不然每帧都校正反而抖。 - 大偏差时不能直接"snap"到服务器位置,否则玩家看到自己被拽回去。要在表现层做"软校正"(lerp 几帧到目标位置)。
- 重放是必须的:服务器只算到 seq=42,但你本地已经预测到 seq=50 了,校正完不重放,画面就会"回退 8 帧"。
4. 远端玩家:插值,而不是预测
本地玩家用预测追求零延迟手感 ,远端玩家用插值追求平滑无跳变:
csharp
// 远端玩家收到两个快照 S0(t=0)、S1(t=50ms)
// 渲染时让显示位置"延后一个 tick",永远在两个真实快照之间插值
float alpha = (Time.now - S0.time) / (S1.time - S0.time);
renderPos = Vector3.Lerp(S0.Pos, S1.Pos, alpha);
要点:
- 远端玩家显示位置故意延后 1~2 个 tick(50~100ms),换来 100% 平滑。
- 丢包补救:如果下一个快照迟到,就外推一小段时间(用最近速度推算),但要限制最大外推距离,否则一旦丢一大堆包,玩家会"飞出去"。
- 本地玩家不要插值 ,要外推(
pos + vel × renderAlpha),否则手感会变迟钝。
5. 输入采集:渲染帧缓存 + 逻辑帧消费
这是我项目里专门写注释强调的一个坑:
csharp
// 错误做法:直接在逻辑帧(20Hz)调 GetKeyDown
if (Input.GetKeyDown(KeyCode.J)) Sprint(); // 60→20 帧降采样,2/3 的按键被吞了
// 正确做法
// 渲染帧(60Hz):
void GatherInput() {
if (Input.GetKeyDown(KeyCode.J)) _sprintBuffered = true;
}
// 逻辑帧(20Hz):
void Tick() {
if (_sprintBuffered) { Sprint(); _sprintBuffered = false; }
}
GetKeyDown 只在按下的那一个渲染帧 返回 true,必须在每个渲染帧采集、在逻辑帧消费,100% 不丢按键。
6. 服务端 Tick:固定步长 + 输入队列
服务端每个房间维护一个 20Hz 的 Tick:
- 每 Tick 从每个玩家的输入队列 里取最早未处理 的一条输入(
PeekOldest)。 - 队列空就标记
isEmptyTick = true,客户端可跳过本玩家的快照校验(避免"假偏差")。 - 处理完后把
lastProcessedSeq写进快照,下发。
csharp
foreach (var player in room.Players) {
if (player.InputQueue.TryDequeue(out var input)) {
ApplyInput(player, input);
player.LastProcessedSeq = input.Seq;
} else {
player.IsEmptyTick = true; // 本帧没消费输入
}
}
BroadcastSnapshot(room);
要点:
- 服务端的 Tick 频率不必等于客户端发送频率,但客户端要按 Tick 节奏发输入,否则队列堆积。
- 输入队列要限长(我设的 32),否则恶意客户端可以瞬间灌满。
- "空帧标记"很重要:客户端检测到
isEmptyTick=true时不要做差异校正,否则本地预测领先服务端的部分会被误判为"偏差"。
7. 网络层:为什么是 KCP 而不是裸 UDP / TCP
- TCP 队头阻塞,一个包丢了后面全卡,对实时游戏致命。
- 裸 UDP 没有重传/有序,自己手搓可靠 UDP 工作量太大。
- KCP:可靠 UDP,提供"快速、可控延迟"的重传策略,可调参数适配不同场景。
我的配置(仅供参考):
csharp
kcp.NoDelay(1, 10, 2, 1); // 极速模式:nodelay=1, interval=10ms, resend=2, nc=1
kcp.WndSize(64, 64);
kcp.SetMtu(1200); // 留出 IP/UDP 头和 KCP 头空间,避免分片
要点:
- 战斗包走 KCP/UDP,大厅、登录走 TCP,按需选型。
- KCP 包要小,能塞下一帧快照就别合并多帧。
interval不要太大,否则延迟会被放大。
8. 快照压缩与差量同步
20Hz × N 玩家 × 几十字节的 BattlePlayerState 字段,带宽很快就上去了。优化思路:
- 量化 :Fix64 直接传
sfixed64(已是最紧凑的二进制形式)。 - 差量同步(Delta):只发与上一帧不同的字段,配合 ACK 机制。
- AOI(Area of Interest):只给玩家发"它能看到的"实体的状态,大世界里这一步省得最多。
- 优先级排序:玩家自己 > 视野内近距离玩家 > 远距离玩家 > 不可见玩家(可降频甚至不发)。
我的项目目前还在"全量广播"阶段,因为房间最多 8 人。但代码留好了 BattlePlayerState 的字段顺序,方便将来做 bitmask 差量。
9. 断线重连
状态同步天然对断线重连友好:
protobuf
message S2C_LoginRsp {
bool inBattle = 6; // 我之前是不是在打?
RoomInfo roomInfo = 7; // 我在哪个房间?
S2C_StartBattleNtf battleData = 8; // 战斗的初始数据(含种子、初始玩家状态)
}
服务器维护"玩家最近一次完整状态",重连时把当前最新快照丢回去,客户端直接进入战斗即可。注意:
- 房间状态要在玩家断线时保留一段时间(我设 60 秒),别立刻销毁。
- 重连后客户端要重置预测缓冲(
_predictionBuffer清空,_lastSentSeq同步到服务器)。
10. 时间与帧号的统一
血泪教训:不要混用 Unity 的 Time.time 和服务端的 tick。在所有跨端逻辑里,统一用:
- 服务端 tick(int,单调递增)作为权威时间。
- 客户端预测帧用 seq(int,每帧 +1)。
- 表现层才允许出现
Time.deltaTime、Time.time。
调试时所有日志都打上 [tick=xxx seq=yyy],排问题事半功倍。
五、踩过的坑总结
- 客户端逻辑用了 float:远端玩家"几秒后开始抖动",根因是浮点漂移累积。改 Fix64 解决。
- GetKeyDown 在逻辑帧调用:冲刺按键经常吞,原因是渲染帧采到的事件没在逻辑帧前被消费。改为渲染帧缓存。
- 预测校正时 snap 到服务器位置:玩家被"拽回去"非常出戏。改成 lerp 软校正。
- 没有空帧标记 :服务端 tick 里玩家输入队列空时,下发的快照看起来"位置没变",但客户端预测继续在跑,误判为偏差→疯狂回拉。加了
isEmptyTick标记,跳过校验。 - KCP MTU 设成 1400:偶尔触发 IP 分片,丢包率飙升。改成 1200 解决。
- 预测缓冲用 List 而不是环形数组 :每次校正要 O(N) 查找 seq,性能差。改环形数组按
seq % SIZE直接索引。
六、最后的几点心得
- 先把"权威服务器"立住,再做体验优化。一开始就为了手感把逻辑放到客户端,后面想加反作弊几乎要重写。
- 逻辑层和表现层一定要彻底分离。逻辑层用 Fix64、按 tick 跑、纯数据;表现层用 float、按渲染帧跑、纯渲染。混在一起就是地狱。
- 预测和插值是两件事,不要混用。本地预测追求"立刻响应",远端插值追求"平滑无跳变"。
- 协议设计要为"将来"留空间。我把战斗协议从大厅协议里拆开,将来想拆独立 BattleServer 时只需要换连接,不用改协议。
- 日志要带 tick 和 seq。不带这两个东西,多人同步的 bug 几乎没法复现。
- 别迷信"零延迟"。状态同步无论怎么优化,远端玩家总有 1~2 tick 的延迟。承认它、规划它,然后用插值/外推把它"藏起来"。
状态同步没有银弹,每一条优化都是在"延迟、带宽、CPU、手感、反作弊"五个维度里做取舍。
真正的功夫是:知道自己游戏的核心体验是什么,然后有意识地把代价付在不影响核心体验的地方。