本文更多是对Exploring in UE4有关网络同步原理以及官方文档的一些自己理解和总结。
UE4进程内部服务器Server与客户端Client的通信 主要如下:
每一个客户端叫做一个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。
Connection和Channel之间的关系:
到这里我们基本上就了解了UE4的基本通信架构了,下面我们进一步分析网络传输数据的流程。首先我们要知道,UE4的数据通信是建立在UDP-Socket的基础上的,与其他的通信程序一样,我们需要对Socket的信息进行封装发送以及接收解析。这里面主要涉及到Bunch,RawBunch,Packet等概念。
如下图所示,这个是主要的接收和发送信息的整体流程:
通过上图可以看出,主要由TickFlush和TickDispatch来监控接收和发送信息。发送信息过程主要是由Channel根据消息类型来封装信息,Connection再把这些信息处理后发送。而接受信息则与这个恰好相反。
以下是借鉴Exploring in UE4的图,分别是详细的发送消息和接收消息的过程。
在第一块我们了解了通信的基本流程,但是在一开始建立通信这个过程是如何连接的呢?
通过这个图我们可以知道网络通信其实是在GameInstance创建之后,然后去创建网络相关的板块。
然而在上面的内容我们都知道了,是由NetDriver去驱动整个网络通信,并且客户端通过Connection连接服务器。
那么这个过程是怎么建立的呢?
二者都完成初始化后,客户端就会开始发送一个Hello类型的ControlChannel消息给服务器(上面客户端初始化最后一步)。服务器接收到消息之后开始处理,然后会根据条件再给客户端发送对应的消息,如此来回处理几个回合,完成连接的建立,主要流程如下:
主要参考这幅图简化而来的。
// message type definitions
DEFINE_CONTROL_CHANNEL_MESSAGE_THREEPARAM(Hello, 0, uint8, uint32, FString); // initial client connection message
DEFINE_CONTROL_CHANNEL_MESSAGE_THREEPARAM(Welcome, 1, FString, FString, FString); // server tells client they're ok'ed to load the server's level
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(Upgrade, 2, uint32); // server tells client their version is incompatible
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(Challenge, 3, FString); // server sends client challenge string to verify integrity
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(Netspeed, 4, int32); // client sends requested transfer rate
DEFINE_CONTROL_CHANNEL_MESSAGE_FOURPARAM(Login, 5, FString, FString, FUniqueNetIdRepl, FString); // client requests to be admitted to the game
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(Failure, 6, FString); // indicates connection failure
DEFINE_CONTROL_CHANNEL_MESSAGE_ZEROPARAM(Join, 9); // final join request (spawns PlayerController)
DEFINE_CONTROL_CHANNEL_MESSAGE_TWOPARAM(JoinSplit, 10, FString, FUniqueNetIdRepl); // child player (splitscreen) join request
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(Skip, 12, FGuid); // client request to skip an optional package
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(Abort, 13, FGuid); // client informs server that it aborted a not-yet-verified package due to an UNLOAD request
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(PCSwap, 15, int32); // client tells server it has completed a swap of its Connection->Actor
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(ActorChannelFailure, 16, int32); // client tells server that it failed to open an Actor channel sent by the server (e.g. couldn't serialize Actor archetype)
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(DebugText, 17, FString); // debug text sent to all clients or to server
DEFINE_CONTROL_CHANNEL_MESSAGE_TWOPARAM(NetGUIDAssign, 18, FNetworkGUID, FString); // Explicit NetworkGUID assignment. This is rare and only happens if a netguid is only serialized client->server (this msg goes server->client to tell client what ID to use in that case)
DEFINE_CONTROL_CHANNEL_MESSAGE_ONEPARAM(SecurityViolation, 19, FString); // server tells client that it has violated security and has been disconnected
DEFINE_CONTROL_CHANNEL_MESSAGE_TWOPARAM(GameSpecific, 20, uint8, FString); // custom game-specific message routed to UGameInstance for processing
DEFINE_CONTROL_CHANNEL_MESSAGE_ZEROPARAM(EncryptionAck, 21);
以上的控制信息具体作用,请阅读源码。
有了前面的描述,我们已经知道NetDiver负责整个网络的驱动,而ActorChannel就是专门用于Actor同步的通信通道。
服务器在NetDiver的TickFlush里面,每一帧都会去执行ServerReplicateActors
来同步Actor的相关内容,大多数 actor 复制操作都发生在 UNetDriver::ServerReplicateActors
内。在这里,服务器将收集所有被认定与各个客户端相关的 actor,并发送那些自上次(已连接的)客户端更新后出现变化的所有属性。
这里还定义了一个专门流程,指定了 actor 的更新方式、要调用的特定框架回调,以及在此过程中使用的特定属性。其中最重要的包括:
AActor::NetUpdateFrequency
- 用于确定 actor 的复制频度 AActor::PreReplication
- 在复制发生前调用 AActor::bOnlyRelevantToOwner
- 如果此 actor 仅复制到所有者,则值为 trueAActor::IsRelevancyOwnerFor
- 用于确定 bOnlyRelevantToOwner 为 true 时的相关性AActor::IsNetRelevantFor
- 用于确定 bOnlyRelevantToOwner 为 false 时的相关性相应的高级流程如下:
循环每一个主动复制的 actor(AActor::SetReplicates( true )
)
DORM_Initial
),如果是这样,则立即跳过。AActor::bOnlyRelevantToOwner
为 true,则检查此 actor 的所属连接以寻找相关性(对所属连接的观察者调用 AActor::IsRelevancyOwnerFor
)。如果相关,则添加到此连接的已有相关列表。AActor::PreReplication
。DOREPLIFETIME_ACTIVE_OVERRIDE
。对于每个连接(connection):
对于每个所考虑的上述 actor
确定是否休眠
是否还没有通道
AActor::IsNetRelevantFor
,以确定 actor 是否相关在归连接所有的相关列表上添加上述任意 actor
这时,我们拥有了一个针对此连接的相关 actor 列表
按照优先级对 actor 排序
官网文档
优先级排序规则是什么?
答案:是按照是否有controller,距离以及是否在视野。通过FActorPriority构造代码可以定位到APawn::GetNetPriority,这里面会计算出当前Actor对应的优先级,优先级越高同步越靠前,是否有Controller的权重最大)
对于每个排序的 actor:
如果连接没有加载此 actor 所在的关卡,则关闭通道(channel)(如存在)并继续
AActor::IsNetRelevantFor
以确定是否应当在下一时钟单位更新UChannel::ReplicateActor
将其复制到连接。UChannel::ReplicateActor
将负责把 actor 及其所有组件复制到连接中。其大致流程如下:
ROLE_AutonomousProxy
,则降级为 ROLE_SimulatedProxy
总之,大体上Actor同步的逻辑就是在TickFlush里面去执行ServerReplicateActors,然后进行前面说的那些处理。最后对每个Actor执行ActorChannel::ReplicateActor将Actor本身的信息,子对象的信息,属性信息封装到Bunch并进一步封装到发送缓存中,最后通过Socket发送出去。
服务器Actor同步堆栈图如下:
客户端Actor创建同步堆栈图如下:
客户端Actor初始化后同步图如下:
其实Actor同步主要是其子组件和其属性的同步。
官方文档
FObjectReplicator
属性同步的执行器,每个Actorchannel对应一个FObjectReplicator,每一个FObjectReplicator对应一个对象实例。设置ActorChannel通道的时候会创建出来。
FRepState
针对每个连接同步的历史数据,记录同步前用于比较的Object对象信息,存在于FObjectReplicator里面。
FRepLayOut
同步的属性布局表,记录所有当前类需要同步的属性,每个类或者RPC函数有一个。
FRepChangedPropertyTracker
属性变化轨迹记录,一般在同步Actor前创建,Actor销毁的时候删掉。
FReplicationChangelistMgr
存放当前的Object对象,保存属性的变化历史记录
一个Actorchannel类对应一个FObjectReplicator,属于属性同步最重要的类。
关于FRepLayout中Parents属性与CMD属性:FRepLayout里面,数组parents示当前类所有的需要同步的属性,而数组cmd会将同步的复杂类型属性【包括数组、结构体、结构体数组但不包括类类型的指针】进一步展开放到这里面 。
下面开始进一步描述属性同步的基本思路:我们给一个Actor类的同步属性A做上标记Replicates(先不考虑其他的宏),然后UClass会将所有需要同步的属性保存到ClassReps列表里面(该过程在反射过程实现),这样我们就可以通过这个Actor的UClass获取这个Actor上所有需要同步的属性,当这个Actor实例化一个可以同步的对象并开始创建对应的同步通道时,我们就需要准备属性同步了。
首先,我们要有一个同步属性列表来记录当前这个类有哪些属性需要同步(FRepLayout,每个对象有一个,从UClass里面初始化,属于一种参考表,不保存具体属性数据);其次,我们需要针对每个对象保存一个缓存数据,来及时的与发生改变的Actor属性作比较,从而判断与上一次同步前是否发生变化(FRepState,里面有一个Staticbuff来保存具体的属性数据,和FRepLayout对照使用);然后,我们要有一个属性变化跟踪器记录所有发生改变同步属性的序号(可能是因为节省内存开销等原因所以不是保存这个属性),便于发送同步数据时处理(FRepChangedPropertyTracker,对各个Connection可见,被各个Connection的Repstate保存一个共享指针,新版本被FRepChangelistState替换)。最后,我们还需要针对每个连接的每个对象有一个控制前面这些数据的执行者(FObjectReplicator)。
这四个类就是我们属性同步的关键所在,在同步前我们需要对这些数据做好初始化工作,然后在真正同步的时候去判断与处理。
在上文,我们知道Actor的同步主要是通过ServerReplcateActors实现的。那么具体的流程又大概是怎样的呢?
通过该图我们可以看出,首先通过SetChannelActor为上述我们网络同步的四个类初始化,然后就是同步Actor。
当Actor同步时如果发现当前的Actor没有对应的通道,就会给其创建一个通道并执行SetChannelActor
。这个SetChannelActor
所做的工作就是属性同步的关键所在,这个函数里面会对上面四个关键的类构造并做初始化,详细的内容参考下图:
通过图片,我们可以看出在SetChannelActor中初始化构建了上述我们所讲的几个类。
// 以下代码有删减
bool FObjectReplicator::ReplicateProperties( FOutBunch & Bunch, FReplicationFlags RepFlags )
{
UObject* Object = GetObject();
// some games ship checks() in Shipping so we cannot rely on DO_CHECK here, and these checks are in an extremely hot path
UNetConnection* OwningChannelConnection = OwningChannel->Connection;
FNetBitWriter Writer( Bunch.PackageMap, 0 );
// Update change list (this will re-use work done by previous connections)
ChangelistMgr->Update( Object, Connection->Driver->ReplicationFrame, RepState->LastCompareIndex, RepFlags, OwningChannel->bForceCompareProperties ); // 更新函数,判断属性是否发生变化。
// Replicate properties in the layout
const bool bHasRepLayout = RepLayout->ReplicateProperties( RepState.Get(), ChangelistMgr->GetRepChangelistState(), ( uint8* )Object, ObjectClass, OwningChannel, Writer, RepFlags ); // 同步属性过程。
// Replicate all the custom delta properties (fast arrays, etc)
ReplicateCustomDeltaProperties( Writer, RepFlags );
//... 下面删减很大一部分
return WroteImportantData;
}
再次拿出服务器同步属性的流程,我们可以看到属性同步是通过FObjectReplicator::ReplicateProperties
函数执行的,进一步执行RepLayout->ReplicateProperties
。这里面比较重要的细节就是服务器是如何判断当前属性发生变化的,我们在前面设置通道Actor
的时候给FObjectReplicator
设置了一个Object指针,这个指针保存的就是当前同步的对象,而在初始化RepChangelistState
的同时我们还创建了一个Staticbuffer
,并且把buffer
设置和当前Object的大小相同,对buffer
取OffSet
把对应的同步属性值添加到buffer
里面。所以,我们真正比较的就是这两个对象,一般来说,staticbuffer
在创建通道的同时自己就不会改变了,只有当与Object
比较发现不同的时候,才会在发送前把属性值置为改变后的。这对于长期同步的Actor
没什么问题,但是对于休眠的Actor
就会出现问题了,因为每次删除通道并再次同步强制同步的时候这里面的StaticBuffer
都是Object默认的属性值,那比较的时候就可能出现0不同步这样奇怪的现象了。真正比较两个属性是否相同的函数是PropertiesAreIdentical()
,他是一个static
函数。
static FORCEINLINE bool PropertiesAreIdentical( const FRepLayoutCmd& Cmd, const void* A, const void* B )
{
const bool bIsIdentical = PropertiesAreIdenticalNative( Cmd, A, B );
return bIsIdentical;
}
在Compareproperties
函数中把现在UObject
的信息和FReplicationChangelistMgr
中的StaticBuffer
比较更新,然后在属性同步前判断条件复制属性的条件,如果符合则发送属性。
虽然属性同步是由服务器执行的,但是FObjectReplicator
,RepLayOut
这些数据可并不是仅仅存在于服务器,客户端也是存在的,客户端也有Channel
,也需要执行SetChannelACtor
。不过这些数据在客户端上的作用可能就有一些变化,比如StaticBuffer
,服务器是用它存储上次同步后的对象,然后与当前的Object比较看是否发生变化。在客户端上,他是用来临时存储当前同步前的对象,然后再把通过过来的属性复制给当前Object
,Object
再与StaticBuffer
对象比较,看看属性是否发生变化,如果发生变化,就在Replicator
的RepState
里面添加一个函数回调通知RepNotifies
。 在随后的ProcessBunch
处理中,会执行RepLayout->CallRepNotifies( RepState, Object )
;处理所有的函数回调,所以我们也知道了为什么接收到的属性发生变化才会执行函数回调了。
在随后的ProcessBunch
处理中,会执行RepLayout->CallRepNotifies( RepState, Object )
;处理所有的函数回调,所以我们也知道了为什么接收到的属性发生变化才会执行函数回调了。
思考:服务器如果发生变化,那么回调函数是否一定会执行?
回答:不是。举个例子;
UPROPERTY(Replicated, ReplicatedUsing = OnRep_Health)
float health;
void AFPSAIGuard::OnRep_Health()
{
GEngine->AddOnScreenDebugMessage(1, 10.f, FColor::Red, GetFName().ToString() + FString::SanitizeFloat(health));
OnHealthChanged(health);
}
void AFPSAIGuard::OnHealthHurt()
{
health -= 20.0f;
if (health <= 0.0f) {
Destroy();
}
}
// health默认是100
就比上面的例子,当服务器和客户端同时都调用这个OnHealthHurt
这个函数的时候,由于服务器的health发生变化改为80,而客户端此时也变为了80,服务器同步到客户端的时候,会去比较两个值,如果发生变化,就在Replicator
的RepState
里面添加一个函数回调通知RepNotifies
。 在随后的ProcessBunch
处理中,会执行RepLayout->CallRepNotifies( RepState, Object )
;处理所有的函数回调,所以我们也知道了为什么接收到的属性发生变化才会执行函数回调了。
思考:RPC与Actor同步谁先执行?
下面我们讨论一下RPC与同步直接的关系,这里提出一个这样的问题
问题:服务器ActorA在创建一个新的ActorB的函数里同时执行自身的一个Client的RPC函数,RPC与ActorB的同步哪个先执行?
答案:是RPC先执行。你可以这样理解,我在创建一个Actor的同时立刻执行了RPC,那么RPC相关的操作会先封装到网络传输的包中,当这个函数执行完毕后,服务器再去调用同步函数并将相关信息封装到网络包中。所以RPC的消息是靠前的。
那么这个问题会造成什么后果呢?
官网文档
//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;
}
通过上述图片和代码,我们可以看出,同步Actor子组件其实最终也是在同步属性。其实也就是我们在第4部分所做的内容。
其实如果了解了属性回调函数执行过程的话,那么RPC其实也是类似的方法。(前提实现了反射机制)
以下是RPC函数执行封包过程:
而我们在代码里的函数之所以必须要加上_Implementation,就是因为在调用端里面,实际执行的是.genenrate.cpp文件函数,而不是我们自己写的这个。同时结合下面的RPC执行堆栈,我们可以看到在UObject这个对象系统里,我们可以通过反射系统查找到函数对应的UFuntion结构,同时利用ProcessEvent函数来处理UFuntion。通过识别UFunction里面的标记,可以知道这个函数是不是一个RPC函数,是否需要发送给其他的端。 当我们开始调用CallRemoteFunction的时候,RPC相关的初始化就开始了。NetDiver会进行相关的初始化,并试着获取RPC函数的Replayout,那么问题是函数有属性么?正常来说,函数本身就是一个执行过程,函数名是一个起始的执行地址,他本身是没有内存空间,更不用说存储属性了。不过,在UE4的反射系统里面,函数可以被额外的定义为一个UFunction,从而保存自己相关的数据信息。RPC函数的参数就被保存在UFunction的基类Ustruct的属性链表PropertyLink里面,RepLayOut里面的属性信息就是从这里获取到的。 一旦函数的RepLayOut被创建,也同样会放到NetDiver的RepLayoutMap里面。随后立刻调用FRepLayout::SendPropertiesForRPC将RPC的参数序列化封装与RPC函数一同发送。
以下是RPC函数接收执行过程:
传递的过程中是以FFieldNetCache
的数据形式保存的。
在ReceivedRPC()
解析函数并且最后执行函数。
UE4版本 4.20
参考:
Exploring in UE4
Actor 复制流程详述