《Exploring in UE4》网络同步原理深入(上)

《Exploring in UE4》网络同步原理深入(上)

前言

UE同步是一块比较复杂而庞大的模块,里面设计到了很多设计思想、技巧和技术。我这里主要是从同步的流程分析,以同步的机制为讲解核心,给大家描述里面是怎么同步的,会大量涉及UE同步模块的底层代码,稍微涉及一点计算机网络底层(Socket、TCP、UDP)相关的知识。

PS:如果只是想知道怎么使用同步,建议阅读这篇文章:关于网络同步的理解与思考。网络同步深入这块我拆成了两篇文章,上篇主要围绕基本架构与概念、网络连接简建立、Actor同步来讲解,下篇针对属性同步、RPC、可靠数据传输等细节做进一步分析。(下篇文章后续会进行推送,记得关注哦~)

目录(上篇)

一、基本概念与架构特点(更新)
二、通信的基本流程(更新)
 1、数据包格式
三、连接的建立(更新)
 1、服务器网络模块初始化流程
 2、客户端网络模块初始化流程
 3、服务器与客户端建立连接流程
四、Actor的同步细节
 1、组件(子对象)同步


一、基本概念与架构特点

UE网络是一个相当复杂的模块,这篇文章主要是针对Actor同步,属性同步,RPC等大致的阐述一些流程以及关键的一些类。这里我尽可能将我的理解写下来。

在UE里面有一些和同步相关的概念与类,这里逐个列举一下并做解释:

基本网络通信:

  • NetDriver
    网络驱动,实际上我们创建使用的是他的子类IPNetDriver,里面封装了基本的同步Actor的操作,初始化客户端与服务器的连接,建立属性记录表,处理RPC函数,创建Socket,构建并管理当前Connection信息,接收数据包等等基本操作。NetDriver与World一一对应,在一个游戏世界里面只存在一个NetDriver。UE里面默认的都是基于UDPSocket进行通信的。

  • Connection
    表示一个网络连接。服务器上,一个客户端到一个服务器的一个连接叫一个ClientConnection。在客户端上,一个服务器到一个客户端的连接叫一个ServerConnection。

  • LocalPlayer
    本地玩家,一个客户端的窗口ViewportClient对应一个LocalPlayer,Localplayer在各个地图切换时不会改变。

  • Channel
    数据通道,每一个通道只负责交换某一个特定类型特定实例的数据信息。
    1、ControlChannel:客户端服务器之间发送控制信息,主要是发送接收连接与断开的相关消息。在一个Connection中只会在初始化连接的时候创建一个该通道实例。
    2、VoiceChannel:用于发送接收语音消息。在一个Connection中只会在初始化连接的时候创建一个该通道实例。
    3、ActorChannel:处理Actor本身相关信息的同步,包括自身的同步以及子组件,属性的同步,RPC调用等。每个Connection连接里的每个同步的Actor都对应着一个ActorChannel实例。
    常见的只有这3种:枚举里面还有FileChannel等类型,不过没有使用。

  • PlayerController
    玩家控制器,对应一个LocalPlayer,代替本地玩家控制游戏角色。同时对应一个Connection,记录了当前的连接信息,这和RPC以及条件属性复制都是密切相关的。另外,PlayerController记录他本身的ViewTarget(就是他控制的Character),通过与ViewTarget的距离(太远的Actor不会同步)来进行其他Actor的同步处理。

  • World
    游戏世界,任何游戏逻辑都是在World里面处理的,Actor的同步也受World控制,World知道哪些Actor应该同步,保存了网络通信的基础设施NetDriver。

  • Actor
    在世界存在的对象,没有坐标。UE4大部分的同步功能都是围绕Actor来实现的。

  • Dormant
    休眠,对于休眠的Actor不会进行网络同步。

底层通信:

  • Packet
    从Socket读出来/输出的数据,一个Packet里面可能有多个Bunch数据或者Ack数据。

  • Bunch
    一个Bunch里面主要记录了Channel信息,NGUID。同时包含其他的附属信息如是否是完整的Bunch,是否是可靠等,可以简单理解为一个从逻辑上层分发下来的同步数据包,该数据包的数据可能不完整,Bunch分为属性Bunch以及RPCBunch。继承自FNetBitWriter InBunch:从Channel接收的数据流串 ,UNetConnection::ReceivedPacket的时候创建 OutBunch:从Channel产生的数据流串,UActorChannel::ReplicateActor()的时候创建。

  • Ack
    Ack是与Bunch同级别概念的网络数据串,用于实现UDP的可靠数据传输。

  • FBitWriter
    字节流书写器,可以临时写入比特数据用于传输,存储等,继承自FArchive。

  • FSocket
    所有平台Socket的基类。FSocketBSD:使用winSocket的Socket封装。

  • UPackageMap
    生成与维护Object与NGUID的映射,负责Object的序列化。每一个Connection对应一个UPackageMap。

  • PacketHandler
    网络包预处理,比如加密,前向纠错,握手等。里面有一个或多个HandlerComponents来执行特殊的数据处理。目前内置的包括加密组件RSA,AES,以及必备的握手组件StatelessConnectHandlerComponent。

(Packet与Bunch的区别:Bunch是Packet子集,Packet里面可能不包含Bunch信息,只包含Ack数据。)

属性同步相关:

  • FObjectReplicator
    属性同步的执行器,每个Actorchannel对应一个FObjectReplicator,每一个FObjectReplicator对应一个对象实例。设置ActorChannel通道的时候会创建出来。

  • FRepState
    针对每个连接同步的历史数据,记录同步前用于比较的Object对象信息,存在于FObjectReplicator里面。

  • FRepLayOut
    同步的属性布局表,记录所有当前类需要同步的属性,每个类或者RPC函数有一个。

  • FRepChangedPropertyTracker
    属性变化轨迹记录,一般在同步Actor前创建,Actor销毁的时候删掉。

  • FReplicationChangelistMgr
    存放当前的Object对象,保存属性的变化历史记录

架构特点:

  • 客户端服务器共用一套代码;
  • 服务器为游戏逻辑服务器,单个服务器为核心,多个客户端连接;
  • 默认通信协议为UDP(应用层实现数据可靠的UDP);
  • 收发UDP数据包都在主线程(GameThread)执行。

二、通信的基本流程

如果我们接触过网络通信,应该了解只要知道对方的IP地址以及端口号,服务器A上进程M_1_Server可以通过套接字向客户端B上的进程M_1_Client发送消息,大致的效果如下:

图2-1 远程进程通信图
图2-1 远程进程通信图

而对于UE4进程内部服务器Server与客户端Client1的通信,与上面的模型基本相似:

图2-2 UE4远程进程通信图
图2-2 UE4远程进程通信图

那这个里面的Channel是什么意思呢?简单理解起来就是一个通信轨道。为了实现规范与通信效率,我们的一个服务器针对某个对象定义了Channel通道,这个通道只与客户端对应的Channel通道互相发送与接收消息。这个过程抽象起来与TCP/UDP套接字的传输过程很像,套接字是在消息发送到进程前就进行处理,来控制客户端进程A只会接收到服务器对应进程A的消息,而这里是在UnrealEditor.exe进程里面处理,让通道1只接收到另一端通道1发送的消息。

上面的只是针对一个服务器到客户端的传输流程,那么如果是多个客户端呢?

请输入图片描述
图2-3 Channel通信图

每一个客户端叫做一个Connection,如图,就是一个server连接到两个客户端的效果。对于每一个客户端,都会建立起一个Connection。在服务器上这个Connection叫做ClientConnection,对于客户端这个Connection叫做ServerConnection。每一个Channel都会归属于一个Connection,这样这个Channel才知道他对应的是哪个客户端上的对象。

接下来我们继续细化,图中的Channel只标记了1、2、3,那么实际上都有哪些Channel?这些Channel对应的都是什么对象?其实,在第一部分的概念里我已经列举了常见的3中Channel,分别是ControlChannel,ActorChannel以及VoiceChannel。一般来说,ControlChannel与VoiceChannel在游戏中只存在一个,而ActorChannel则对应每一个需要同步的Actor,所以我们再次细化上面的示意图:

请输入图片描述
图2-4 Connection下的Channel通信图

到这里我们基本上就了解了UE4的基本通信架构了,下面我们进一步分析网络传输数据的流程。首先我们要知道,UE4的数据通信是建立在UDP-Socket的基础上的,与其他的通信程序一样,我们需要对Socket的信息进行封装发送以及接收解析。流程如图所示:

请输入图片描述
图2-5 发送同步信息流程图

请输入图片描述
图2-6 接收同步信息流程图

数据包格式概述

关于网络包的封装与解析,主要涉及到Bunch,RawBunch,Packet等概念,可以参考第一部分的基本概念去理解,最后一部分可靠数据传输还会讲到。每次通道最后发出去的包都成为一个Packet,Packet里面的信息是通过Bunch或者Ack塞进去的(也就是UNetConnection的Sendbuffer),里面可能有多个bunch与Ack,数量取决于你Flush的频率,默认情况下是在NetDriver Tick的时候或者buffer到达一定值的时候发送。

在Channel、Connection类里面有许多具体的函数通过处理Bunch/Packet实现了可靠传输,文章的最后一部分会做分析。

请输入图片描述


三、连接的建立

前面的内容已经提到过,UE的网通通信是基于Channel的,而ControlChannel就是负责

控制客户端与服务器建立连接的通道,所以客户端与服务器的连接信息都是通过UControlChannel执行NotifyControlMessage函数处理的。下面首先从服务器与客户端的网络模块初始化说起,然后描述二者连接建立的详细流程:

1、服务器网络模块初始化流程

从创建GameInstance开始,首先创建NetDriver来驱动网络初始化,进而根据平台创建对应的Socket,之后在World里面监听客户端的消息。

请输入图片描述
图3-1 服务器网络模块初始化流程图

2、客户端网络模块初始化流程

客户端前面的初始化流程与服务器很相似,也是首先构建NetDriver,然后根据平台创建对应的Socket,同时他还会创建一个到服务器的ServerConnection。由于客户端没有World信息,所以要使用一个新的类来检测并处理连接信息,这个类就是UpendingNetGame。

请输入图片描述
图3-2 客户端网络模块初始化流程图

3、服务器与客户端建立连接流程

二者都完成初始化后,客户端就会开始发送一个Hello类型的ControlChannel消息给服务器(上面客户端初始化最后一步)。服务器接收到消息之后开始处理,然后会根据条件再给客户端发送对应的消息,如此来回处理几个回合,完成连接的建立,详细流程参考下图:
(该流程是本地局域网的连接流程,与在线查找服务器列表并加入有差异。)


图3-3 客户端服务器连接建立流程图


四、Actor的同步细节

Actor的同步可以说是UE4网络里面最大的一个模块了,里面包括属性同步,RPC调用等,这里为了方便我将他们拆成了3个部分来分别叙述。

有了前面的描述,我们已经知道NetDiver负责整个网络的驱动,而ActorChannel就是专门用于Actor同步的通信通道。

这里对Actor同步做一个比较细致的描述:服务器在NetDiver的TickFlush里面,每一帧都会去执行ServerReplicateActors来同步Actor的相关内容。在这里我们需要做以下处理:

1、获取到所有连接到服务器的ClientConnections,首先获取引擎每帧可以同步的最大Connection的数量,超过这个限制的忽略。然后对每个Connection几乎都要进行下面所有的操作。

2、找到要同步的Actor,只有被放到World.NetworkActors里面的Actor才会被考虑,Actor在被Spawn时候就会添加到这个NetworkActors列表里面。(新的版本里面已经把需要同的ACtor放到了NetDriver的NetworkObjects列表里面了。)

3、找到客户端玩家控制的角色ViewTarget(ViewTaget与摄像机绑定在一起),这个角色的位置是决定其他Actor是否同步的关键。

4、验证Actor,对于要销毁的以及所有权Role为ROLE_NONE的Actor不会同步。

5、是否到达Actor同步时间,Actor的同步是有一定频率的,Actor身上有一个NetUpdateTime,每次同步前都会通过下面这个公式来计算下一次Actor的同步时间,如果没有到达这个时间就会放弃本次同步。Actor->NetUpdateTime = World->TimeSeconds + FMath::SRand() * ServerTickTime + 1.f/Actor->NetUpdateFrequency;

6、如果这个Actor设置OnlyRelevantToOwner,那么就会放到一个特殊的列表里面OwnedConsiderList然后只同步给属于他的客户端。否则会把Actor放到ConsiderList里面。

7、对于休眠状态的Actor不会进行同步,对于要进入休眠状态的Actor也要特殊处理关闭同步通道。

8、查看当前的Actor是否有通道Channel,如果没有,还要看看Actor是否已经加在了场景,没有加载就跳过同步。

9、接第8个条件——没有Channel的情况下,还会执行Actor::IsNetRelevantFor判断是否网络相关,对于不可见的或者太远的Actor会返回false,不会同步。

10、Actor的同步数量可能非常大,所以有必要对所有的Actor进行一个优先级的排列 。
处理完上面的逻辑后会对优先级表里的所有Actor进行排序。

11、排序后,如果连接没有加载此 actor 所在的关卡,则关闭通道(如果存在)并继续
每1秒钟调用一次AActor::IsNetRelevantFor,确定Actor是否与连接相关,如果不相关的时间达到5秒钟,则关闭通道 。

如果要同步的Actor没有ActorChannel就给其创建一个并绑定Actor,执行同步并更新:NetUpdateTime = Actor->GetWorld()->TimeSeconds + 0.2f * FMath::FRand();
如果此连接出现饱和剩下的Actor会根据连接相关时间判断是否在下一个时钟更新。

12、执行UActorChannel::ReplicateActor执行真正的Actor同步以及内部数据的同步,这里会将Actor(PackageMap->SerializeNewActor),Actor子对象以及其属性序列化(ReplicateProperties)封装到OutBunch并发送给客户端 。

(备注:我们当前版本下面的逻辑都是写在UNetDriver::ServerReplicateActors里面,4.12以后的UE4已经分别把Connection预处理,获取同步Actor列表,优先级处理等逻辑封装到单独的函数里了,详见ServerReplicateActors_BuildConsiderlist,ServerReplicateActors_PrioritizedActors,ServerReplicateActors_ProsessPrioritizedActors等函数。优先级排序规则是什么?答案是按照是否有Controller,距离以及是否在视野。通过FActorPriority构造代码可以定位到APawn::GetNetPriority,这里面会计算出当前Actor对应的优先级,优先级越高同步越靠前,是否有Controller的权重最大。)

总之,大体上Actor同步的逻辑就是在TickFlush里面去执行ServerReplicateActors,然后进行前面说的那些处理。最后对每个Actor执行ActorChannel::ReplicateActor将Actor本身的信息,子对象的信息,属性信息封装到Bunch并进一步封装到发送缓存中,最后通过Socket发送出去。

下面是服务器的同步Actor的发送Bunch堆栈:(代码修改过,与UE默认的有些不同。)

请输入图片描述
图4-1 服务器同步Actor堆栈图

下面描述客户端是如何接收到服务器同步过来的Actor的。首先客户端TickDispatch检测服务器的消息,收到消息后通过Connection以及Channel进行解析,最后一步解析出完整数据的操作在UActorChannel::ProcessBunch执行,在这个函数里面:

1、如果发现当前的ActorChannel对应的Actor为NULL,就对当前的Bunch进行反序列化Connection->PackageMap->SerializeNewActor(Bunch, this, NewChannelActor);解析出Actor的内容并执行PostInitializeComponents。如果Actor不为NULL,跳过这一步。(参考下图4-2堆栈)

2、随后根据Bunch信息找到同步过来的属性值并对当前Actor对应的属性进行赋值。

3、最后执行PostNetInit调用Actor的BeginPlay。(参考下图堆栈)

下面截取了客户端接收到同步Actor并初始化的调用堆栈:

请输入图片描述
图4-2 客户端接收并序列化同步的Actor堆栈图

请输入图片描述
图4-3 客户端初始化同步过来Actor堆栈图

从上面的描述来看,基本上我们可以很容易的分析出当前的Actor是否被同步,比如在UActorChannel::ReceivedBunch里面打个断点,看看当前通道里有没有你要的Actor就可以了。

1、组件(子对象)同步

组件(还有其他子对象)是挂在Actor上面的,所以组件的同步与Actor同步是紧密相连的,当一个Actor进行同步的时候会判断所有的子对象是否标记了Replicate,如果标记了,就对其以及其属性进行同步。

这些子对象同步方式(RPC等)也与Actor相差无几,实际上他们想要同步,需要借助ActorChannel创建自己的FObjectReplicator以及属性同步的相关数据结构。简单来说,就是一个Actor身上的组件同步需要借用这个Actor的通道来进行。下面3段代码是服务器序列化子对象准备发送的逻辑:

//UActorChannel::ReplicateActor()  DataChannel.cpp
// The Actor
WroteSomethingImportant |= ActorReplicator->ReplicateProperties( Bunch, RepFlags );
// 子对象的同步操作
WroteSomethingImportant |= Actor->ReplicateSubobjects(this, &Bunch, &RepFlags);
//ActorReplication.cpp
boolAActor::ReplicateSubobjects(UActorChannel *Channel, FOutBunch *Bunch, FReplicationFlags *RepFlags)
{
   check(Channel);
   check(Bunch);
   check(RepFlags);
   bool WroteSomething = false;

   for (int32 CompIdx =0; CompIdx < ReplicatedComponents.Num(); ++CompIdx )
   {
    UActorComponent * ActorComp = ReplicatedComponents[CompIdx].Get();
    //如果组件标记同步
    if (ActorComp && ActorComp->GetIsReplicated())
    {
       WroteSomething |= ActorComp->ReplicateSubobjects(Channel, Bunch, RepFlags);      // Lets the component add subobjects before replicating its own properties.检测组件否还有子组件
       WroteSomething |= Channel->ReplicateSubobject(ActorComp, *Bunch, *RepFlags); // (this makes those subobjects 'supported', and from here on those objects may have reference replicated)  同步该组件   
    }
   }
   return WroteSomething;
}
//DataChannel.cpp
boolUActorChannel::ReplicateSubobject(UObject *Obj, FOutBunch&Bunch, constFReplicationFlags&RepFlags)
{
   if ( !Connection->Driver->GuidCache->SupportsObject( Obj ) )
   {
    FNetworkGUID NetGUID = Connection->Driver->GuidCache->AssignNewNetGUID_Server(Obj );    //Make sure he gets a NetGUID so that he is now 'supported'
   }

   bool NewSubobject = false;
   if (!ObjectHasReplicator(Obj))
   {
    Bunch.bReliable = true;
    NewSubobject = true;
   }
   //组件的属性同步需要先在当前的ActorChannel里面创建新的FObjectReplicator
   bool WroteSomething = FindOrCreateReplicator(Obj).Get().ReplicateProperties(Bunch, RepFlags);
   if (NewSubobject && !WroteSomething)
   {
      ......
   }
   return WroteSomething;
}

下面一段代码是客户端接收服务器同步过来的子对象逻辑:

// void UActorChannel::ProcessBunch( FInBunch & Bunch )DataChannel.cpp
// 该函数前面的代码主要是是进行反序列化当前Actor的相关操作
while ( !Bunch.AtEnd() && Connection != NULL&& Connection->State != USOCK_Closed )
{
   bool bObjectDeleted = false;
   //当前通道的Actor以及反序列化成功,这里开始继续从Bunch里面寻找子对象进行反序列化
   //如果当前Actor没有子组件,这里返回的就是Actor自身
   ......
   TSharedRef<FObjectReplicator>& Replicator = FindOrCreateReplicator( RepObj );
   bool bHasUnmapped = false;
   // 找到当前子对象(或当前Actor)的Replicator以后,这里开始进行属性值的读取了
   if ( !Replicator->ReceivedBunch( Bunch, RepFlags, bHasUnmapped ) )
   {
       ......
   }
   ......
}

前面Actor同步有提到,当从ActorChannel解析Bunch信息的时候就可以尝试对该数据流进行Actor的反序列化。从这段代码可以进一步看出,Actor反序列化之后会立刻开始判断Bunch里面是否存在其子对象,如果存在还会进一步读取子对象同步过来的属性值。如果没有子对象,就读取自身同步过来的属性。

关于子组件的反序列化还分为两种情况。要想理解这两种情况,还需要清楚两个概念——动态组件与静态组件。

对于静态组件:一旦一个Actor被标记为同步,那么这个Actor身上默认所挂载的组件也会随Actor一起同步到客户端(也需要序列化发送)。什么是默认挂载的组件?就是C++构造函数里面创建的默认组件或者在蓝图里面添加构建的组件。所以,这个过程与该组件是否标记为Replicate是没有关系的。

对于动态组件:就是我们在游戏运行的时候,服务器创建或者删除的组件。比如,当玩家走进一个洞穴时,给洞穴里面的火把生成一个粒子特效组件,然后同步到客户端上,当玩家离开的时候再删除这个组件,玩家的客户端上也随之删除这个组件。

对于动态组件,我们必须要设置他的Replicate属性为true,即通过函数 AActorComponent::SetIsReplicated(true)来操作。而对于静态组件,如果我们不想同步组件上面的属性,我们就没有必要设置Replicate属性。下面截取了函数ReadContentBlockHeader部分代码来区分这两种情况:

//静态组件,不需要客户端Spawn
FNetworkGUID NetGUID;
UObject * SubObj = NULL;
Connection->PackageMap->SerializeObject(Bunch, UObject::StaticClass(), SubObj, &NetGUID );
//动态组件,需要在客户端Spawn出来
FNetworkGUID ClassNetGUID;
UObject * SubObjClassObj = NULL;
Connection->PackageMap->SerializeObject(Bunch, UObject::StaticClass(), SubObjClassObj, &ClassNetGUID );

我们在这两段代码看到了FNetworkGUID的使用,因为这里涉及到UObject的引用(指针)同步。对于不同端的同一个对象,他们的内存地址肯定是不同的,那服务器上指向A的指针同步到客户端上如何也能正确的指向A呢?这就需要通过FNetworkGUID来解析,具体细节在下一节属性同步里面分析。


这是侑虎科技第520篇文章,感谢作者Jerish供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/chang-xiao-qi-86,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!