前言

基于行为树的Moba技能系统系列文章总目录:https://www.lfzxb.top/nkgmoba-totaltabs/

从我个人的感受而言,如果说技能系统开发难度为7,那么网络同步的开发难度就是10,因为它的触手涉及技能系统方方面面,稍有不慎就会有可怕的连锁反应导致混乱。

包括守望先锋的三面分享,其中网络同步部分读起来最为吃力,断层的感觉最强,但越是这样,越能说明守望先锋网络同步方案的健壮性

对于本文内容的拓展延伸与具体实现,参见

本文大量内容参照了:《守望先锋》GDC2017技术分享精粹重制版总目录 中的文章

守望先锋网络同步总结

同步数据设计

感觉先说明一下网络同步环境和数据结构的设计比较容易理解一些

客户端和服务端都各自维护着整局游戏所有的实体和数据,差别就是客户端不负责逻辑计算,逻辑计算是服务器权威的,客户端要能从服务器发来的帧数据恢复到和服务器发送数据那一帧相同的世界状态

对于客户端来说,本地有两种实体类型,Local(玩家操控的实体本身)和Remote(其他玩家和网络化实体),其中Local会进行预测和回滚,但Remote只会从服务端接收的数据中直接重置状态,所以即使同一个实体,在不同的客户端上(一个作为Local,一个作为Remote)它所接受的数据格式也是不一样的

对于服务端来说,每个网络化实体都有一个Statescript组件,其中有StatescriptSyncManager负责数据同步工作,StatescriptDeltas就是用来记录变化的数据内容了,最后通过StatescriptGhosts记录每个客户端对于此实体的认知(即数据同步程度),并且将最终要传输的数据放到StatescriptPackets中,他们的对应关系就是一个实体->一个Statescript组件->一个StatescriptDeltas->多个StatescriptGhosts->多个StatescriptPackets

image-20210302203338808

之所以StatescriptGhosts和StatescriptPackets之间是多对多的关系,有两个原因

  • 每个客户端网络质量不一样,接受的数据可能参差不齐,有丢包的的情况需要服务器重传相关数据,那么这个StatescriptPackets就和传往其他客户端的StatescriptPackets不一样了
  • 上文提到的由于客户端和Entity的关系不同( Local和Remote的区别 )

那么对于一个StatescriptDeltas而言,他是一个当前帧起始到结束期间变化的数据集合,由于每个客户端网络质量不一致,他会在所有客户端都收到这些数据后才会从服务端移除

基础网络同步

守望先锋使用的同步方案为状态同步+帧同步结合的方式,通俗点说就是将状态同步的原本离散更新数据与帧同步的按固定帧更新(确定性)结合起来,这样我们有每一帧的数据内容和状态可以方便的进行回滚操作。

然后有几个名词和概念需要明确一下:

  • RTT(Round-Trip Time):Ping值 ,也就是说从客户端发送一条信息到收到服务器的回复所用时间

  • 缓冲帧时长:这个是服务器收到客户端发来的消息进行缓冲的时长,用于应对丢包的特殊情况,这些缓存的信息会在推迟这个缓存的时长后处理,然后发回客户端,这个缓冲帧时长至少为一个帧步进时间长度,因为这样才能填补一帧的丢包,否则就毫无意义,当然,如果网络条件异常恶劣,这个缓冲帧时长就会很大,比如会长达5个帧步进,这样的话,最多可以容忍5帧的丢包,因为第6帧的发来的数据包中包含了前五帧所有的操作,这样可以填补上前面空缺的5帧。最后,如果第6帧的数据包也丢了,那就只能复制使用上一次客户端有效输入的数据了。

所以客户端总共需要领先服务器半个RTT和一个缓冲帧时长的时间才能抹除网络通信的延迟的影响,保证客户端发出帧和服务端处理帧对的上

v2-8a54474393ea5c1bd9a73fc087457ab4_b

同时,我们看到,整个过程我们为了高响应速度和支持自定义多帧数据打包,自己处理了丢包,重发,所以完全可以使用UDP,甚至是KCP作为网络传输协议来获得更高的性能(如果是TCP的话,丢失了从90-95这五帧的包后,就算96帧的包到达了,也只会等到90-95帧的包从服务器重发,客户端收到并处理后,才能处理96帧的包,这就会导致恶劣网络环境下的延迟问题被再次放大)

预测和回滚

我们来用这张图理解预测和回滚操作

img

CF代表Command Frame,ICF代表StateScript的内部Command Frame,稳定状态下,这两个命令帧步进应该是一致的,这里我们在客户端第100帧的时候按下一个按键,在第103帧抬起,此期间客户端依旧在前进,进行各种预表现,比如技能释放,动画播放等等,同时也会把这段操作发送到服务器,经过一个RTT后传回客户端(这里应该是为了便于讲解,忽略了服务器的缓存帧时长),但是此时客户端来到了105帧,但是收到的是自己100帧的回包,所以要根据这个100帧的回包,回滚到100帧的状态,然后从101帧模拟到105帧,注意,这个往前的模拟过程是原子过程,不可被打断,想象成一个while循环就行了。

这就是预测回滚的全过程。

网络总结

这里我们模拟一个环境来体会整个网络传输过程,一个帧步进需要16ms,ping值是128ms,缓存帧时长为16ms,所以客户端比服务端领先5个帧步进(16 + 128/2 = 80ms),我们客户端发出的消息A的帧步进为100,所以在A消息结构体中记录的帧步进为100,此时服务端帧步进为95,此时时间继续往前推移,服务端会在第99帧(此时客户端帧步进为104)收到消息A,然后再等待缓冲帧的时长,将在第100帧(此时客户端帧步进为105)处理完毕回复客户端,客户端收到消息的帧步进为109,但是消息A是第100帧的数据,所以客户端要从第101帧开始重新演算到第109帧

我的方案

对于数据同步模型,我准备照搬守望先锋的模式,不同的是,因为我们使用了行为树,确保了相同的黑板数据会有相同的行为树状态,但是由于现在采用的是客户端和服务器的行为树完全分离的模式,所以需要进行改动,其实也简单,就是把两颗树融合在一起,并且利用Group划分好哪些是客户端,哪些是服务器节点,这样在运行的时候就不会导致混乱了,最后服务器直接下发变化的黑板数据以及其他数据(例如位置信息这种需要单独同步的)即可

对于网络同步的丢包问题,我准备使用基于UDP的KCP方案,KCP开源库见韦易笑大佬的KCP,因为使用KCP的话这一块的处理可以在保证效率的同时节省一些操作

KCP是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如UDP)的收发,需要使用者自己定义下层数据包的发送方式,以 callback的方式提供给 KCP。 连时钟都需要外部传递进来,内部不会有任何一次系统调用。

整个协议只有 ikcp.h, ikcp.c两个源文件,可以方便的集成到用户自己的协议栈中。也许你实现了一个P2P,或者某个基于 UDP的协议,而缺乏一套完善的ARQ可靠协议实现,那么简单的拷贝这两个文件到现有项目中,稍微编写两行代码,即可使用。

使用KCP可以简化一些网络丢包的处理逻辑,因为其本身实现了快速重传逻辑,比如我们快速重传参数为2

那么发送端发送了1,2,3,4,5几个包,然后收到远端的ACK: 1, 3, 4, 5,当收到ACK3时,KCP知道2被跳过1次,收到ACK4时,知道2被跳过了2次,此时可以认为2号丢失,不用等超时,直接重传2号包,大大改善了丢包时的传输速度。

但同样有问题需要注意:如何避免缓存积累延迟

再来谈到我们的丢包问题,如果网络状况很好,基本上不会丢包,那么KCP自带的快速重传机制就会帮我们把数据重发,但是如果网络条件非常差,这个重传机制就有些鸡肋了,因为快速重传的那一次依旧可能丢,比如丢了6帧,需要重传6次,但是使用守望先锋的那种前向纠错(FEC)技术只要一次成功就可以恢复所有丢失的包,概率比六次成功高很多,这样弱网表现会好很多

所以我们最终还是要在UDP上一层处理FEC和处理缓存累积延迟问题,参见 KCP的网络单元KCP的最佳实践

对于FEC的具体做法,就是在KCP之下,UDP之上单独设立一个FEC层,在收发包的时候,先经过FEC层的解析(解析包头数据),对包进行分析,拆解,然后把处理后的数据传给KCP层,让KCP层进行处理,最后到达应用层。

最后是KCP的接入,有两种方案可选

  • C版KCP使用DLL导出给C#进行pInvoke
  • 使用C#版本的KCP

个人倾向于第一种方案,方便跟进更新,如果使用C#版本的,原作者仓库也有推荐

  • kcp-csharp: 新版本 Kcp的 csharp移植。线程安全,运行时无alloc,对gc无压力。
  • csharp-kcp: csharp版本KCP,基于dotNetty实现(包含fec功能)

推荐使用第一个作为基础的KCP协议,第二个用于参照FEC处理