# 帧同步(Lockstep)⭐
最省带宽的同步方式,但确定性要求极高;RTS/MOBA 的首选方案
前置知识:第02章 网络同步基础
阅读指南(初学者必看)
为什么你需要学习帧同步?
帧同步是网络同步三大模型中最"优雅"的——只传输入,不传状态。带宽极低,回放天然支持,但代价是确定性要求极高。理解帧同步,是理解"分布式系统一致性"的绝佳入口。
学完本章,你能回答:
- 帧同步的核心原理是什么?为什么"输入相同 + 逻辑确定 → 结果必然相同"?
- 确定性为什么这么难?浮点数、随机数、遍历顺序分别怎么解决?
- 输入压缩怎么做到2字节/人/帧?
- 断线重连怎么实现?延迟隐藏有哪些技术?
本文结构
第一部分:核心原理——只传输入,不传状态
第二部分:确定性引擎——帧同步最难的部分(FixedPoint/LCG/查找表)
第三部分:输入压缩与传输——位掩码/增量编码/坐标量化
第四部分:延迟隐藏技术——输入缓冲/本地预测/自适应延迟
第五部分:断线重连与追帧——快照恢复/快速追帧/FrameCatcher
一、核心原理
生活类比:帧同步就像下棋。两个棋手不在同一房间,但只要每步棋的走法一样,棋盘就一定一样。
核心思想:
- 所有客户端运行相同的逻辑代码
- 只同步玩家的输入操作(按键、点击)
- 每帧所有客户端执行相同的输入序列
- 只要输入相同 + 逻辑确定 → 结果必然相同
数据流:
客户端A ──输入──▶ 服务器 ──广播输入──▶ 所有客户端
客户端B ──输入──▶ 服务器 ──广播输入──▶ 所有客户端
每帧传输数据:
{ frame: 1234, inputs: [player1: {dx: 1, dy: 0, attack: false},
player2: {dx: 0, dy: 1, attack: true}] }
严格Lockstep vs 乐观Lockstep
| 类型 | 特点 | 延迟感受 | 适用游戏 |
|---|---|---|---|
| 严格Lockstep | 等所有输入到齐才执行 | 高(最慢玩家决定速度) | 回合制、慢节奏RTS |
| 乐观Lockstep | 本地先执行,输入到后校验/回滚 | 低 | 格斗、MOBA、RTS |
现代游戏基本都是乐观Lockstep。
二、确定性引擎设计
为什么确定性这么难?
不确定的来源:
1. 浮点数精度:0.1 + 0.2 ≠ 0.3(IEEE 754)
2. 随机数:不同客户端随机序列不同
3. 遍历顺序:HashMap/Object 的 key 顺序不确定
4. 物理引擎:浮点误差累积导致结果分叉
2.1 定点数(FixedPoint)
用整数模拟小数,彻底消除浮点不确定性。
class FixedPoint {
constructor(value, precision = 4) {
this.precision = precision;
this.multiplier = Math.pow(10, precision);
if (typeof value === 'number') {
this.raw = Math.round(value * this.multiplier);
} else {
this.raw = value; // 已经是定点数
}
}
add(other) {
return new FixedPoint(this.raw + other.raw, this.precision);
}
sub(other) {
return new FixedPoint(this.raw - other.raw, this.precision);
}
mul(other) {
// 定点数乘法:(a * b) / multiplier
return new FixedPoint(
Math.floor(this.raw * other.raw / this.multiplier),
this.precision
);
}
div(other) {
// 定点数除法:(a * multiplier) / b
return new FixedPoint(
Math.floor(this.raw * this.multiplier / other.raw),
this.precision
);
}
toNumber() {
return this.raw / this.multiplier;
}
}
// 使用
const pos = new FixedPoint(1.5);
const vel = new FixedPoint(0.1);
const newPos = pos.add(vel);
console.log(newPos.toNumber()); // 1.6(确定性结果)
2.2 确定性随机数(LCG)
线性同余生成器(LCG),全平台结果一致。
class DeterministicRandom {
constructor(seed = 12345) {
this.seed = seed;
}
next() {
// LCG参数:a = 1664525, c = 1013904223, m = 2^32
this.seed = (1664525 * this.seed + 1013904223) & 0xFFFFFFFF;
return this.seed / 0xFFFFFFFF;
}
nextInt(min, max) {
return Math.floor(this.next() * (max - min + 1)) + min;
}
}
// 使用:所有客户端用相同的种子和调用次数
const rng = new DeterministicRandom(12345);
console.log(rng.next()); // 0.000038564...
console.log(rng.next()); // 0.171941...
2.3 确定性三角函数
用查找表代替 Math.sin/cos,避免平台差异。
const SIN_TABLE_SIZE = 1024;
const SIN_TABLE = new Array(SIN_TABLE_SIZE);
for (let i = 0; i < SIN_TABLE_SIZE; i++) {
const angle = (i / SIN_TABLE_SIZE) * Math.PI * 2;
SIN_TABLE[i] = Math.sin(angle);
}
function deterministicSin(angle) {
// angle范围:0 ~ 2*PI
const idx = Math.floor((angle / (Math.PI * 2)) * SIN_TABLE_SIZE) % SIN_TABLE_SIZE;
return SIN_TABLE[idx];
}
2.4 物理引擎的确定性
const FIXED_DT = 1000 / 60; // 16.667ms
function gameLoop(realDeltaTime) {
accumulator += realDeltaTime;
while (accumulator >= FIXED_DT) {
physicsWorld.step(FIXED_DT / 1000); // 固定步长
accumulator -= FIXED_DT;
}
}
三、输入压缩与传输
3.1 为什么需要压缩输入
假设5v5 MOBA,每秒30帧:
- 每帧每个玩家发送位置(x, y) + 按键状态 = 约20字节
- 10人 * 30帧 * 20字节 = 6000字节/秒 = 48Kbps/人
- 服务器广播给10人:480Kbps上行
如果压缩到2字节/人/帧:
- 10人 * 30帧 * 2字节 = 600字节/秒 = 4.8Kbps
- 效率提升10倍!
3.2 按键状态位掩码
// 用1个字节表示8个按键状态
const INPUT_UP = 1 << 0; // 00000001
const INPUT_DOWN = 1 << 1; // 00000010
const INPUT_LEFT = 1 << 2; // 00000100
const INPUT_RIGHT = 1 << 3; // 00001000
const INPUT_ATK = 1 << 4; // 00010000
const INPUT_SKILL = 1 << 5; // 00100000
const INPUT_ITEM = 1 << 6; // 01000000
const INPUT_DASH = 1 << 7; // 10000000
// 玩家同时按了上+右+攻击
const input = INPUT_UP | INPUT_RIGHT | INPUT_ATK; // 00001001 = 9
// 服务器收到9后解析:
if (input & INPUT_UP) console.log('上');
if (input & INPUT_RIGHT) console.log('右');
if (input & INPUT_ATK) console.log('攻击');
3.3 输入增量编码
如果玩家这一帧和上一帧的输入相同,可以只发"无变化"标志。
// 帧N-1的输入:{ x: 100, y: 200, keys: 9 }
// 帧N的输入:{ x: 101, y: 200, keys: 9 } // y和keys没变
// 只发送变化量:
const deltaInput = {
dx: 1, // x变化了+1
keys: 0 // 0表示keys无变化
};
// 大小:约4字节,比完整输入(约12字节)节省67%
3.4 坐标量化
浮点坐标 -> 整数网格:
// 原始坐标:x = 123.456789
// 量化精度:0.01
const quantizedX = Math.round(123.456789 * 100); // 12346
// 传输:2字节(int16)
// 还原:12346 / 100 = 123.46
// 误差:0.003,肉眼不可见
3.5 服务器广播优化(二进制帧数据)
// 帧数据结构(二进制)
// 帧头:帧号(2字节) + 玩家数量(1字节)
// 玩家数据:[玩家ID(1字节) + 输入掩码(1字节) + 变化量...] * N
function packFrame(frameNumber, playerInputs) {
const buffer = new ArrayBuffer(3 + playerInputs.length * 4);
const view = new DataView(buffer);
view.setUint16(0, frameNumber, true); // 帧号
view.setUint8(2, playerInputs.length); // 玩家数
let offset = 3;
for (let input of playerInputs) {
view.setUint8(offset++, input.playerId);
view.setUint8(offset++, input.keyMask);
view.setInt16(offset, input.dx || 0, true);
offset += 2;
}
return buffer;
}
四、延迟隐藏技术
4.1 输入缓冲(Input Buffer)
问题:需要等所有玩家输入到齐才能推进帧
→ 慢的玩家拖累快的玩家
解决方案:
- 客户端提前 N 帧发送输入
- 服务器等待所有输入后广播
- N 越大,延迟隐藏越好,但操作延迟越大
4.2 本地预测 + 延迟执行
为了抵消输入延迟的"滞后感",客户端可以预测操作结果并立刻显示,等服务器确认后再校正。
class DelayedInputSystem {
constructor(delayFrames = 3) {
this.delayFrames = delayFrames; // 延迟3帧(约100ms)
this.localInputs = []; // 本地输入队列
this.confirmedInputs = []; // 服务器确认的输入
this.currentFrame = 0;
}
// 玩家按下按键
onPlayerInput(input) {
// 把这个输入安排在 delayFrames 之后执行
const targetFrame = this.currentFrame + this.delayFrames;
this.localInputs.push({ frame: targetFrame, input: input });
// 立刻本地预测显示(视觉效果)
this.predictAndRender(input);
// 发送给服务器(带上目标帧号)
this.sendToServer({ frame: targetFrame, input: input });
}
// 服务器广播确认的输入
onServerFrame(frameData) {
for (let input of frameData.inputs) {
this.confirmedInputs[input.frame] = input;
}
}
// 每帧执行
tick() {
const frame = this.currentFrame;
// 1. 合并本地输入和服务器确认输入
const local = this.localInputs.filter(i => i.frame === frame);
const confirmed = this.confirmedInputs[frame];
// 2. 执行确定性逻辑
this.executeFrame(local, confirmed);
// 3. 如果预测和确认不一致,校正
if (confirmed && !this.predictionMatches(confirmed)) {
this.rollbackAndReplay(frame);
}
this.currentFrame++;
}
}
4.3 自适应延迟
网络条件好时延迟可以小,差时延迟需要大:
function calculateAdaptiveDelay(rttSamples) {
// 取P95 RTT
const sorted = [...rttSamples].sort((a, b) => a - b);
const p95 = sorted[Math.floor(sorted.length * 0.95)];
// 延迟 = P95 RTT / 2 + 缓冲
const delay = Math.ceil(p95 / 2) + 20; // 20ms缓冲
// 限制范围:最小2帧(66ms),最大10帧(333ms)
return Math.max(66, Math.min(delay, 333));
}
五、断线重连与追帧
5.1 断线重连的挑战
玩家掉线5秒后重连:
- 本地状态停留在第1000帧
- 其他玩家已经执行到第1150帧
- 差了150帧 = 5秒
5.2 追帧(Catch-up)方案
方案A:快照恢复(适合状态同步)
- 服务器发送第1150帧的完整状态快照
- 客户端直接跳到最新状态
- 缺点:中间过程的视觉效果丢失
方案B:快速追帧(适合帧同步)
- 服务器发送第1001-1150帧的所有输入
- 客户端以10倍速执行这150帧
- 150帧 / 10 = 15秒追完,但玩家通常只离开几秒
方案C:状态快照+输入追帧(混合方案,推荐)
- 服务器发送第1100帧的快照
- 客户端从1100帧开始,用输入追剩余50帧
- 追帧速度5倍,10秒完成
5.3 追帧代码示例(FrameCatcher)
class FrameCatcher {
constructor(gameLogic) {
this.gameLogic = gameLogic;
this.normalFrameTime = 1000 / 30; // 33ms
this.isCatchingUp = false;
}
async catchUp(targetFrame, inputs) {
this.isCatchingUp = true;
const catchUpSpeed = 5; // 5倍速追帧
const frameTime = this.normalFrameTime / catchUpSpeed;
let currentFrame = this.gameLogic.currentFrame;
while (currentFrame < targetFrame) {
const frameInputs = inputs.filter(i => i.frame === currentFrame);
this.gameLogic.executeFrame(frameInputs);
currentFrame++;
// 每帧稍微 sleep 一下,避免卡住主线程
if (currentFrame % 5 === 0) {
await this.sleep(frameTime);
}
}
this.isCatchingUp = false;
console.log('追帧完成,当前帧: ' + currentFrame);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
六、初学者常见错误
错误1:用浮点数做位置同步
误区:JavaScript的Number很方便,直接传{x, y}。 后果:3分钟后客户端之间位置偏移,角色站在不同地方。 正确做法:使用定点数,或者服务器定期校正权威状态。
错误2:帧率不一致
误区:客户端A 60fps,客户端B 30fps,各自按自己的帧率执行。 后果:A比B快一倍,状态迅速分歧。 正确做法:逻辑帧率固定(如30fps),渲染帧率可以不同。
错误3:不考虑断线重连
误区:开发时所有人都在局域网,从不掉线。 后果:上线后玩家一掉线就无法回来,体验极差。 正确做法:从第一天就设计追帧机制,定期保存快照。
错误4:随机数不同步
误区:用Math.random()生成伤害、暴击。 后果:客户端A看到暴击,客户端B看到未命中。 正确做法:使用确定性随机数生成器,所有客户端同种子。
自问自答
Q:帧同步和状态同步哪个更好? A:没有绝对的好坏。帧同步带宽低、天然支持回放,但确定性要求高、反作弊难;状态同步服务器权威、反作弊容易,但带宽大、服务器计算量高。选哪个取决于游戏类型和团队能力。
Q:为什么 JavaScript 不适合做帧同步? A:JavaScript 的浮点运算在不同引擎/平台上可能有微小差异,而且对象遍历顺序不保证确定性。如果非要用 JS,必须使用定点数和确定性容器,并做大量跨平台测试。
Q:输入缓冲设几帧合适?
A:取决于网络质量。本地网络好设 12 帧,网络一般设 35 帧。设太小容易卡顿,设太大操作延迟明显。动态调整是最佳方案:根据实时 RTT 自动调整缓冲帧数。
Q:断线重连为什么不能直接跳到最新帧? A:因为帧同步要求所有客户端从相同的初始状态执行相同的输入序列。如果直接跳到最新帧,客户端没有中间过程的输入数据,状态无法对齐。必须从快照+重放来恢复。
实践任务
- 任务1:实现一个双人帧同步对战 demo,用定点数保证确定性(移动+碰撞即可)
- 任务2:在 demo 中故意引入浮点数误差,观察状态分叉现象
- 任务3:实现断线重连机制——保存快照 + 重放输入
- 任务4:实现输入缓冲和延迟隐藏,对比不同缓冲帧数的操作手感
- 任务5:在 demo 中添加确定性随机,实现暴击/闪避等随机效果
- 任务6:实现输入位掩码压缩和坐标量化,对比压缩前后的带宽占用
- 任务7:实现自适应延迟算法,根据实时RTT动态调整缓冲帧数
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| 确定性引擎 | 第05章 预测与回滚 | 预测回滚也依赖确定性,但允许回滚纠正 |
| 输入缓冲 | 第05章 预测与回滚 | 输入延迟是预测回滚的核心参数 |
| 断线重连 | 第04章 状态同步 | 状态同步的断线重连只需拉最新状态,更简单 |
| 只传输入 | 第02章 网络同步基础 | 帧同步是最"省带宽"的同步方式 |
| 输入压缩 | 第06章 自定义UDP协议与KCP | 自定义协议进一步优化传输效率 |
| 反作弊 | 第08章 游戏安全与反作弊 | 帧同步的反作弊是最难的——客户端不可信但又有完整逻辑 |