LOADING

加载过慢请开启缓存 浏览器默认开启

帧同步系统设计记录

2025/9/6 技术

实现结果展示

给予一个初始方向的力

演示中多个客户端在给予初始球体一个碰撞后运行轨迹完全一致,同时世界哈希值(物体速度、位置、方向决定)计算值完全一致

github 链接: https://github.com/chu123122/LockStepSystem

帧同步的介绍

对于网络多人游戏,如何在不可靠的网络上建立起一个共享一致的虚拟世界是一个重要的问题,主流的两种解决方案分别是:状态同步帧同步

状态同步,个人的理解是:可以概括为结果同步。由服务端周期性地将游戏物体的状态数据发放给各个客户端,各客户端接收到数据后进行插值同步,常用于卡牌、策略、回合制等无需高精度的游戏。(服务端持有权威世界,客户端强制性同步)

帧同步,我目前的理解是:确定性世界中的输入同步。在每一帧(或固定的时间步)中,所有玩家的输入操作被收集并广播给所有客户端,每个客户端再用完全一致的逻辑去本地计算游戏状态,从而保持整个游戏世界的一致性。常用于 RTS、格斗、MOBA 等操作频繁、需要精确一致的游戏。(服务端只持有输入历史,客户端各跑各的,所以确定性世界的构建无比重要,不过这种实现是很古早的版本了)

之后收集相关文章时发现这一个帧同步中的输入同步中其实有很多地方可以细究,例如:客户端A推迟发送输入,先等服务器发送其他客户端的输入以此作弊怎么办?(称呼为lookahead cheats)输入同步如何处理网络波动和延迟?如何减少同步频率波度导致的影响?
·

帧同步系统的设计思路

总览

服务端将从客户端收集到的玩家输入进行分发给各个客户端,具体的单位执行逻辑由客户端本身持有,我们只进行一个输入驱动,在这种情况下,需要是一个极具确定性的系统才能够保证各个客户端运行出的结果一致。

单个客户端进行本地输入采集,发送给服务端-->

服务端将收集到的各个输入封装后发送给所有客户端-->

客户端收集到服务端发送的指令集后,一一执行输入,模拟出结果。

1.逻辑时钟的定义

在构建帧同步系统时,我们遇到的第一个问题就是时间步长。unity中的Update()循环,其心跳Time.deltaTime取决于玩家的硬件性能和渲染负载,完全不稳定、不可预测

为此,我们在服务端和客户端引入了逻辑时钟的概念,通过一个accumulator(时间累加器)和固定的TIME_STEP,让时间累加器在主循环中不断积累,到达一定时间后进行一次逻辑更新,由此我们在服务端和客户端都建立起了一个确定性的逻辑循环。

//Unity中客户端逻辑时钟循环
public void LogicUpdate()
  {
      accumulator += Time.deltaTime;//时间累加器累计

        while (accumulator >= TIME_STEP)//当超过TIME_STEP时进行一次逻辑更新
        {
            //....
        }	
    //...
    }
//cpp中服务端逻辑时钟循环
const float SERVER_TICK_RATE = 30.0f;
const float TIME_STEP = 1.0f / SERVER_TICK_RATE; //即代表每秒30帧
int main(void)
{
  //...
     while (true)
    {
        // 时间管理计算
        auto frameStartTime = std::chrono::high_resolution_clock::now();
        std::time_t now_c = std::chrono::system_clock::to_time_t(frameStartTime);

        std::chrono::duration<float> deltaTime_duration = frameStartTime - lastTime;
        float deltaTime = deltaTime_duration.count();
        lastTime = frameStartTime;

        accumulator += deltaTime;
        while (accumulator > TIME_STEP)
        {
          //...
        }
     }
  //...
}

2. 客户端的双时钟模型

对于单一的客户端和服务端来说,简单的逻辑时钟便已经足够。但是对于多客户端来说,却远远不够。我们无法做到让后续加入的客户端可以安全地重演历史逻辑

如何让一个‘活在过去’的新玩家,安全地追上‘现在’,并向‘未来’提交输入?为此,我们选择了双时钟模型

输入帧 (currentInputFrame),每次步长积累足够后直接递进一次,会在客户端和服务端连接上时,和服务端当前逻辑帧进行同步;

逻辑帧 (currentLogicFrame),只有确定当前逻辑帧的指令集存在时才进行逻辑更新,同时逻辑帧+1,不会进行同步。

输入帧和逻辑帧的分开设计,可以避免后加入客户端用户的输入干扰到重播的历史。同时,由于后加入客户端以及具备了所有的历史指令集,它在重播时逻辑帧和输入帧步进速度保持了一致,使得落后的客户端可以快速追上前面的客户端。(这里当时研究半天为什么逻辑帧会出现快进的情况)

public void LogicUpdate()
{ 
  accumulator += Time.deltaTime;

  while (accumulator >= TIME_STEP)
  {
    //...
    if(_clientManager.ServerCommandSetDic.Keys.
           Contains(executeLogicFrame)) //检查执行帧的指令集是否到达
    {
      //...
      
      currentLogicFrame += 1; //逻辑帧步进
      _physicsManager.LogicUpdate();//物理逻辑更新
      OnGameLogicUpdate?.Invoke();//逻辑更新
    }
    //...
    accumulator -= TIME_STEP;
    currentInputFrame += 1;//输入帧步进
  }
}

3.网络延迟处理

帧同步的核心在于锁步,我们需要等待所有客户端指令发出后才进行逻辑更新,但这也带来了一个问题:客户端需要及其频繁,每秒多少帧的输入,才可以填充服务端该帧的指令集。反之,则会出现服务端迟迟不发送该帧的指令集,客户端产生死锁

为了解决这个问题,我们在服务端引入了超时处理和空指令,对于任一逻辑帧的指令集来说,我们只在规定时间内收集,超时默认填充空指令进去。

//....
if (status == frameStatus::Collecting)//指令集还处在收集阶段
{
  auto now = std::chrono::high_resolution_clock::now();
  auto age = now - frame_data->creationTime;

  // 收集完成
  if (command_set.size() == client_count)
  {
    status = frameStatus::Ready;
  }
  // 超时填充默认指令
  else if (age > TIMEOUT_DURATION)
  {                     		 	 frame_sync_manager.full_null_command_in_frame_data(
    *frame_data, 
    client_count);
    status = frameStatus::Ready;
  }
}
//....

4. 确定性物理世界的构造

在解决了时钟和网络的问题后,很快遇到了另一个问题。Unity本身物理引擎的黑箱浮点数在不同硬件上的计算差异,我们无法计算出一样的结果在不同客户端上。

为了解决这个问题,我们需要抛弃Unity的Rigidbody或Collider,构建一个自己的简单物体系统。(注,该处为了避免复杂的实现,同时我们所有客户端和服务端都运行在同一硬件下,所以我们仍然使用浮点数作为数据类型而非定点数)

  public void LogicUpdate()
  {
    for (int i = 0; i < _physicsObjects.Count; i++)
    {
      PhysicsBase obj = _physicsObjects[i];
      float velocityValue = Vector3.Magnitude(obj.currentVelocity);
      if (velocityValue < 0.1f)
        obj.currentVelocity = Vector3.zero;
      else
        Debug.Log(velocityValue);
      //-----------------------------------------------------
      obj.currentVelocity *= 0.98f;//模仿摩擦力
      obj.currentLogicPosition += obj.currentVelocity * GameClockManager.TIME_STEP;//逻辑位置更新
                
      //  墙壁碰撞检测与响应
      //...
                
      // 对象间碰撞 (处理物体与物体之间的关系) 
       for (int i = 0; i < _physicsObjects.Count; i++)
       {
         for (int j = i + 1; j < _physicsObjects.Count; j++)
         {
           var ballA = _physicsObjects[i];
           var ballB = _physicsObjects[j];

           float logicDistance = Vector3.Distance(ballA.currentLogicPosition, ballB.currentLogicPosition);
           

           if (ballA.name != ballB.name &&
               logicDistance < (ballA.ballRadius + ballB.ballRadius))
           {
             Debug.Log($"球体发生碰撞," +
                       $"球体A:{ballA.gameObject.name}," +
                       $"球体B:{ballB.gameObject.name}");
             ResolveCollision(ballA, ballB);//我们假设完全弹性碰撞,速度交换
           }
         }
       }
    }
  }

我们这里采用的先移动再检测的模式,可能会导致隧道效应,为了解决这个问题,有两种思路,一种是子步模拟(Sub-stepping)将一个大的逻辑时间步,分割成多次更小的迭代,在每一次迭代中都执行一次‘移动-检测’,从而以更高的时间频率来捕捉碰撞。(有点类似图形学中的超采样技术,提高采样率,自然提高了精度)

另一种思路是像Unity的Continuous模式一样,采用‘连续碰撞检测’,通过计算物体运动的‘扫掠体’来预测碰撞。

5. 渲染和逻辑的解耦和割裂

由于帧同步的逻辑时钟,我们物体的实际逻辑位置和玩家看见的渲染位置实际是不一样的。也就是,在帧同步中,渲染永远落后于逻辑。我们需要让渲染针对逻辑插值,从而平滑表现。

对于一个移动的单位来说,其逻辑位置是在逻辑帧中阶段性更新,如果只是简单的同步我们会得到一个类似瞬移+顿步的结果。(注意,该处卡顿的效果不是gif图帧率过低导致)

对此我们简单地进行插值处理

  protected virtual void RenderUpdate()
  {
    if (GameClockManager.Instance.IsReplayTime())
    {
      smoothTime = 0.03f;//回放时由于逻辑更新加快,我们渲染速度也需要加快
    }
    else
    {
      smoothTime = 0.25f;
    }

    transform.position = Vector3.SmoothDamp(
      transform.position,
      currentLogicPosition,
      ref _velocity,
      smoothTime);
  }

当前采用的”SmoothDamp“(外插值)方案,虽然实现了基本的平滑,但在网络抖动较大或高速追帧时,仍存在视觉上的追赶感和不稳定性,会产生凭空碰撞的现象。

一个更健壮的工业级解决方案是采用”快照内插值“。让渲染逻辑固定地、延迟一个或多个逻辑帧,来播放两个‘已确认’的历史状态之间的动画,从而彻底将渲染表现与网络波动解耦,实现绝对的视觉平滑。这需要为每个同步对象,维护一个小的历史状态缓冲区。

总结

借助课程设计的机会完成了这份基础的确定性帧同步系统,主要困难还是在于实际实现中的各种问题以及对于socket函数的不熟悉,帧同步系统中还要许多地方没有弄明白,预测、回滚,仍然有待优化,不够之后再写可能就是很久之后了。

主要实现了:双时钟模型、超时处理、确定性物理、渲染插值。意外之喜是,由于后加入的客户端每次逻辑更新中,由于回放历史满足,必然可以推进一步逻辑世界,从而实现了快进追帧功能。(这个导致当时研究半天为什么后加入客户端莫名其妙就是跑的快)

需要注意的是,局限于课程设计,避免过于复杂,只是简单建立在浮点数的基础上,没有实现定点数,本系统的确定性只能建立在同一设备上。以及输入延迟只是简单的进行初步缓冲,并没有建立缓冲池。

另外,借助帧同步的这份指令集系统,其实可以很好的实现一份指令回放系统(以前实现过一份指令回放系统,可以说基本上一模一样),回放系统可谓是帧同步的一份副产物了。


补充:

和课程老师讨论时问到了许多我没有仔细思考过的地方。

Q1:帧同步具体解决的不确定性问题的细节

A1:底层API(动画、物理、数学库)调用的黑盒,导致的细微差异;

数据结构内部算法在不同电脑、不同版本上都无法保存一致。(eg:哈希表 (std::unordered_map, Dictionary<,>): 它的迭代顺序,取决于哈希算法的实现和内部冲突解决的策略。)

现代编译器优化性能的不确定性,不同CPU对于指令的性能优化的操作可能导致不一样的结果(因为对于浮点数运算中**(a + b) + c可能不等于a + (b + c)!**)

Q2:帧同步的同步具体在哪里?对于每次广播的大量空的指令集,我是不是可以省略掉呢?以及它的确定性如何表现出的?

A2:帧同步的核心点在于过程同步。我们为了简化逻辑,做了一个大胆的假设:只要我保证所有人的输入(原因)一致,那么他们的结果必然一致。因此服务端无需同步那个复杂、庞大的结果了。

对于状态同步来说,唯一权威的是服务端,允许接受不同客户端运算结果一致的情况,在遵守服务端权威的架构下,即便有小的差异也会在下一次数据包到达时被强制拉回。

而帧同步下,唯一权威的是所有客户端共享的初始状态和指令序列表,服务端完全信任客户端计算出来的结果,在这个模型下没有任何纠错机制

因此确定性是帧同步架构的唯一地基!


扩展

最近阅读到了网易雷火大佬的几篇技术文章,感觉收获颇多,借此梳理一下自己实现的系统和业界之间的标准化的差异。 同时强烈推荐Jerish - 知乎大佬的几篇文章!

细谈网络同步在游戏历史中的发展变化(上) - 知乎

细谈网络同步在游戏历史中的发展变化(中) - 知乎

细谈网络同步在游戏历史中的发展变化(下) - 知乎

帧同步发展

  • 早期的Lockstep——“确定性锁步同步(Deterministic Lockstep)”
  • Bucket Synchronization—— “乐观帧锁定”
  • 锁步同步协议 Lockstep protocol—— 用于解决外挂的一种协议(具体略)
  • RTS中的Lockstep —— 操作延迟和可变步长
  • Pipelined Lockstep protocol——指令流水线(不会与其他玩家发送通途冲突的行为直接执行)
  • Time Warp——时间快照引入(世界快照)

状态同步发展

  • 快照同步 —— 由客户端输入计算服务端世界状态,发送快照给所有客户端

  • 客户端预测和回滚—— 本地先执行。 本地预执行记录时间戳放在一个BUFFER里,回滚时进行事件列表回溯,重新执行一遍

  • **事件锁定与时钟同步 **——基于事件队列严格按序执行

  • 延迟补偿 —— 服务端计算结果时暂时回溯客户端单位位置进行计算,从而弥补网络延迟导致的位置偏差(注意是否应用对于不同游戏类型也不一样)

  • Trailing state synchronization—— 在TimeWarp的基础上减少了快照存储。(详细可以去看 细谈网络同步在游戏历史中的发展变化(中) - 知乎

物理同步

”在较为复杂的物理模拟环境或有物理引擎参与计算的游戏里,如何对持有物理状态信息的对象做网络同步”

主要问题:

  • 物理引擎的不确定性

    1.编译器优化后的指令顺序

    2.约束计算的顺序

    3.不同版本、不同平台浮点数精度问题

  • 在物理引擎参与模拟的条件下,网络同步的微小误差很容易被迅速放大

    (预测回滚牺牲了事件顺序的一致性从而换取到了主控端玩家的及时响应)

常见同步优化技术

  • 表现优化

    a.插值优化

    b.客户端预先执行+回滚

  • 延迟对抗

    a.延迟补偿

    b.命令缓冲区

  • 丢包对抗

  • 带宽优化

  • 帧率优化