帧同步技术(二)

第一篇文章中我们讲了帧同步的核心:如何保证各个客户端的一致性。保证不同客户端各自计算的结果都相同就能确保帧同步玩法的正确有效,但如果服务端消息没下来前整个客户端画面一动不动,体验是非常不好的。在确保一致性的前提下做一些预表现以及降低网络传输的延迟能给到玩家更好的体验。这篇文章主要讲这方面的一些优化,另外还有关于不同步问题的定位排查、防止客户端修改作弊、断线重连与网络消息堆积时的一些处理方案。

UDP传输:TCP传输因为在底层做了各种关于可靠性及稳定性的处理(详细可见《计算机网络——传输层:TCP与UDP》),所以延迟会比较高。而在帧同步战斗中,操作需要上行到服务器然后经过转发到达到各个客户端才能推进真正的逻辑,低延迟对玩家操作体验非常重要。而帧同步战斗这种频繁(1秒30帧)的传输小量(只转发操作)的数据,用UDP也是很有优势的。在相同的网络环境下,UDP可以在一个逻辑帧内完成来回的,延迟在33ms以内,而使用TCP至少要3-4个逻辑帧才能完成来回,延迟在130ms以上。

使用UDP传输需要自己处理乱序和丢包的问题。帧同步战斗中,服务器定时派发的帧数据本来就是有序号的,所以乱序到达的问题只要缓存起来按顺序使用就可以了。而丢包的问题我们可以采用冗余传输来尽量避免。服务器一次下行中不仅带上当前第n帧的信息,还可以带上n-1,n-2或更多的冗余信息,这样只要不是连续丢多个包,都能保证客户端最终收到连续的帧信息。如果客户端当前在等第t帧, 但已经收到第m帧的信息,m-t的差值大于一定程度,就认为已经连续丢了多个包,这个时候靠冗余信息已经拼凑不回来完整的序列了,就需要利用TCP重新请求缺失的帧,服务端用TCP把客户端请求的帧信息重新补发。利用TCP来做请求重传虽然速度慢,但可以保证一定能收到。具体流程图如下:

另外,传输协议要设计为可变长。在丢包少的情况下可以只冗余1帧,一旦触发丢包重传则加大冗余数量。这样既可以节省带宽,又能尽量降低TCP重传的概率,保证体验。客户端上行的丢包避免也是类似的方式,稍有不同的是客户端没必要每帧都上行操作,只需要在操作发生变化时发给服务端,每次发送除了带上当前的帧编号及有效操作以外,也冗余上n次的有效操作及对应的帧编号。服务端会记录每个客户端上一次有效操作是第t帧,在收到新的操作数据里大于t的都会合并到当前帧转发。这样能避免玩家的一些操作因为丢包而没有反应。具体协议设计如下:

逻辑表现分离、预计算:上面提到如何用UDP传输来降低延迟,但依然会有暂时没有下一帧信息而逻辑无法推进的情况。在这种情况下如果整个客户端是完全停止的体验会很差。为了解决这样的问题我们需要把逻辑与表现分离,允许在逻辑无法推进时做一定的预表现。首先角色逻辑与表现分离的处理方法是分两个Animator,逻辑Animator是接管的,跟着逻辑帧递进的,各种位移、伤害标签也是跟着逻辑状态机的动画而触发的。表现Animator则是由引擎驱动,跟着渲染帧更新的,角色具体的模型动画由表现状态机驱动。当我们本地有输入操作时,可以让表现状态机先跳转到对应的动画上开始播,但逻辑状态机的信息要等服务器的转发指令到达才能真正的推进。角色的位移、血量、Buff等信息是跟逻辑状态机的,而旋转,模型动画这些由表现状态机来做预表现。当逻辑状态机更新后发现表现状态机不一致时,用逻辑状态机的数据来修正表现状态机。

子弹这些飞行物也需要做逻辑与表现的分离,逻辑的子弹其实只需要维护位置、范围以及具体的飞行信息等,表现部分的数据则是模型或者粒子特效等。表现模块跟着渲染帧进行更新,确保画面流程,而飞行碰撞,伤害触发等需要跟着逻辑帧来推进,确保各个客户端一致。

为了做到逻辑与表现分离,还需要一些工具支持。第一个是动画事件的导入导出工具。策划在美术做好的动画文件上打事件标签,这些信息是跟在表现动画上的,而逻辑与表现分离后,表现动画允许预先播放,但这些事件标签是要跟着逻辑帧触触发才能保证各个客户端的一致性。所以要做工具在策划编辑完动画时间成,将表现动画上的事件导出到逻辑动画文件上,而需要重新打开时则要从逻辑动画上把事件还原到表现动画上,方便策划修改。第二个工具是攻击\受击挂点的位移信息导出,在动作游戏中会有一些攻击触发或受击判定是跟着角色模型的某个部位来的。因为角色模型动画是跟着渲染帧做预表现的,而逻辑相关的信息需要跟着逻辑帧推进,所以我们要做工具将表现动画上某个部位(骨骼)的动画中位移信息导出,由逻辑状态机推进时读取这些位移信息来更新真正的攻击\受击挂点。

如果想进一步位移伤害等都做预计算的话,有两种方案。第一种方案是服务器同时在跑战斗过程,这样服务器是有真正的逻辑状态的,客户端可以完全放开自己做各种预表现,当发现与服务器的逻辑状态不一致时,由服务器把最近的关键节点逻辑数据全部同步下来,客户端回滚到那个状态下再追帧赶上当前进度。这一套方案在守望先锋上运用得非常好,服务端还可以利用真正的逻辑状态来做防外挂以及一些射击判定的延迟补偿。第二种方案是客户端再进一步把位移及战斗相关的信息也分为逻辑与表现两套,表现的位移战斗信息都可以做预计算,逻辑的信息则等服务器信息更新下来再推进,遇到不一致时用本地的逻辑数据来修正表现数据。这方案的好处是依然可以保持轻服务器架构,战斗逻辑只在客户端做,服务端只做转发,压力很小。

不同步排查:帧同步战斗实现的核心是保证各个客户端计算结果的一致性。在实际开发过程中需要花大量的时间精力在这上面,有一些方法可以帮我们更好的定位排查不同步的问题。具体是各个客户端每一帧都将需要对比的调试信息记录下来,然后针对这些调试信息每帧会生成一个MD5值。每隔1秒将这30个MD5值上报给服务器,服务器对比每一个客户端上报的MD5值,看是否一样。如果不一样的话再让各个客户端将不一样的那一帧的详细调试信息上报,由服务端来输出具体差异,方便后续排查定位问题。

具体的调试信息我们会分为三个级别。数量最多的是Info级别,任何我们关心的数据都可以用Info级别来收集,例如移动或伤害计算的中间变量,Buff跳数,物理引擎返回值等等。只要我们怀疑有可能出现不一致的值都可以用Info级别收集。第二个级别是Event级别,这个级别的信息通常指在这一帧中触发的各种关键事件,例如打击点触发,位移触发,AI行为触发,刷怪刷Buff等等。第三级别是Result级别,该级别的信息最少,基本就是各个角色的位置血量等关键信息。正常情况下我们只需要开启Result级别的信息收集,这样能避免其他两个级别大量的信息收集和字符串处理。如果Result级别的信息有不一致,我们就会视情况而定把收集级别调到Event或者Info,收集更详细的信息来排查不同步的问题。这里讲的级别调整是针对这个玩法的下一局开始的,当前正在进行的这一局游戏,已经出现了不一致的情况下,再收集后续更详细的信息意义也不大。

快进处理:快进处理在客户端内有两种情况会触发,第一种是因为网络波动导致某一些帧的重传,而等待过程中又累积了后续比较多帧需要快进。第二种是断线重连后的多帧快进,直至赶上当前的进度为止。而在验证服务器其实就是一直在做快进。快进的时候客户端有很多表现相关的逻辑是可以省略的,例如表现状态机的更新,播特效,播音效,伤害飘字,UI处理等等。结合项目的具体情况,只处理逻辑相关的推进,可以使快进更迅速。

验证服:帧同步战斗绕不开的一个话题就是如何防作弊。关于客户端安全的一些策略可以参考《游戏开发安全问题》。之前项目针对帧同步玩法的防作弊采用的方法是校验服重放校验。如果某一局战斗中服务器发现客户端有不一致的情况,就会将当局玩法的所有操作以及同步事件在这一局结束后发往验证服务器进行重放校验,最终通过验证服返回的结果来做仲裁。我们采用Unity的ServerBuild功能构建一个剥离了渲染模块的console程序,放在服务器上进行快进演算。在验证服上除了上面提到的尽量简化非必要逻辑以外,还需要考虑是否会有内存泄漏的问题。因为客户端有切场景的时机来做很多清理工作,而验证服是一直反复在跑同一个玩法,每一局结束后立刻开始下一局演算,不进行切场景操作。如果没有处理好的话会有内存泄漏的问题。

这两篇文章就是关于上一个项目中帧同步战斗实现时遇到的具体问题以及优化措施。《月夜狂想曲》的帧同步战斗虽然说没有做到尽善尽美,但一来以前没有做同步战斗的经验,这是第一个实现帧同步战斗的项目,第二就是项目组人手有限,绝大部分功能都是靠自己摸索完成的。在游戏历经的数次测试中,同步战斗这块也没出什么问题,自己对此还是挺满意的。当然这套方案还有很多可以改进优化的地方,希望后续有机会再深挖同步战斗这一块,做得更好,再与大家分享。

Bookmark the permalink.

Comments are closed.