搭建状态同步框架的实践心得

游戏内状态同步的一些心得与实现要点

作者基于自己 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.deltaTimeTime.time

调试时所有日志都打上 [tick=xxx seq=yyy],排问题事半功倍。


五、踩过的坑总结

  1. 客户端逻辑用了 float:远端玩家"几秒后开始抖动",根因是浮点漂移累积。改 Fix64 解决。
  2. GetKeyDown 在逻辑帧调用:冲刺按键经常吞,原因是渲染帧采到的事件没在逻辑帧前被消费。改为渲染帧缓存。
  3. 预测校正时 snap 到服务器位置:玩家被"拽回去"非常出戏。改成 lerp 软校正。
  4. 没有空帧标记 :服务端 tick 里玩家输入队列空时,下发的快照看起来"位置没变",但客户端预测继续在跑,误判为偏差→疯狂回拉。加了 isEmptyTick 标记,跳过校验。
  5. KCP MTU 设成 1400:偶尔触发 IP 分片,丢包率飙升。改成 1200 解决。
  6. 预测缓冲用 List 而不是环形数组 :每次校正要 O(N) 查找 seq,性能差。改环形数组按 seq % SIZE 直接索引。

六、最后的几点心得

  1. 先把"权威服务器"立住,再做体验优化。一开始就为了手感把逻辑放到客户端,后面想加反作弊几乎要重写。
  2. 逻辑层和表现层一定要彻底分离。逻辑层用 Fix64、按 tick 跑、纯数据;表现层用 float、按渲染帧跑、纯渲染。混在一起就是地狱。
  3. 预测和插值是两件事,不要混用。本地预测追求"立刻响应",远端插值追求"平滑无跳变"。
  4. 协议设计要为"将来"留空间。我把战斗协议从大厅协议里拆开,将来想拆独立 BattleServer 时只需要换连接,不用改协议。
  5. 日志要带 tick 和 seq。不带这两个东西,多人同步的 bug 几乎没法复现。
  6. 别迷信"零延迟"。状态同步无论怎么优化,远端玩家总有 1~2 tick 的延迟。承认它、规划它,然后用插值/外推把它"藏起来"。

状态同步没有银弹,每一条优化都是在"延迟、带宽、CPU、手感、反作弊"五个维度里做取舍。

真正的功夫是:知道自己游戏的核心体验是什么,然后有意识地把代价付在不影响核心体验的地方。

相关推荐
野生技术架构师6 小时前
从 B+ 树到应用层分表:MySQL 海量数据架构解析
数据库·mysql·架构
Maimai108086 小时前
Web3 前端交易系统如何落地:从下单 UI 到 Operation 编码、签名与实时状态更新
前端·react.js·ui·架构·前端框架·web3
heimeiyingwang7 小时前
【架构实战】灰度发布实战:安全上线不翻车
安全·架构
ttwuai7 小时前
XYGo Admin 后端分层架构:Controller→Service→Logic→DAO 实战解析
架构·goframe·后台框架
myenjoy_17 小时前
大规模采集架构——从单台网关到千点集群
架构·wpf
qq_411262427 小时前
AI-02模组架构与Coze智能体接入说明
人工智能·ai·架构·esp32-c3·coze·四博
HavenlonLabs7 小时前
三年内,AI 控制会走向安全的一线
人工智能·安全·金融·架构·安全架构
故渊at7 小时前
第十三板块:Android 综合架构与未来演进 | 第三十一篇:Android 架构演进与 Fuchsia OS 的挑战
android·架构·宏内核·微内核·fuchsia·ipc 性能博弈
咚为8 小时前
Claude Code 深度定制指南:从分层架构到 AI 参谋系统的高级搭建实践
人工智能·架构
X54先生(人文科技)8 小时前
X54先生与“启”关于涌现对话
人工智能·架构·开源·零知识证明