帧同步详解:确定性模拟与 30Hz 帧中继,附试玩 Demo

帧同步封面:确定性模拟与 30Hz 帧中继
封面:帧同步——确定性模拟与 30Hz 帧中继

试玩地址:http://101.133.145.229:7100/。打开两个标签页,各自点一下 Start Matchmaking,匹配上就是一场双人帧同步对局:WASD 移动、空格跳跃,双方看到的是同一个物理世界。

上一篇文章聊了单机物理引擎怎么让物体动起来。这篇再进一步:两个人在两台机器上玩同一个物理世界,怎么保证两边算出来的结果分毫不差?答案就是帧同步(lockstep)——不传状态,只传输入,让两端用相同的规则各自重放。

状态同步与帧同步的分工

多人游戏同步状态有两条路线。

状态同步:服务器跑权威模拟,把实体位置速度下发给客户端,客户端做插值和预测。带宽随状态量增长,服务器承担全部计算,FPS 和 MMO 基本都是这条路。

帧同步:服务器只转发输入,所有客户端各自跑一份确定性模拟。带宽只有输入量(每帧几个数),一致性靠确定性保证而不是靠纠正。代价同样明确:任何一端的分歧都会累积成画面不一致;玩家输入要等广播到齐才生效,天然对延迟敏感。RTS 的传统强项——星际争霸、帝国时代都是帧同步,《王者荣耀》也选了帧同步方案。

本项目是帧同步的完整最小实现:Go 中继服务器加基于 Bounce 确定性物理引擎的浏览器客户端。

确定性:帧同步的地基

帧同步成立的唯一前提:同一时刻,两台机器上的世界状态逐位相同。工程上守住三条规则:

  • 固定步长:所有客户端用相同的 dt 调 takeOneStep(1/30),一步都不能差;
  • 避开超越函数:物理相关逻辑不碰 Math.sin/cos/tan,这类函数在不同平台的实现不保证逐位一致;
  • 保持运算顺序:浮点加法不满足结合律,(a+b)+c 可能不等于 a+(b+c),两端所有计算顺序必须一致。

Bounce 是纯 TypeScript 实现的确定性物理引擎,任何 IEEE 754 兼容平台上,相同输入产出相同结果。这是整个 demo 能帧同步的前提。

架构:一个中继、两个确定性世界

帧同步整体架构:一个中继、两个确定性世界
图 1:帧同步整体架构——两个客户端各跑一份确定性物理,中继服务器只负责匹配、帧广播与哈希校验

客户端 A、B 各跑一份相同的物理世界。中继服务器只做四件事:匹配(1Hz 轮询,凑满 2 人开局)、房间生命周期管理、30Hz 帧广播、状态哈希校验。它完全不运行游戏模拟。

输入流动方向:客户端在按键变化时才把 PlayerInput 发给服务器;服务器每个 tick(每秒 30 次)把房间内全部玩家的最近输入打包成 FrameInput 广播给所有人。两端收到同样的帧序列,用同样的步长重放,画面自然一致。

匹配与开局

帧同步匹配与开局时序图
图 2:匹配与开局时序——入队、配对、发种子、30Hz 帧广播、每 300 tick 哈希校验

流程很直白:双方各发 MatchmakingRequest 进队,服务器 1Hz 轮询发现队列凑够 2 人,建房间、分配 playerId,然后发 GameStart 带上随机种子。种子是帧同步的关键——两端的物理世界用同一个种子初始化,之后每一步才可能一致。开局后进入 30Hz 循环:客户端不预测、不回滚,收到一帧就推进一步物理。

线协议

WebSocket 上跑一种极简线路格式,每帧 = 4 字节小端长度 + 1 字节类型标签 + JSON 正文:


[4 字节小端长度][1 字节类型标签][JSON UTF-8]

标签方向消息用途
5C→SMatchmakingRequest进入匹配队列
2C→SPlayerInputmoveX / moveY / jump
3C→SStateHash确定性校验
6S→CMatchmakingStarted排队确认
1S→CJoinResponse分配 playerId
2S→CGameStart随机种子、玩家列表
3S→CFrameInput30Hz 输入广播
4S→CGameOver胜者信息
5S→CPlayerEliminated断线淘汰

服务器:Go 中继

服务器代码量很小:匹配队列、房间状态机、输入缓冲、30Hz tick 循环。核心是这两段:

服务器完整源码:github.com/beijian128/lockstep-server(Go)


// tick/tick.go —— 30Hz 定时循环
const TickRateHz = 30

func (t *TickLoop) Start() {
    ticker := time.NewTicker(time.Second / TickRateHz)
    go func() {
        for {
            select {
            case <-ticker.C:
                tick := t.current.Add(1)
                t.onTick(tick) // 即 dispatch.BroadcastFrame(tick)
            case <-t.stopCh:
                ticker.Stop()
                return
            }
        }
    }()
}


// dispatch/dispatch.go —— 每个 tick 打包全部玩家输入并广播
func (d *Dispatch) BroadcastFrame(tick uint32) {
    players := make([]*pb.PlayerInput, 0, len(d.players))
    for pid := range d.players {
        inp := d.inputBuf[pid] // 该玩家最近一次输入;没发过就是空输入
        inp.Tick = tick
        players = append(players, inp)
    }
    msg := &pb.ServerMessage{Payload: &pb.FrameInput{Tick: tick, Players: players}}
    for _, session := range d.players {
        session.Send(msg)
    }
}

值得注意的三个取舍:

  • 输入缓冲按玩家存最近一次输入,某玩家这一 tick 没发输入,就沿用他上一次的——按键没变化等于持续按住;
  • 断线 30 秒超时,移除玩家并广播 PlayerEliminated,没有重连机制;
  • 每 300 tick(10 秒)收集各客户端状态哈希,至少两份哈希可对比时做校验,不一致记一条 DIVERGENCE 日志——只发现分歧,不负责修复。

客户端:收到帧才步进

客户端完整源码:github.com/beijian128/lockstep-cli(TypeScript,含 Bounce 引擎源码与全部示例工程)


// game.ts —— FrameSync:帧队列,收到一帧推进一步物理
const TIME_STEP = 1 / 30;

consumeAll(world, playerManager) {
  while (this._queue.length > 0) {
    const frame = this._queue.shift()!;
    for (const input of frame.players) {
      const c = playerManager.getController(input.playerId);
      c?.setTargetMovement(input.moveX, input.moveY, input.jump);
      c?.update(TIME_STEP);
    }
    world.takeOneStep(TIME_STEP); // 每帧全体执行同一步物理
  }
}

帧队列攒下服务器广播,渲染循环里逐帧取出:输入灌给对应玩家的角色控制器,然后世界统一前进一步。这种服务器驱动步进把节奏问题整体外包给了网络——帧到得快就快进,到得慢就等,但每一步物理都是相同的 1/30 秒。

两个输入细节:WASD 是相机相对输入,发送前按相机水平角旋转,保证 W 永远是”远离镜头”;状态哈希用 FNV-1a 对全部玩家角色的位置和速度做摘要,每 300 tick 上报一次。

帧循环与一致性体检

30Hz帧循环与状态哈希校验流程
图 3:30Hz 帧循环与每 300 tick 的一致性体检

一帧的完整闭环:采集输入、上行、服务器打包广播、客户端消费并步进。副线是每 10 秒一次的体检:两端各自哈希自己的世界,服务器比对,一致静默通过,不一致打日志。

部署与试玩

部署形态很简单:中继服务作为常驻进程运行,客户端是一个纯静态网页,打开浏览器就能玩。

再放一次试玩地址:http://101.133.145.229:7100/。开两个标签页,各自点 Start Matchmaking,匹配上就开局——没有对手的话,两个标签页就是两个人。

局限与下一步

  • 无预测无回滚:每个 tick 都要等广播到齐,跨地域高延迟下输入响应会明显变慢;
  • 断线无重连:30 秒后判负,只能重新匹配;
  • 哈希校验只检测不纠正:真出分歧,只能靠日志排查;
  • 广播量随人数平方增长:每 tick 把所有人的输入发给所有人,2 人 demo 无压力,扩到 N 人需要输入汇总优化。

适合接手的方向:GGPO 式回滚 netcode——Bounce 的 world.toArray / fromArray 序列化就是为快照回滚准备的;断线重连;以及扩房间规模时的输入分发优化。

参考

滚动至顶部