# 状态同步 ⭐
服务器说了算,客户端只负责渲染;MMO/RPG 的标配方案
前置知识:第02章 网络同步基础 + 第03章 帧同步Lockstep
阅读指南(初学者必看)
为什么你需要学习状态同步?
状态同步是游戏行业用得最多的同步方案。与帧同步不同,状态同步的服务器是唯一权威——客户端只负责渲染和预测。理解状态同步,是理解"服务器权威架构"的入口。
学完本章,你能回答:
- 状态同步的核心原理是什么?为什么服务器必须是权威?
- 全量同步和增量同步怎么选?增量同步如何处理丢包?
- 客户端预测和服务器校正怎么配合?为什么不能只靠服务器?
- 插值和外推怎么让画面不卡顿?
本文结构
第一部分:核心原理——服务器权威,客户端渲染
第二部分:状态快照与增量更新——带宽优化
第三部分:客户端预测与服务器校正——消除操作延迟
第四部分:插值平滑与外推——让画面不卡顿
一、核心原理
生活类比:状态同步就像 GPS 导航。服务器是卫星,告诉每个客户端"你现在应该在这个位置"。
核心思想:
- 服务器是唯一权威(Authoritative Server)
- 服务器运行完整游戏逻辑
- 服务器把状态广播给所有客户端
- 客户端只负责渲染和预测
数据流:
客户端A ──输入──▶ 服务器 ──状态──▶ 所有客户端
│
计算新状态
│
客户端B ──输入──▶ 服务器 ──状态──▶ 所有客户端
每帧传输数据(比帧同步大得多):
{ frame: 1234,
entities: [
{ id: 1, x: 100.5, y: 200.3, hp: 80, state: "running" },
{ id: 2, x: 150.2, y: 180.1, hp: 100, state: "idle" }
]
}
状态同步 vs 帧同步
| 维度 | 帧同步 | 状态同步 |
|---|---|---|
| 权威 | 客户端各自计算 | 服务器唯一权威 |
| 带宽 | 小(只传输入) | 大(传状态) |
| 客户端计算 | 重(跑完整逻辑) | 轻(只插值渲染) |
| 防作弊 | 弱(需额外校验) | 强(服务器说了算) |
| 适用人数 | 2-10人 | 10-10000+人 |
| 网络容错 | 差(一致性要求高) | 好(可丢包插值) |
| 代表游戏 | 星际争霸、LOL | 魔兽世界、绝地求生 |
二、状态快照与增量更新
2.1 全量同步 vs 增量同步
全量同步(每帧发送完整状态):
优点:简单可靠
缺点:带宽大(100 个实体 × 每实体 100 字节 = 10KB/帧 × 20帧/秒 = 200KB/秒)
增量同步(只发送变化的部分):
优点:带宽小
缺点:需要处理丢包(缺了某个增量,后续状态不对)
基础快照 + 增量:
Frame 100: { base: true, entities: [...] } ← 全量
Frame 101: { base: 100, delta: {1: {x: 102, y: 201}} } ← 增量
Frame 102: { base: 100, delta: {1: {x: 104}} } ← 增量
2.2 增量同步丢包处理
两种策略:
- 定期发送全量快照作为基准(如每 5 秒一次),客户端收到全量快照后可以重建状态
- 检测增量包序号不连续,主动请求重传或拉取全量快照
2.3 二进制序列化
不要用JSON!用二进制协议:
// 自定义二进制格式
// [帧号: uint16][实体数量: uint8]
// 对每个实体:[实体ID: uint8][变更掩码: uint16][变更字段...]
function encodeDelta(frame, deltas) {
// 估算最大大小
const maxSize = 2 + 1 + deltas.length * (1 + 2 + 16);
const buf = new ArrayBuffer(maxSize);
const view = new DataView(buf);
let offset = 0;
view.setUint16(offset, frame, true); offset += 2;
view.setUint8(offset, deltas.length); offset += 1;
for (let delta of deltas) {
view.setUint8(offset, delta.id); offset += 1;
view.setUint16(offset, delta.mask, true); offset += 2;
// 根据掩码写入具体字段
if (delta.mask & 0x01) { view.setFloat32(offset, delta.x, true); offset += 4; }
if (delta.mask & 0x02) { view.setFloat32(offset, delta.y, true); offset += 4; }
if (delta.mask & 0x04) { view.setUint16(offset, delta.hp, true); offset += 2; }
// ...
}
return buf.slice(0, offset);
}
三、客户端预测与服务器校正
3.1 为什么需要预测
网络延迟50ms:
- T=0:玩家按右键
- T=50ms:服务器收到,计算新位置
- T=100ms:客户端收到新位置
如果客户端什么都不做,玩家会感觉到100ms的延迟!
解决方案:客户端预测操作结果并立刻显示。
3.2 预测代码示例
class ClientPrediction {
constructor() {
this.serverState = { x: 0, y: 0 }; // 服务器权威状态
this.predictedState = { x: 0, y: 0 }; // 本地预测状态
this.pendingInputs = []; // 已发送但未确认的操作
}
// 玩家输入
onInput(input) {
// 1. 立刻应用到预测状态
this.applyInput(this.predictedState, input);
// 2. 记录到待确认队列
this.pendingInputs.push({
seq: this.nextSeq++,
input: input,
timestamp: Date.now()
});
// 3. 发送给服务器
this.sendToServer(input);
}
// 收到服务器校正
onServerCorrection(serverState) {
this.serverState = serverState;
// 重新从服务器状态开始,重放所有待确认输入
this.predictedState = { ...serverState };
// 过滤掉已经被服务器确认的输入
this.pendingInputs = this.pendingInputs.filter(p => p.seq > serverState.ackedSeq);
// 重放剩余输入
for (let pending of this.pendingInputs) {
this.applyInput(this.predictedState, pending.input);
}
}
applyInput(state, input) {
if (input.type === 'move') {
state.x += input.dx;
state.y += input.dy;
}
}
}
3.3 平滑校正(Smooth Correction)
如果预测和服务器状态差异太大,直接"瞬移"会很突兀。
function smoothCorrect(predicted, server, factor = 0.3) {
// 渐进式校正:每帧向服务器状态移动30%
return {
x: predicted.x + (server.x - predicted.x) * factor,
y: predicted.y + (server.y - predicted.y) * factor
};
}
// 如果差异很小,直接采纳服务器状态
// 如果差异很大,可能是作弊或严重丢包,强制瞬移
四、插值平滑与外推
4.1 插值(Interpolation)
用于其他玩家的位置显示:
服务器 20Hz 更新,客户端 60Hz 渲染 → 每 3 帧才有一次新数据,中间帧会卡顿。
解决方案:插值
class Interpolator {
constructor(delay = 100) { // 延迟100ms缓冲
this.buffer = []; // 收到的状态包队列
this.delay = delay;
}
onStateReceived(state) {
this.buffer.push({
state: state,
timestamp: Date.now()
});
// 清理过旧的包
const cutoff = Date.now() - this.delay * 2;
this.buffer = this.buffer.filter(b => b.timestamp > cutoff);
}
getInterpolatedPosition(now) {
const renderTime = now - this.delay;
// 找到 renderTime 前后两个状态包
let prev = null, next = null;
for (let i = 0; i < this.buffer.length - 1; i++) {
if (this.buffer[i].timestamp <= renderTime &&
this.buffer[i + 1].timestamp >= renderTime) {
prev = this.buffer[i];
next = this.buffer[i + 1];
break;
}
}
if (!prev || !next) {
return prev ? prev.state.position : null;
}
// 线性插值
const t = (renderTime - prev.timestamp) / (next.timestamp - prev.timestamp);
return {
x: prev.state.position.x + (next.state.position.x - prev.state.position.x) * t,
y: prev.state.position.y + (next.state.position.y - prev.state.position.y) * t
};
}
}
4.2 外推(Extrapolation)
用于自己的位置显示(当没有新数据时猜测未来位置):
class Extrapolator {
constructor() {
this.lastPosition = { x: 0, y: 0 };
this.velocity = { x: 0, y: 0 };
this.lastUpdateTime = 0;
}
onUpdate(position, timestamp) {
const dt = timestamp - this.lastUpdateTime;
if (dt > 0) {
this.velocity = {
x: (position.x - this.lastPosition.x) / dt,
y: (position.y - this.lastPosition.y) / dt
};
}
this.lastPosition = position;
this.lastUpdateTime = timestamp;
}
getExtrapolatedPosition(now) {
const dt = now - this.lastUpdateTime;
return {
x: this.lastPosition.x + this.velocity.x * dt,
y: this.lastPosition.y + this.velocity.y * dt
};
}
}
自问自答
Q:状态同步和帧同步可以混用吗? A:可以。很多游戏的核心战斗用帧同步(保证确定性),周边系统用状态同步(聊天、背包、商店)。关键是明确哪些数据走帧同步,哪些走状态同步,避免状态冲突。
Q:增量同步丢包了怎么办? A:两种策略——1)定期发送全量快照作为基准(如每 5 秒一次),客户端收到全量快照后可以重建状态;2)检测增量包序号不连续,主动请求重传或拉取全量快照。
Q:客户端预测错了怎么办? A:服务器会发回真实状态,客户端收到后纠正。关键是平滑校正——不能直接跳到正确位置(玩家会感觉"被拉扯"),要在几帧内平滑过渡。差距小时用插值,差距大时直接跳转。
Q:插值平滑会不会增加延迟? A:会。插值意味着客户端始终渲染"过去"的状态,插值周期就是额外的延迟。典型值是 100ms,加上 RTT/2 的网络延迟,总延迟约 RTT/2 + 100ms。这是画面流畅度的代价。
实践任务
- 任务1:实现一个状态同步的实时游戏(服务器 10Hz 广播位置,客户端渲染)
- 任务2:实现客户端预测 + 服务器校正,对比有无预测的操作延迟
- 任务3:实现插值平滑,对比有无插值的视觉效果(录像对比)
- 任务4:实现增量同步,模拟丢包场景,测试状态恢复能力
- 任务5:对比状态同步和帧同步在同样场景下的带宽占用
- 任务6:实现外推预测,测试丢包时的画面表现
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| 服务器权威 | 第08章 游戏安全与反作弊 | 服务器权威是反作弊的基础——客户端不可信 |
| 客户端预测 | 第05章 预测与回滚 | 第05章是预测的进阶——预测错了就回滚 |
| 插值平滑 | 第02章 网络同步基础 | 插值是解决网络抖动的核心手段 |
| 增量同步 | 第06章 自定义UDP协议与KCP | 增量同步对丢包敏感,需要可靠 UDP 支持 |
| 状态快照 | 第05章 预测与回滚 | 回滚也依赖状态快照的保存和恢复 |
| 二进制序列化 | 第13章 弱网优化与实战 | 协议精简是弱网优化的重要手段 |
上一章:03-帧同步Lockstep 下一章:05-预测与回滚