笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 接入与通信

    • 游戏基础架构与工具
    • 接入网关(长连接接入层)
    • 消息总线(共享内存 IPC)
    • 帧同步(Lockstep)
    • 全栈极限低延迟(物理层→应用层)
    • KCP 与 QUIC
  • 网络与服务网格

    • CNI 与 K8s 网络插件
    • 容器运行时:Docker 与 containerd
    • Istio 与 Cilium 服务网格
    • 服务网格:中心化 vs 去中心化
    • eBPF 原理与在网络/可观测/安全的落地
    • 自研 Mesh 服务网格 × K8s 部署
    • 自研 Mesh × K8s · 数据面深水区
    • Tco 协程框架 · 运行时深水区
    • K8s 异构 & 网络插件
  • 有状态服务

    • 分布式游戏存储(分布式 KV)
    • 有状态服务的数据迁移
    • 有状态服务的数据恢复与容灾
    • 有状态服务的 K8s 编排层治理(防漂移 + 节点崩溃恢复)
    • 内存配置热刷新与更新机制
  • 算法与协程

    • 一致性哈希四算法实现(Ring Hash / Ketama / Maglev / Jump Hash)
    • 蓄水池抽样(Reservoir Sampling)与加权扩展
    • C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)
    • Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播
  • 流量与承载

    • 游戏秒杀场景承载
    • 令牌桶与漏桶
    • 限流与熔断
    • 限流:互联网 vs 游戏
    • 灰度发布 · Canary / BlueGreen / A-B / Shadow
    • 服务器日常系统开发与维护 SOP
    • 极限发压工具设计(多长连接 + 发压任务)
    • 性能分析与优化方法论(六条排查线)
  • 编译与排查

    • 编译优化与 LLVM/Clang/GCC
    • Sanitizer 工具链与内存泄漏定位

帧同步(Lockstep)

帧同步是多人实时游戏中保持所有客户端游戏状态完全一致的核心同步机制。
原理 → 为什么用 → 实现细节 → 与状态同步对比 → C++ 核心代码。


一句话结论

只同步操作输入,靠确定性逻辑让各端算出完全一致的状态。

场景问题

打个比方:帧同步就像一支不设指挥、却要合奏出同一首曲子的交响乐团。所有客户端跟着同一份乐谱、同一个节拍器(帧锁),各自埋头演奏——只要每个人拿到的乐谱(每帧的操作输入)完全相同、且每个人都分毫不差地照谱演奏(确定性逻辑),最后合出来的音乐(游戏状态)必然一模一样。于是根本不用互相喊"我现在演到第几个音符了"——只同步输入、不同步状态,带宽省到极致,一帧几十个字节就够。类比失效边界:这套的命门恰恰是"每个人必须严格照谱、不许有半点即兴"。只要某个客户端的计算掺进一丁点不确定性——浮点数在不同 CPU 上算出的末位不同、随机数种子没对齐、遍历容器的顺序不固定——各端就会悄悄算出不一样的状态,而且误差逐帧累积、越滚越大,最终彻底分裂(不同步、game desync)。所以帧同步的逻辑必须用定点数、统一随机种子、确定性容器,这也是它最难调、最容易翻车的地方。

实现方案

[待补充]

为什么这么做

[待补充]

为什么别的选择不行

[待补充]

1. 是什么

帧同步(Lockstep Synchronization)的核心思想:

所有客户端运行完全相同的确定性逻辑,只同步操作输入,不同步状态。

每个逻辑帧(Game Frame):

  1. 收集本机玩家本帧的操作指令(Input)
  2. 上传到服务器,服务器广播所有玩家的指令
  3. 所有客户端收到全量指令后,在相同逻辑帧执行完全相同的计算
  4. 结果:所有客户端状态完全一致,无需同步状态数据

2. 为什么用帧同步

与状态同步(State Sync)的根本差异

维度帧同步状态同步
同步内容操作指令(Input)游戏状态(Position/HP 等)
带宽极低(只传指令)较高(全量/增量状态)
客户端计算重(完整逻辑)轻(接收并渲染)
一致性强一致(完全相同)最终一致(插值/预测)
断线重连需回放所有帧直接同步当前状态
反作弊易(服务端可验证)难(客户端权威问题)
录像/回放天然支持(存指令序列)复杂(需快照)

适合帧同步的场景

  • RTS(实时战略):单位数量多,状态同步代价极高(《星际争霸》《魔兽争霸》经典帧同步)
  • MOBA:英雄技能碰撞判定强一致(《王者荣耀》早期帧同步)
  • 格斗游戏:帧级别精度的判定必须完全一致
  • 物理模拟:大量刚体,状态同步带宽不可接受

3. 核心原理

3.1 确定性(Determinism)

帧同步的生死线:相同输入 → 相同输出,永远。

破坏确定性的常见原因:

原因说明解决
浮点数IEEE 754 不同平台/编译器结果可能不同用定点数(Fixed-Point)替代
随机数rand() 状态不同步所有端共享同一个伪随机数生成器,用相同种子
容器遍历顺序unordered_map 遍历顺序未定义改用有序容器或排序后处理
多线程并发执行顺序不确定逻辑帧单线程执行
时间戳用系统时间做逻辑判断用帧号代替时间戳
平台差异int 大小、字节序明确类型宽度(int32_t)

3.2 帧号与时钟

逻辑帧率:固定(如 15fps、20fps、30fps)
渲染帧率:自由(如 60fps、120fps)

逻辑帧间隔 = 1000ms / 逻辑帧率

逻辑帧与渲染帧解耦:逻辑帧推进游戏状态,渲染帧在逻辑帧之间做插值表现。

3.3 等帧策略(Wait vs. Predict)

严格帧同步(Hard Lockstep):

  • 必须收到所有玩家本帧指令才能推进
  • 任何一个玩家卡顿 → 所有人卡顿
  • 经典 RTS 做法

乐观帧同步(Optimistic Lockstep / 预测推进):

  • 超时未收到某玩家指令 → 用上一帧指令补帧(Hold Input)
  • 收到延迟指令后回滚重算(Rollback)
  • 现代格斗/MOBA 做法

3.4 延迟补偿(Input Delay)

人为给所有输入增加固定延迟(如 3 帧),确保网络抖动时仍能在 deadline 前收到所有端指令:

本地输入帧 N → 实际执行帧 N+InputDelay

InputDelay 越大,容忍网络抖动越强,但操作手感越差(需根据实际 RTT 动态调整)。


4. 实现架构

4.1 整体流程

4.2 服务端职责

服务端是中继 + 裁判:

  • 收集所有玩家当前帧 Input
  • 打上服务端帧号,广播给所有玩家
  • 可选:执行同一份逻辑做校验(反作弊)
  • 不做游戏逻辑运算(逻辑在客户端)

4.3 断线重连

存储所有帧的 Input 序列(帧录像):

frame[0] = {p1: input, p2: input, ...}
frame[1] = {p1: input, p2: input, ...}
...

重连时从 frame[0] 开始快速回放(追帧),或使用快照 + 局部回放减少追帧时间。


5. C++ 核心实现

5.1 定点数(Fixed-Point)

#include <cstdint>

// Q16.16 定点数:高 16 位整数部分,低 16 位小数部分
struct Fixed {
    int32_t raw;

    static Fixed fromInt(int32_t v)   { return {v << 16}; }
    static Fixed fromFloat(float v)   { return {(int32_t)(v * 65536.0f)}; }

    float toFloat() const { return raw / 65536.0f; }

    Fixed operator+(const Fixed& o) const { return {raw + o.raw}; }
    Fixed operator-(const Fixed& o) const { return {raw - o.raw}; }
    Fixed operator*(const Fixed& o) const {
        return {(int32_t)(((int64_t)raw * o.raw) >> 16)};
    }
    Fixed operator/(const Fixed& o) const {
        return {(int32_t)(((int64_t)raw << 16) / o.raw)};
    }
    bool operator<(const Fixed& o) const { return raw < o.raw; }
    bool operator==(const Fixed& o) const { return raw == o.raw; }
};

5.2 确定性随机数(LCG)

#include <cstdint>

struct DeterministicRng {
    uint64_t state;

    explicit DeterministicRng(uint64_t seed) : state(seed) {}

    uint32_t next() {
        // LCG: Numerical Recipes 参数
        state = state * 6364136223846793005ULL + 1442695040888963407ULL;
        return (uint32_t)(state >> 33);
    }

    int32_t nextRange(int32_t lo, int32_t hi) {
        return lo + (int32_t)(next() % (uint32_t)(hi - lo));
    }
};

5.3 输入结构

#include <cstdint>
#include <array>
#include <unordered_map>

// 每帧每个玩家的操作指令
struct PlayerInput {
    uint32_t frameNo;   // 帧号
    uint8_t  playerId;
    uint16_t buttons;   // bit flags: MOVE_UP=1, MOVE_DOWN=2, ATTACK=4...
    int8_t   moveX;     // 方向输入 [-100, 100]
    int8_t   moveY;

    // 序列化(网络传输)
    void serialize(uint8_t* buf) const {
        memcpy(buf, this, sizeof(PlayerInput));
    }
    static PlayerInput deserialize(const uint8_t* buf) {
        PlayerInput inp;
        memcpy(&inp, buf, sizeof(PlayerInput));
        return inp;
    }
};

// 一帧所有玩家的输入集合
struct FrameInputs {
    uint32_t frameNo;
    std::array<PlayerInput, 4> inputs;  // 最多 4 人
    uint8_t  playerCount;
};

5.4 帧缓冲区(Input Buffer)

#include <vector>
#include <mutex>
#include <optional>

class InputBuffer {
public:
    explicit InputBuffer(uint32_t capacity = 256)
        : buffer_(capacity), capacity_(capacity) {}

    // 网络线程写入
    void push(const FrameInputs& fi) {
        std::lock_guard<std::mutex> lk(mu_);
        buffer_[fi.frameNo % capacity_] = fi;
    }

    // 逻辑线程读取
    std::optional<FrameInputs> get(uint32_t frameNo) const {
        std::lock_guard<std::mutex> lk(mu_);
        const auto& slot = buffer_[frameNo % capacity_];
        if (slot.frameNo == frameNo) return slot;
        return std::nullopt;
    }

private:
    mutable std::mutex mu_;
    std::vector<FrameInputs> buffer_;
    uint32_t capacity_;
};

5.5 帧同步主循环

#include <chrono>
#include <thread>

class LockstepSimulator {
public:
    static constexpr int  LOGIC_FPS       = 20;           // 逻辑帧率
    static constexpr int  FRAME_MS        = 1000 / LOGIC_FPS;  // 50ms/帧
    static constexpr int  INPUT_DELAY     = 2;            // 输入延迟帧数
    static constexpr int  WAIT_TIMEOUT_MS = 200;          // 等帧超时

    void run() {
        using Clock = std::chrono::steady_clock;
        auto nextTick = Clock::now();

        while (!gameOver_) {
            // 1. 采集本地输入,打上延迟帧号
            PlayerInput localInput = collectLocalInput();
            localInput.frameNo = currentFrame_ + INPUT_DELAY;
            network_->sendInput(localInput);

            // 2. 等待当前帧所有玩家输入(含超时补帧)
            FrameInputs fi = waitForInputs(currentFrame_);

            // 3. 确定性模拟
            simulate(fi);
            currentFrame_++;

            // 4. 定时推进(固定帧率)
            nextTick += std::chrono::milliseconds(FRAME_MS);
            std::this_thread::sleep_until(nextTick);
        }
    }

private:
    FrameInputs waitForInputs(uint32_t frameNo) {
        auto deadline = std::chrono::steady_clock::now()
                      + std::chrono::milliseconds(WAIT_TIMEOUT_MS);

        while (true) {
            auto fi = inputBuffer_.get(frameNo);
            if (fi && fi->playerCount == expectedPlayers_) {
                return *fi;
            }
            if (std::chrono::steady_clock::now() >= deadline) {
                // 超时:补帧(复用上一帧输入)
                return holdInputs(frameNo);
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        }
    }

    FrameInputs holdInputs(uint32_t frameNo) {
        FrameInputs fi = lastInputs_;
        fi.frameNo = frameNo;
        return fi;
    }

    void simulate(const FrameInputs& fi) {
        // 按确定性顺序处理(玩家 ID 排序保证顺序)
        for (uint8_t i = 0; i < fi.playerCount; ++i) {
            applyInput(fi.inputs[i]);
        }
        world_.step(Fixed::fromFloat(FRAME_MS / 1000.0f));
        lastInputs_ = fi;
    }

    PlayerInput   collectLocalInput();
    void          applyInput(const PlayerInput&);

    uint32_t      currentFrame_    = 0;
    uint8_t       expectedPlayers_ = 2;
    bool          gameOver_        = false;
    FrameInputs   lastInputs_      = {};
    InputBuffer   inputBuffer_;
    World         world_;      // 游戏世界(确定性)
    Network*      network_;
};

5.6 校验码(Checksum)反作弊

#include <cstdint>

// 每 N 帧对关键状态做 hash,上报服务端比对
uint32_t computeChecksum(const World& world) {
    uint32_t h = 2166136261u;  // FNV-1a
    for (const auto& unit : world.units()) {
        auto x = unit.pos.x.raw;
        auto y = unit.pos.y.raw;
        auto hp = unit.hp;
        h ^= (uint32_t)x;  h *= 16777619u;
        h ^= (uint32_t)y;  h *= 16777619u;
        h ^= (uint32_t)hp; h *= 16777619u;
    }
    return h;
}

// 每 5 帧上报一次
if (currentFrame_ % 5 == 0) {
    network_->sendChecksum(currentFrame_, computeChecksum(world_));
}

6. 关键挑战与解法

挑战现象解法
浮点不一致不同机器/平台同帧结果不同,逐渐分叉全局替换为定点数
网络抖动等帧卡顿,影响操作流畅InputDelay + 超时补帧
高延迟断线重连追帧时间长定期保存快照,从最近快照追帧
作弊修改本地逻辑得到不同结果服务端运行同一份逻辑 + Checksum 校验
追帧太慢断线后回放全程耗时追帧期间跳过渲染,纯逻辑加速
单位寻路A* 结果依赖遍历顺序固定遍历顺序,确保确定性

7. 与状态同步选型


沉淀结论

记忆口诀

核心思想:只传输入 / 各端同算 / 状态自洽 / 不传状态
确定性:定点数 / 同种子随机 / 有序遍历 / 逻辑单线程 / 帧号代时间
等帧策略:InputDelay 预留 / 超时补帧 HoldInput / 延迟回滚 Rollback
vs 状态同步:省带宽 / 客户端重算 / 强一致 / 天然录像回放


参考

  • GGPO Rollback Networking SDK:格斗游戏帧同步回滚标准库
  • 《游戏编程精粹》帧同步章节
  • Riot Games: Development Postmortem of League of Legends Networking
  • 王者荣耀技术团队:帧同步在 MOBA 中的实践(KM)

自测:合上资料能说清楚吗?

帧同步为什么必须用定点数,而不能直接用浮点数?

参考答案

IEEE 754 浮点在不同平台/编译器/优化选项下同一运算结果可能有细微差异,帧同步要求相同输入→完全相同输出,任何微小误差都会随帧数逐渐分叉导致各端状态不一致。改用 Q16.16 定点数(整数运算)保证跨平台位级一致。

严格帧同步(Hard Lockstep)和乐观帧同步(Optimistic)有什么区别?各适合什么场景?

参考答案

严格:必须收齐本帧所有玩家输入才推进,一人卡顿全体卡顿,经典 RTS 用。乐观:超时用上一帧输入补帧先推进,收到延迟输入后回滚重算(Rollback),手感好但实现复杂,现代格斗/MOBA(如 GGPO)用。

InputDelay(输入延迟)的作用是什么?调大调小分别有什么影响?

参考答案

给所有输入人为增加固定延迟帧(本地帧 N 实际执行帧 N+delay),让网络抖动时仍能在 deadline 前收齐全部输入。调大:抗抖动强但操作手感变差;调小:手感好但易卡顿。需按实际 RTT 动态调整。

帧同步如何实现断线重连和反作弊?

参考答案

重连:服务端存全量 Input 序列(帧录像),从头快速回放追帧,或用快照 + 局部回放减少追帧时间(期间跳过渲染)。反作弊:服务端跑同一份逻辑做校验,客户端每 N 帧上报关键状态 Checksum,不一致即判定作弊。

同样是多人同步,帧同步和状态同步该如何选型?

参考答案

单位多(RTS)/ 帧级判定(格斗、MOBA) 选帧同步,带宽优势碾压且强一致、天然录像。单位少 / 判定宽松(MMO、FPS) 选状态同步,客户端轻、断线直接同步当前状态。跨平台客户端需统一定点数实现,成本高时优先评估状态同步。

相关专题:帧同步依赖的 UDP 可靠层与低延迟收发在端到端链路中的整体取舍见 全栈极限低延迟;与帧同步相对的另一条路线——状态同步 + 客户端预测回滚 + 服务器回溯命中判定——见 UE 移动预测与回滚 与 UE 延迟补偿。

最近更新: 2026/9/10 11:38
Prev
消息总线(共享内存 IPC)
Next
全栈极限低延迟(物理层→应用层)