帧同步(Lockstep)
帧同步是多人实时游戏中保持所有客户端游戏状态完全一致的核心同步机制。
原理 → 为什么用 → 实现细节 → 与状态同步对比 → C++ 核心代码。
一句话结论
只同步操作输入,靠确定性逻辑让各端算出完全一致的状态。
场景问题
打个比方:帧同步就像一支不设指挥、却要合奏出同一首曲子的交响乐团。所有客户端跟着同一份乐谱、同一个节拍器(帧锁),各自埋头演奏——只要每个人拿到的乐谱(每帧的操作输入)完全相同、且每个人都分毫不差地照谱演奏(确定性逻辑),最后合出来的音乐(游戏状态)必然一模一样。于是根本不用互相喊"我现在演到第几个音符了"——只同步输入、不同步状态,带宽省到极致,一帧几十个字节就够。类比失效边界:这套的命门恰恰是"每个人必须严格照谱、不许有半点即兴"。只要某个客户端的计算掺进一丁点不确定性——浮点数在不同 CPU 上算出的末位不同、随机数种子没对齐、遍历容器的顺序不固定——各端就会悄悄算出不一样的状态,而且误差逐帧累积、越滚越大,最终彻底分裂(不同步、game desync)。所以帧同步的逻辑必须用定点数、统一随机种子、确定性容器,这也是它最难调、最容易翻车的地方。
实现方案
[待补充]
为什么这么做
[待补充]
为什么别的选择不行
[待补充]
1. 是什么
帧同步(Lockstep Synchronization)的核心思想:
所有客户端运行完全相同的确定性逻辑,只同步操作输入,不同步状态。
每个逻辑帧(Game Frame):
- 收集本机玩家本帧的操作指令(Input)
- 上传到服务器,服务器广播所有玩家的指令
- 所有客户端收到全量指令后,在相同逻辑帧执行完全相同的计算
- 结果:所有客户端状态完全一致,无需同步状态数据
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 延迟补偿。