h27454440t 发表于 2024-7-15 18:31

UE5中的网络同步能力和同构处事器框架(上)

上一篇我们已经介绍了最基础的Unreal的Gameplay框架(UE5 新项目Gameplay框架设计(以Lyra为例)),此中多次提到了处事端和客户端的概念,比如GameMode就存在于处事端,而GameState虽然存在于处事端,却可以同步到客户端。但从GameState的各种代码中,都没有看到跟数据/网络同步相关的逻辑,那么这一切是如何发生的呢?



关于Unreal的网络模块,应该是当前能检索到的信息最丰硕的模块之一了,其他的还有GAS,动画系统等。比如我这里就找到了一些斗劲好的,从分歧视角来介绍Unreal网络模块的高质量好文章(当然好的文章不止这些哈,知乎随便搜索“UE网络同步”就能看到很多很多)。


[*]城市|差值_UE4 UDP是如何进行可靠传输的
[*]UE4网络模块分析
[*]《Exploring in UE4》网络同步道理深入(上)[道理分析]
[*]《Exploring in UE4》网络同步道理深入(下)[道理分析]
[*]UE4网络同步思考(一)---经典同步方案
[*]UE4网络同步思考(二)---大世界同步方案ReplicationGraph
[*]UE4网络同步-基础流程
如果都能结合代码吃透的话,那么UE的网络同步基本就能斗劲熟练的掌握了。话说回来,虽然已经有了这么多前人的高质量的文章,我还是想要加上一些我本身的理解,也给大师提供多一个的视角来看Unreal的网络同步。
我在构思这篇文章的时候,思考了两种视角模式:

[*]一种是以最基础的Actor为切入点,不竭增加它的能力来达到网络同步的方针,并基于此来不竭引入更上一层的设计和相关的同步框架。但Actor本身是Unreal常用的基础单元,除了网络同步之外还承担非常多的其他本能机能和感化,再加上以成果去猜测原因有点站不住脚。


[*]第二种是以宏不雅观的网络同步需求为切入点,从最顶层的需求来分析和解析Unreal的对应实现,不仅可以了解Unreal框架的设计原因,还能不竭的解决实际开发中遇到的各种问题,但错误谬误就是没有法子一次性完整的介绍某个框架或者某个组件的整体实现细节,甚至某个类可能会在分歧的需求下反复提及,会有分裂感。
结合前面大师的介绍思路城市方向于先从底层开始,这里我就从宏不雅观切入,尽量把一个类的用途一次讲完。

1 UE处事器介绍


网络游戏自然会有网络同步的需求,而同步的主要内容则是各种对象的属性和RPC。在过去的游戏开发中,跟客户端打交道的大部门是业务处事器和战斗处事器。前者用来存储和同步玩家的各种数据,后者则大多同步玩家的各种操作来达到多人联机畅玩的目的。

标题里我们提到的同构处事器和异构处事器框架则是指处事端的执行逻辑和客户端的执行逻辑是否是同一套代码。这说的可能有点抽象,可以举个例子,稍微展开说一下。

1.1 同构和异构


一般的开发团队里,我们都有处事器开发组和客户端开发组,两个组之间使用的框架,代码,和框架城市有很大的分歧。比如处事器注重玩家数据的措置,注重数据存储和安全性,而客户端则注重衬着表示和用户交互。就一个战斗框架来说,客户端更多的是如何如何做好玩家体验和开发配置效率,而处事器则注重玩家的数据计算。

正是因为分歧的业务端的业务重点差异斗劲大,所以处事器和客户端通过约定一些协议,再通过网络来传递玩家的操作请求和执行后的变化。例如玩家A向BOSS释放了一个魔法飞弹,客户端要措置的是,玩家按了快捷键,快捷键绑定了哪个技能,方针是谁。将这些信息包裹起来,通过网络和协议发送给处事器,处事器校验数据和操作的合法性(比如是不是有CD,是不是距离不够等等),通过执行技能机制之后,记录玩家的状态变化,记录BOSS的状态变化,并将变化通过协议发送给所有参与本场战斗的成员。客户端接收到处事器的状态变化之后,执行BOSS的血量扣除,播放对应的技能特效等等。

由于客户端和处事器的执行逻辑完全纷歧样,所以逻辑必定不能通用,那就是处事器和客户端分袂各自实现,我们把这种方式叫做异构处事器:即处事器和客户端的开发是纷歧样的,框架设计纷歧样,代码也纷歧样,甚至开发语言都纷歧样。

基于异构处事器的概念,我们再去理解同构处事器就简单了:处事器和客户端用同一套框架和代码,通过在启动时指定本身作为处事器还是客户端即可。

那么既然处事器和客户端措置的逻辑纷歧致,为什么会有同构处事器的需求呢?答案就是有的游戏处事器和客户端逻辑的措置是一致的,甚至不需要专属处事器都可以完成。比如:魔兽争霸。

类似于这种对战类型的游戏,房主开一个房间,玩家进入房间,每个人玩家的逻辑必需得完全一致才能保障游戏的公安然安祥准确。这就要求作为处事器的这个房主的战斗逻辑必需和所有其他人是一致的,那么同构处事器的需求就是必需的了。同时因为处事器的计算逻辑一致,那么处事器的感化就只限于转发玩家的操作给所有其他玩家了。

上面只是同构处事器的一种需求场景。再比如此刻的一些SLG或者休闲类型的游戏,战斗本身的并不复杂,但对战斗的准确性要求较高,又或者需要斗劲一致的战斗回放,用异构处事器实现的代价会比同构处事器大很多。所以我们就需要设计一个同构处事器,让它在分歧端上的使用不异的初始数据得到的成果都一样,这样我们就可以先信任客户端的战斗成果,然后将数据复制到一个专属的验算处事器来后置的措置作弊情况。

当然如果不想后置措置,先丢到战斗服上去算,将成果作为战报下发,客户端看回放也是一样的。这取决于游戏的玩法策略。相关的同构处事器的框架,可以参考我以前写过的同构框架:
Unity手游实战:从0开始SLG——ECS战斗(三)逻辑与表示分手
Unity手游实战:从0开始SLG——ECS战斗(四)实战ECS架构和优化
这里简单斗劲一下同构处事器和异构处事器的差异:

[*]异构处事器。可以针对性的开发处事器和客户端的逻辑,由于是两个完全独立的端,可以让单端专精的开发人员负责开发。框架可以设计的更加精炼,性能更加高效,开发人员的思路也会斗劲清晰。劣势则在于开发的人力成底细对较高,需要斗劲紧密的协作,犯错之后定位问题较麻烦。


[*]同构处事器。由于代码自身扮演的本能机能在没有启动之前都无法预估,所以在开发过程中要加大量的判断来决定某条逻辑是作为处事器执行还是客户端执行。因为客户端和处事器逻辑糅杂在一起,在开发阶段需要开发人员时刻切换本身的身份来实现双端的逻辑。但好处就是,代码逻辑是一致的,犯错之后定位问题更简单一些。


好了,之所以要先介绍同构处事器的概念,是因为,Unreal 引擎天生就自带了这一套同构处事器框架。而且它是从最底层的Actor上就实现了网络同步的机制。


1.2 UE中的专属处事器(Dedicated Server)


Unreal中,一共提供了两种分歧的处事器模式,一种是传统的MMO类型的(Dedicated Server),即处事端逻辑是运行在一个独立的处事器上,所有的玩家都需要连接到这个处事器长进行数据交换和逻辑更新;第二种则是房间式的(Listen Server),处事器部署在某个玩家的客户端上,该端既承担本身的客户端运算逻辑,又需要转发变化本身端计算的成果给其他的玩家。

对比于房间式的处事器(LS)来说,专属处事器(DS)拥有更多的优势:


[*]因为不需要实例化界面,DS的整体代码体量和性能城市更好。


[*]因为LS本身“既被选手,又当裁判”会导致其拥有其他客户端无法对比的数据权威性和网络延迟,这对其他玩家并不公平


[*]DS因为没有当地的客户端需要措置,所以可以更加专注的完成Gameplay的逻辑,并打点分歧玩家之间的数据,同时能兼顾所有玩家的公平性。
关于如何部署DS,可以查看官方文档:设置专用处事器 。
但Unreal的处事器框架设计不是没犯错误谬误,由于其同构性,而且还撑持分歧的处事器模式,所以它在代码上是斗劲痴肥的。也因为它网络同步的对象是按Actor为单元来打点的,所以其作为处事器本身的承载能力对比于异构的处事器来说要差很多(相对于碉堡之夜,一场百人局的战斗来说 还是是绰绰有余的),同时其网络流量也会随着需要同步的Actor数量增多而增多(虽然它本身已经做了很大都据合并的逻辑了)。

1.3 网络模块的初始化


之前的文章我们就介绍过一些概念,比如Gamemode是跑在处事器上的,GameState是跑在处事器上但能同步在客户端上的,但其实UE也可以制作单机游戏,也就没有处事器一说。

所以UE对网络的初始化判断是来自于Gamemode中定义是否需要网络来决定的。在介绍网络之前,我们需要还是需要先简述一下UE的场景初始化法则(详细可以参考:《InsideUE4》GamePlay架构(三)WorldContext,GameInstance,Engine):
如果开发者是一位上帝,而且整个宇宙是他玩的一场游戏,那么UEngine就是支撑它运行整场游戏的底层框架。而掌控这场游戏的类就是GameInstance。World就是宇宙里一个个的星球,Level就是星球上的一片片大陆(或者国家),Actor就是大陆上的所有物体的基本组成元素。这些Actor可以通过各自的进化,变成分歧的小组件(Component),然后再组合在一起,变成千奇百怪,光怪陆离的世界组成部门。

每个星球(World)有本身分歧的生态系统,他们可能差异微小,也可能大相径庭。上帝以星球(world)为单元创建分歧的游戏法则(GameMode),存储当前的游戏状态(GameState),有一个代办代理角色(Character),有一个附身看世界的视角(Camera),在加上代办代理角色的操控器(Controller)。这些加在一起就是针对这个星球(world)的玩法(也叫Gameplay)。

当上帝来到一个星球(world)的时候,它其实是一次星球旅行(WorldTravel)。由于星球之间的独立性,数据并不互通,所以需要一个量子口袋(WorldContext)到临时存储要带到方针星球(World)去的东西。到了这个星球之后,它先翻一翻之前缔造星球时候的配置,看看当草缔造的时候这个星球的法则是什么样的(加载 GameMode),然后从量子口袋(WorldContext)里找找记录,看看这个星球是否需要撑持其他平行宇宙的上帝伴侣一起来玩(决定是否要开启联机系统)。如果需要,那就要开启通信系统,让它的伴侣们能以量子纠缠态(NetDriver)将本身的数据投射到这个星球(World)上。(也可以看看这个,画的挺有意思:《图解UE4衬着体系》Part 0 引擎基础)

那么此刻我们要讨论的重点就是这个量子纠缠态的投射:网络模块的初始化。

虽然从一个World切换到此外一个World我们调用了ServerTravel,但其实它们只是把下一个World的信息拼装成了一个URL,并存放在WorldContext里。




当引擎Tick执行的时候,它会先去WorldContext看一下,如果FURL不为空,暗示要开启下一次旅行了。然后再查看FURL中的参数,是否携带了需要初始化网络的参数需求。下面是一次网络初始化的调用仓库,可以看到是从tick进来的。




如果需要初始化网络,就会创建NetDriver。





1.4 NetDriver的类型


前面铺垫了这么久,终于来到了网络模块的门口。但从这个类的定名其实可以看出,它其实是更方向于底层的连接模块,Driver这个词就很明显,它干的是网络驱动层的事,主要是打点各种实际的网络连接(NetConnections)以及它们之间的数据交换。
但就一个NetDriver类型的话,并不能很好的抽离出所有的开发需求,因此按照实际的事情情况,它又分为几个子类型,分袂在分歧业务场景下使用:


[*]GameNetDriver 。尺度的游戏网络连接,正常游戏中城市使用这个模式,但它其实是一个代号,GameNetDriver 的实际Class是可以在引擎配置文件中配置的,大部门平台下,默认都是UIpNetDriver。



[*]DemoNetDriver 。主要用于录制和回放。
[*]BeaconNetDriver 。其他非正常的gameplay可能会用到。
[*]Custom。开发者自行定义。(从NetDriver担任,然后在项目配置中指定为默认的网络驱动器即可)
我们沿着主线,只讲正常网络连接,也就是GameNetDriver 。回放可以看看这篇:《Exploring in UE4》Unreal回放系统分解

2网络驱动层


首先这个框架是基于Unreal写的,所以它有必要撑持Unreal常规的反射和全局的config配置。




这些配置都在BaseEngine.ini文件里,也是常规的Unreal的配置方式。也就是说它是可以通过配置文件来进行初始值的覆盖的。如果我们本身写了自定义的netDriver的话,可以通过在编纂器开出参数进行默认Class的覆盖(注意 Config这个特性描述,带这个配置的参数都是可以在ini文件下配置的)。




2.1 连接打点


作为一个“网络驱动层”,它最基本的义务就是维持正常的网络连接,而且打点它们的状态。由于是同构的关系,它还需要按照本身当前是处事器还是Client来确定当前的网络连接数量,并确保相关的逻辑没有问题。
那么这个部门里最核心的几个函数为(注意,InitConnect和InitListen在NetDriver里并没有实现,它们必需要在派生类中实现):




在第一小节我们已经看到了,Uworld初始化的时候会按照是否有“Listen”的命令来决定是否需要创建NetDriver,并执行它的InitListen,这个时候它代表的是处事器。而如果是在客户端的话,它需要执行InitConnect,但不管是处事器还是客户端在创建连接的时候,必需要先执行InitBase,它主要是预措置了一些命令行,看看是否需要覆盖当前的InitialConnectTimeout参数和ConnectionTimeout参数。




然后是初始化各种配置和派生类。




注意,这里UReplicationDriver的用途是优化需要同步的数据,后面我们还会碰到,这里不展开。
接下来会初始化一个奇怪的东西FDDoSDetection。这玩意儿常规的理解是放DDoS流量攻击的,不外因为UE底层使用的是UDP+可靠性传输,而UDP又是DDoS攻击的方针,所以有这玩意儿仿佛也不稀奇了。




由于InitListen和InitConnect是虚函数,NetDriver本身没有实现,我们可以往后放一放再说。此刻我们知道了驱动层的初始化挨次,那么它是如何打点连接的呢?


[*]首先作为客户端来说,它应该只会有一个连接,用来连接处事器。
[*]其次作为处事器来说,它会有非常多的连接,用来打点客户端。
这两个在类的浮现就是ServerConnection和ClientConnections。




此外,如何判断本身是不是处事端呢?答案就是判定ServerConnection是否为空,因为只有在客户端才会初始化ServerConnection,如果它为空暗示它不是客户端而是处事端。




当作为处事端的时候,它的连接众多,所以它需要一个Map来保留Ip地址和客户端连接的对应关系,以便区分连接上来的客户端是否是新的客户端。




FConnectionMap的定义如下(就不往下一层层展开了,这里看类定名应该就能知道存储布局和用途了):




此刻的处事器其实城市有排队功能,当处事器的容量超载的时候,多余的人会在处事器外面排队。这时候,如果有一个已经在游戏的玩家俄然掉线了,那么他上线重连就不得不从最后一名来时排队,万一正在打个副本或者组队,体验非常不好。那么这里就可以加一个比来掉联的客户端列表。如果可以的话,我们可以改写部门逻辑,如果某玩家短时间掉线,再上线排队可以直接插入在最前面,以保障玩家的游玩体验。




那么以上基本的网络功能布局已经出来了,我们有连接的定义了(Server,Client的Connection),有连接和初始化的调用接口(Init的几个函数)了,剩下就是给它们配备斗劲完全的控制和参数了。比如初始化连接的超不时间,连接本身的超不时间,作为客户端时候的tickRate,作为处事器时候的tickRate,掉线多久算比来掉线等等,围绕Connection成立的各种参数和逻辑措置。

2.2 Channel打点


Channel可以理解为网络同步的信道。每一个Connection可以视作一个玩家,但玩家的数据可能分为很多种,比如某个玩家的Connection连接状态需要发送给其他的玩家,那么就可以通过一个单独的Connection类型的信道把数据发出去,而接收端也使用Connection的信道做接收,专人专项措置,让数据不稠浊。
在目前UE默认的实现中,只有3种已经实现的Channel:


[*]ControlChannel 。看起来仿佛是CS之间传递信息的信道,但实际上只发送和接收了一些连接相关的信息。
[*]VoiceChannel 。 跟语言通话相关的动静信道。
[*]ActorChannel 。最基本的Actor信道。也是最常用,最复杂的信道打点了。ActorChannel 并不是一个打点集,而是每一个需要进行网络数据交互的Actor城市有一个Channel信道。也就是说一个UE处事器中,可能会存在成千上万个Actor信道,这也是Unreal需要重点打点的数据调集。
关于Channel的讨论可以放后一些,这里还是往下说说NetDriver对Channel的打点。



由于Actor的Channel众多,所以要做一个Pool来反复操作和打点,当然逻辑中也就会包含Actor的复用和入池等。



同样的,还会有一些跟Channel相关的配置参数和定义,以及Channel的名称映射之类的辅助逻辑。




整个NetDriver并不会介入实际的Connection和Channel的业务逻辑,它只负责对它们的初始化和打点。比如创建ClientChannel,实际上还是调用的Connection来执行的。



PS:思考一下,既然Channel的逻辑调用归属于Connection,是不是这些初始化和打点逻辑放到Connection里会更好点呢?
理论上来说,Channel应该确实归属于Connection打点才对,NetDriver作为一个最高层权限的拥有着,它应该只存眷Connection,而不应该存眷Connection的数据对象Channel的。
但从实现上来说,NetDriver里存在了大量的配置属性,而Channel作为可配置的部门也就一起配置在NetDriver模块里的,这样可以让网络相关的配置更加集中。同时NetDriver确实也是只负责了Channel的初始化工作,而且还是调用Connection的函数执行的,只是参数的措置和初始化NetDriver本身完成了。
比如前面提到的ChannelDefinition,就是从配置文件读取的。比如FChannelDefinition的定义




在ini下的相关配置。




最终成为NetDriver的成员变量,被用作各种初始化参数。




到这里,NetDriver的核心筹备工作已经完成了。能区分本身是客户端还是处事端,能按照配置正确的初始化Connection,能完成Channel的配置,而且辅助Connection来完成Channel的初始化。

2.3 数据收发打点


在NetDriver正式开始搬砖之前,还需要先成立一个握手和监听逻辑:




此中,StatelessConnectComponent负责对新连接成立握手过程,而ConnectionlessHandler则负责措置网络数据包。
在执行InitListen或者InitConnect的时候就会调用InitConnectionlessHandler来完成Handler的初始化。




那么接下来就要开始搬砖了。NetDriver搬砖的动力来自于 下面4个函数。



此中TickDispatch负责接收网络数据。如果它接收到一个来自陌生地址的数据就会测验考试进行一个握手的过程,通过了之后创建一个新的Connection。如果是已知的Connection,就会把数据转移给它并开始解包数据。
TickFlush则负责整理需要发送的数据,比如针对需要复制的Actor做措置的ServerReplicateActors逻辑。




以及需要长途调用的逻辑ProcessRemoteFunction。


这两个逻辑都还是斗劲复杂的,因为网络复制在处事端非常复杂。它需要针对分歧的Connection筛选分歧的需要复制的内容(但对于客户端来说就斗劲简单),所以如果想了解细节的可以直接看对应代码,这里就跳过了。
除了措置正常的数据之外,还有额外的专门针对音频数据的措置。因为Unreal会措置《碉堡之夜》这样的游戏,所以它的local发送法则是在附近的人。



以上就是NetDriver的核心本能机能了,打点连接,打点Channel信道,收发数据。但作为一个健壮的框架来说,还远远不够。

2.4 命令行


NetDriver担任自FExec,暗示它会接受并措置来自命令行的数据。






如果不是Shipping包的话,它预置了以下的命令措置:


纷歧一介绍,这里就看第一个Sockets的的命令行,它其实就是序列化了当前所有的Connection和Channel信息。



2.5 性能统计


除了命令行之外,框架内还会统计一些关键指标和部门性能数据。比如客户端的连接数量,收发网络包的数量,大小和频率,音频的网络流量等。






在每次的TickFlush的最后会调用UpdateNetworkStats来计算和填充这些数据并更新当前网络的lag情况。




2.6 调试


一些开发期间的调试信息,比如打印一些关心的数据。




比如在场景里区分出分歧的同步法则的Connection和玩家等。






对于NetDriver而言,以上就是它的全部职责了。作为一个网络驱动层来说,他核心的任务就是打点连接和信道,负责接收和发送数据(当然这些数据的解析和筹备工作都是转交了其他模块完成的),同时作为一个健壮的框架,它有一些调试信息,能统计自身的状态和性能数据。又因为是Unreal引擎所支撑的框架,它还需要接受必然的外部配置。整体而言NetDriver斗劲完美的完成了它的使命。

但从设计上来说,个人认为还是有些许的小瑕疵,比如Channel信道应该交给Connection来打点,Actor数据的筹备和复制应该放在Connection上。作为一个逻辑驱动层,它往下应该和逻辑传输层(TCP/UDP)成立良好的组合关系,往上应该只存眷Connection相关的部门,就仿佛顺丰的营业部一样,从客户(Connection)手上拿包裹,然后选择走海陆空(TCP/UDP)运的方式寄送包裹。至于客户怎么打包应该交给客户本身决定。

2.7 派生类IPNetDriver


前面我们提过,NetDriver本身只是一个基类,正常游戏里的使用的是它的派生类IPNetDriver,这是一个实例化的类。因为派生类是可以在ini里配置的,所以在高层的逻辑里,大师会把它抽象成GameNetDriver。也就是说,ini里配置GameNetDriver,实际指向IPNetDriver。好处就是,如果你要变化GameNetDriver的配置,只需要本身从头配置一下就好了,不会影响到高层逻辑里的理解。
它的逻辑没有NetDriver本体那么复杂,主要做了两件事:


[*]覆写了斗劲重要的几个函数,同时实现了基类中没有实现的部门,比如InitListen和InitConnect。
[*]前面我们说过NetDriver应该打点“逻辑传输层”,也就是socket,但基类中并没有实现,所以在IPNetDriver中需要完成。

它对基类逻辑的补充和接管浮现不才面几个核心函数的改动上:



首先,在InitBase中,除了执行父类的基础逻辑之外,还初始化了整个Socket系统,而且完成绑定。



此中Resolver的类型是FNetDriverAddressResolution,负责实际意义上的IP地址打点。



如果socket创建成功,则创建一个单独的接收线程用于接收socket数据。到此InitBase的逻辑结束。



接下来是InitListen,这个是作为处事端的时候会调用的,之前在NetDriver基类中没有实现,此刻实现也很简单,就是调用以下InitBase,成功之后调用Handler的初始化,来完成处事器的连接和握手逻辑的初始化。



而InitConnet则是客户端才会调用的,它也是需要先调用InitBase来完成基本的网络模块初始化,然后创建ServerConnection,并完成Channels的创建。



最后就是数据收发的逻辑了,先在TickDispatch里做具体的数据接收。



数据的发送在基类中已经完成了。逻辑过程概略是这样的,把本身的TickFlush注册到World的tick中,然后在TickFlush调用FlushHandler,这里会得到所有Channel信道里收集好的Packets,然后调用Connection的Tick进行发送。



当然在IPNetDriver里也进行了LowLevelSend的覆写逻辑。调用了Socket进行最后的数据发送。


总结一下,NetDriver制定了整个网络驱动层的工作规范和框架,而IPNetDriver则完成了实际的实现。尤其是针对socket的各种初始化和收发逻辑来说,基本几乎没有涉及,全部在子类中完成了业务落地。
页: [1]
查看完整版本: UE5中的网络同步能力和同构处事器框架(上)