《Unity3D高级编程之进阶主程》第六章,网络层(六) - 网络同步解决方案

当前网络游戏中网络同步方案有三种,即状态同步,预测信息同步,帧同步。三种方式并不是完全互相排斥的,很多时候我们在开发的时候,为了能都让游戏显得更加逼真,会让多种的同步方案一起使用。例如魔兽世界这种开放世界的多人在线RPG游戏,就使用了状态同步和预测信息同步两种方案,绝地求生、和平精英等战地竞技类游戏,也同时使用了状态同步与预测信息同步方案,而传奇世界、热血传奇等传奇类游戏因为不需要预测信息就只使用状态同步,王者荣耀等一批5v5地图类竞技游戏则使用了帧同步方案。

===

同步方案的目标是在针对多人游戏中如何用更少的信息同步量来逼真的’模拟‘其他玩家的一举一动,让我们在玩游戏的时候能知道其他人的位置、动作、以及状态。这里有个关键词’模拟‘,我们获取的信息由于网络因素的关系通常都是延后的,如何通过这些延迟的信息来模拟是关键的关键。

在同步的解决方案中不仅涉及到信息同步还涉及到同步的范围,比如魔兽世界、绝地求生、和平精英、传奇世界、热血传奇,如果他们每次都同步地图上所有玩家的信息,那么同步的数据量太大,不仅客户端承载不了渲染压力和信息通信压力,服务器也同样承受巨大的数据传输压力。因此同步方案对游戏来说只是一个方面的解决方案,除了有同步方案,还需要其他解决方案来支撑和优化同步数据压力才得以实现良好的游戏体验。

状态同步

为什么要状态同步?如果每帧(每秒30帧)都同步信息,传输的信息量就太大了,而且信息到位也不及时,为了能更逼真的模拟其他玩家的行为,我们把每个人的行为动作都切分成若干个状态。比如站立状态,所有人都一样,站在某个位置不断循环播放某个动画,只要告诉玩家说我在某个位置进入了站立状态,只要状态不变,我们就知道这个玩家就是一直在原地播放站立动画。

在状态同步里,人身上每个状态就相当于一个固定的行为模式,这个固定行为模式就像个黑盒,只要给到需要的数据,就能表现出固定的行为,比如攻击,就是播放一个攻击动画,并在某个时间点判定攻击效果,攻击完毕后就进入站立状态,比如打坐休息,就是循环播放一个动画,并每隔一个是时间段加一次血量,又比如行走,就是从某点到某点做A星的寻路操作,并边播放行走动画边跟随路线移动到各个路线节点,最终移动到目的地。

这些状态只要给予需要的固定数据就能展现出相同画面的个体效果。如果我们要让这些状态连贯起来拼凑成一个可操控的人,我们可以向这个人发送各种各样的指令,告诉它你应该触发这状态,应该触发那状态,指令中包含了状态需要的数据。

在状态同步中,服务器端扮演了幕后操纵木偶人的那个大老板,而客户端里渲染的对象就是那个木偶人,服务器端发出指令说ID为5的木偶人开始攻击,客户端里那个ID为5的木偶人就开始进入攻击状态并且播放攻击动画。

当动画播放到一半服务器端又发来指令说,被攻击的那个ID的怪兽受伤了并受到500点伤害,这时客户端就会在指定的怪物头上冒出500点的伤害值并且让怪物进入受伤状态播放受伤动画。

当攻击动画播放完毕时,服务器又发来指令说继续攻击,ID为5的木偶人就又从站立状态切换到了攻击状态,这次攻击到一半服务器发来指令说,这个怪物受到600点伤害并且死了,于是客户端在怪物头上冒出600点伤害的数字并且让怪物进入死亡状态播放死亡动画。木偶人也在播放完攻击动画后,进入了空闲状态播放站立动画。

服务器扮演着发送指令操控木偶人的角色,这个木偶人也包括玩家自己的角色,即玩家操控着’我‘自己的在游戏中的人物,也同样需要经过服务器的同意,并发送指令给玩家当前的角色进行状态的切换和模拟。

有时候也未必要一定要经过服务器同意,为了让玩家能够在网络环境糟糕的时候也能够看起来比较顺畅,玩家可以随意操控自己的角色,并不受限于服务器的延迟指令,但是在稍后的服务器校验时再对玩家进行矫正,比如我们玩传奇世界是状态同步,在网络很卡时我们任然能不停的移动,但是过一段时间,服务器把正确的数据发送到客户端后,客户端进行了位置和状态的矫正。

状态同步的特点是状态是固定的,除非状态改变,否则相同的状态数据得到的是相同的个体状态结果,人物角色实体的行为都是通过状态切换来表现画面。

预测信息同步

在一些竞技游戏中,人物的行动速度和旋转速度需要不断的变化,要实时更新这些信息,状态同步不能满足这样需求,因为我们速度和旋转的变化太快,我们无法每帧都同步状态信息来模拟。

但是状态同步依然可以用于除了移动和旋转意外的同步,因为除了移动和旋转,其他信息都没有这样快速、多样的变化,并且它们仍旧遵守原有的规则,完全可以继续用状态来划分,所以状态同步常常与预测信息同步同时存在。

预测信息同步方案的主要特点是,位置、旋转信息都是由客户端决定的,客户端将自身的位置、旋转信息发给服务器端,再由服务器端分发给其他玩家,当其他玩家收到位置、旋转信息后根据收到的位置和旋转信息预测其当前的位置,速度,加速度,以及旋转角度,旋转速度,旋转加速度并进行模拟和展示。

在这种竞技性比较强,移动速度比较快的游戏中,通常都需要玩家不停的改变移动速度和旋转角度来体现其控制角色的灵活性。比较常见为枪战类游戏CS,玩家不停在变化自己的移动和旋转的速度以适应战术的需要。或者跑跑卡丁车中,玩家要在高速移动下,不停的调整自己的方向和速度,让自己能够躲过众多障碍,同时在急弯处要旋转自己的车进行漂移等。还有在类似魔兽世界这种开放世界下的RPG游戏,需要不停的改变自己的速度与旋转角度来让战斗显得更加丰富和灵活。

为了能更加逼真的同步模拟这种变化频率很高的人物移动和旋转,我们不得不让客户端来决定其位置和旋转角度,牺牲一些数据的安全性来让画面模拟的更加流畅。

每个玩家的客户端会在1秒内发送15-30次左右自身的移动和旋转数据给服务器端,为的就是能更加顺畅的模拟玩家在游戏中的移动旋转的表现,也只有这样,才能让其他客户端不停的更新玩家的位置和旋转信息。

不过只是单纯的更新位置和旋转数据,会导致玩家在屏幕中不停的闪跳,而用速度的方式表示则更加流畅,所以计算速度和预测速度,以及加速度,让模拟的对象按速度和加速度的形式在屏幕中运动,而不是只更新位置信息,会让角色在画面中模拟行走的位置和方向会更加流畅。

预测信息同步的算法和公式并不复杂,首先要取已经收到的该玩家的位置信息前5个除以间隔时间,就能得到一个平均的速度,再取这样5个一组的3-5组,就能得到一个平均的加速度,根据这个速度和加速,就能让角色在屏幕中模拟跑动了。

但方向还不对,没有方向怎么跑都是错的,在角度的同步上也依然可以按照这种速度和加速度的方式去预测,取最近5个角度的值得到平均旋转速度,再取5个一组的3-5组这样的数据计算得到旋转加速度。

但是还是有偏差,即使预测和模拟了速度和方向,由于数据的量不够多,网络延迟大且不稳定,所以很容易造成位移的偏差,所以定时的矫正比较是必须的。

矫正可以在生硬的基础上加入一些当前数据的预测,比如每秒矫正一次,每隔1秒重新计算一次从最近收到一个位置上到现在这个时间点的预测位置,如果网络延迟比较小的时候,由于原本预测的位置和矫正的位置是差不多的,所以角色矫正时的抖动现象完全无法用肉眼看出来,但如果网络延迟比较大的时候,矫正的位置和预测的位置就会偏离的比较远,角色看起来总是一闪一闪的不停抖动甚至飞来飞去。

这也正是为什么我们在玩CF穿越火线,跑跑卡丁车这类游戏时,假如对方的网络比较卡,就会看到对方角色不停的一闪而过,因为预测数据和矫正数据偏离的太多了,客户端在不断的矫正角色的位置和速度。

帧同步

状态同步既能控制数据计算的安全性,也能保证所有客户端的同步性,不过在位置和角度变化很快的竞技游戏中,状态同步无法承受这样又多又快的位置和旋转变化,所以就加入了预测信息同步的解决方案,这种解决方案放弃了玩家的位置和旋转角度的强校验,使得各个客户端能更加顺利和准确的模拟其他玩家的位置和旋转角度。

但是更加严格的同步要求下预测信息同步也无能为力,因为每个玩家的手机和电脑端的设备好坏不一样,网络环境也不同,一台好的机子和手机,同一时间段能位移的距离可能也不一样,预测信息同步解决方案无法在差异性巨大的设备和网络通信之间做到精准的同步,也无法保证数据校验的安全性,帧同步解决方案就很好的解决了预测信息同步解决不了的问题。

在同步性和安全性要求很高的游戏中,例如王者荣耀,拳皇类格斗游戏,游戏中的每一帧都是非常关键的,一两帧都有可能决定胜负,对于这种类型的游戏,同步的要求性高,安全性也有很高的要求,所以帧同步的解决方案正好契合这种类型游戏。

与状态同步和预测信息同步不同的是,帧同步的逻辑不再由客户端本身的逻辑帧Update来决定,而是转由从网络收到帧数据包来驱动执行逻辑更新,所有逻辑更新都放在了收到帧数据包时的操作中,包括人物角色的移动,攻击,释放技能等,每收到一个服务器发过来的帧数据包,就更新一帧,如果服务器端发过来的帧数据到达的比较慢,就有可能像放慢动作一样,一帧一帧的播放走路,攻击的慢动作动画。

帧同步的服务器需要向每个客户端每秒发送20-30个帧数据包,每隔0.033-0.05秒发送一个,即使没有任何信息也会发送空的帧数据,因为客户端要根据这些帧数据包来‘演算’游戏逻辑。

为什么要说‘演算’呢,一个比较容易理解的比喻是原来在客户端的Update里角色每帧移动xx米的逻辑,转移到放在了从网络收到的帧数据包里,每收到一个帧数据包,角色就移动一步,这样使得每台游戏设备在拥有不同的帧率的情况下,执行了相同数量的逻辑帧,也执行了相同时间点的逻辑指令,因为指令时存在帧数据里的,帧数据的执行顺序一致,则指令执行的时间点也是一致的。

当玩家有操作指令时,把指令发给服务器,服务器在随后下发的帧数据中,某一帧中带有我们上传的指令数据。因此在帧数据里除了有指令数据外,其他时候都是空的。

随着客户端不断收到网络数据帧,执行到某一帧带有指令数据时,就执行该指令,比如指令数据表示某角色以每帧1米速度向前移动,那么客户端就开始启动移动状态执行该指令,不过不是在客户端自己的Update中执行,而是从网络中每收到一个帧数据就执行一次,比如后面总共收到20帧的网络空数据帧,那么就执行了20次每帧1米的行走状态,直到玩家再次操作,停止移动指令,并把该停止指令发送给服务器,服务器在再以帧数据的形式发送给所有玩家,当玩家收到这个带有停止指令的帧数据时,才停止移动。

逻辑在网络数据帧中执行,就相当于服务器控制了所有玩家的播放的帧数,让所有玩家收到的帧数据的数量是相同的,那些需要执行的指令在第几个逻辑帧的位置也是相同的,由于执行帧的时间点,和执行的逻辑的次数是相同的,从而使得所有收到帧数据的客户端做出的表现也是相同的,这就是帧同步的基本原理,由服务器发送的帧数据来完成所有客户端的同步执行操作,在每个客户端设备中所使用来‘演算’的算法是一致的,执行的次数一致,执行的指令数据和时间点一致,执行的算法一致,使得执行的结果一致,那么在画面上所表现的出来的也就是一致的。

同步快进

不过现实是并不是所有客户端的网络都是流畅的,通常的网络环境都是时好时坏的,客户端在收帧数据时,经常都是不平稳的,偶尔一堆帧数据涌过来,或者又忽然完全没有了帧数据。

所以如何及时同步落后的客户端画面成了客户端解决同步问题的一个关键。

最简单的方式是我们可以一下子执行全部堆积在队列里的网络帧,这样我们一下子就能到达画面的最后一帧,然后继续等待网路发送过来的数据帧。

但是这样一次性执行所有的帧的方式,如果堆积的数据帧太多,会导致游戏卡住很久,画面会停止不动而很久,游戏体验比较差。所以我们可以在渲染帧中每次执行N帧(N大于10)来让画面快速推进,这样一来玩家又能看到动态的画面,又能快速的跟上最后的同步帧数据,同步完所有的帧数据后,再把执行速度降下来,继续按每收到一个网络帧来同步画面。

不过如果落后太多太多,比如落后了几千几万帧的时候,快进的方法也不管用了,因为执行的帧数落后太多,按普通快进的节奏得要快进很久,如果快进得太快则画面卡住的时间太久,体验太差用户无法接受。

这时就只能用快照方式做同步操作,快照的意思是把某一帧下面的所有数据,包括玩家,怪物,可破坏的障碍等动态物体的数据像照片一样记录下来存储在服务器,当客户端需要时,打包发给客户端,客户端直接使用该快照数据来渲染画面,因为这一帧的快照离最后需要同步的帧数最近,中间可能跨越了几千几万的帧数,所以如果从快照那一帧数据出发,从该帧数据开始快进到最近的数据帧,相对于从头开始快进来说要快的多,也节省了许多网络流量。

在同步数据帧时会用以上的快进处理方式,但是如果发送的指令过于频繁也会造成网络数据的灾难,比如玩家控制角色不断释放技能或者不停的旋转奔跑,就会导致客户端以渲染帧的速度每帧大量的向服务器发送指令数据像机关枪一样扫射式的发射,这种数据发送量无法承受。

在不与帧同步冲突的规则下,我们可以选择把需要发送的指令存起来,等到收到一个网络帧时再发送,如果有很多指令则不断替换未发送的指令直到收到网络帧并发送。这也符合多端帧同步的规则,其实是我们限制了每帧只能有一个操作,就像定制了一个两个规则为‘不能在同一帧有多个操作’的规则。这个规则使得,不能在同一帧中既前进又后退,也不能在同一帧中既释放技能又取消技能。这样的话操作指令的数据包也跟随着同步的数据帧规则一起发送。

但这里又出现问题了,如果等到有网路数据帧时再发送,那么位移类和旋转的指令就不那么灵活了,因为在实际操作中,旋转和位移的变化是非常快的。

为了解决这个问题,我们可以加大每秒发送网络数据帧的数量,比如每秒到30帧甚至50帧,但这样网络宽带的消耗量又变的很大了,有时候会得不偿失,因此我们需要优化和权衡一下。由于需要向所有客户端每秒广播数据30-50次的数据,所以发送数据的频率非常高,如果每次广播的数据要足够的小,能在一个MTU以下,这样就能有效的降低底层网络的延迟,既加大了逻辑帧的数量,提高了画面渲染的流畅度,增加了操作的灵活性,网络宽带的占用率也没有这么高。

精度问题

帧同步的核心战斗的演算算法都是在各自设备的客户端中进行的,前面说了所有设备执行的帧数一致,执行的指令和时间点一致,执行的算法一致,就能得出相同的结果。但是这里又出现一个由于不同设备之间的采用不同的算法来计算浮点数的问题,‘精度’。

浮点数在各不同设备上的计算结果有细微的差异,导致随着计算的增多,差异变得越来越大,虽然执行次数、时间、算法都一致,最终计算出来的结果还是会由于浮点数计算结果不同而导致不同步的问题。

所以浮点数的精确计算也是帧同步在同步问题上的一大困难,归根结底,是因为把帧同步方案把计算结果交给不同设备而导致的问题。

其实也并不是什么难题,市面上有很多用浮点数精度问题的解决方案,比如定点数就是其中之一,所谓定点数,就是把小数部分当整数来计算,这样计算起来就不会有误差了。

通常的浮点数(即有整数部分和小数部分)在计算机中的表示法是 V = (-1)^s x (1.x) x 2^(E-f) 也就是说浮点数的表达其实是模糊的,它用了另一个数的指数来表示当前的数。而定点数则不同,它把整数部分和小数部分拆分开来,都用整数的形式表示,这样计算和表达都使用整数的方式,整数的计算是确定的,这样就不会存在误差,缺点是占用的字节数多了,计算的范围也缩小了。

用定点数来替换浮点数计算就能保证在各设备上的计算结果一致性,不过直接使用c#自带的decimal定点数在使用时也存在很多问题,比如无法和浮点数随意的互相转换,所以在计算上也会需要进行一定的封装,又比如无法控制末尾小数点,使得精度还是无法根据项目需求来控制,无法控制内存大小,大量使用使得堆栈内存增加。

因此大部分项目都是自己实现定点数的,把整数和小数拆开来都用整数表示封装在某个类中,再写一些额外的数学库的编写。其实也没那么恐怖的,就是把定点数与其他类型数字的加减乘除重写一下,如果涉及到更多的数学运算,则再建立一个定点数的数学库,存放一些数学运算的函数。

最最最快速简单,性价比最高的的方式,其实是将所有数字乘以1000,或者10000,再以整数的方式来计算结果,所有需要计算的数字都以这种方式存储,只有显示的时候才除回来回到浮点数,这样即控制了精度的一致性,也不用这么麻烦去实现定点数的封装,而且浮点数还能继续用,因为在大部分帧同步游戏中,200万的数字,甚至20万都已经是足够大了,所以他们无需劳心费神的去封装一个定点数以及定点数数学库,而且这个定点数还无法与表现层有很好的连接。直接乘一下就能达到一样的效果,大大降低了研发成本。

这里也了解下同步锁的机制。在更加严格的同步类游戏中,比如星际争霸1中,如果有玩家网络环境不好,希望能够等待该玩家的进度,就会使用同步锁的机制。

同步锁的机制,要求每个客户端每隔一段时间都发送一个锁帧数据,类似‘心跳’数据包,服务器端在帧数据中嵌入心跳包,告诉其他玩家该玩家仍然在线并正常游戏中,如果客户端在接受帧数据时超过50帧没有收到某个玩家的锁帧数据的话则停止播放网络帧数据,等待该玩家跟上大部队后,再所有客户端同时从最近的一次锁帧数据点开始,以该点为最后一数据帧,一起继续各自演算。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第六章,网络层(六) - 网络同步解决方案

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号