文章

【Unity/Lock Step】帧同步理论:Part 2预测&回滚&优化Predict&Rollback&Optimization

【Unity/Lock Step】帧同步理论:Part 2预测&回滚&优化Predict&Rollback&Optimization

概述

本文为帧同步理论系列中第二篇,描述在帧同步网络同步模型中CS如何进行同步,如何实现预测和回滚,以及一些需要注意的点和优化点。由于帧同步对网络敏感,我们需要预测来让游戏流畅,下图为简单的预测回滚示意图


回滚

状态备份(回滚的前提)

帧同步网络同步模型的特点就是只同步输入。因此当C端发现预测输入与服务器下行输入不同时,便需要进行回滚。回滚分为两步,状态恢复以及追帧,而恢复状态的前提就是需要在游戏步进时进行状态备份。有以下实现点需要注意

  • 状态备份和还原,采用何种数据格式,如果数据特别大该如何处理
    • 大型数据只需要初始化进行一次备份,之后不对整个数据进行备份,而是使用命令模式,根据执行的命令进行反执行,这需要维护一个命令栈
    • 用命令模式比较复杂,还有第三种memorycopy的方法进行数据备份,局限如开篇所说。memorycopy对于Entity的备份比较好,可以自定义将数据写入到紧凑内存中。这种形式若要获取引用需要根据id获取到要操作的struct(不可以使用class),然后再进行操作。并且这种方式备份和回滚都非常快。可以看lockstepengine中的行为树,unsafe指针+struct。不过有点超纲。
  • 如果服务器用.NET而不是Unity,双端都继承类似IEntityConfig接口来不同的数据加载,.NET通常直接以二进制作为序列化格式。客户端配置好之类序列化给服务器用




回滚步骤

  1. 收到服务器下行包,比对输入是否与预测相同,不相同则进入回滚逻辑
  2. 某个顶层Service下调回滚逻辑
  3. 游戏状态恢复,Entity→Entity所持有的引用,Component→View层回滚(调用销毁和创建逻辑)



预测

预测指的是客户端在未收到服务器下行包时使用预测输入进行游戏更新,通常简单地使用上一帧的输入即可

更高级预测可以使用某些算法,下面预测优化项中提到的Input Buffer的动态调整也是一种


CS同步

客户端更新流程

  • 对服务器下行包更新,将输入存入RingBuffer,通常RingBuffer为20,再大可能导致客户端追帧性能压力过大
  • 判断输入是否与本地相同,不相同则进行回滚
  • 先收集输入,然后上传输入时合并没有被确认的输入(对特定输入是否需要处理?例如释放关键技能等)
  • 之后进行帧更新,首先判断服务器和本机帧是否差的很多。差得多就开始追帧,同时限制每帧追帧数目。之后进行回滚判断,需要回滚的话再用服务器现在已经下来的帧进行追帧到当前帧。但服务器下来的帧并不一定已经到了现在的本地游戏帧,因此后面的这些帧要用预测来进行
  • 最后就是step,进行正常逻辑更新


服务器更新流程

  • 接受玩家输入包,如果当帧所有玩家输入都到了,直接进行分发。如果某个玩家输入在指定时间还没有到,直接填充默认空包。发包给客户端时填充滑动窗口帧数内的输入,相当于冗余信息,这样也就不需要收确认包
  • 预发送与预测,网络好时,客户端上fps很高,那么预测帧数可以降低,否则到时候回滚帧数太多,预发送也需要降低,因为服务器丢失客户端上行包时,在buffer期间如果收到后续的输入包,也可以获得之前的输入,这样就避免了抖动
  • 如果服务器下行包时有玩家输入没有到达,就标记这个包发生了丢失,客户端收到有标记的包知道了丢包,那么客户端,客户端的逻辑帧step会变快,比如原来是16.6ms,变成15.2ms,这样会导致客户端发送的上行包变快(一段时间),预测的帧数会变多,服务器接收更多的包因此服务器的buffer会扩大。客户端和服务器的buffer会在网络情况正常后重新变小。



优化项

回滚优化项

  • rollback时,view层也需要bind和unbind,对于entity view的创建和销毁,如果rollback回去发现view层 oldentity和entity大的prefabid是相同的,那么view层不需要重新创建,重新调用一次view.bindentity而不需要调用ubind,是一种优化。view层unbind一般都是销毁gameobject,销毁在bind之后创建的其他东西(血条等)


预测优化项

  • 预测buffer动态调整
    • 客户端会向服务器发送ping包,服务器返回游戏开始时间,客户端根据这个时间和rtt来矫正本地的step和预测数目,保证客户端游戏时间不会晚于服务器时间(否则客户端因为某些原因卡顿时,客户端的时间presend输入的数目太晚晚于服务器时间,服务器已经step过这一帧,便会丢弃客户端的输入,客户端永远无法再进行任何行动),因为预测数目越少,游戏回滚次数越少,注意预测的目的是在网络环境波动/较差的情况下保证较好的体验。网络环境较好时,便不需要过强的预测。具体可以参考守望先锋的分享
  • PreSendInput
    • TODO


插值和Input Delay

#插值解释

由于渲染帧和逻辑帧分离,在逻辑帧间进行渲染帧插值能够平滑表现

#Input Delay

  • 由于不同客户端之间输入总是存在延迟(一个客户端的输入到达另一个客户端),为RTT+服务器处理和延后客户端的时间。有input delay可以使用,input delay只是最简单地将本地的输入延迟n帧生效
    • 缺点就是体验上会比较差,但可以通过让所有客户端都应用input delay,减少玩家操控角色的加速器和减速度来减少影响。
    • 只采用input delay很少有收益,通常和插值一起使用,插值会导致真实画面在玩家游玩过程中有延迟感觉,这时候搭配本地的input delay就能比较好的中和


其他优化项/注意项

  • 客户端上行包输入压缩,以减少网络同步量,同时这个输入包不能超过MTU(通常不超过1500bytes),否则会发生丢包,通常不超过1200bytes
  • 不能使用基于秒的timer,需要基于帧的timer,动画,技能等都要根据帧来步进
  • 回滚时,引用类型的恢复,需要手动处理
  • 备份时,引用类型保存必须复制,例如容器,需要将数据全部拷贝。其他class需要通过某种方式进行映射,比如string,id等,方便回滚时进行恢复
  • 状态字典最好用string作为key,输入则用enum,也是为了方便debug
  • 可以让_和负数开头的key都不会被用作状态hash,但确实会被保存为状态。比如某些技能可能会改变本地状态导致本地和其他peer状态不一致,例如隐身,自己看得见别人看不见。这时候就让相关的状态不参与hash
  • 动画模块可能是有状态的,回滚后通常对状态进行reset而不是保存到状态字典。因为很难控制保存状态后的状态机转换逻辑,以及会让动画这一渲染模块与逻辑有所绑定
  • 要想在回滚后保持随机数的确定性,需要保存随机数种子以及随机数类的状态
  • 要想在多个客户端使用多个不同的随机数,可以让服务器持有一个mother随机数种子,在游戏开始时同步给客户端,用这个mother随机数种子去生成其他随机数种子



本文由作者按照 CC BY 4.0 进行授权

热门标签