1.什么是网络同步
所谓同步,就是要多个客户端表现效果是一致的,比如我们玩王者荣耀的时候,需要十个玩家的屏幕显示的英雄位置完全相同、技能释放角度、释放时间完全相同,这个就是同步。
2.为什么要有网络同步
略
3.如何使用网络同步,使用网络同步的不同方案比较。
3.1 状态同步
将其他玩家的状态行为同步的方式(请求其他玩家的状态并显示在NPC上),一般情况下AI逻辑,技能逻辑,战斗计算都由服务器运算,将运算的结果同步给客户端,客户端只需要接受服务器传过来的状态变化,然后更新自己本地的动作状态、Buff状态,位置等就可以了,但是为了给玩家好的体验,减少同步的数据量,客户端也会做很多的本地运算,减少服务器同步的频率以及数据量。(就是客户端需要哪个对象的状态,就请求哪个对象的状态,或者服务的把这个物体的状态信息推过来,然后更新这个对象的显示)
3.1.1 状态同步优点
第一,它的安全性非常高,外挂基本上没有什么能力从中收益。
第二,状态同步对于网络的带宽和抖动包有更强的适应能力,即便出现了200、300的输入延迟再恢复正常,玩家其实也感受不到不太舒服的地方。
第三,在开发游戏过程中,它的断线重连比较快,如果我的游戏崩溃了,客户端重启之后只需要服务器把所有重要对象的状态再同步一次过来,重新再创建出来就可以了。
第四,逻辑性能优化有优势,它的客户端性能优化优势也比较明显,比如优化时可以做裁剪,玩家看不到的角色可以不用创建,不用对它进行运算,节省消耗。
3.1.2 状态同步缺点
第一,它的开发效率低(人力消耗),相对帧同步而言要差一些,很多时候你需要保证服务器与客户端的每一个角色对象的状态之间保持一致,但事实上你很难做到一致。
比如客户端和服务器端更新的频率,对优化的一些裁剪,网络的抖动等等,你要让每一个状态在客户端同步是比较难的,而你要想调试这些东西,来优化它带来的漏洞、不一致的现象,花费的周期也会比较长,想要达到优化好的水平也比较难。
第二,打击感差(表现效果),它比较难做出动作类游戏打击感和精确性。比如说你要做一个射击类角色,他的子弹每秒钟要产生几十颗,基于状态同步来做是比较难的,因为系统在很短时间内,会产生很多数据,要通过创建、销毁、位置运算来同步。
第三,它的流量会随着游戏的复杂度,而逐渐增长(流量消耗),比如角色的多少。希望在3G、4G的网络条件下也能够玩PvP,所以我们希望它对付费流量的消耗能控制在比较合理的水平,不希望打一局游戏就消耗几十兆的数据流量。(比如一个角色放了个AOE,一片角色都要进行状态更新)
3.2 帧同步
帧同步是一种前后端数据同步的方式,一般应用于对实时性要求很高的网络游戏。
简单来说,就是相同的状态+相同的指令+ 按帧顺序执行=相同的结果。
状态:所有客户端确保逻辑一致,接收一样的随机种子(randomseed),一样的房间信息;
指令:服务器只负责收集接收每个客户端操作指令(cmd),转发指令,服务器以恒定帧率(30帧1秒)派发指令,没有指令或指令没有变化也需要派发;
执行:真正游戏逻辑由各个客户端单独计算 ,客户端需要收到服务器派发的指令才能推进逻辑,没有收到指令时不能推进逻辑(LockStep)
3.2.1 基本实现流程及思路可以概括为:
1.所有客户端每帧上传操作指令集到服务器;
2.服务端将这些操作指令集保存到对应帧序列字典,并记录帧号,并在下一帧将其广播给所有客户端;
3.客户端收到指令集后,分别按帧序,帧号进行执行指令集中的操作命令。
3.2.2 帧同步基础知识
一.顺序执行
帧同步会必定按到从第一帧开始一帧一帧的执行,才能保证运行结果一样,跳帧会导致逻辑不一样,如果玩家网络不好,则会在当前帧等待至下一帧的接受,如果丢包超时,则会再次发出需要帧的请求。
二.追帧
什么是追帧:当前玩家播放到帧比服务器的帧落后时,服务器下发多个帧,玩家便要开始快进到服务器当前帧
为什么要追帧:如果网络波动,服务器会有最晚的接受帧时间,
做法:超过则下次发送多个帧,然后快进播放(多次DoAction),快进期间,不播放特效音效等不影响运行结果的逻辑
三.重连
做法:接受从0开始所有帧重新快速播放到当前帧,如果帧列表count大于规定速度则按照最大速度播放,否则按照剩余的count播放相应次数的帧。
四.Lock-Step
-
我们把游戏的前进分为一帧帧,这里的帧和游戏的渲染帧率并不是一个,只是借鉴了帧的概念,自定义的帧,我们称为turn。游戏的过程就是每一个turn不断向前推进,每一个玩家的turn推进速度一致。
-
每一帧只有当服务器集齐了所有玩家的操作指令,也就是输入确定了之后,才可以进行计算,进入下一个turn,否则就要等待最慢的玩家。之后再广播给所有的玩家。如此才能保证帧一致。
-
Lockstep的游戏是严格按照turn向前推进的,如果有人延迟比较高,其他玩家必须等待该玩家跟上之后再继续计算,不存在某个玩家领先或落后其他玩家若干个turn的情况。使用Lockstep同步机制的游戏中,每个玩家的延迟都等于延迟最高的那个人。
-
由于大家的turn一致,以及输入固定,所以每一步所有客户端的计算结果都一致的。
我们来看看具体的执行流程:
上图中我们可以明显看到,这种囚徒模式的帧同步,在第二帧的时候,因为玩家1有延迟,而导致第二帧的同步时间发生延迟,从而导致所有玩家都在等待,出现卡顿现象。
五.Bucket Synchronization(乐观锁)
囚徒模式的帧同步,有一个致命的缺陷就是,若联网的玩家有一个网速慢了,势必会影响其他玩家的体验,因为服务器要等待所有输入达到之后再同步到所有的c端。另外如果中途有人掉线了,游戏就会无法继续或者掉线玩家无法重连,因为在严格的帧同步的情况下,中途加入游戏是从技术上来讲是非常困难的。因为你重新进来之后,你的初始状态和大家不一致,而且你的状态信息都是丢失状态的,比如,你的等级,随机种子,角色的属性信息等。 比如玩过早期的冰封王座都知道,一旦掉线基本这局就废了,需要重开,至于为何没有卡顿的现象,因为那时都是解决方案都是采用局域网的方式,所以基本是没有延迟问题的。
后期为了解决这个问题,如今包括王者荣耀,服务器会保存玩家当场游戏的游戏指令以及状态信息,在玩家断线重连的时候,能够恢复到断线前的状态。不过这个还是无法解决帧同步的问题,因为严格的帧同步,是要等到所有玩家都输入之后,再去通知广播client更新,如果A服务器一直没有输入同步过来,大家是要等着的,那么如何解决这个问题?
采用“定时不等待”的乐观方式在每次Interval时钟发生时固定将操作广播给所有用户,不依赖具体每个玩家是否有操作更新。如此帧率的时钟在由服务器控制,当客户端有操作的时候及时的发送服务器,然后服务端每秒钟20-50次向所有客户端发送更新消息。如下图:
上图中,我们看到服务器不会再等到搜集完所有用户输入再进行下一帧,而是按照固定频率来同步玩家的输入信息到每一个c端,如果有玩家网络延迟,服务器的帧步进是不会等待的,比如上图中,在第二帧的时候,玩家A的网速慢,那么他这个时候,会被网速快的玩家给秒了(其他游戏也差不多)。但是网速慢的玩家不会卡到快的玩家,只会感觉自己操作延迟而已。
Bucket Synchronization 是 Lock-Step 的改良算法. 算法流程可以参考下图:
Bucket Synchronization 算法应用于网状网络, 网络中有一个 master 节点(也是 client).
master 在启动之初, 会对所有 client 做网络对时, 计算网络包的超时时间.
master 会设置一个 bucket 时间, 在每个 bucket 时间节点, master 执行收集到的所有 step 指令, 并将更新推送到所有的 client 上. (上图的例子是一个简化流程, 只有俩 client, 没有 master 推送)
master 对收集到的 step 包做超时校验机制, 如果收到的 step 指令包的时间戳, 延迟超过了预设的阈值, 就当作超时包丢弃.
与 Lock-Step 相比, Bucket Synchronization 改进的是: 设置了 bucket 的概念, 执行每一帧的时间是固定的 bucket 时间节点, 而不必等到收到所有的 client step 指令, 从而网络不再受最差的 client 限制.
六.TimeWrap Synchronization
它是一个基于某些状态支持回滚(rollback)的同步算法。有点类似HL的做法。 简言之,就是对每个操作指令的执行后保存一个状态快照(snapshot), 各个peer按照自己的预测先行显示,但在发生一致性冲突的情况下, 回滚到上一个状态,并重新将指令序列在基于回滚后的快照的基础上再 执行一次,以获得正确的当前状态。
七.Trailing State Synchronization
对TimeWrap Synchronization的一种改进。TimeWrap方案中建立snapshot是 以指令数量(1或少量几个指令)间隔为单位;而TSS方案则以某种延迟值(100ms) 间隔为单位对游戏做snapshot(比如100ms前做一个,200ms前做一个…)。 当发生一致性冲突时,寻找最远需要开始计算的snapshot,并将该snapshot到 现在为止的时间内的指令重新执行,得到正确的最新状态。
八.State Hash
在实现中客户端需要计算一些关键信息的hash值,提供给服务器以便发现游戏中的同步问题,例如玩家的位置信息,各个客户端计算结果是否一致等等。
客户端执行完每个逻辑帧后,会根据游戏的状态计算出一个Hash值,用其标定一个具体的游戏状态。不同客户端通过对比这个值,即可判断客户端之间是否保持同步,平常也可用于不同步Debug。
游戏外挂的种类有很多,这里所谈的外挂仅指会更改游戏逻辑执行或数值的外挂,应该也是题主最关心的类型。对于帧同步防外挂,因为游戏逻辑执行在本地,假如某个客户端使用了外挂的话,那么必然会导致其计算出的State Hash与其他客户端不一致。
1、 客户端自验证(PVP 3人及以上)
PVP3人及以上的战斗中,客户端上报服务器各自计算的State Hash,服务器可以通过对比State Hash判断具体哪一个客户端发生了不同步。当然,不同步也可能是客户端BUG,不同步也不一定就结算不一致。根绝不同的需求,你也可以在发现不同步后马上中断游戏。这个方法的缺点主要在于3人以下或者单机模式的话就没法使用了。
2、客户端分布式验证
假如客户端的核心逻辑写得足够干净和独立的话,服务器可以将某一场战斗的数据下发给一个空闲客户端,令其新起一个线程慢慢地计算验证,再将结果上报至服务器。能做到这一点的话,任何战斗模式都可以进行验证了。
3、服务器验证
与客户端分布式验证相同,客户端逻辑如果足够干净和独立,那么服务器也可以自己验算战斗结果。
4、服务器统计与运营策略
非单机模式下,服务器都根据客户端的State Hash对战斗的同步情况进行记录。将经常发生不同步的客户端标记出来,然后进一步处理。运营可以为玩家每日不同步可结算的次数设定一个阈值,超过则当日之后的战斗结算均无效。
1和4是任何帧同步游戏都可以做的,2与3对游戏的框架要求比较高。我们的游戏因为是从单机版改造过来的,所以也只做了1和4。
优点
第一,它的开发效率比较高。(人力消耗)如果你开发思路的整体框架是验证可行的,如果你把它的缺点解决了,那么你的开发思路完全就跟写单机一样,你只需要遵从这样的思路,尽量保证性能,程序该怎么写就怎么写,服务端逻辑简单,只需要负责转发指令,压力也小。
比如我们以前要在状态同步下面做一个复杂的技能,有很多段位的技能,可能要开发好几天,才能有一个稍微过得去的结果,而在帧同步下面,英雄做多段位技能很可能半天就搞定了。
第二,它能实现更强的打击感(表现效果),打击感强除了我们说的各种反馈、特效、音效外,还有它的准确性。利用帧同步,游戏里面看到这些挥舞的动作,就能做到在比较准确的时刻产生反馈,以及动作本身的密度也可以做到很高的频率,这在状态同步下是比较难做的。
第三,它的流量消耗是稳定的(流量消耗)。大家应该看过《星级争霸》的录像,它只有几百K的大小,这里面只有驱动游戏的输入序列。帧同步只会随着玩家数量的增多,流量才会增长,如果玩家数量固定的话,不管你的游戏有多复杂,你的角色有多少,流量消耗基本上都是稳定的。
四,可以更方便地实现观战,录像的存储、回放,以及基于录像文件的后续处理。
缺点
第一,最致命的缺点是网络要求比较高,帧同步是锁帧的,如果有网络的抖动,一段时间调用次数不稳定,网络命令的延迟就会挤压,引起卡顿。
第二,它的反外挂能力更弱,帧同步的逻辑都在客户端里面,你可以比较容易的修改它。但为什么《王者荣耀》敢用帧同步,一方面是因为当时立项的时候开发周期很短,半年时间要做上线,要有几十个英雄,存在时间的压力,另一方面,MOBA类游戏不像数值成长类的游戏,它的玩法是基于单局的,单局的作弊修改,顶多影响这一局的胜负,不会存档,不会出现刷多少钱刷多少好的装备的问题,而且作弊之后我们也很容易监测到,并给予应有的惩罚,所以我们认为这不是致命的缺点。
第三,它的断线重回时间很长,相信台下也有很多王者玩家,也曾碰到过闪退以后重回加载非常长的情况,甚至加载完以后游戏也快结束了,这是帧同步比较致命的问题。
第四,它的逻辑性能优化有很大的压力。大家应该没有见到哪一款大型游戏是用帧同步来做的,因为这些游戏的每一个逻辑对象都是需要在客户端进行运算的。如果你做一个主城,主城里面有上千人,上千人虽然玩家看不到它,但游戏仍然需要对他们进行有效的逻辑运算,所以帧同步无法做非常多的对象都需要更新的游戏场景。
五,debug困难,出现不同的情况,难以查找问题所在,一般通过debug输出关键改变信息来定位问题,但问题可能在1-20个函数之内,但只在第20个函数打了debug信息,然后需要一层层去查找出现问题的所在,考虑把出问题的局录下来,然后不断重播和调试,方便找到问题。
3.3 帧同步解决方案的适用范围
通常针对于RTS[即时战略],ACT(多人实时格斗),MOBA类的游戏类型提供帧同步解决方案建议。上述类型的网络对战游戏的特点是:实时性要求极高,追求公平竞技,打击感。
综上所述类型的游戏开发时应该优先考虑帧同步解决方案,以追求一致性。
3.4 帧同步解决方案内容
帧同步游戏开发中,其核心原理实现的解决方案主要有三种:
1.0 帧锁步[Lockstep]
- 当客户端A存在网络延迟导致服务器第X帧收集不到A的第X帧输入指令包时,
- 服务器就需要等待所有客户端的X帧指令收集完成,才会下发X帧的所有客户端包数据以此保持同步,
- 也就意味着一人延迟,所有客户端都得等,显然这种同步概念不太适用于竞技性比较高的游戏,玩家体验会很差。
2.0 乐观帧
- 其他乐观帧是在帧锁步的基础上进行改良,为了提高游戏体验,服务器不再要求必须收集所有客户端X帧的操作指令才进行分发,
- 而是每个客户端都进行本地输入指令快照存储,存储各自的输入指令(并带有帧号)。
- 如果A客户端因为延迟从第2帧(逻辑帧)开始丢包直到第5帧传输正常,那么服务器即使收不到A客户端2-5帧的输入指令还是继续分发,
- 直到第5帧再将A客户端2-5帧的本地快照一并分发出去,同时将其他客户端2-5帧的输入指令同步到本地客户端,当然丢失3帧的数据直接在一帧同步
- 到客户端会导致卡顿瞬移,所以A客户端需要进行加速操作执行指令操作,通常会做差值计算,通过补间动画的方式让同步更平滑。
3.0 预测回滚
- 预测回滚是一种同步机制,是乐观帧的一种解决方案,上面提到的乐观帧有个最大的缺点是一旦A客户端丢包,那么其他客户端的A在丢包期间将不会有任何
- 操作指令同步,这样会影响游戏体验,所以这里引用预测机制,如果客户端在X帧没有指令数据那么服务器会给它在X帧一个预测的输入指令,当A客户端连接后,
- 服务器会对预测数据和真实丢失快照数据进行和解,回滚。
4.源码实现
https://blog.csdn.net/u013617851/article/details/123009965https://blog.csdn.net/u013617851/article/details/123009965
5.关键点,重要节点,疑难杂症场景
5.1 如何保证客户端独自计算的正确,即一致性?
帧同步的基础,是不同的客户端,基于相同的操作指令顺序,各自执行逻辑,能得到相同的效果。就如大家所知道的,在unity下,不同的调用顺序,时序,浮点数计算的偏差,容器的排序不确定性,coroutine内写逻辑带来的不确定性,物理浮点数,随机数值带来的不确定性等等。
有些比较好解决,比如随机数值,只需要做随机种子即可。
有些注意代码规范,比如在帧同步的战斗中,逻辑部分不使用Coroutine,不依赖类似Dictionary等不确定顺序的容器的循环等。
还有最基础的,要通过一个统一的逻辑tick入口,来更新整个战斗逻辑,而不是每个逻辑自己去Update。保证每次tick都从上到下,每次执行的顺序一致。
物理方面,因为我们战斗逻辑不需要物理,碰撞都是自己做的碰撞逻辑,所以,跳过不说,这块可以参考别的文章。
最后,说一下,浮点数计算无法保证一致性,我们需要转换为定点数。关于定点数的实现,比较简单的方式是,在原来浮点数的基础上乘1000或10000,对应地方除以1000或10000,这种做法最为简单,再辅以三角函数查表,能解决一些问题,减少计算不一致的概率,但是,这种做法是治标不治本的方式,存在一些隐患(举个例子,例如一个int和一个float做乘法,如果原数值就要*1000,那最后算出来的数值,可能会非常大,有越界的风险。)。
最佳的解决办法,是使用实现更加精确和严谨,并经过验证的定点数数学库,在c#上,有一个定点数的实现,Photon网络的早期版本,Truesync有一个很不错的定点数实现。
定点数的实现
其中FP,就可以完全代替float,我们只需要将我们自己的逻辑部分,float等改造为FP,就可以轻松解决。并且,能够很好的和我们protobuf的序列化方式集成(注意代码中的Attribute,如下图),保证我们的配置文件,也是定点数的。
TSVector对应Vector3,只要基于FP,可以自己扩展自己的数据结构。(当然,如果用到了复杂的插件,并且不开源,那么对于定点数的改造,就会困难很多)
(三角函数通过查表方式实现,保证了定点数的准确)
我个人认为,这一套的实现,是优于简单的乘10000,除10000的方式。带来的坏处,可能就是计算性能略差一点点,但是我们大量测试下来,对计算性能的影响很小,应该是能胜任绝大部分项目的需求。
对于计算的不确定性,我们也有一些小的隐患,就是,我们用到了Physics.Raycast来检测地面和围墙,让人物可以上下坡,走楼梯等高低不平的路,也可以有形状不规则的墙。这里会获得一个浮点数的位置,可能会导致不确定性,这里,我们用了数值截断等方式,尽量规避,经过反复测试,没有出现过不一致。但是这种方式,毕竟在逻辑上,存在隐患,更好的方式,是实现一套基于定点数的raycast机制,我们人力有限,就没时间精力去做了。这块,有篇文章讲得更细致一些,大家可以参看 帧同步:浮点精度测试。
总结如何保证同步:
- 同样的逻辑帧数(10-30),渲染帧可以更高(30以上)
- 随机数、Time的接管
- 接管物理的update,Physics.SyncTransforms Physic.Simulate(time)
- 顺序一致 角色的创建顺序、各种玩法的创建顺序、OnTriggerXX的顺序
- 帧同步驱动各系统update代码,不使用Mono的update和Invoke等、
- 写功能要注意 功能逻辑符合多人玩法,区分音效特效震动的播放,对是否自己的判断
- 帧同步会动画突然变化 不过帧同步的都无法避免吧 做一些过度和假动画无逻辑(例如转身和走)前端照常播放动画 不会但不会自己切换动画处于一个循环,只有接收到服务器的cmd进行下一个操作而转变动画才会跳到下一个动画
- 浮点数处理 方案一、定点数 方案二、公式尽量简化(随机float 转int),并且截取一定的精度范围(目前是小数点后3位)
- 不使用不确定性的插件、例如有时间的、随机数的。
5.2 帧同步网络协议的实现
在处理好了基础的计算一致性问题后,我们就要考虑网络如何通信。这里,我不谈p2p方式了,我们以下谈的,都是多client,一个server的模式,server负责统一tick,并转发client的指令,通知其他client,可以参看文章网游流畅基础:帧同步游戏开发。
首先,是网络协议的选择。TCP和UDP的选择,我就不多说了,帧同步肯定要基于UDP才能保证更低的延迟。在UDP的选择上,我看网上有些文章,容易导入一个误区,即,我们是要用可靠传输的UDP,还是冗余信息的UDP。
基于可靠传输的UDP,是指在UDP上加一层封装,自己去实现丢包处理,消息序列,重传等类似TCP的消息处理方式,保证上层逻辑在处理数据包的时候,不需要考虑包的顺序,丢包等。类似的实现有Enet,KCP等。
冗余信息的UDP,是指需要上层逻辑自己处理丢包,乱序,重传等问题,底层直接用原始的UDP,或者用类似Enet的Unsequenced模式。常见的处理方式,就是两端的消息里面,带有确认帧信息,比如客户端(C)通知服务器(S)第100帧的数据,S收到后通知C,已收到C的第100帧,如果C一直没收到S的通知(丢包,乱序等原因),就会继续发送第100帧的数据给S,直到收到S的确认信息。(kcp+fec的模式,可以比冗余方式,有更好的效果)
有些文章介绍的时候,没有明确这两者的区别,但是这两种方式,区别是巨大的。可靠传输的UDP,在帧同步中,个人认为是不合适的,因为他为了保证包的顺序和处理丢包重传等,在网络不佳的情况下,delay很大,将导致收发包处理都会变成类似tcp的效果,只是比TCP会好一些。必须要用冗余信息的UDP的方式,才能获得好的效果。并且实现并不复杂,只要和服务器商议好确认帧和如何重传即可,自己实现,有很大的优化空间。例如,我们的协议定义类似如下:
(双方都要通知对方,已经接受哪一帧的通知了,并通过cmd list重发没有收到的指令)
对于这种收发频繁的消息,如果使用protobuf,会造成每个逻辑帧的GC,这是非常不好的,解决方案,要么对protobuf做无GC改造,要么就自己实现一个简单的byte[]读写。无GC改造工程太大,感觉无必要,我们只是在战斗的几个频繁发送的消息,需要自己处理一下byte[]读写即可。
5.3 逻辑和显示的分离
这块很多讲帧同步的文章都提过了。我在前面讲技能编辑器的时候,也提过,配置的数据和显示要分离,在战斗中,战斗的逻辑,也要和显示做到分离。
例如,最基本,我们动作切换的逻辑,是基于自己抽象的逻辑帧,而不是基于animator中一个clip的播放。比如一个攻击动作,当第10帧的时候,开始出现攻击框,并开始检测和敌人受击框的碰撞,这个时候的第10帧,必须是独立的逻辑,不能依赖于animator播放的时间,或者AnimatorStateInfo的normalizedTime等。甚至,当我们不加载角色的模型,一样可以跑战斗的逻辑。如果抽离得好,还可以放到服务器跑,做为战斗的验证程序,王者荣耀就是这样做的。
5.4 如何验证同步(主要是测试吧)
1.把一些关键的运行(例如cmd,位置,动画,攻击,发射子弹等)在一帧的最后,整理为一个string然后转hashcode,上传(①int 帧 ②int hashcode)至服务器,服务器对比hashcode,如果hashcode不一样,则发生了不一样的逻辑,然后服务器去请求这一帧的详细日志(string DebugDetail),获取回来有何不一样。
2.做验证服校验哪里不一样,既是把让服务器把cmd、相关玩家数据和随机种子存下来,然后再播放一次本局然后一次性上传日志,比较差异。
5.5 联机如何做到流畅战斗
前面所有的准备,最终的目的,都是为了战斗的流畅。特别是我们这种Act游戏,或者格斗类游戏,对按键以后操作反馈的即时性,要求非常高,一点点延迟,都会影响玩家的手感,导致玩家的连招操作打断,非常影响体验。我们对延迟的敏感性,甚至比MOBA类游戏还要高,我们要做到好的操作手感,还要联机战斗(PVP,组队PVE),都需要把帧同步做到极致,不能因为延迟卡住或者操作反馈出现变化。
因为这个原因,我们不能用lockstep的方式,lockstep更适合网络环境良好的内网,或者对操作延迟不敏感的类型(例如我听过还有项目用来做卡牌类的帧同步)。
我们也最好不能用缓存服务器确认操作的方式(讲的时候先讲这个,再说最好不要有),也就是一些游戏做的指令buffer。具体描述,王者荣耀的分析文章,讲得很具体了。这也是他们说的模式,这个模式能解决一些小的网络波动,对一些操作反馈不需要太高的游戏,例如有些游戏攻击前会有一个比较长的前摇动作,这类游戏,用这种方式,应该就能解决大部分问题。但是这种方式还是存在隐患,即使通过策略能很好地动态调整buffer,也还是难以解决高延迟下的卡顿和不流畅。王者荣耀优化得很好,他们说能让buffer长度为0,文章只提到通过平滑插值和逻辑表现分离来优化,更细节的没有提到,我不确定他们是否只是基于这个方式来优化的。目前也没有看到更具体的分析。
指令buffer的方式,也不能满足我们的需求,或者说,我没有找到基于此方式,能优化到王者荣耀的效果的办法。我也测试过其他moba和act,arpg类游戏的联机,在高延迟,网络波动情况下,没有比王者表现更好的了。
最后,在仔细研究了我们的需求后,找到一篇指导性的文章,非常适合我们。
就是Understanding Fighting Game Networking,这篇文章非常详细地介绍了各种方式,最终回滚逻辑(rollback)是终极的解决方案,国内也有文章提到过,即Skywind Inside " 再谈网游同步技术里面提到的Time Warp方式,我理解回滚逻辑,和Time Warp是一个概念。
游戏逻辑的回滚
回滚逻辑,就是我们解决问题的方案。可以这样理解,客户端的时间,领先服务器,客户端不需要服务器确认帧返回才执行指令,而是玩家输入,立刻执行(其他玩家的输入,按照其最近一个输入做预测,或者其他更优化的预测方案),然后将指令发送给服务器,服务器收到后给客户端确认,客户端收到确认后,如果服务确认的操作,和之前执行的一样(自己和其他玩家预测的操作),将不做任何改变, 如果不一样(预测错误),就会将游戏整体逻辑回滚到最后一次服务器确认的正确帧,然后再追上当前客户端的帧。
此处逻辑较为复杂,我尝试举个例子说明下。
当前客户端(A,B)执行到100帧,服务器执行到97帧。在100帧的时候,A执行了移动,B执行了攻击,A和B都通知服务器:我已经执行到100帧,我的操作是移动(A),攻击(B)。服务器在自己的98帧或99帧收到了A,B的消息,存在对应帧的操作数据中,等服务器执行到100帧的时候(或提前),将这个数据广播给AB。
然后A和B立刻开始执行100帧,A执行移动,预测B不执行操作。而B执行攻击,预测A执行攻击(可能A的99帧也是攻击),A和B各自预测对方的操作。
在A和B执行完100帧后,他们会各自保存100帧的状态快照,以及100帧各自的操作(包括预测的操作),以备万一预测错误,做逻辑回滚。
执行几帧后,A,B来到了103帧,服务器到了100帧,他开始广播数据给AB,在一定延迟后,AB收到了服务器确认的100帧的数据,这时候,AB可能已经执行到104了。A和B各自去核对服务器的数据和自己预测的数据是否相同。例如A核对后,100帧的操作,和自己预测的一样,A不做任何处理,继续往前。而B核对后,发现在100帧,B对A的预测,和服务器确认的A的操作,是不一样的(B预测的是攻击,而实际A的操作是移动),B就回滚到上一个确认一样的帧,即99帧,然后根据确认的100帧操作去执行100帧,然后快速执行101~103的帧逻辑,之后继续执行104帧,其中(101~104)还是预测的逻辑帧。
因为客户端对当前操作的立刻执行,这个操作手感,是完全和pve(不联网状态)是一样的,不存在任何delay。所以,能做到绝佳的操作手感。当预测不一样的时候,做逻辑回滚,快速追回当前操作。
这样,对于网络好的玩家,和网络不好的玩家,都不会互相影响,不会像lockstep一样,网络好的玩家,会被网络不好的玩家lock住。也不会被网络延迟lock住,客户端可以一直往前预测。
对于网络好的玩家(A),可以动态调整(根据动态的latency),让客户端领先服务器少一些,尽量减少预测量,就会尽量减少回滚,例如网络好的,可能客户端只领先2~3帧。
对于网络不好的玩家(B),动态调整,领先服务器多一些,根据latency调整,例如领先5帧。
那么,A可能预测错的情况,只有2~3帧,而网络不好的B,可能预测错误的帧有5帧。通过优化的预测技术,和消息通知的优化,可以进一步减少A和B的预测错误率。对于A而言,战斗是顺畅的,手感很好,少数情况的回滚,优化好了,并不会带来卡顿和延迟感。
重点优化的是B,即网络不好的玩家,他的操作体验。因为客户端不等待服务器确认,就执行操作,所以B的操作手感,和A是一致的,区别只在于,B因为延迟,预测了比较多的帧,可能导致预测错,回滚会多一些。比如按照B的预测,应该在100帧击中A,但是因为预测错误A的操作,回滚重新执行后,B可能在100帧不会击中A。这对于B来说,通过插值和一些平滑方式,B的感受是不会有太大区别的,因为B看自己,操作自己都是及时反馈的,他感觉自己是平滑的。
这种方式,保证了网络不好的B的操作手感,和A一致。回滚导致的一些轻微的抖动,都是B看A的抖动,通过优化(插值,平滑等),进一步减少这些后,B的感受是很好的。我们测试在200~300毫秒随机延迟的情况下,B的操作手感良好。
这里,客户端提前服务器的方式,并且在延迟增大的情况下,客户端将加速,和守望先锋的处理方式是一样的。当然,他们肯定比我做得好很多。
希望我已经大致讲清楚了这个逻辑,大家参看几篇链接的文章,能体会更深。
这里,我要强调的一点是,我们这里的预测执行,是真实逻辑的预测,和很多介绍帧同步文章提到的预测是不同的。有些文章介绍的预测执行,只是view层面的预测,例如前摇动作和位移,但是逻辑是不会提前执行的,还是要等服务器的返回。这两种预测执行(View的预测执行,和真实逻辑的预测执行)是完全不是一个概念的,这里需要仔细地区分。
这里有很多的可以优化的点,我就不一一介绍了,以后可能零散地再谈。
5.6 游戏逻辑的快照(snapshot)
我们的逻辑之所以能回滚,都是基于对每一帧状态可以处理快照,存储下每一帧的状态,并可以回滚到任何一帧的状态。在Understanding Fighting Game Networking 文章和守望先锋网络 文章中,都一笔带过了快照的说明。他们说的快照,可能略有不同,但是思路,都是能保存下每一帧的状态。如果去处理快照(Understanding那篇文章做的是模拟器游戏,可以方便地以内存快照的方式来做),是一个难点,这也是我前面文章提到ECS在这个方式下的应用,云风的解释:
通过一个回滚接口,需要数据回滚的部分,实现接口,各自处理自己的保存快照和回滚。就像我们序列化一个复杂的配置,每个配置各自序列化自己的部分,最终合并成一个序列化好的文件。
首先,定义接口,和快照数据的reader和writer
然后,就是每个模块,自己去处理自己的takeSnapshot和rollback,例如:
思路理顺以后,就可以很方便地处理了,注意write和read的顺序,注意处理好list,就解决了大部分问题。当然,在实现逻辑的过程中,时刻要注意,一个模块如何回滚(例如获取随机数也需要回滚)。
有一个更简单的方式,就是给属性打Attribute,然后写通用的方法。例如,我早期的实现方案:
根据标签,通用的读写方法,通过反射来读写,就不需要每个模块自己去实现自己的方法了:
当我们有了快照,就可以支持回滚,甚至跳转。例如我们要看战斗录像,如果没有快照,我们要跳到1000帧,就需要从第一帧,根据保存的操作指令,一直快速执行到1000帧,而有了快照,可以直接跳到1000帧,不需要执行中间的过程,如果需要在不同的帧之间切换,只需要跳转即可,这将带来巨大的帮助。
5.7 自动测试
由于帧同步需要测试一致性的问题,对我们来说,回滚也是需要大量测试的问题。自动测试是必须要做的一步,这块没有什么特别的点,主要就是保存好操作,快照,log,然后对不同客户端的数据做比对,找到不同的地方,查错改正。
我们现在做到,一步操作,自动循环战斗,将每一盘战斗数据上传内网log服务器。
当有很多盘战斗的数据后,通过工具自动解析比对数据,找到不同步的点。也是还可以优化得更好,只是现在感觉已经够用了。经过大量的内部自动测试,目前战斗的一致性,是很好的。
5.8 帧同步如何解决不同步的问题?
帧同步是由客户端计算,发送命令给服务器,服务器转发命令达到两边客户端显示一致。服务器是不知道具体逻辑的,所以如果两边客户端计算的某些变量出现不一致的时候,就会出现不同步的情况,随着时间的推进,一个小小的不同步也会造成滚雪球效应,最后的结果可能就南辕北辙了。
所以为什么会出现不同步的情况,也很好解释了,两边客户端无法保证输入的一致性(包括输入的值,输入的顺序),计算的一致性,就无法保证唯一的输出。
- 首先,一个战斗系统的可玩性很大的一点是战斗的伤害具有随机性,幅度会在一定范围内随机显示,然后还有几率出现暴击;而帧同步的特性就是要消灭这样的随机性,两边客户端在同一时间计算出来的伤害一定是一个数字。那么如何控制这个随机性呢?通过服务器在帧同步开始的时候下发同一个随机数种子,通过自定义的随机数算法保证这个数字看上去是随机的,但是实际上是必然的。
- 其次是计算过程中的精度损失,最为关键的就是浮点数导致的精度损失,在不同的硬件环境下,相同的浮点数可能会造成不同的运算结果,虽然这个结果在这一帧内影响有限,但是由于滚雪球效应,最终的战斗结果可能就会出现非常明显的不同步问题了。对于这个问题,采用定点数去代替浮点数做计算,使用一个万分比的参数去转换定点数和浮点数,这样就能避免规避掉浮点数的问题。
- 然后是逻辑顺序执行不一致,这个一般是由于使用到了一些插件,而这些插件的Update并不能由帧同步去控制,导致两边客户端可能执行某个计算片段的时间并不是在同一逻辑帧,所以解决这个问题的方案就是,不要使用这些插件,尽量去使用开源插件或者自己写,保证Update函数掌控在自己的逻辑中,去避免出现不同步的问题。例如物理系统、动画系统、AI等这些插件,就尽量去使用自己写的,保证一致性。
- 然后是网络接收数据的先后顺序不一致的不同步问题,通常的帧同步为了保证网络的流程,会丢弃掉成熟的TCP,采用UDP去自行实现可靠的网络收发,但是如果一旦不考虑清楚网络的复杂性,就会出现网络波动的情况下,后发的消息可能会比先发的消息先发送到接收端,如果这时候接收端不进行处理的话,就会出现接收端先处理后一条消息,再处理前一条消息的情况,这样输入的顺序不一致,也是会造成最终的不同步的。解决方法也很简单,对每一个发出的消息做一个自增的编号,根据编号的连续性确定消息的顺序,就算先收到后面的消息,也可以等待前面的消息收到之后进行顺序传入游戏逻辑中。
总结来说,就是保证客户端的输入一致,计算一致,就能解决帧同步的不同步问题。
最后,一个优秀的帧同步系统,首先应该尽量去避免不同步的问题,其次就是在出现不同步问题的时候,能快速查找出问题的根源,然后去解决问题。
首先,定义一下流畅的战斗,流畅的战斗并不是代表着游戏的帧数一定要多高多高,而是说游戏的帧数在某一个帧数范围内小规模的变动,例如稳定在30帧这样。
对于单机游戏来说,如果要保证稳定在30帧,每一帧的逻辑、渲染等耗时不能高于33MS,所以需要小心的规划每一个函数的使用,采用分帧执行、算法优化等手段优化卡顿点。
而对于帧同步来说,不仅仅需要完成单机游戏的优化、还要针对网络波动进行针对的优化。我们知道,网络传输无法保证每一次传输的延迟都是一定的,有时候10MS就能完成一次传输,有时候1000MS才能完成一次传输,那么客户端如何保证网络传输不稳定的情况下,逻辑帧的运行还是相对稳定的呢?下面是给到的两种解决方案。
- 收到逻辑帧消息之后,不立即去播放,而是存着,播放使用固定的延迟进行播放,表现上就是玩家每次进行操作的时候,并不是马上生效的,而是有一小段固定延迟,这样网络在我们设置的延迟内波动的话,玩家是感受不出来卡顿的。但是缺点也很明显,每一个玩家都会有一段延迟。
- 另外一个处理方式就是,逻辑和表现进行分离,表现帧由玩家和逻辑帧进行驱动,逻辑帧卡顿并不影响表现帧,表现帧由玩家进行控制播放,一旦收到了新的逻辑帧,立即去做校验,如果说表现帧的播放轨迹和逻辑帧有出入,立即进行纠正。如果说新的逻辑帧并没有和表现帧有差异,但是表现帧相对逻辑帧差距很大,例如逻辑帧才到100帧,表现帧就已经到了200帧,这时候就对输入进行限制,比如移动速度放慢等操作。等待逻辑帧追上表现帧。对于其他玩家,采用AI预测的方式进行预测,一旦预测结果与新收到的逻辑帧不一致,就立即回退;同时,随着逻辑帧和表现帧的差距越来越大,相应的减缓预测的效果,最终停止预测。如果说逻辑帧突然来了一大波,应该适当的进行加速播放逻辑帧,尽量去追上表现帧。
5.9 帧同步环境下如何做网络流量优化?
网络流量的优化,实际上就是网络包大小、数量的优化;首先,尽量去减小发送的数据,合理的规划每一个byte的作用,数量上尽量去合并同一批发送的数据包,减小由于包头造成的流量损失。
5.10 如何解决UDP丢包问题?
首先明确一点,丢包问题是不可能解决的,在TCP和UDP下都会存在丢包的情况,那么为什么TCP号称是可靠的传输协议呢?这是由于TCP处理了丢包的情况,而UDP是无连接的,每次发出一个数据包之后就不管了,所以丢包的情况是需要我们自己去处理的。
首先,丢包是不可避免的,但是我们应该尽量去避免代码逻辑造成的丢包,还有就是完善丢包重传机制。
代码逻辑造成的丢包,具体来说,是以下几点:
- 发送端发送的数据堆积导致的丢包,也就是发送缓存被填满了导致的丢包,处理方式就是,控制发送频率,避免同时发送过多、过大的数据包。
- 数据包过大导致的丢包,internet协议规定的MTU大小为576,也就是目前最差的路由器支持的最小包大小就是576,如果大于这个数字,路由器就有可能对数据包进行分包,这样就一个包变成了两个包,两个包只要有其中一个包丢掉了,就代表这个数据包丢了,所以我们要严格控制数据包的大小,去掉UDP包头20个字节的大小,我们可用的还有556,我们程序中定义为512,只要超过这个数字,就会强制进行分包发送,接收方接收到数据包的时候,再进行重组。
- 接收端卡顿导致的丢包,接收端运行卡顿,导致数据包到接受端的时候Recv函数并没有在执行,处理方式就是使用多线程去执行Recv函数,这个线程里面只处理Recv,然后将接收到的数据缓存起来。
但是就算这样处理了之后,由于网络问题导致的丢包是没法解决的,所以我们必然要建立可靠的UDP传输。也就是实现丢包重传机制。
给每一个包规定一个自增的唯一序号,通过ACK确认双方发送到了那个数据包。
合理设置RTO(重传超时时间),TCP貌似是2*RTO, KCP采用的是1.5*RTO。
然后这里面试官追问,如果说这个包丢了,你一定要等到确认丢包之后重传吗?
答案是不会,格斗游戏既然使用了帧同步,就是要求实时性能,所以在重传机制上,还做了冗余发送的机制,就是在发送当前帧的时候,把前面几帧的数据一起合并起来发过去,相当于这几个消息中,只要收到了其中一个消息,就能流畅的播下去。
5.11 如何保证在UDP环境下接收逻辑顺序一致性?
针对每一个数据包进行编号,后发的数据包如果先到了客户端,就存着,直到接收到的数据包是顺序的,才发生到逻辑层进行顺序处理
5.12 如何实现帧同步环境下的断线重连?
因为UDP是无连接的,所以断线只能通过上次收到的消息时间超时了多久进行判断,断线之后客户端逻辑帧停止,表现帧停止预测。
重连就比较麻烦了,重连回来之后,将逻辑帧、状态帧拉回来,服务器一次性将断线之后的消息发给客户端,客户端加速播放。
但是如果断线时间比较久,重连回来之后,消息数据量非常大,需要播放的帧数很多,有没有优化方法呢?有的,帧同步因为逻辑是在客户端运算的,所以帧同步的作弊也比较容易,我们通常会采用专门的验证服务器去验证,这个服务器同样接收客户端的操作指令,运行战斗逻辑,所以我们断线时间比较久的话,可以去验证服取到最新的现场数据,再发送现场数据到最新的逻辑帧给客户端,即可实现快速重连.
5.13 怎么去做战斗预测?
等待答案
5.14 在帧同步的环境下怎么防止作弊的情况?
等待答案
5.15 帧同步和状态同步的区别?为什么我们游戏要用到帧同步?
帧同步和状态同步都不是新东西,很早以前就有了,以前由于网络问题,帧同步只存在于局域网游戏中。帧同步的逻辑放在客户端,服务器只管转发;状态同步的逻辑放在服务端,客户端只管表现。由于帧同步逻辑是客户端计算的,所以帧同步的每一个客户端都要知道场上的所有信息,才能正确的进行计算。而状态同步的逻辑都在服务端,所以状态同步不需要每一个客户端都知道所有的信息,只需要知道这个客户端所关心的信息就可以了。
因此状态同步更适合MMORPG这样的同步客户端数量较多,信息较为复杂的情况;而帧同步更适合MOBA这样的,客户端数量固定,更关注实时性的情况。
为什么我们游戏需要用到帧同步?我们游戏是一个格斗类型的游戏,对于打击感、实时性要求较高,而且参与战斗的只有两个客户端,战斗的场景非常简单,是一个典型的适合使用帧同步的场景。如果我们使用的是状态同步,每一个客户端在同一帧的表现不一致,就非常难调打击感,那么对于格斗游戏来说,没有打击感这个游戏就没有了灵魂。还有一点,帧同步的逻辑都在客户端,所以如果需要新增加战斗机制,只需要在客户端修改就可以了,不需要像状态同步那样,服务端增加计算逻辑,客户端增加表现逻辑。开发上非常简单,逻辑上也非常清晰。
5.16 如何和解(预测之后出现偏差)?
等待答案
5.17 为什么推荐使用ECS架构做预测回滚?
- ECS框架可以很轻松的做到逻辑与渲染分离,所有的数据快照都存储在Component中,这将让回滚变得容易,
- 我们只需要将每帧的Component快照进行存储,我们就可以很容易的回滚到任何逻辑帧的状态。
5.18 什么是定点数?为什么要使用定点数?
定点数是指在计算机中小数点的位置固定的数。相反小数位数不固定的则是浮点数,例如float,double数据类型,它们属于高精度单双浮点数类型,由于不同机器CPU对浮点数的处理结果会存在偏差,再加之网路延迟影响,由此而带来的蝴蝶效应影响,终会导致不相同的计算结果,导致逻辑不可控,因此为了数据的完全的一致,应采用定点数替代浮点数。
由此我们发现帧同步网络服务器只负责收发指令数据,不进行战斗逻辑的计算。那么由于客户端性能算力不一样,导致浮点数数据类型(float(单),double(双))不再适用,
因为浮点数带有精度,会导致每个客户端的计算结果会存在偏差,从而导致不同步,进而产生蝴蝶效益导致正常战斗完全不同步。所以这里引入定点数。
定点数专为解决浮点数精度差,主要实现方式有两种:
a.尾数截断
可以到这里去看看这里帧同步项目的实例。
GitHub – aaa719717747/TrueSyncExample: 这是一个帧同步的插件,具有确定性物理引擎,定点函数库,碰撞检测,高度封装,跟unity函数库保持高度一致。https://github.com/aaa719717747/TrueSyncExample注明:这是早期Photon的TrueSync经过大佬完善后的实例,有参考价值,其中用到就是[尾数截断]实现定点数。
b.移位运算
ps:左移操作时将运算数的二进制码整体左移指定位数,左移之后的空位用0补充。
右移操作是将运算数的二进制码整体右移指定位数,右移之后的空位用符号位补充,如果是正数用0补充,负数用1补充。
通过移位操作我们可以消除精度,至于移位数我觉得(16位)已经完全可以满足需求。
注:65536是右移位16的结果
将浮点数右移16位得到一个long类型,long类型赋值给Fixed类型编码,便得到一个定点数。
var l= (long)unchecked(value * 65536f)
public static Fixed FromRaw(long value)
{Fixed r;r.RawValue = value;return r;
}
//同样将反向转换只需左移位操作即可:
public static explicit operator float(Fixed value)
{return (float)value.RawValue / 65536f;
}
5.19 帧同步防止攻击
- 1.特别是搞棋牌的项目,还有小公司没法通过法律手段来防止别人攻击。特别有用处。因为高防实在太贵,用不起。
- 2. 需要有很多ip,客户端能够随时切换连接,这样别人攻击一个可以切换其他的进程去连接。
- 3. 需要保证状态不丢失,消息不丢失不重发,显然tcp做不到。
- 4. 使用udp。因为udp是无连接的
- 5. 需要保证消息可靠,所以kcp是非常合理的选择
- 6. 可以设计一个路由进程来转发,udp消息通过路由再转发给realm gate等等
- 7. 路由进程可以起非常多个,客户端在连接realm或者gate之前先请求路由进程,告诉路由进程自己需要真正连接的地址,路由进程记录下来。然后客户端用kcp连接,路由进程把发过来的udp消息转发给真正的地址,比如gate。所以服务端对外的是路由进程,gate realm变成了内网地址。
- 8. 客户端连接会每隔2秒ping一次,ping超过10s没有回消息则重新请求一个路由来连接。这样别人攻击一台路由我们就可以不停的关闭被攻击的路由进程,或者开启新的路由都可以。
- 9.因为udp无连接状态,kcp会保证不丢消息会重发,所以即使换了路由进程,仍然能够保证消息一致性
参考
码农-27 – 知乎(大佬写的教程)
unity游戏开发【王者荣耀核心技术-帧同步】教程_哔哩哔哩_bilibili
Unity游戏帧同步技术分享篇【01】帧同步解决方案概述__阿松先生的博客-CSDN博客_unity帧同步
【Unity】网络同步方案 帧同步与状态同步_PA_的博客-CSDN博客_帧同步和状态同步
关于帧同步和网游游戏开发的一些心得 – GameRes游资网
帧同步优化难点及解决方案_UWA—简单优化,优化简单!-CSDN博客_帧同步延迟
【面经】腾讯U3d面试面经 帧同步方向 (一)帧同步如何解决不同步的问题?_NxShow的博客-CSDN博客 【面经】腾讯U3d面试面经 帧同步方向(总)_NxShow的博客-CSDN博客
六:帧同步联机战斗(预测,快照,回滚) – 知乎
云风的 BLOG: 浅谈《守望先锋》中的 ECS 构架
帧同步的相关问题 – 钢与铁 – 博客园