帧同步游戏开发备忘

2,273 阅读8分钟

游戏同步方案

在多人互动的游戏中,多个客户端为了同步所有玩家的状态,通常使用以下的方案:

  • 状态同步

    客户端发送游戏动作到服务器,服务器收到后,计算游戏行为的结果,然后通过广播下发游戏中各种状态,客户端收到状态后显示内容。这种做法类似于各个客户端都远程操作服务器上的软件。

  • 帧同步

    客户端发送游戏动作到服务器,服务器广播转发所有客户端的动作(或者客户端直接通过P2P技术发送),客户端根据收到的所有游戏动作来做游戏运算和显示。这种做法等于客户端之间互相远程控制其他客户端上的游戏软件。


同步逻辑

用最简单的模型来描述帧同步的关键逻辑如下

客户端检测服务器的逻辑帧 -> 拿到逻辑帧 -> 进行处理 -> 处理出结果 -> 完成本次逻辑帧处理 ->表现层将处理结果渲染到屏幕上 -> 结束

客户端检测用户操作 -> 打包上报到服务器 -> 结束

而为了保证游戏在各端的一致性,帧同步游戏一定要分为逻辑层和表现层;

  • 逻辑层

    逻辑层用于将逻辑帧统一处理计算,需要保证各端下的结果一致,确定性是逻辑层最大的特点;

    关于确定性,可以在以下几个部分去实现:

    • 数学运算确定性

      1. 随机数

        生成随机数时,种子要一致,不建议时间戳,尽量约定好各端使用同一个随机数生成器,达到生成的随机数是一致的,该随机函数为伪随机函数,例如参考随机函数如下:

        // let randomSeed = 123;
        function random(){
            randomSeed = ( randomSeed * 9301 + 49297 ) % 233280.0;
            return randomSeed / ( 233280.0 ); 
        };
        
      2. 定点数

        js 经典的精度问题就是0.1 + 0.2 !== 0.3,在游戏逻辑计算时,由于硬件环境和系统平台的不一致,导致了浮点数会计算出不一样的结果,然后在累积的计算过程中就会放大误差导致最后呈现的不一致。因此我们需要处理浮点数,使用整数或定点数来计算

        处理浮点数的方法有如下方法:

        • 查表计算

          在计算三角函数时,会用到查表的方法获取相应的值。

          以sin A为例,编译阶段提前建立好[0°、1°, … , 90°] -> [sin 0°, sin 1°, … , sin 90°]的映射。运行时计算目标角度坐落的角度区间,查表获得映射结果。其他角度、cos的问题,也可以转换为sin 0-90°的问题。

        • 放大截断

          乘以一个固定的整数(比如1000),或者左移n位。目的是转换成整数运算。好处是简单、高效、直观(注意:JS的移位不能超过32位,比如1<<32 === 1)

      3. 物理引擎

        大多数游戏引擎自带的物理引擎,或者说市面上可见的大部分物理引擎,都不会考虑计算精度问题,因此也是一个会引起数学精度问题的因素,因此,尽可能避免使用通用的物理引擎计算,有能力的话可以定制物理引擎,或者避免使用复杂的物理模拟,通过自己实现简单的物理模拟来完成。

    • 帧率确定性

      逻辑数据不能通过引擎自带的update 来更新,必须与渲染分离,可以在接收到帧数据的时候缓存帧数据,然后按一定帧率去计算,引擎的update 只作为将计算结果渲染到画面中的作用。

      逻辑层通常设置为15帧/秒,这样可以减少表现层的定时器的压力,当然,如果设置为30帧/秒也可以,好处就是表现层可以不做插值也能看起来流畅

    • 执行顺序确定性

      1. 不同的字典实现的排序方案是不一样的,这也导致keys/values的顺序很可能无法统一。为了安全,如果一定要遍历字典,需要先对keys做一次sort,而values需要通过遍历sorted_keys来进行获取。
      2. 引擎生命函数的执行顺序在不同客户端下可能是不一致的,以cocos 为例,其自带的start / onDestroy 回调函数就是可能会出现不一致的情况的,所以需要注意开发过程中对一些生命周期函数的调试来确定其调用时机
  • 表现层

    表现层相比逻辑层来说比较宽松自由一些,但为了体验上的优化,比如在逻辑层计算完毕映射到表现层时,可能会因为累积帧过多导致渲染的闪动,因此,需要考虑做一些平滑帧的处理,还可以做一些简单的预测,让用户操作能够比较实时地映射在画面中

综上所述,同步逻辑的架构设计通常可以以下面的两种形式实现:

  1. 命令架构,适合大部分游戏

    • 彻底分离逻辑层和渲染层代码
    • 逻辑层与渲染层代码通过事件完成交互
  2. 映射架构,适用于物理模拟类游戏

    • 渲染层对象都有一个逻辑对象,并将其挂载到渲染层对象上
    • 渲染层对象在每一次update 函数中,从逻辑对象获取并更新数据

渲染优化

由于逻辑层的帧率与渲染层的帧率不同,且考虑到延迟问题,因此如果直接在每次渲染的时候将逻辑计算结果渲染到画面的时候会造成闪动的感觉,所以需要对渲染层进行一些优化,有如下的几种方案:

  • 缓存帧

    当客户端检测到新的逻辑帧的时候,不立即处理,而是先缓存到一个队列中,等到满足一定数量之后再将队列栈推出处理

  • 平滑帧

    平滑帧则是在缓存帧的基础上,将上述的推出操作做成定时操作,从而抵抗网络波动造成的逻辑帧到达时间间隔不一致的问题

  • 预测

    在其他玩家的操作输入的数据还没有抵达或者处理之前,为了使游戏能够流畅推动,客户端可以不需要服务器确认帧返回才执行指令,而是玩家输入就立即执行,其他玩家的输入则是按照最近一个输入或者其他更优方案进行预测,在收到服务器的最新数据之后,如果预测错误则回滚到最后一次服务器确认的正确帧,再继续追客户端的帧

  • 插值

    如果没有进行插值,物体的移动就会因为帧率的不同步出现卡顿感,可以将一个逻辑帧要移动的距离分解成几个渲染帧来完成,以cocos 为例子,可以将物体从当前位置使用 cc.tween 缓动到逻辑位置

断连追帧

游戏在进行过程中可能由于种种原因会遇到断线的情况,考虑到帧同步是没有状态的,为了让玩家恢复到当前的游戏进度,可以通过请求追帧,从第一帧到当前最新帧之间的数据重新获取后执行一遍,期间把服务器下发的更新的逻辑帧缓存,直到追帧完毕时再更新,从而达到断线重连复位的效果;

但这个做法却会有一个很致命的问题,如果玩家断线时间很长,或者游戏进行时间过久,都会导致重新获取的帧数据过多,或者本地处理的时间过长,导致玩家卡住的时间变长,体验会很糟糕;

针对第一点,简单粗暴的方法是可以将断线时间过长的玩家视为退出游戏,如果不行的话就只能允许一定程度的偏差,使用定时同步玩家状态的方法来实现;

而第二点的话,也是通过定时同步游戏状态的方法来实现,可以定时将当前游戏的主要逻辑数据和当前最大帧上传到另一个服务上,下次重连的时候获取到最后缓存的数据,将游戏复位到缓存的数据状态后,请求缓存的帧到最新帧之间的数据,进行逻辑计算,从而减少请求的数据量和计算压力