游戏中的网络同步机制<二> 王者荣耀对帧同步的应用

参考
解密:腾讯如何打造一款实时对战手游
从《王者荣耀》来聊聊游戏的帧同步
《王者荣耀》技术总监复盘回炉历程:没跨过这三座大山,就是另一款MOBA霸占市场了

纵观AppStore畅销榜前十的游戏,过半都支持玩家实时的PK或者合作攻关。由于实时对战有玩家之间自发进行强互动的特点,活跃度和社交强度都是比较高,为游戏的用户活跃和流水的提高奠定了坚实的基础。腾讯的游戏开发团队,很早就观察到实时对战这一核心玩法对游戏生命周期影响的重要性,因此在自研产品方面,加大力度开发围绕实时对战这一核心玩法的游戏,从而诞生了《王者荣耀》、《穿越火线·枪战王者》、《全民超神》、《全民突击》、《天天炫斗》等一大批优秀的作品,其中不乏日活跃过千万的大作。而早期的休闲类游戏如《全民飞机大战》等,也加入了实时双打等游戏特性,所以现在依然可以经常在AppStore畅销榜前十看到《全民飞机大战》这款游戏的身影。既然实时对战是一个非常重要的游戏玩法,为什么我们现在看到的许多游戏,都不具备这一的玩法,或者并不是游戏的主要玩法?其中一个重要的原因,就是开发实时对战的功能,在技术上需要有一定的门槛。本文希望能向大家分享腾讯是如何跨过这些门槛,解决实时对战游戏开发的一系列核心技术难题。

首先我们介绍实时对战手游中最难解决的技术问题——弱网络下的同步问题。

通过对玩家的游戏数据进行观察,发现玩家的游戏环境存在很大差异,不同玩家会使用不同的2G/3G/4G/Wifi网络,不同网络之间的延迟相差很大。另外移动网络质量不稳定,且都是按流量收费,这些都是需要考虑的问题。手机在网络间的切换,又会造成底层网络断线、地址变化等问题,都是常见的情况。这些问题的统一解决手段,最重要的是通盘考虑各种需求,选择一个合理的游戏状态同步模型。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第1张图片
不同网络间的延时

腾讯在大量游戏开发的实践中,总结出三种游戏的同步模型:

一、MMOG模式

这种同步模型,在端游时代就使用的非常广泛,特别是MMORPG里面。

它的主要实现要点是:服务器负责计算全部的游戏逻辑,并且广播这些计算的结果,客户端仅仅负责发送玩家的操作,以及表现收到的游戏结果。一般来说,玩家发送一个操作到服务器上,服务器根据玩家操作去修改内存中的游戏世界模型,同时运算游戏世界对这个操作的反应,然后把这些反应都广播给相关的多个客户端,每个客户端负责把这些数据表现出来给玩家看。

这种做法的优点是非常安全,由于整个游戏逻辑都在服务器上,服务器只接受合法的玩家操作,一切都经过既定逻辑的运算。另外一个优点是游戏的逻辑更新很方便,因为主要逻辑都在服务器端。一般的游戏玩法需要更新,游戏开发团队自己更新重启服务器就可以了,无需让千万个手机去下载更新包。

但是这种做法的缺点也很明显,首先就是用户的体验非常依赖网络质量,如果一个用户的网速慢,其他玩家都会发现他在游戏中明显的变卡。

另外一个缺点就是服务器负责了太多的游戏逻辑运算。在动作游戏里,服务器往往需要针对二维或者三维空间进行运算。

最后,使用这种同步方案,由于每个游戏表现都要以数据包发往客户端,所以当一起玩的用户数量较多,这种广播的数据包量就会非常大。

因此根据以上的特点,腾讯一般会在那些同局游戏人数不太多,但讲求玩法变化快和安全性高的游戏中采用这种同步方案。腾讯自研手游中比较著名的《穿越火线·枪战王者》、《全民超神》、《炫斗之王》都是使用这种方案。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第2张图片
image.png
二、主机模式

这种同步方案的做法是:以参与对战的一个客户端为“主机”,其他的客户端为“副机”。

游戏逻辑的主要运算由“主机”完成,所有的“副机”把操作指令,通过服务器中转,集中发送给“主机”;“主机”完成游戏运算后,把结果指令再通过服务器中转,广播给所有的“副机”。

这个方案看起来有点奇怪,但是却有很明显的优点:首先是大量的实时动作游戏,其游戏过程的逻辑代码,都是在客户端上开发和运行的。客户端的游戏引擎对于二维、三维空间中的位置运算、碰撞检测等功能,都有很好的支持。

因此把整个游戏逻辑由客户端负责,就能让服务器端无需再开发这部分功能。服务器只负责做转发、广播的操作,所以能承载的人数和第一种方案有数量级上的差别。由于“主机”客户端运行游戏逻辑,所以其体验是最好的,就算“副机”由于网络不佳造成体验下降,对于“主机”来说,只是发现“副机”动作有点迟缓而已。

在以PVE玩法为主的游戏中,用户关注的是自己的体验,不会太在意同伴的准确动作,这种情况下,主机模式就是一种不错的同步方案。腾讯的《全民飞机大战》的双打模式就是采用这种方式,效果相当不错。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第3张图片
image.png
三、帧同步模式

又叫“锁步模式”。这种模式用形象的比喻来说,就是把所有参与对战的客户端,看成是排成一列的囚犯。这些囚犯们的左脚都被链子所在一起,因此他们如果要往前走,就只能同时迈步,如果其中某个人走快了,或者走慢了,都会让整队人停下来。

在实现上,一般是以服务器按固定的帧率,来搜集每个客户端的输入,然后把这些输入广播给所有的客户端;由于每个操作指令到达所有客户端的时间(帧)都是一样的,所以每个客户端运算的结果也是一样的,同样的输入就会得到同样的结果。

这就好像:其他玩家通过网络,把操作手柄接到你的手机。这种同步方案,是传统单机-局域网游戏中最常用的。

这种同步模型的最大优点是:强一致性。每个客户端的表现是完全一样的,非常适合高度要求操作技巧的游戏。由于广播的仅是玩家的操作,所以数据量很少。不管游戏中的角色数、状态量有多大、多复杂,都不会影响广播的数据量。

但是这个方案也有缺点:对所有玩家的延迟都有要求,一般来说要求在50毫秒以内。如果有一个客户端网络卡了,所有的客户端都要停下来等,大家在玩《星际争霸》就见识过:一个玩家断线,全部玩家的游戏都暂停。腾讯游戏中的《王者荣耀》、《全民突击》由于竞技性非常强,所以采用了这种方案。

另外在帧同步模式中,数据同步的频率较高,网络延迟越小越好。由于TCP的滑动窗口机制和重传机制,导致延时无法控制,因此帧同步一般采用udp进行网络传输,但udp又会衍生出可靠性问题,对于客户端,如果某些udp包没有收到,就会出现丢帧的情况,所以这里我们自己研发了一套《可靠UDP传输》的协议,应用在《王者荣耀》项目。关于《可靠UDP传输》的相关技术介绍,后续会作为专题继续分享给大家。大体上是如此来解决:

  1. 为每个数据包增加序列号,每发一次包,增加本地序号。

  2. 每个数据包增加一段位域,用来容纳多个确认符。确认字符多少个,跟进应用的发包速率来觉得,速率越高,确认字符的数量也相应越多。

  3. 每次收到包,把收到的包上序列号变为确认字符,发送包的时候带上这些确认字符。

  4. 如果从确认字符里面发现某个数据包有丢失,把它留给应用程序来编写一个包含丢失数据的新的数据包,必要的话,这个包还会用一个新的序列号发送。

  5. 针对多次收到同一包的时候可以放弃它

游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第4张图片
image.png

乐观锁&断线重连
囚徒模式的帧同步,有一个致命的缺陷就是,若联网的玩家有一个网速慢了,势必会影响其他玩家的体验,因为服务器要等待所有输入达到之后再同步到所有的c端。

另外如果中途有人掉线了,游戏就会无法继续或者掉线玩家无法重连,因为在严格的帧同步的情况下,中途加入游戏是从技术上来讲是非常困难的。因为你重新进来之后,你的初始状态和大家不一致,而且你的状态信息都是丢失状态的,比如,你的等级,随机种子,角色的属性信息等。

比如玩过早期的冰封王座都知道,一旦掉线基本这局就废了,需要重开,至于为何没有卡顿的现象,因为那时都是解决方案都是采用局域网的方式,所以基本是没有延迟问题的。

后期为了解决这个问题,如今包括王者荣耀,服务器会保存玩家当场游戏的游戏指令以及状态信息,在玩家断线重连的时候,能够恢复到断线前的状态。

不过这个还是无法解决帧同步的问题,因为严格的帧同步,是要等到所有玩家都输入之后,再去通知广播client更新,如果A服务器一直没有输入同步过来,大家是要等着的,那么如何解决这个问题?

采用“定时不等待”的乐观方式在每次Interval时钟发生时固定将操作广播给所有用户,不依赖具体每个玩家是否有操作更新。如此帧率的时钟在由服务器控制,当客户端有操作的时候及时的发送服务器,然后服务端每秒钟20-50次向所有客户端发送更新消息。如下图:

游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第5张图片
image.png

上图中,我们看到服务器不会再等到搜集完所有用户输入再进行下一帧,而是按照固定频率来同步玩家的输入信息到每一个c端,如果有玩家网络延迟,服务器的帧步进是不会等待的,比如上图中,在第二帧的时候,玩家A的网速慢,那么他这个时候,会被网速快的玩家给秒了(其他游戏也差不多)。但是网速慢的玩家不会卡到快的玩家,只会感觉自己操作延迟而已。

四、帧同步的技术要点

参考帧同步游戏开发基础指南

在一般的帧同步系统中,会有一个Relay Server负责广播(转发)所有客户端的数据。为了让各个客户端能持续的运行,而不是卡住,所以需要定时的下发一个个“网络帧”数据来驱动各个客户端。因为客户端已经放弃了本地的时间,本地的循环驱动,所以这些“网络帧”就必不可少了。这些网络帧大部分实际上是“空”的,只有当玩家有输入的时候,才会把玩家的游戏操作的数据,填入到网络帧数据包中。对于客户端来说,就好像有很多键盘、鼠标、游戏手柄在通过网络操作自己一样。

一般来说,大多数的游戏客户端引擎,都会定时调用一个接口函数,这个函数由用户填写内容,用来修改和控制游戏中各种需要显示的内容。比如在Flash里面叫OnEnterFrame(),在Unity里面叫Update()。这类函数通常会在每帧画面渲染前调用,当用户修改了游戏中的各个角色的位置、大小后,就在下一帧画面中显示出来。而在帧同步的游戏中,这个Update()函数依然是存在,只不过里面大部分的内容,需要挪到另外一个类似的函数中,我们可以称之为UpdateByNet()函数——由网络层不断的接收服务器发来的“网络帧”数据包,每收到一个这样的数据包,就调用一次这个UpdateByNet()函数,这样游戏就从通过本地CPU的Update()函数的驱动,改为根据网络来的UpdateByNet()函数驱动了。显然,网络发过来的同步帧速度会明显比本地CPU要慢的多,这里就对我们的游戏逻辑开发提出了更高的要求——如何同步的同时,还能保证流畅?

1.帧数据要小
帧同步游戏中,由于需要“每一帧”都要广播数据,所以广播的频率非常高,这就要求每次广播的数据要足够的小。最好每一个网络帧,能在一个MTU以下,这样才能有效降低底层网络的延迟。同样的理由,我们为了提高实时性,一般也倾向于使用UDP而不是TCP协议,这样底层的处理会更高效。但是,这样也会带来了丢包、乱序的可能性。因此我们常常会以冗余的方式——比如每个帧数据包,实际上是包含了过去2帧的数据,也就是每次发3帧的数据,来对抗丢包。也就是说三个包里面只要有一个包没丢,就不影响游戏。另外我们还会在RelayServer上保存大量的客户端上传的数据,如果客户端发现丢了包(如果乱序了也认为是丢包),那么就发起一次“下载”请求,从服务器上重新下载丢失了的帧数据包(这个可能会使用TCP)。这一切,都依赖于每个帧数据要足够的小。所以我们一般要求,每次客户端发送的数据,应该小于128字节。你可以大概计算一下,如果我们的游戏有4个玩家,我们的冗余是3帧,那么一个下行的网络帧数据包大小会到128x4x3=1536字节,而每秒我们发15个网络帧,那么占用的带宽会到1536x15=23,040字节/秒,加上一些底层协议包头也就是24kB/s,这个速度看起来已经要求手机是3G网络才能支持了(实测中GPRS一般很难稳定到这个速度)。
我们使用的游戏引擎,特别是3D游戏引擎,里面使用的位置数据,大多数是浮点数,大家知道,一个浮点数需要占用8个字节,这可比简单的整数4个字节大了足足一倍。而我们需要广播的游戏操作,往往不需要那么高的精确度,所以我们应该把这些浮点数,想办法变成整数来广播。有时候我们甚至有可能只用1~2个字节(0-256-65535)来表达一个操作所需要的数字(比如按键值、鼠标坐标)。这样就能大大降低广播的数据长度。最简单的方法,就是把浮点数乘以1000或100然后取整。
另外一个降低广播数据量的做法就是自己编写序列化函数:一般现代编程语言,特别是面向对象的语言,都带有把对象序列化和反序列化的功能。我们要广播游戏操作的时候,这些操作往往也是一个个的“对象”,因此最简单的方法就是使用编程语言自带的序列化库来把对象转换成字节数组去广播。但是这些编程语言的默认序列化功能,为了实现诸如反射等高级功能,会把很多游戏逻辑所“不必要”的数据也序列化了,比如对象的类名、属性名什么的。如果我们自己去针对特定的数据对象来编写序列化函数,就没有这个问题了,我们可以仅仅提取我们想要的数据,甚至能合并和裁剪一些数据项,达到最小化数据长度的目的。

游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第6张图片
image.png

2.加速播放
在网络游戏中,各个客户端的运行条件和环境往往千差万别,有的硬件好一些,有的差一些,各方的网络情况也不一致;时不时玩家的网络还会在游戏过程中,发生临时的拥堵,我们称之为“网络抖动”。网络游戏有时候还会需要有中途加入游戏的需求(乱入),有游戏录像和观看、快进录像的功能。这些功能,都可能导致客户端收到“过去时间”里的一堆网络帧,因此,客户端必须要有处理这些堆积起来的网络数据的能力。最简单的做法就是加速播放(快进)——如果收到网络数据处理完游戏逻辑后,然后在同一个渲染帧(同一次Update()函数里)内,马上继续收下一个网络数据,然后又立刻处理。这样往往能在一个渲染帧的时间内,加速赶上服务器广播的最新游戏进度。但是这样做也会有副作用,如果客户端积累的包太多(比如游戏已经开始玩了10分钟,新的用户中途加入),会导致这个用户长时间卡住,因为程序正在疯狂的下载积累的帧同步包和运算快进。为了解决这个问题,有些程序员会限制每一个渲染帧中所快进的操作次数,这样用户还是能看到画面有活动。如果实在要快进的进度太多,就要采用“快照”技术,通过定时保存的游戏状态数据,来减少快进的进度了。这个快照功能这里就不展开了。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第7张图片
image.png

游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第8张图片
image.png

3.发送玩家操作频率
一般来说,我们的客户端的渲染帧率都会大大高于网络帧的接收频率。如果我们每个渲染帧都去发送一次玩家操作(比如触摸屏上的手指位置),那么可能会导致发送的游戏操作远远大于收到的操作,这样做要么会让游戏操作堆积在服务器上,导致操作的严重延迟,要么导致下行的网络包非常大(服务器每次都把收到的所有操作一次下发),这样会让网络带宽占满,同样是会感觉延迟。不管怎么处理,都是不太好的结果。正确的做法应该是控制发包频率,最好是至少收到一个网络下行帧,才发送一个上行的游戏操作,避免堆积。另外,刚刚讲到的“快进”,如果我们在快速播放游戏逻辑的时候,每次播放同时也采集玩家输入去发送,那么同样会导致短时间内发送一大堆上行数据给服务器,而这些数据很可能客户端接收时产生大量的延迟。所以最好是在快进的时候不采集玩家的输入,因为玩家在看到快进过程中,实际上也很难有效的做出合理的反应,一个常见的做法,就是快进的时候,给游戏覆盖一个“等待”或“Loading”的蒙皮层,让玩家不可以输入操作。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第9张图片
image.png

游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第10张图片
image.png

4.容忍不一致
我们做帧同步的目标是各个客户端都能看到一致的显示。但是游戏内容有很多,有一部分内容是可以容忍“不一致”的,比如我们做飞行射击弹幕游戏,满屏幕有很多子弹,而每一颗子弹本身的存在的时间很短,如果我们不是做对打的游戏(而是一起打电脑),那么这些子弹是可以不一致的。又比如我们做一个横版过关的配合游戏,几个玩家一起打电脑控制的怪物,大家关心的是怪物是怎么被打死的,而玩法本身又比较容忍不一致(横版动作游戏的攻击范围往往比较大),所以就算有些不一致问题也不大。在以上的条件下,我们就可以尝试,把更多的游戏逻辑,从网络帧的UpdateByNet()函数里面拿出去,放回到单机游戏中的Update()函数里去。这样就算网络有点卡,起码整个画面里还是有很多东西是不会被“卡住”的。但是必须注意的是,一般玩家控制的角色的动作,包括当前客户端控制的角色,还是应该从网络帧里面获得行为数据,因为如果玩家爱控制角色不一致的太多,整个游戏场面就会差更多。很多游戏中的怪物AI都是根据玩家角色来设定的,所以一旦玩家角色的行为是同步的,那么大多数的怪物的表现还是一致的。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第11张图片
image.png

5.非完全实时
一般来说,我们都希望游戏中的角色控制是灵敏的,实时的。我们的游戏角色往往在会玩家输入操作后的几十分之一秒内,就开始显示变化。在帧同步游戏中,我们可以让玩家一输入完操作,就立刻发包,然后尽快在下一个收到的网络帧中收到这个操作,从而尽快的完成显示。然而,网络并不是那么稳定,我们常常会发现一会快一会慢,这样玩家的操作体验就非常奇怪,无法预测输入动作后,角色会在什么时候起反应。这对于一些讲求操作实时性的游戏是很麻烦的。比如球类游戏,控制的角色跑的一会儿快一会儿慢,很难玩好“微操”。要解决这个问题,我们一般可以学习传输语音业务的做法,就是接收网络数据时,不立刻处理,而是给所有的操作增加一个固定的延迟,后在延迟的时间内,搜集多几个网络包,然后按固定的时间去播放(运算)。这样相当于做了一个网络帧的缓冲区,用来平滑那些一会儿快一会儿慢的数据包,改成匀速的运算。这种做法会让玩家感觉到一个固定延迟:输入操作后,最少要隔一段时间,才会起反应。但是起码这个延迟是固定的,可预计的,这对于游戏操作就便捷很多了,只要掌握了提前量,这个操作的感觉就好像角色有一定的“惯性”一样:按下跑并不立刻跑,松开跑不会立刻停,但这个惯性的时间是固定的。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第12张图片
image.png

6.不锁步
我们和其他玩家一起游戏的时候,有时候不希望对方因为电脑速度比较快,网络比较好,而能比我们更早的看到游戏的运行结果,从而提早作出操作。这一点在格斗对打游戏(如《街霸》)里面非常关键,在一些RTS(《星际争霸》)里面,提早看到游戏运行结果也是很有竞争优势的。因此我们为了让网络、硬件不一样的玩家能公平游戏,往往会使用一种叫“锁步”的策略:就好像一串绑着脚镣的囚犯,他们只能一起抬起左脚,然后再一起抬起右脚的走路,谁也不能走的更快。技术上的实现,就是每个客户端都定时(每N个渲染帧)发送一个网络帧到服务器上,就算玩家没操作,也类似心跳的这样发送空数据帧,所有客户端都要完整的收到所有的其他客户端的“心跳帧”才能开始运算一次游戏逻辑。这就是让所有的客户端,都互相等待,如果任何一个客户端卡了,其他的客户端都立刻就能知道,然后弹出界面让玩家停止输入来等待。因此在很多场合,帧同步的技术也被成为“锁步”技术,事实上,在没有统一的Relay Server服务器的时代(IPX局域网连机对战的时代),帧同步的网络帧其实就是上面所说的某个客户端的“心跳帧”,是由某个客户端产生并广播的(比如以前的局域网游戏,都会由一个客户端充当Host主机)。在《星际争霸》连机游戏中,如果有一个玩家掉线了,所有其他玩家就会发现有一个界面弹出来挡住画面,表示在等某某某。这种做法实际上是牺牲了流畅度的,因为你会发现一旦有网络、硬件卡的玩家加入游戏,所有其他玩家都受他的影响。为了减少这种对流畅度的影响,我们可以在需要“锁步”的时候,尽量少锁一点,比如不是发现缺了一帧就停下来,而是缺了若干帧,还是可以以“不公平”的方式继续玩一会儿(比如几秒),如果这段时间内还是没有补齐所缺的帧,才宣布锁住游戏等待。当然这个“容忍”的帧数我们可以调节到“最大”——就是没有。那么一个完全不锁步的游戏,肯定不是一个公平的游戏,但是也会在流畅性产生最大的好处,就是完全不受其他玩家影响。在那些不是PVP(玩家对战)的帧同步游戏中,不公平这个往往问题不大。我们完全可以在游戏的不同玩法里,打开、调整、甚至关闭这个“锁步”的机制,从而让游戏最大程度的平衡公平性和流畅性。


游戏中的网络同步机制<二> 王者荣耀对帧同步的应用_第13张图片
image.png
五、王者荣耀技术总监分享历程

参考《王者荣耀》技术总监复盘回炉历程:没跨过这三座大山,就是另一款MOBA霸占市场了

1.状态同步

先看一下状态同步的优点。

第一,它的安全性非常高,外挂基本上没有什么能力从中收益。

第二,状态同步对于网络的带宽和抖动包有更强的适应能力,即便出现了200、300的输入延迟再恢复正常,玩家其实也感受不到不太舒服的地方。

第三,在开发游戏过程中,它的断线重连比较快,如果我的游戏崩溃了,客户端重启之后只需要服务器把所有重要对象的状态再同步一次过来,重新再创建出来就可以了。

第四,它的客户端性能优化优势也比较明显,比如优化时可以做裁剪,玩家看不到的角色可以不用创建,不用对它进行运算,节省消耗。

再说一下我认为的缺点。

第一,它的开发效率相对帧同步而言要差一些,很多时候你需要保证服务器与客户端的每一个角色对象的状态之间保持一致,但事实上你很难做到一致。

比如客户端和服务器端更新的频率,对优化的一些裁剪,网络的抖动等等,你要让每一个状态在客户端同步是比较难的,而你要想调试这些东西,来优化它带来的漏洞、不一致的现象,花费的周期也会比较长,想要达到优化好的水平也比较难。

第二,它比较难做出动作类游戏打击感和精确性。比如说你要做一个射击类角色,他的子弹每秒钟要产生几十颗,基于状态同步来做是比较难的,因为系统在很短时间内,会产生很多数据,要通过创建、销毁、位置运算来同步。

第三,它的流量会随着游戏的复杂度,而逐渐增长,比如角色的多少。我们做《王者荣耀》时,希望在3G、4G的网络条件下也能够玩PvP,所以我们希望它对付费流量的消耗能控制在比较合理的水平,不希望打一局游戏就消耗几十兆的数据流量。

2.帧同步

另一种同步策略是帧同步。

这种技术应用的很广泛,最早的《星际争霸》《魔兽争霸3》都采用了帧同步,他们都基于局域网运行,网络的条件非常好,也不需要服务器就能搞定。帧同步的优点有几个:

第一,它的开发效率比较高。如果你开发思路的整体框架是验证可行的,如果你把它的缺点解决了,那么你的开发思路完全就跟写单机一样,你只需要遵从这样的思路,尽量保证性能,程序该怎么写就怎么写。

比如我们以前要在状态同步下面做一个复杂的技能,有很多段位的技能,可能要开发好几天,才能有一个稍微过得去的结果,而在帧同步下面,英雄做多段位技能很可能半天就搞定了。

第二,它能实现更强的打击感,打击感强除了我们说的各种反馈、特效、音效外,还有它的准确性。利用帧同步,游戏里面看到这些挥舞的动作,就能做到在比较准确的时刻产生反馈,以及动作本身的密度也可以做到很高的频率,这在状态同步下是比较难做的。

第三,它的流量消耗是稳定的。大家应该看过《星级争霸》的录像,它只有几百K的大小,这里面只有驱动游戏的输入序列。帧同步只会随着玩家数量的增多,流量才会增长,如果玩家数量固定的话,不管你的游戏有多复杂,你的角色有多少,流量消耗基本上都是稳定的。这点延伸开来还有一个好处,就是可以更方便地实现观战,录像的存储、回放,以及基于录像文件的后续处理。

帧同步也有它的缺点。

第一,最致命的缺点是网络要求比较高,帧同步是锁帧的,如果有网络的抖动,一段时间调用次数不稳定,网络命令的延迟就会挤压,引起卡顿。

第二,它的反外挂能力很弱,帧同步的逻辑都在客户端里面,你可以比较容易的修改它。但为什么《王者荣耀》敢用帧同步,一方面是因为当时立项的时候开发周期很短,半年时间要做上线,要有几十个英雄,存在时间的压力,另一方面,MOBA类游戏不像数值成长类的游戏,它的玩法是基于单局的,单局的作弊修改,顶多影响这一局的胜负,不会存档,不会出现刷多少钱刷多少好的装备的问题,而且作弊之后我们也很容易监测到,并给予应有的惩罚,所以我们认为这不是致命的缺点。

第三,它的断线重回时间很长,相信台下也有很多王者玩家,也曾碰到过闪退以后重回加载非常长的情况,甚至加载完以后游戏也快结束了,这是帧同步比较致命的问题。

第四,它的逻辑性能优化有很大的压力。大家应该没有见到哪一款大型游戏是用帧同步来做的,因为这些游戏的每一个逻辑对象都是需要在客户端进行运算的。如果你做一个主城,主城里面有上千人,上千人虽然玩家看不到它,但游戏仍然需要对他们进行有效的逻辑运算,所以帧同步无法做非常多的对象都需要更新的游戏场景。

3.选择

那么我们为什么选择了帧同步而放弃了状态同步呢?

我们前面提到它两个优点缺点是相对的,这边的优点对于那边来说就是缺点。对于我们手游立项的时候,最重要就是时间。当时市面上正在开发的MOBA手游不止王者一款,大家都在争上线的时间,所以我们要选择一个开发周期最短的方案。

然后我们做端游的时候也有一个深刻的体会,如果要做有趣的英雄,有趣的技能,它在状态同步上面很难调出一个比较满意的效果。所以最后我们依然选择帧同步的方案。

现在来看,选择帧同步方案之后,我们再把它的缺点进行优化或是规避,之后它带来的好处是非常明显的。《王者荣耀》重除了英雄的设计以及技能的感觉,还有很重要的一点,就是它确实在做一些非常有特色的英雄,它的技能、反馈、体验上面都做得不错,这些都是基于帧同步技术方案带来的优势。
我们选择了方案之后,当时觉得很high,觉得这样一个技术方案开发起来得心应手,效率如此之高,做出来的效果也很好。

但事实上,它也有好的一面,也有坏的一面,技术测试版本上线后质量不好,其中技术层面遇到的问题就是下面这三座大山。

第一是同步性,同步性这块容易解决,其实也解决了;

第二也是最大一块网络问题,帧同步它的网络问题导致我们对它技术方案的原理没有吃透,碰到了一些问题,那时候游戏的延迟很重,画面卡顿,能明显感觉走路抖动的现象;

第三是性能问题,这个问题始终存在,我们也一直在优化。

4.第一座大山:同步

第一座大山,最容易解决的同步问题。

帧同步的技术原理相当简单,10、20年前在应用这种技术了,从一个相同初始的状态开始,获得一个相同的输入,往下一帧一帧执行,执行时所有代码的流程走得都是一样的,这个结果调用完了以后,又有一个新状态,完成循环。相同的状态,相同的流程,不停的这样循环下去。

这个原理虽然简单,但是你要去实现它的时候,还是会有很多坑。
首先,我们所有的运算都是基于整数,没有浮点数。浮点数是用分子分母表达的。

其次,我们还会用到第三方的组件,帧组件也要需要进行一个比较严格的甄别。我们本身用的公司里面关于时间轴的编辑器里面,最初也是是浮点数,我们都是进行重写改造的。

再次,很多人初次接触帧同步里面的问题,就是在写逻辑的时候和本地进行了关联、和“我”相关,这样就导致不同客户端走到了不同的分支。实际上,真正客户端跟逻辑的话,要跟我这样一个概念无关。

接下来还有随机数,这个要严格一致。这是实现的要点,严格按照这上面的规则写代码还是有可能不同步,本身就很难杜绝这样的问题。

最后,真正重要的是开发者要提升自己发现不同步问题的能力,什么时候不同步了,不同步你还要知道不同步在什么点,这是最关键的。你需要通过你的经验和总结提升这样的能力。这个能力还是通过输出来看不同客户端不同输出,找到发生在什么点。

比如在《王者荣耀》里,我们看到不同步的现象应该是这样,有人对着墙跑,你看到的和别人玩的游戏是不一样的,就像进入平行世界。

最开始测试《王者荣耀》的,我们希望不同步率达到1%,就是100局里面有1局出现不同步,我们就算游戏合格,但实际上对于这么大体量游戏来说,这个比率是有问题的,经过我们不停的努力,现在已经控制在万分之几,一万局游戏里面,可能有几局是不同步的。

这个问题不一定是代码原因或者没有遵循这些要点才出现的,有可能是你去修改内存,你去加载资源的时候,本地资源有损害或者缺失,或者是异常。说白了,你没有办法往下执行,大家走了不同分支,这都可能引起最终是不同步的。

如果你不同步概率比较低,到了这种万分之几概率的时候,很难通过测试来去还原,去找到这样不同步的点。

最开始我们游戏出现不同步的时候,就是在周末玩家开黑多的时候,随着你的概率越来越低,基本上你就自己就还原不出这些问题了,只能依靠玩家帮你还原这样的场景,来分析这样的不同步问题。

同步性遵循这样的要点,按照这样的思路来写,加上你不同步定位的能力,有了监控手段能够去发现,这个问题其实就解决了。解决之后,你就可以好好享受帧同步的开发优势。

5.第二座大山:网络

第二座大山就是网络,《王者荣耀》技术测试版本出台的时候,延迟非常大,而且还是卡顿,现在看一下帧同步里面比较特别的地方。帧同步有点像在看电影,它传统的帧同步需要有buffer,每个玩家输入会转发给所有客户端,互相会有编号,按顺序输入帧。

比如我现在已经收到第N帧,只有当我收到第N+1帧的时候,第N这一帧我才可以执行。服务器会按照一定的频率,不同的给大家同步帧编号,包括这一帧的输入带给客户端,如果带一帧给你的数据你拿到之后就执行,下一帧数据没来就不能执行,它的结果就是卡顿。

网络绝对理想的情况下还好,但现实的网络环境不是这样的。帧同步要解决问题就是调试buffer,以前有动态的buffer,它有1到n这样的缓冲区,根据网络抖动的情况,收入然后放到队列里面。

这个buffer的大小,会影响到延迟和卡顿。如果你的buffer越小,你的延迟就越低,你拿到以后你不需要缓冲等待,马上就可以执行。但是如果下一帧没来,buffer很小,你就不能执行,最终导致的结果你的延迟还好,但是卡顿很明显。

如果调到帧同步的buffer,假如我们认为网络延迟是1秒,你抖动调到1秒,那得到的结果虽然你画面不抖动了,但是你的延迟极其高。如果连最坏的网络情况都考虑进去,buffer足够大,那么记过就跟看视频是一样的,平行的东西,看你调大条小。一些局部的措施我们都做过,都是一样的问题。

具体我们怎么优化卡顿的问题呢?

刚才提到该帧同步与buffer,这个buffer可以是1也可以到n,我们要解决我们的延迟问题,我们就让buffer足够小。事实上《王者荣耀》最后做到的buffer是零,它不需要buffer,服务器给了我n,马上知道是n,我收到n,我知道下一次肯定是n+1,所以我收到n之后马上就把n这一帧的输入执行了。

那么为什么不卡顿了,画面不抖动了?

最后一个关键点,是本地插值平滑加逻辑与表现分离。客户端只负责一些模型、动画、它的位置,它会根据绑定的逻辑对象状态、速度、方向来进行一个插值,这样可以做到我们的逻辑帧率和渲染帧率不一样,但是做了插值平滑和逻辑表现分离,画面不抖了,延迟感也是很好的。

做了这些后,我们还把TCP换成UDP,在手机环境下,弱网的情况下,TCP很难恢复重连,所以最后用了UDP来做。整体来说,在网络好的情况下,它延迟也是很好的,在网络比较差的情况下做插值,也是传统CS的表现。

我们经常见到角色A和B,有些客户端A在左B在右,有些是A在右B在左,帧同步逻辑上面AB之间的距离和坐标都是完全一样,但是画面上看到他们可能会不重合,那就是你把它们分离之后的表现。网络极其好的情况下,它应该是重合的,但是在网络差的情况下,可能会有些偏差。这里面是最重要的一块优化。

5.第三座大山:性能优化

第三座大山,是我们对性能的优化。

本身帧同步逻辑上面在优化上面存在一些缺点,所有的角色都需要进行运算。这方面我们也是借助Unity的特性,如果你想追求性能上的极致,有些东西你需要寻求好的方式。

第一点是热点的处理。

我们是不用反射的,它都有GC性能开销,我们的做法里面,会把对象的显示隐藏放在不同的渲染层里面,尽量让整个游戏帧率是平滑的过程。还有我们本身有自己的系统,比如AI,在《王者荣耀》这样的多角色游戏中,你如果想要做出比较好的体验,那么AI就要做得比较复杂。

而要去优化热点,我觉得就只有这三个步骤可以走。

首先,从程序的结构上面能找到更优的,它的优化效果就是最明显的;其次,如果你的结构都是用的最好,就去挖掘局部的算法,调整你代码的一些写法。最后,如果局部的算法都已经调到最优还是没有什么办法,那只有一条路,就是牺牲整个质量,就是分帧降频。

第二点是GC,这块刚才说不用反射,还有装箱和拆箱的行为也是尽量少用。Unity指导过我们的优化,从GC上面的考虑,他们建议每一帧应该在200个字节以内是比较好的状态,其实很难做到,王者也是每一帧在1k左右,很难做到200。

第三点是Drawcall,这些传统的优化手段大家都用的很熟了。

第四点是裁剪,帧同步里面是不能裁剪的,表现里面我看不到的可以降低频率或者不更新它,这在表现里面可以做的。

第五点是3DUI的优化,比如《王者荣耀》的血条、小地图上面叠的元素等等,这些UI都比较丰富,这块我们用了31UI的方式来优化,没有用UGUI里面进行血条方面的处理。

我们也牺牲了一些东西,我们把所有东西都加载了,在游戏过程当中,我们希望不要有任何IO行为,包括输出我们都是要布局的。你处理的决策和复杂度,如果在一帧里面放出100颗子弹,在放100颗子弹的时候一定要掉帧的,一定要在力所能及的时候把这些东西做到极致。

你可能感兴趣的:(游戏中的网络同步机制<二> 王者荣耀对帧同步的应用)