今年的春节非同寻常,假期时间比较长,恰好自己也打算利用春节的假期充充电,所以系统的研究和设计了一下帧同步方案,将其作为自己和公司的一个技术储备,本文中如有疏漏和错误,还请各位多多指教。
我一直在脑补这样一种游戏类型,开放世界+位面战斗,这是一种状态同步与帧同步结合的游戏玩法,既不失去在大世界中与玩家交互的乐趣,又可以在战斗时达到极致的爽快感,只要没有进入战斗,那么一直是状态同步的,所有的技能在状态同步下只支持空放,这时的技能只是一个展示炫耀的作用,所有的开放世界玩法都是在状态同步下进行的,那么打怪和PVP则需要原地进入一个位面,在位面中使用帧同步技术来处理战斗,理论上是可以让战斗效果达到主机游戏的水平的,当然这种类型的游戏就失去了野外抢怪、偷袭玩家的乐趣,但是换来的是极致的战斗体验,个人认为舍弃这些也值得。
国内的大厂都很喜欢做MMORPG和ARPG,也深耕于此,但是直至目前为止,战斗系统都做的比较简单,而且在表现上都是取巧的设计,国内的ARPG的A,不是Action而是Animation,甚至很多游戏都还是站桩式的战斗系统,打击感完全依靠夸张的动画或者特效来模拟,之所以没法做成真正的动作游戏是因为大都在采用状态同步,战斗的判定和AI也都是由服务器来驱动,前后端的同一帧状态不对等,无法保证每一个客户端的每一帧都得到相同的运算结果,所以在设计一些实时性高的技能玩法时捉襟见肘,例如:投技、实时QTE、锁镜头慢放、空中连击、打怪的复杂表现等等,虽然我们使用状态同步也实现了这些玩法,但在这中间做了很多的让步和取巧处理,对于战斗策划的限制还是比较多的。目前市面上的大部分RPG、ARPG都是使用的状态同步,网上的资料也比较多,所以本篇文章就不详细介绍状态同步了。而帧同步的理论基础就是保证每个客户端的帧步调一致(Lockstep),每个客户端表现的无论是玩家还是NPC都是一致的,所以在理论上,帧同步网游的战斗效果可以做到跟单机游戏一样的表现。
我们从最核心的同步部分开始,帧同步,顾名思义就是每个人的每一帧都是同步的、一致的,流程如图1:
图1
忽略图中的播放帧和逻辑帧,后面的内容会对其进行描述。图例中有2个玩家,这2个玩家与服务器的开始时间和帧率都是相等的,理想情况下是不存在谁快谁慢,客户端每一帧都会将玩家的操作指令上报给服务端,服务端收集齐了所有玩家在这一帧的指令或者服务端的时间已经到达了该帧的时间点,服务端统一将此帧产生的指令广播给客户端,客户端收到后,再按照服务端广播的指令进行相应帧的表现处理,然后步进逻辑帧,所有的游戏逻辑计算都放在了客户端,服务端只负责转发每一帧的消息。上图中的逻辑帧、缓冲帧、播放帧会在后面有所说明,在此只需要清楚,逻辑帧所包含的指令是经过确认的真实指令。
电影院播放的电影胶片是按照每秒24张图片进行的刷新,也就是每秒24帧,每帧的时间是41ms,那么游戏只要大于等于24帧,玩家看到的图像就是连贯的,我所做过的手机游戏都是维持在30帧的,所以上图中我默认每帧是33ms。
但是现实总是事与愿违,每个玩家的网络情况是不确定的、终端机的性能是不确定的,我们来看图2这种情况:
图2
上图出现了两个典型的问题:
我将这种问题称作信息不对等状态,一种处理方式是帧锁定,如果有一个人跟大家不同步了,那么大家就陪着一起锁定住,通过一系列的消息恢复措施处理,直到恢复以后才继续进行游戏。另一种处理方式是预测-快照-回滚机制,客户端在本地接收到了用户的操作指令后,无论在这一帧的时间到达时,是否收到了服务端发送的帧指令,客户端都会预测本次的指令是成功的,并将这个指令做出渲染上的处理,而对于其他玩家的操作则不会进行预测,当收到了延迟到来的数据后,如果自己预测的成功,万事大吉,如果自己预测的失败,需要将自己的逻辑回滚到出错的帧,在渲染表现上做一个平滑处理。
帧锁定的原理比较简单,对于不对等的处理比较粗暴,带来的负面影响比较大,一个用户的网络条件不好,其他用户都要受牵连,非常影响用户体验。预测-快照-回滚机制的原理则要相对复杂一些,对于网络条件不好的用户,他只会影响到自己的表现,不会影响到其他玩家,所以基于用户体验优先的原则,项目应该选择预测-快照-回滚机制来作为帧同步的方案,我们在下文简称为PSR(Prediction Snapshoot Roll-Back)。
首先描述一下这三个词的含义
预测:当本地执行到了某一帧,但是没有收到服务器发来的这一帧的真实指令信息,本地客户端认为自己在这一帧的输入是有效的,并执行了这一帧指令所产生的渲染效果,由于指令的执行没有依赖服务端的返回,是一种客户端先行的表现,我们称这种处理叫做预测,除了自己的行为,还会预测其他玩家的移动行为。
快照:保存了最后一次服务端确认的帧和每一个预测帧的数据和状态,在做数据回滚时,基于快照的数据,可以直接将逻辑回滚到指定的帧再继续执行。
回滚:由于存在着预测失败的情况,当失败时需要将自己预测的帧都回滚到最后一次成功的帧。因为保存了之前的快照数据,所以将快照数据覆盖回去即可完成逻辑上的回滚,在渲染表现上再做一些平滑的过度处理即可。如上图中,在第2帧的时候服务端由于网络原因并没有收到Client1的用户指令,服务端认为在第2帧时Client1没有任何操作,而Client1认为自己产生了用户指令,并且预测了该指令的执行逻辑,Client1收到了第2帧的消息后,发现自己的预测是失败的,随即就进行了回滚处理。
有了这三个概念后,还需要对PSR进行逻辑上的串联处理,这里又引入了三个概念,分别是:逻辑帧、缓冲帧、播放帧。
逻辑帧:基于从服务端收到的帧指令执行的帧,所有客户端和服务端的帧同步,指的就是逻辑帧同步,我们将从服务端发送过来的帧统一称为逻辑帧指令。
播放帧:负责渲染表现的帧称之为播放帧,客户端在某一帧产生了一个指令,如果在该执行该帧逻辑的时候,还没收到服务端发来的逻辑帧指令,由于有预测机制的存在,客户端会直接执行此帧所产生的渲染表现,在理想的情况下(图1),播放帧和逻辑帧一直是在同一帧。
缓冲帧:指在播放帧与逻辑帧中间的那些帧,当播放帧播放完毕,还没有收到服务端的逻辑帧指令时,随着时间的推移,进入了下一帧,下一帧会变成播放帧,而当前这个帧由播放帧变成缓冲帧。
逻辑帧存在于服务端和客户端,且两端的帧指令数据要一致,播放帧和缓冲帧只存在于客户端,下面用一个图例来说明他们与PSR的关联:
图3
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机制、面向过程的思想,都可以做到这一点,其他博主有很多这方面的资料,我就不展开介绍了。
通过几个图例和上文的描述,同步的流程处理已经逐渐清晰了,流程图可以简化表示为:
图4
以上是帧同步业务层的同步处理机制和流程,总结一下就是我们使用PSR模式来驱动整个同步,每个客户端都是一个独立的个体,逻辑信任服务端发送回来的帧数据,渲染表现基于本地的播放帧,在有必要的时候做平滑处理。为了支撑同步机制和流程,还需要很多其他层面或者模块的支持,我这里列出了一些:
平台无关其实包含了三类问题:浮点数、伪随机、容器遍历一致
众所周知,浮点数在不同的硬件平台会表现出一定的误差,这种误差对于逻辑计算都放在客户端的帧同步来说是不能容忍的,因为只要有一帧没有计算一致,后续帧的差距会越来越大,所以浮点数必须要做到平台无关且计算一致。
有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++的逻辑跑在一个第三方进程来进行延迟验证,或者在帧同步的线程中做实时验证。
状态同步中,大部分的逻辑都是服务端来驱动的,客户端只负责播放相应的表现即可,它在防作弊方面有机制上的优势,而帧同步的逻辑都是放在客户端的,客户端的进程是运行在玩家的终端上,破解的难度相对服务器要低很多,难免的会出现外挂,帧同步游戏的外挂类型主要是全图挂、修改运行内存、拦截修改网络消息以达到欺骗的作用,我们的应对方法也比较多:
网游的客户端就是一个命令模式来驱动的表现逻辑,我们把每一帧的命令和开局时所有对象的属性信息保存到日志里,终端想要在线或者离线观战时,只需要按照命令执行逻辑即可,无需额外的开发工作。这对游戏开发有诸多好处,我们可以利用日志回放来查找同步的bug、策划可以通过玩家的日志回放来分析版本的战斗数值和技能设计是否有问题、怪物的AI也可以通过观看日志回放来进行优化、服务端也可以通过日志来进行战斗结果的正确验证。
日志的格式越简单越好,占用的空间越小越好,所以需要遵循能用整形的绝不用字符串,能用英文简写代替的绝不用完整单词,然后再做一次压缩处理即可。在保存时由于要进行IO操作,如果我们在每一帧都去写,IO会占用帧的逻辑执行时间,这很不划算,所以我们要在这一局结束后、错误发生时或缓存填满时再写入文件。
日志分为服务端和客户端,服务端的日志可以保存到本地或者直接通过网络推送到Hadoop集群,再由后端的分布式服务做统一的处理,而客户端的日志必须先保存到本地,如果本局内自己没有出现同步的异常,则可以删除本局的记录,如果有异常,则保存到本地,待用户没有战斗时再进行上传到Hadoop集群保存。
这次对帧同步做了一个总体的分析和设计,有的设计并没有细化到具体的实现细节。路要一步一个脚印走,接下来会花时间按照本文的设计实现一个小游戏来验证这一套设计,实现过程中如有方案的变化也会随时更新到文章中。