网游帧同步的分析与设计
本文是我20年春节期间写的,最初发表在了知乎上,本wiki中省略掉了前面的吹水环节,直接开始讲干的,内容是直接从网页粘贴过来的,有些格式不太友好,如有兴趣可以查看原文,原文链接:https://zhuanlan.zhihu.com/p/105390563
我们从最核心的同步部分开始,帧同步,顾名思义就是每个人的每一帧都是同步的、一致的,流程如图1:
忽略图中的播放帧和逻辑帧,后面的内容会对其进行描述。图例中有2个玩家,这2个玩家与服务器的开始时间和帧率都是相等的,理想情况下是不存在谁快谁慢,客户端每一帧都会将玩家的操作指令上报给服务端,服务端收集齐了所有玩家在这一帧的指令或者服务端的时间已经到达了该帧的时间点,服务端统一将此帧产生的指令广播给客户端,客户端收到后,再按照服务端广播的指令进行相应帧的表现处理,然后步进逻辑帧,所有的游戏逻辑计算都放在了客户端,服务端只负责转发每一帧的消息。上图中的逻辑帧、缓冲帧、播放帧会在后面有所说明,在此只需要清楚,逻辑帧所包含的指令是经过确认的真实指令。
电影院播放的电影胶片是按照每秒24张图片进行的刷新,也就是每秒24帧,每帧的时间是41ms,那么游戏只要大于等于24帧,玩家看到的图像就是连贯的,我所做过的手机游戏都是维持在30帧的,所以上图中我默认每帧是33ms。
但是现实总是事与愿违,每个玩家的网络情况是不确定的、终端机的性能是不确定的,我们来看图2这种情况:
上图出现了两个典型的问题:
- 第1帧执行之前的操作一切都是正常的,Client1和Client2的用户指令都发给了服务端,服务端也在第1帧执行之前将这一帧的指令返回给了两个客户端,其中Client1正常收到了返回,但是Client2由于网络或者终端卡顿,在第一帧应该执行的时候并没有收到服务端返回的第1帧指令。
- 在第1帧和第2帧之间,Client1和Client2都向服务端发送了用户指令,由于网络延迟导致了服务端在执行到第2帧的时候,一直没有收齐所有用户指令,此时服务端认为Client1在这一帧没有任何操作,于是将其余的用户指令下发下去,由于客户端和服务端的帧都是同步的,当Client2收到服务端的第2帧指令时,在自己本地的时间轴上,已经过了第2帧,甚至已经执行到了第3帧。
我将这种问题称作信息不对等状态,一种处理方式是帧锁定,如果有一个人跟大家不同步了,那么大家就陪着一起锁定住,通过一系列的消息恢复措施处理,直到恢复以后才继续进行游戏。另一种处理方式是预测-快照-回滚机制,客户端在本地接收到了用户的操作指令后,无论在这一帧的时间到达时,是否收到了服务端发送的帧指令,客户端都会预测本次的指令是成功的,并将这个指令做出渲染上的处理,而对于其他玩家的操作则不会进行预测,当收到了延迟到来的数据后,如果自己预测的成功,万事大吉,如果自己预测的失败,需要将自己的逻辑回滚到出错的帧,在渲染表现上做一个平滑处理。
帧锁定的原理比较简单,对于不对等的处理比较粗暴,带来的负面影响比较大,一个用户的网络条件不好,其他用户都要受牵连,非常影响用户体验。预测-快照-回滚机制的原理则要相对复杂一些,对于网络条件不好的用户,他只会影响到自己的表现,不会影响到其他玩家,所以基于用户体验优先的原则,项目应该选择预测-快照-回滚机制来作为帧同步的方案,我们在下文简称为PSR(Prediction Snapshoot Roll-Back)。
首先描述一下这三个词的含义
预测:当本地执行到了某一帧,但是没有收到服务器发来的这一帧的真实指令信息,本地客户端认为自己在这一帧的输入是有效的,并执行了这一帧指令所产生的渲染效果,由于指令的执行没有依赖服务端的返回,是一种客户端先行的表现,我们称这种处理叫做预测,除了自己的行为,还会预测其他玩家的移动行为。
快照:保存了最后一次服务端确认的帧和每一个预测帧的数据和状态,在做数据回滚时,基于快照的数据,可以直接将逻辑回滚到指定的帧再继续执行。
回滚:由于存在着预测失败的情况,当失败时需要将自己预测的帧都回滚到最后一次成功的帧。因为保存了之前的快照数据,所以将快照数据覆盖回去即可完成逻辑上的回滚,在渲染表现上再做一些平滑的过度处理即可。如上图中,在第2帧的时候服务端由于网络原因并没有收到Client1的用户指令,服务端认为在第2帧时Client1没有任何操作,而Client1认为自己产生了用户指令,并且预测了该指令的执行逻辑,Client1收到了第2帧的消息后,发现自己的预测是失败的,随即就进行了回滚处理。
有了这三个概念后,还需要对PSR进行逻辑上的串联处理,这里又引入了三个概念,分别是:逻辑帧、缓冲帧、播放帧。
逻辑帧:基于从服务端收到的帧指令执行的帧,所有客户端和服务端的帧同步,指的就是逻辑帧同步,我们将从服务端发送过来的帧统一称为逻辑帧指令。
播放帧:负责渲染表现的帧称之为播放帧,客户端在某一帧产生了一个指令,如果在该执行该帧逻辑的时候,还没收到服务端发来的逻辑帧指令,由于有预测机制的存在,客户端会直接执行此帧所产生的渲染表现,在理想的情况下(图1),播放帧和逻辑帧一直是在同一帧。
缓冲帧:指在播放帧与逻辑帧中间的那些帧,当播放帧播放完毕,还没有收到服务端的逻辑帧指令时,随着时间的推移,进入了下一帧,下一帧会变成播放帧,而当前这个帧由播放帧变成缓冲帧。
逻辑帧存在于服务端和客户端,且两端的帧指令数据要一致,播放帧和缓冲帧只存在于客户端,下面用一个图例来说明他们与PSR的关联:
Client2由于网络条件较好,终端性能优越,所以可以一致跟服务端的逻辑帧保持一致,在第4帧的时候播放帧、缓冲帧、逻辑帧都在同一帧,Client2是最理想的同步状态。
Client1由于网络的原因只收到了第1帧的逻辑帧指令数据,而实际的时间已经走到了第4帧,因为有了自己本地的预测,负责渲染表现的播放帧也在第4帧进行了相应的渲染表现。例如玩家释放了一个技能,那么会在播放帧将这个技能播放出来,但是在逻辑处理上,并不认为玩家使用了技能,如果没有收到逻辑帧指令,那么这个技能是不允许产生任何收益的,这里就提出了一个必要的需求,逻辑和渲染要做解耦处理,逻辑的处理不应该耦合渲染的处理。第1帧逻辑帧,第2、3帧缓冲帧都做了快照,假如Client1在这时刚好收到了服务端发送回来的第2、3、4逻辑帧指令,Client1的缓存帧里保存的指令和服务端发送的逻辑帧指令是一致的,Client1将2、3、4帧都标记为了逻辑帧,并且在逻辑帧的tick中执行了追帧操作,追帧操作是指让本地的逻辑帧追赶上服务端发送来的逻辑帧,由于缓冲帧和播放帧以前先行做出了一些渲染了,直接拉拽或者加速会显得很生硬,渲染表现上需要做平滑处理,追帧也需要有一个阈值,不能在一帧的时间内处理太多的逻辑帧,这样容易造成卡顿。追帧操作在图3中就是指Client1在第4帧的tick处理中快速执行第2、3帧的业务逻辑;如果Client1的缓存帧里保存的指令和服务端发送的逻辑帧指令是不一致的,则需要进行回滚操作。
回滚操作依赖于快照的数据,快照数据的收集将是一个逻辑复杂、消耗内存的操作,首先要做的就是操作与数据的分离解耦,Unity的ECS、UE4的controller机制、面向过程的思想,都可以做到这一点,其他博主有很多这方面的资料,我就不展开介绍了。
通过几个图例和上文的描述,同步的流程处理已经逐渐清晰了,流程图可以简化表示为:
以上是帧同步业务层的同步处理机制和流程,总结一下就是我们使用PSR模式来驱动整个同步,每个客户端都是一个独立的个体,逻辑信任服务端发送回来的帧数据,渲染表现基于本地的播放帧,在有必要的时候做平滑处理。为了支撑同步机制和流程,还需要很多其他层面或者模块的支持,我这里列出了一些:
- 运行平台无关,无论是在andorid、ios、windows,还是不同硬件平台的机器,同一条指令甚至同一个函数方法都需要得到同样的一个结果,不允许出现由于平台和操作系统导致的结果不一致。
- 网络的实时性越高越好,TCP由于需要建立连接、超时重传、滑动窗口等一系列通信质量保证机制,所以网络的响应速度并不如UDP。
- 帧同步的逻辑部分只使用一种语言实现,如果服务端或者客户端是不同语言的,需要帧同步的实现语言具有多语言互通特性,例如:帧同步逻辑部分使用C++,客户端unity,服务端java,由于C#和java都可以调用C++的库,所以这样是可以接受的,当然另外一种情况是客户端使用UE4,服务端使用C++,那么本身大家都是使用的同一种语言,减少了语言互通所带来的各种问题。
- 服务端和客户端要严格控制或者避免由于GC导致的卡顿,如果gc时间过长,将出现跳帧的情况,若选择使用C++实现帧同步部分的逻辑,则不存在此问题,C#和Java由于语言特性导致都会存在GC的影响。
- 不依赖引擎提供的object的tick顺序,使用自有的Object体系管理器来管理所有对象的tick,从而保证了每中对象的tick顺序,避免出现顺序不一致导致的运算结果差异。
- 游戏逻辑和渲染不能耦合在一起处理,需要做逻辑和渲染的分离,因为帧同步如果想表现的好需要做PSR处理,而且还考虑到服务端验证这种不需要渲染的使用场景,所以必须将两者做分离解耦处理。
- 客户端的玩家操作指令,需要区分同帧互斥和同帧并行,比如普攻和必杀技是不可以同时使用的,所以它们是同帧互斥操作,比如吃药和释放技能,是可以同时使用的,那么就是并行操作。
- 一帧中,每个玩家的指令执行顺序的不同会导致最终的表现结果不同,我们可以将一帧中再细分N个时间点,比如33ms内再分3个时间点,这样将每11ms作为一个输入顺序的判断,逻辑帧根据每个人的释放时间点,先做一个排序,如果仍然出现在同一个时间段出手的情况,就可以随机一个人先出手。
- 因为是帧同步的,所以在游戏世界中的所有逻辑和动画、粒子特效等都应该是基于帧的,比如动画在某一时刻要派发一个开启攻击框的事件,这一时刻就应该用帧而不是时间来表达。
- 对于断线重连和中途加入的玩家,有两种方法可以处理:
- 服务端保存了从开始到当前帧的所有逻辑帧数据,将这些帧数据打包下发给加入的客户端,客户端收到后可以在loading界面执行一遍逻辑帧的逻辑直到当前最新的帧,然后将最后一帧的画面渲染出来,随即开始正常的游戏。
- 客户端和服务端一起使用快照处理,服务端也保存了每一帧的快照数据,这样基于第一种方法的处理方式,服务端只需要发送当前帧的快照数据给客户端,客户端按照快照数据进行还原,这个方式看起来要更优秀一些,带来的负面影响是服务端相当于一个没有渲染的客户端,需要实时的计算每一帧的逻辑以便产生快照数据,这造成了一定的CPU消耗。
- 在开发阶段经常需要在单机模式下运行游戏,单机模式可以无干扰的调试表现相关的逻辑,将网络发送部分取消即可,并且在流程上认为每帧都收到了服务端的逻辑帧指令。
- 我所画的图中,服务端和客户端都是一起开始的,但实际的情况是服务端会比客户端稍微慢一些的,这种慢是可以容忍的,而且服务端比客户端慢几帧,其实是可以给网络留出更多的容错时间的。
平台无关其实包含了三类问题:浮点数、伪随机、容器遍历一致
众所周知,浮点数在不同的硬件平台会表现出一定的误差,这种误差对于逻辑计算都放在客户端的帧同步来说是不能容忍的,因为只要有一帧没有计算一致,后续帧的差距会越来越大,所以浮点数必须要做到平台无关且计算一致。
有2种方法可以解决这一问题:一是使用double类型,二是使用定点数。
double类型:由于double类型可以表示的范围更大,所以只是降低了出现不一致的概率,并没有从根源上解决浮点问题。
定点数:定点数通俗来讲就是将固定小数点位数的数字使用一个整形的数据来表达,可以用乘法的方式,将浮点数乘以一个10的倍数,也可以用二进制中高位和低位的思想,将高位放置整数部分低位放置小数部分,采用二进制高低位处理的方式在运算时效率更高。
目前我所开发过的游戏都是使用定点数解决这一问题的,我们将浮点型数据乘以1000,用来表示小数点后3位,这个定点数类型也只是用在服务端,因为服务端要处理一些数据表格的数据计算,如果使用浮点数会有概率造成计算的数值结果不是策划所期望的,二是用来描述客户端的坐标信息,由于之前做的都是状态同步,技能的判定、AI的计算都是在服务端处理的,客户端只负责播放动画和特效以及少量的逻辑,所以不需要严格的浮点一致。
客户端的很多模块都用到了浮点数,比如:transform、数学库、物理库、地图数据、导航、碰撞检测、业务逻辑等等,这些模块有的是引擎底层做的实现,有些是用户的gameplay做的实现,所以需要将每个模块的浮点数都替换成统一的定点数,这个工程量虽然很大,但是必须要这么做。
游戏逻辑中经常使用随机数,我们要保证每个客户端在每一个逻辑计算出来的随机数都是一致的,那么就需要我们使用伪随机,开局时,服务端发送给所有客户端一个种子,每个客户端使用这个种子初始化随机数类,这样在做每一次随机时就可以实现大家都是一致的,伪随机的实现有很多,随便使用一个即可。
由于一些非线性存储的数据结构在遍历时,无法保证顺序,例如有的逻辑是必须保证按照添加的顺序来遍历的,但是这个数据结构还必须使用map,那么这种遍历顺序不一致就是不被允许的,所以在处理上需要重写迭代器方法或者自己重新实现一套可以保证顺序的数据结构,这类数据结构主要包括:map、hashtable等。
网游与单机最根本的区别就是网游的很多表现都依赖于服务端的返回结果,即使是很简单的服务端逻辑,网络消息的一来一回也需要一定的时间,如果客户端保持33ms的帧率刷新画面,那么理论上在33ms内必须完成这一帧的所有操作,网络消耗在这个过程中占用的时间越少越好,目前大部分游戏都是使用TCP来实现的网络层逻辑,TCP是一个面向连接的协议,需要双端都建立连接、保持连接、保证通信质量,它很安全而且能保证消息可达以及顺序,但缺点是网络速率低,TCP为了保持连接和保证通信质量,设计了很多复杂的机制,比如:建立连接的三次握手、数据确认、超时重传、拥塞控制、慢启动,这些机制使TCP的传输效率远低于UDP,而UDP则是一个无连接的协议,它没有TCP那么多影响网速的机制,但是不保证通信质量,需要应用层自己来处理消息顺序、丢包问题,所以只要我们处理好了消息顺序和丢包的问题,UDP对网络速率的提升还是非常明显的。
若想在UDP基础保证消息顺序和处理丢包情况有两种思路:可靠UDP和冗余UDP。
可靠UDP顾名思义就是将TCP中的数据确认和超时重传机制简化,目前以后几个开源实现,比较热门的是KCP、UDT,两个项目的介绍里都把原理说的很清楚了,感兴趣的可以去github上搜索相关开源实现。
冗余UDP则比较简洁,每个数据都会发送多份,用概率优势来保证通信质量,同时每个数据包都有数据编号,需要增加一个数据确认机制,这个机制放在客户端或者服务端均可,如果放在服务端,则还需要每个客户端收到了数据以后给服务端一个确认信息,服务端再把消息从发送缓冲中移除,如果放在客户端,客户端应该有一个数据接收的等待超时,超时后像服务端发送消息,由于帧同步的机制是每一帧服务端都会发送指令给客户端,所以是比较方便做等待超时的,因为客户端知道自己一定要收到服务端的帧消息。
可靠UDP虽然已有实现,但是毕竟还是有机制上带来的消耗,并不能完全的展现UDP的速率优势,基于我们所追求的实时性最强,还有现在的网络状况已经较前些年有了质的飞跃,不会出现大量丢包的情况,所以我选择使用更加简洁的冗余UDP方案。
使用UDP需要注意的一点是包的大小不能超过MTU,因为一旦超出了MTU那么在IP层会对包进行拆包重组,一旦发生了重组现象,一个数据包变成了多个数据包,这将增加在传输过程中丢包的概率,并且给应用层组装带来了额外的复杂度。
应用层心跳逻辑跟TCP一致,不需要额外处理,游戏的网络层和应用逻辑层也一样要做分层的处理,网络层对于上层来说是透明的,应用逻辑层只有接消息和发消息接口,并不需要关心冗余处理、收包确认等等网络方面的逻辑。状态同步中应用层的网络处理可以按照每个人来进行tick,然后再处理每个人的N个网络包,但是在帧同步中消息的处理要将消息按照对应帧,分帧进行tick,在这一点上双端需要保持一致,否则将导致由于tick消息顺序导致的不一致。
作为一名前C++程序员在使用了Java和C#后会有一种如释重负的感觉,自己不用管理指针和内存了,该死的段错误也不会再出现了,可以把更多的精力放在业务的设计开发上了,但是任何事情都是具有两面性的,GC的停顿(Stop The World)问题是无法忽略的,而且GC的时间是没法把控的,虽然现在Java除了G1,可以自己设定一个期待的时间,但还是会经常出现超时的情况,而且设置的太小反而会造成频繁的Mixed GC,那么在帧同步中由于对于每一帧的执行时间是有严格要求的,一旦出现了跳帧现象,将对玩家的体验非常不友好,所以这时候我又考虑回头使用C++来实现帧同步的逻辑,由于是自己管理内存,完全可以用空间换时间的方式来避免GC,并且Java和C#都可以直接调用C++编译好的库。
在玩家进入了位面或者战斗场景,无论服务端还是客户端,逻辑就交给C++的库里面进行处理了,在战斗结束后再将数据传回给Java或者C#,如果一些逻辑是需要热更的,可以将热更逻辑用lua来编写,对于防作弊而言,服务端需要绝对的权威,我们可以把C++的逻辑跑在一个第三方进程来进行延迟验证,或者在帧同步的线程中做实时验证。
状态同步中,大部分的逻辑都是服务端来驱动的,客户端只负责播放相应的表现即可,它在防作弊方面有机制上的优势,而帧同步的逻辑都是放在客户端的,客户端的进程是运行在玩家的终端上,破解的难度相对服务器要低很多,难免的会出现外挂,帧同步游戏的外挂类型主要是全图挂、修改运行内存、拦截修改网络消息以达到欺骗的作用,我们的应对方法也比较多:
- 对应用进行加固增加反编译难度。
- 对于一些核心的数据(对象的位置、自己的属性等等)做数据加密和混淆处理。
- 使用C++将帧同步的战斗部分封装到库中,而对于模拟客户端消息的,服务端按照指令执行战斗逻辑,做一部分数据验证和关键节点的验证,数据包括血量、伤害、位置、速度、掉落等,节点包括:死亡、副本结束等,客户端上传每个角色的关键数据hash值,服务端比对hash值,如果出现了不一致,遵循少数服从多数的原则进行投票,处理hash值不一致的玩家,对于验证的时效性,服务端可以即时验证或者交给其他应用做延迟验证。
- 网络消息加密,将每一条消息或者隔条消息使用加密算法进行加密,增加破解网络消息的难度,但是只要客户的代码被反编译了,那么通过加密的方式也没有太大的用处,最重要的还是要做好服务端对关键数据的正确性验证。
网游的客户端就是一个命令模式来驱动的表现逻辑,我们把每一帧的命令和开局时所有对象的属性信息保存到日志里,终端想要在线或者离线观战时,只需要按照命令执行逻辑即可,无需额外的开发工作。这对游戏开发有诸多好处,我们可以利用日志回放来查找同步的bug、策划可以通过玩家的日志回放来分析版本的战斗数值和技能设计是否有问题、怪物的AI也可以通过观看日志回放来进行优化、服务端也可以通过日志来进行战斗结果的正确验证。
日志的格式越简单越好,占用的空间越小越好,所以需要遵循能用整形的绝不用字符串,能用英文简写代替的绝不用完整单词,然后再做一次压缩处理即可。在保存时由于要进行IO操作,如果我们在每一帧都去写,IO会占用帧的逻辑执行时间,这很不划算,所以我们要在这一局结束后、错误发生时或缓存填满时再写入文件。
日志分为服务端和客户端,服务端的日志可以保存到本地或者直接通过网络推送到Hadoop集群,再由后端的分布式服务做统一的处理,而客户端的日志必须先保存到本地,如果本局内自己没有出现同步的异常,则可以删除本局的记录,如果有异常,则保存到本地,待用户没有战斗时再进行上传到Hadoop集群保存。
这次对帧同步做了一个总体的分析和设计,有的设计并没有细化到具体的实现细节。路要一步一个脚印走,接下来会花时间按照本文的设计实现一个小游戏来验证这一套设计,实现过程中如有方案的变化也会随时更新到文章中。