帧同步这部分比较复杂,细枝末节有很多优化点,也有一些不同的优化方向,根据不同项目类型、对操作手感的要求、联机玩家的个数等,会有不同的难点和痛点。不同的优化方向,优化手法的差异,可能导致一些争论。并且,帧同步,本身也有很多变种,以应对不同的需求。所以,本文一切都是基于作者的项目类型(ACT)来做的方案和优化,并不一定适合其它也需要帧同步的游戏,故在此提前说一下,以免引起一些不必要的误解。
帧同步的几个难点
帧同步的基础原理,以及和状态同步的区别,已经有很多文章介绍,我就不再赘述,大家可以自行google。以下只说几个难点。
保证客户端独自计算的正确,即一致性
帧同步的基础,是不同的客户端,基于相同的操作指令顺序,各自执行逻辑,能得到相同的效果。就如大家所知道的,在Unity引擎中,不同的调用顺序,时序,浮点数计算的偏差,容器的排序不确定性,Coroutine内写逻辑带来的不确定性,物理浮点数,随机数值带来的不确定性等等。
有些比较好解决,比如随机数值,只需要做随机种子即可。
有些需要注意代码规范,比如在帧同步的战斗中,逻辑部分不使用Coroutine,不依赖类似Dictionary等不确定顺序的容器的循环等。
还有最基础的,要通过一个统一的逻辑Tick入口,来更新整个战斗逻辑,而不是每个逻辑自己去Update。保证每次Tick都从上到下,每次执行的顺序一致。
物理方面,因为我们战斗逻辑不需要物理,碰撞都是自己做的碰撞逻辑,所以,跳过不说,这块可以参考别的文章。
最后,浮点数计算无法保证一致性,我们需要转换为定点数。关于定点数的实现,比较简单的方式是,在原来浮点数的基础上乘1000或10000,对应地方除以1000或10000,这种做法最为简单,再辅以三角函数查表,能解决一些问题,减少计算不一致的概率。但是,这种做法是治标不治本的方式,存在一些隐患(举个例子,例如一个int和一个float做乘法,如果原数值就要*1000,那最后算出来的数值,可能会非常大,有越界的风险。)
最佳的解决办法是:使用实现更加精确和严谨,并经过验证的定点数数学库,在C#上,有一个定点数的实现,Photon网络的早期版本,Truesync有一个很不错的定点数实现。
定点数的实现
其中FP,就可以完全代替Float,我们只需要将我们自己的逻辑部分,Float等改造为FP,就可以轻松解决。并且,能够很好的和我们Protobuf的序列化方式集成(注意代码中的Attribute,如下图),保证我们的配置文件,也是定点数的。
TSVector对应Vector3,只要基于FP,可以自己扩展自己的数据结构。(当然,如果用到了复杂的插件,并且不开源,那么对于定点数的改造,就会困难很多)
三角函数通过查表方式实现,保证了定点数的准确
我个人认为,这一套的实现,是优于简单的乘10000,除10000的方式。带来的坏处,可能就是计算性能略差一点点,但是我们大量测试下来,对计算性能的影响很小,应该是能胜任绝大部分项目的需求。
对于计算的不确定性,我们也有一些小的隐患,就是我们用到了Physics.Raycast来检测地面和围墙,让人物可以上下坡,走楼梯等高低不平的路,也可以有形状不规则的墙。这里会获得一个浮点数的位置,可能会导致不确定性,这里,我们用了数值截断等方式,尽量规避,经过反复测试,没有出现过不一致。但是这种方式,毕竟在逻辑上,存在隐患,更好的方式,是实现一套基于定点数的raycast机制,我们人力有限,就没时间精力去做了。这块有篇文章讲得更细致一些,大家可以参看:
帧同步:浮点精度测试
https://zhuanlan.zhihu.com/p/30422277
帧同步网络协议的实现
在处理好了基础的计算一致性问题后,我们就要考虑网络如何通信。这里,我不谈p2p方式了,我们以下谈的,都是多client,一个server的模式,server负责统一tick,并转发client的指令,通知其他client,可以参看文章:
网游流畅基础:帧同步游戏开发
http://www.10tiao.com/html/255/201609/2650586281/4.html
首先,是网络协议的选择。TCP和UDP的选择,我就不多说了,帧同步肯定要基于UDP才能保证更低的延迟。在UDP的选择上,我看网上有些文章,容易导入一个误区,即,我们是要用可靠传输的UDP,还是冗余信息的UDP。
基于可靠传输的UDP,是指在UDP上加一层封装,自己去实现丢包处理,消息序列,重传等类似TCP的消息处理方式,保证上层逻辑在处理数据包的时候,不需要考虑包的顺序,丢包等。类似的实现有Enet,KCP等。
冗余信息的UDP,是指需要上层逻辑自己处理丢包,乱序,重传等问题,底层直接用原始的UDP,或者用类似Enet的Unsequenced模式。常见的处理方式,就是两端的消息里面,带有确认帧信息,比如客户端(C)通知服务器(S)第100帧的数据,S收到后通知C,已收到C的第100帧,如果C一直没收到S的通知(丢包,乱序等原因),就会继续发送第100帧的数据给S,直到收到S的确认信息。
有些文章介绍的时候,没有明确这两者的区别,但是这两种方式,区别是巨大的。可靠传输的UDP,在帧同步中,个人认为是不合适的,因为他为了保证包的顺序和处理丢包重传等,在网络不佳的情况下,Delay很大,将导致收发包处理都会变成类似TCP的效果,只是比TCP会好一些。必须要用冗余信息的UDP的方式,才能获得好的效果。并且实现并不复杂,只要和服务器商议好确认帧和如何重传即可,自己实现,有很大的优化空间。例如,我们的协议定义类似如下:
双方都要通知对方,已经接受哪一帧的通知了,并通过cmd list重发没有收到的指令
这里简单说一下,对于这种收发频繁的消息,如果使用Protobuf,会造成每个逻辑帧的GC,这是非常不好的,解决方案,要么对Protobuf做无GC改造,要么就自己实现一个简单的byte[]读写。无GC改造工程太大,感觉无必要,我们只是在战斗的几个频繁发送的消息,需要自己处理一下byte[]读写即可。
在这部分需要补充一下,KCP作者韦易笑提到KCP+FEC的模式,可以比冗余方式,有更好的效果,我之前并没有仔细研究过这个模式,不过可以推荐大家看一下。
因为我们项目早期,服务器定下了使用Enet,我评估了一下,反正使用冗余包的方式,所以没有纠结Enet或KCP,后续其实想改成KCP,服务器不想再动,也就放下了。
Enet麻烦的地方是,Enet的ipv6版本,是一个不成熟的Pull Request,Enet作者没有Merge(并且存在好几个ipv6的Pull Request),我不确定稳定性,还好看了下Commit,加上测试下来,没有太大问题。KCP我没有评估过ipv6的问题,不过Github上有C#版本,改一下ipv6支持应该很简单。
逻辑和显示的分离
这部分在很多讲帧同步的文章中都提过了。配置的数据和显示要分离,在战斗中,战斗的逻辑,也要和显示做到分离。
例如,我们动作切换的逻辑,是基于自己抽象的逻辑帧,而不是基于Animator中一个Clip的播放。比如一个攻击动作,当第10帧的时候,开始出现攻击框,并开始检测和敌人受击框的碰撞,这个时候的第10帧,必须是独立的逻辑,不能依赖于Animator播放的时间,或者AnimatorStateInfo的NormalizedTime等。甚至,当我们不加载角色的模型,一样可以跑战斗的逻辑。如果抽离得好,还可以放到服务器跑,做为战斗的验证程序,王者荣耀就是这样做的。
联机如何做到流畅战斗
前面所有的准备,最终的目的,都是为了战斗的流畅。特别是我们这种ACT游戏,或者格斗类游戏,对按键以后操作反馈的即时性,要求非常高,一点点延迟,都会影响玩家的手感,导致玩家的连招操作打断,非常影响体验。我们对延迟的敏感性,甚至比MOBA类游戏还要高,我们要做到好的操作手感,还要联机战斗(PVP,组队PVE),都需要把帧同步做到极致,不能因为延迟卡住或者操作反馈出现变化。
因为这个原因,我们不能用Lockstep的方式,Lockstep更适合网络环境良好的内网,或者对操作延迟不敏感的类型(例如我听过还有项目用来做卡牌类的帧同步)。
我们也不能用缓存服务器确认操作的方式,也就是一些游戏做的指令Buffer。具体描述,王者荣耀的分析文章,讲得很具体了。这也是他们说的模式,这个模式能解决一些小的网络波动,对一些操作反馈不需要太高的游戏,例如有些游戏攻击前会有一个比较长的前摇动作,这类游戏,用这种方式,应该就能解决大部分问题。但是这种方式还是存在隐患,即使通过策略能很好地动态调整Buffer,也还是难以解决高延迟下的卡顿和不流畅。王者荣耀优化得很好,他们说能让Buffer长度为0,文章只提到通过平滑插值和逻辑表现分离来优化,更细节的没有提到,我不确定他们是否只是基于这个方式来优化的。目前也没有看到更具体的分析。
指令Buffer的方式,也不能满足我们的需求,或者说,我没有找到基于此方式,能优化到王者荣耀的效果的办法。我也测试过其他MOBA和ACT,ARPG类游戏的联机,在高延迟,网络波动情况下,没有比王者表现更好的了。
最后,在仔细研究了我们的需求后,找到一篇指导性的文章,非常适合我们:
Understanding Fighting Game Networking
http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/
这篇文章非常详细地介绍了各种方式,最终回滚逻辑(Rollback)是终极的解决方案,国内也有文章提到过,即:
Skywind Inside 再谈网游同步技术
http://www.skywind.me/blog/archives/1343#more-1343
文章里面提到的Time Warp方式,我理解回滚逻辑,和Time Warp是一个概念。
游戏逻辑的回滚
回滚逻辑,就是我们解决问题的方案。可以这样理解,客户端的时间,领先服务器,客户端不需要服务器确认帧返回才执行指令,而是玩家输入,立刻执行(其他玩家的输入,按照其最近一个输入做预测,或者其他更优化的预测方案),然后将指令发送给服务器,服务器收到后给客户端确认,客户端收到确认后,如果服务确认的操作,和之前执行的一样(自己和其他玩家预测的操作),将不做任何改变,如果不一样(预测错误),就会将游戏整体逻辑回滚到最后一次服务器确认的正确帧,然后再追上当前客户端的帧。
此处逻辑较为复杂,我尝试举个例子说明下。
当前客户端(A,B)执行到100帧,服务器执行到97帧。在100帧的时候,A执行了移动,B执行了攻击,A和B都通知服务器:我已经执行到100帧,我的操作是移动(A),攻击(B)。服务器在自己的98帧或99帧收到了A,B的消息,存在对应帧的操作数据中,等服务器执行到100帧的时候(或提前),将这个数据广播给AB。
然后A和B立刻开始执行100帧,A执行移动,预测B不执行操作。而B执行攻击,预测A执行攻击(可能A的99帧也是攻击),A和B各自预测对方的操作。
在A和B执行完100帧后,他们会各自保存100帧的状态快照,以及100帧各自的操作(包括预测的操作),以备万一预测错误,做逻辑回滚。
执行几帧后,A和B来到了103帧,服务器到了100帧,他开始广播数据给AB,在一定延迟后,AB收到了服务器确认的100帧的数据,这时候,AB可能已经执行到104了。A和B各自去核对服务器的数据和自己预测的数据是否相同。例如A核对后,100帧的操作,和自己预测的一样,A不做任何处理,继续往前。而B核对后,发现在100帧,B对A的预测,和服务器确认的A的操作,是不一样的(B预测的是攻击,而实际A的操作是移动),B就回滚到上一个确认一样的帧,即99帧,然后根据确认的100帧操作去执行100帧,然后快速执行101~103的帧逻辑,之后继续执行104帧,其中(101~104)还是预测的逻辑帧。
因为客户端对当前操作的立刻执行,这个操作手感,是完全和PVE(不联网状态)是一样的,不存在任何Delay。所以,能做到绝佳的操作手感。当预测不一样的时候,做逻辑回滚,快速追回当前操作。
这样,对于网络好的玩家,和网络不好的玩家,都不会互相影响,不会像Lockstep一样,网络好的玩家,会被网络不好的玩家Lock住。也不会被网络延迟Lock住,客户端可以一直往前预测。
对于网络好的玩家(A),可以动态调整(根据动态的Latency),让客户端领先服务器少一些,尽量减少预测量,就会尽量减少回滚,例如网络好的,可能客户端只领先2~3帧。
对于网络不好的玩家(B),动态调整,领先服务器多一些,根据Latency调整,例如领先5帧。
那么,A可能预测错的情况,只有2~3帧,而网络不好的B,可能预测错误的帧有5帧。通过优化的预测技术,和消息通知的优化,可以进一步减少A和B的预测错误率。对于A而言,战斗是顺畅的,手感很好,少数情况的回滚,优化好了,并不会带来卡顿和延迟感。
重点优化的是B,即网络不好的玩家,他的操作体验。因为客户端不等待服务器确认,就执行操作,所以B的操作手感,和A是一致的,区别只在于,B因为延迟,预测了比较多的帧,可能导致预测错,回滚会多一些。比如按照B的预测,应该在100帧击中A,但是因为预测错误A的操作,回滚重新执行后,B可能在100帧不会击中A。这对于B来说,通过插值和一些平滑方式,B的感受是不会有太大区别的,因为B看自己,操作自己都是及时反馈的,他感觉自己是平滑的。
这种方式,保证了网络不好的B的操作手感,和A一致。回滚导致的一些轻微的抖动,都是B看A的抖动,通过优化(插值,平滑等),进一步减少这些后,B的感受是很好的。我们测试在200~300毫秒随机延迟的情况下,B的操作手感良好。
这里,客户端提前服务器的方式,并且在延迟增大的情况下,客户端将加速,和
守望先锋的处理方式是一样的。当然,他们肯定比我做得好很多:
https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg
希望我已经大致讲清楚了这个逻辑,大家参看几篇链接的文章,能体会更深。
这里,我要强调的一点是,我们这里的预测执行,是真实逻辑的预测,和很多介绍帧同步文章提到的预测是不同的。有些文章介绍的预测执行,只是View层面的预测,例如前摇动作和位移,但是逻辑是不会提前执行的,还是要等服务器的返回。这两种预测执行(View的预测执行,和真实逻辑的预测执行)是完全不是一个概念的,这里需要仔细地区分。
这里有很多的可以优化的点,我就不一一介绍了,以后可能零散地再谈。
游戏逻辑的快照(snapshot)
我们的逻辑之所以能回滚,都是基于对每一帧状态可以处理快照,存储下每一帧的状态,并可以回滚到任何一帧的状态。
Understanding Fighting Game Networking文章:
http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/
守望先锋网络文章:
https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg
以上两篇文章都一笔带过了快照的说明。他们说的快照,可能略有不同,但是思路,都是能保存下每一帧的状态。如果去处理快照(Understanding那篇文章做的是模拟器游戏,可以方便地以内存快照的方式来做),是一个难点。
这也是我前面文章,提到ECS在这个方式下的应用:
https://zhuanlan.zhihu.com/p/38280972
云风的解释:
云风博客截图,地址https://blog.codingnow.com/2017/06/overwatch_ecs.html
ECS是一个好的处理方式,并且我找到一篇文章,也这样做了(我看过他开源的demo,做得还不够好,应该还是demo阶段,不太像是一个成型的项目)。
https://www.kisence.com/2017/11/12/guan-yu-zheng-tong-bu-de-xie-xin-de/
这篇文章的思路是很清晰的,并且也点到了一些实实在在的痛点,解决思路也基本是正确的,可以参看。
这块我做得比较早了,当时守望先锋的文章还没出,我的战斗也没有基于ECS,所以,在处理快照上,只有自己理顺逻辑来做了。
我的思路是,通过一个回滚接口,需要数据回滚的部分,实现接口,各自处理自己的保存快照和回滚。就像我们序列化一个复杂的配置,每个配置各自序列化自己的部分,最终合并成一个序列化好的文件。
首先,定义接口,和快照数据的Reader和Writer
然后,就是每个模块,自己去处理自己的TakeSnapshot和Rollback,例如:
简单的数值回滚
复制的列表回滚和调用子模块回滚
思路理顺以后,就可以很方便地处理了,注意Write和Read的顺序,注意处理好List,就解决了大部分问题。当然,在实现逻辑的过程中,时刻要注意,一个模块如何回滚(例如获取随机数也需要回滚)。
有一个更简单的方式,就是给属性打Attribute,然后写通用的方法。例如,我早期的实现方案:
给属性打标签
根据标签,通用的读写方法,通过反射来读写,就不需要每个模块自己去实现自己的方法了:
部分代码
这种方法,能很好地解决大部分问题,甚至前面提到的Truesync,也是用的这种方式来做。
但是这种方法有个难以回避的问题,就是GC,因为基于反射,当我们调用field的GetValue和SetValue的时候,GC难以避免。并且,因为全自动,不方便处理一些特殊逻辑,调试优化也不方便,最后改成了现有的方式,虽然看起来笨重一些,但是可控性更强,我后续做的很多优化,都方便很多。
关于快照,也有很多可以优化的点,无论是GC内存上的,还是运行效率上的,都需要优化好,否则,可能带来性能问题。这块优化,有空另辟文章再细谈吧。
当我们有了快照,就可以支持回滚,甚至跳转。例如我们要看战斗录像,如果没有快照,我们要跳到1000帧,就需要从第一帧,根据保存的操作指令,一直快速执行到1000帧,而有了快照,可以直接跳到1000帧,不需要执行中间的过程,如果需要在不同的帧之间切换,只需要跳转即可,这将带来巨大的帮助。
自动测试
由于帧同步需要测试一致性的问题,对我们来说,回滚也是需要大量测试的问题。自动测试是必须要做的一步,这块没有什么特别的点,主要就是保存好操作,快照,log,然后对不同客户端的数据做比对,找到不同的地方,查错改正。
我们现在做到,一步操作,自动循环战斗,将每一盘战斗数据上传内网log服务器。
当有很多盘战斗的数据后,通过工具自动解析比对数据,找到不同步的点。也是还可以优化得更好,只是现在感觉已经够用了。经过大量的内部自动测试,目前战斗的一致性,是很好的。
总结
我们现在的帧同步方案,总结下来,就是预测,快照,回滚。当把这些有机地结合起来,优化好,就有了非常不错的帧同步联网效果,无论网络速度如何,只要不是延迟大到变态,都保证了非常好的操作手感。
快照回滚的方式,也不是所有游戏都适用,例如:
Skywind Inside 再谈网游同步技术文章中对此模式(Time warp或Rollback)的缺点,也说明了。
http://www.skywind.me/blog/archives/1343#more-1343
如图所述,这种模式不适合太多人的联网玩法,例如MOBA,可能就不太适用。我们最多三人联机,目前优化测试下来,效果也没有太大问题。但是联机人数越多,预测操作的错误可能性越大,导致的回滚也会越多。
一篇文章,难以讲得面面俱到,很多地方可能描述也不一定明确,并且,个人能力有限,团队人员有限(3个客户端)的情况下,必定有很多设计实现不够好的地方,大家见谅。
一些有帮助的文章再列一下:
1、Understanding Fighting Game Networking
http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/
2、Skywind Inside " 再谈网游同步技术
http://www.skywind.me/blog/archives/1343#more-1343
3、《守望先锋》回放技术:阵亡镜头、全场最佳和亮眼表现
https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg
4、《王者荣耀》技术总监复盘回炉历程
http://youxiputao.com/articles/11842
5、帧同步:浮点精度测试
https://zhuanlan.zhihu.com/p/30422277
6、A guide to understanding netcode
https://www.gamereplays.org/overwatch/portals.php?show=page&name=overwatch-a-guide-to-understanding-netcode
7、网游流畅基础:帧同步游戏开发
http://www.10tiao.com/html/255/201609/2650586281/4.html
最后啰嗦一句,如最开始所述,帧同步有很多变种、实现方式和优化方向。有时候,可能不同文章提到帧同步这个术语的时候,里面的意思,可能都有区别,大家需要仔细理清和区分。