# 帧同步(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章 游戏安全与反作弊 帧同步的反作弊是最难的——客户端不可信但又有完整逻辑

上一章:02-网络同步基础 下一章:04-状态同步