结论
先说一下我们在研究和使用了帧同步之后,得出的结论:
如果项目没有录像、观战功能,请先放弃使用帧同步的念头,尝试使用状态同步。因为设计得好的状态同步,可以在很少流量基础上,完成类帧同步的效果。除非在通信上没有压缩空间,再考虑帧同步。
如果项目需求有中途加入,且不能容忍从头Replay带来的等待,游戏互动玩法(游戏内个体之间的相互作用)还非常复杂,则请慎重考虑是否使用帧同步。
要点
确定性运算
- 浮点:目前除了主机平台上由于其平台的统一性使用的技术不同外,其他平台基本上都是使用的定点数做的确定性运算。这需要将所有涉及同步部分的逻辑运算,都要使用定点运算。这个工作,对于不同规模的项目而言,工作量可大可小。如果项目中引用了第三方库到同步逻辑部分的话,那么这个第三方库也要被定点数重写。关于定点数,如果没有太多的精力自写的话,可以尝试在网上查找定点库,网上已经有一些开源的定点库,可以拿来直接用,但注意在使用这些库时的精度问题。因为这些定点数库的设计,在使用时,依然使用浮点数作为定点数的声明和定义,面对不同的编译器和运算器,浮点转成定点数的精度处理上,可能是不一致的。所以,在使用前,请验证精度有没有问题。或者采用其他的方式,比如不再使用浮点数进行声明和定义。
- 随机:如果游戏中同步部分的逻辑,使用了随机,则需要自己实现一套跨平台的随机算法,保证所有平台随机的一致。当然,随机还会带来另一个问题,就是中途加入时随机数的一致性问题,要保证中途加入的客户端,执行与其他客户端一致的随机序列。这些可能需要我们在实现随机算法时,兼顾到需要同步到另一端的需求。
- 物理:基本上,大部分的游戏都会用到物理,抛开物理,也会用到碰撞。因为确定性运算的问题,我们需要重写一套确定性物理引擎。
- 动画:如果游戏部分同步逻辑,比如AnimationEvent,坐标等是由动画驱动的,那这部分也需要重新设计,不能使用内置的驱动逻辑。
- 其他:这里就包括所有涉及浮点的非运算逻辑了,比如invoke、yield等,这些接口要避免使用。
时序
- 时序:要保证不同的客户端,数据的存储、逻辑的执行保持时序一致。所以,一些常用的数据结构就不能胜任了,比如常用的Dictionary、hashset等,需要我们使用其他的数据结构,比如类SortedDictionary这个效率偏差,或者自写一套保证时序的数据结构和算法。
难点-中途加入
帧同步的难点在于中途加入,当然这里的中途加入,不讨论从头Replay一遍的方案,如果你的项目,能够容忍从头Replay,那就可以跳过了。这里讨论的是,将其他客户端的现场正确地同步给中途加入的客户端这种方案。在最开始的结论部分已经提过,如果游戏玩法比较简单,中途加入还是很好实现的。但如果包含复杂的互动玩法,那对于游戏开发来说,将是类似两万五千里长征似的漫长负担了。中途加入要处理的问题很多,一个很小的功能需求改动,就可能导致整个同步机制挂掉。而且要注意,这个改动带来的同步不一致还不一定是必现的。这就对开发和测试人员提出了极高的要求,必须一点问题都没有,不能有一丁点的bug,一旦出现bug,就是致命的——不同步。不同步不像其他的bug,可以忍受,不同步一旦发生,玩家的所有付出就都白费了,结果不能上传,得不到服务器的认可,还可能会被认为作弊。
刚开始接触帧同步的开发人员,可能觉得,中途加入,就把所有对象的状态(比如:位置)同步给另一端不就行了吗?那这里举几个比较简单的例子,说明一下中途加入的复杂:
碰撞反弹:这就涉及到时序问题,先碰撞的就要先反弹。那么,某一时刻,3个物体碰撞在一起。此刻有玩家B中途加入战局,就需要将玩家A现场同步给玩家B,那如何同步才能保证,在B端玩家下一刻执行反弹逻辑顺序与A一致呢?
碰撞过程:在使用碰撞过程中,经常会依赖某个指定的碰撞过程,比如Enter、Stay、Exit等。以Enter为例,某一时刻,玩家A现场两个物体碰撞触发了Enter过程,并且A端将Enter的回调逻辑处理完毕,则此时A现场的状态就是处理完之后的状态。此时,B加入战局,需要将A现场同步给B,那如果将A当前状态同步给B的话,B端检测到这两个物体碰撞了,又会触发一次Enter,则再次调用Enter的回调逻辑,而A端不会再触发回调。这就导致A与B端不一致。
上面只是举了几个物理相关的例子而已,还有很多其他的会导致不同步的问题,比如跟时间相关的状态等等,这里没有列举。
总之,帧同步的终极问题是中途加入。其他的问题都还好,中途加入会导致后期的每一个功能改动,都可能会带来整个版本的复查,其维护成本之高,可能会令很多团队承担不起。因为我们不能允许中途加入出现bug,一旦出现bug,就是致命的。所以,还是开篇的那句话:如果项目不需求中途加入,帧同步向你敞开大门,如果需求中途加入,请仔细评估玩法和开发之间的矛盾,选择一条更适合的道路。