Tizeng's blog Ordinary Gamer

UE源码学习——网络同步

2022-06-30
Tizeng

客户端连接DS(2022.6.30)

客户端在连接ds时,会经过如下几个阶段:

  1. 向ds请求,得到许可后开始加载地图(c->s: Hello, s->c: Challenge, c->s: Login)
  2. ds调用AGameModeBase::PreLogin,如果没有问题就通知客户端(s->c: Welcome)
  3. 客户端设置bSuccessfullyConnected=true,在UEngine::LoadMap中加载地图(同时发送NetSpeed),加载完毕后告知ds(c->s: Join)
  4. ds调用AGameModeBase::Login创建PlayerController并同步到客户端
  5. 最后ds调用AGameModeBase::PostLogin,此时ds可以安全的调用PlayerController上的RPC函数,且在HandleStartingNewPlayer中初始化Player

服务器在UWorld::NotifyControlMessage中处理客户端的消息,客户端则在UPendingNetGame::NotifyControlMessage等方法中处理。

AGameMode中处理了多人射击游戏相关的内容,它维护了一个MatchState用来表示当前比赛的状态,当其进入InProgress状态时,说明游戏正式开始,HandleMatchHasStarted会被调用,通知所有Actor调用BeginPlay。

PostLogin中会拿到之前生成的Controller,调用它的一个Client函数,生成AHUD,也就是说只有客户端有,它的RemoteRole是NONE。

地图切换(官方文档

多人游戏时地图加载有两种模式,一种是无缝(seamless),一种是非无缝(non—seamless),它们的主要区别在于无缝是非阻塞的(non-blocking),而非无缝是阻塞的操作(blocking)。非无缝切换时客户端会先断开连接,重连后再加载新的地图,有三种情况必然会发现非无缝转移:初次加载、客户端初次连接服务器、重新开始游戏时。

无缝切换时会有一个过度地图(transition map),我们可以将当前场景中的部分Actor带进最终地图中,通过重载GameMode或PlayerController中的GetSeamlessTravelActorList方法,把想要直接带入新关卡的Actor加到一个列表中。

网络连接(Connection)

先看一下相关的基本类型:

  • UPlayer:包含一个PlayerController,代表该玩家
  • ULocalPlayer:继承自UPlayer,客户端上每个玩家都有一个LocalPlayer,增加了Viewport有关的信息。服务器上可能不存在
  • UNetConnection:继承自UPlayer,包含NetDriver的引用。有多个Channel,并用一个Map对应了名字,可以按名字查找
  • UNetDriver:保存在当前的World中,包含一个ServerConnection(客户端情况),多个ClientConnections(服务器情况),服务器上ServerConnection会为空

  • UChannel:包含一个OwnerConnection。用FName存了自己的名字
  • UActorChannel:继承自UChannel,用来处理Actor的同步和RPC,包含ActorNetGUID(FNetworkGUID),ActorReplicator(FObjectReplicator),每个同步的Actor都对应了一个ActorChannel
  • UControlChannel:每个Connection中只有一个
  • UVoiceChannel:每个Connection中只有一个

  • Packet:网络传输的基本单位,可以包含多个bunch
  • bunch:引擎网络同步的基本单位,分为发送用的FOutBunch和接收用的FInBunch
  • FNetworkGUID:用于在网络同步中识别对应的UObject,用一个uint32来区分

  • FObjectReplicator:用来表示正在被复制的对象或者执行的RPC,包含该对象的GUID

APlayerController会在客户端连接ds时被创建,并绑定对应的connection,每个连接至ds的客户端都被看做是一个connection。 PC中包含与之关联的Player(UPlayer,可能是LocalPlayer也可能是NetConnection),同时还有单独的NetConnection成员。 这也说明只有和玩家相关的actor才有连接。

当连接被关闭时,NetDriver的TickDispatch中会检测到,然后调用对应连接的CleanUp方法清除数据,最终这个连接所属的Controller会调用OnNetCleanup将自己释放。

Owner

官方文档

Owner和所属连接(owning connection)的概念在网络同步中很重要,每个actor上都有一个Owner的引用,它可能为空,当我们用一个PlayerController控制一个Pawn时,这个controller就是Owner,一个actor所属的连接和它所关联的controller有关。 RPC调用时服务器通过对应actor的所属连接,找到正确的客户端进行远程调用。

通过方法GetNetConnection可以获取一个actor所属的连接,它内部的实现就是再调用Owner的这个方法,递归的往上找,而Pawn则是去调用当前controller的这个方法。如果一个component需要网络复制,除了自身需要设置replicated,它的Owner也必须要设置。

客户端自己创建的Actor,Role是Authority,RemoteRole会是None,就算将其Owner设置为有连接的PlayerController,它对服务器来说也没有意义。

时序问题(2022.7.5)

参考知乎文章。 如果RPC函数和Actor同步在服务器上同时发生,理论上RPC会较快到达客户端,但是由于网络延迟等问题这是不确定的,比如服务器上修改了Actor上的某个变量,我们期望客户端上收到同步后调用绑定的OnRep函数,如果此时有RPC函数也修改了客户端上的值,那么这个函数可能就不会被调用了。 还有一种情况是用Controller去Possess一个新生成的Pawn时,会调用PRC函数ClientRestart到客户端,这时该Pawn可能还没有同步下来,导致客户端拿不到。引擎中对这种情况做了处理,PlayerController的PlayerTick中会时刻检查客户端上的Pawn是否为空,如果是则持续尝试GetPawn,直到成功拿到并Set,再去调用ClientRestart

RPC与属性同步还有一个执行时机的区别,如果服务端调用Client函数时客户端由于距离等问题还没有这个Actor,那么客户端之后便再也不会收到,此时就可以用属性同步来解决,只要客户端和服务端的数据不一致便会触发同步。但是有些信息比如特效,我们只希望播一次,如果有客户端延迟进入了同步范围,我们希望只同步Actor的状态而不再播放一次特效,这个可以将特效用NetMulticast、其他状态用Replication来解决。

相关性(Relevancy

场景中的actor数量可能非常多,而并不是每个actor都需要同步,为了提高效率,服务器只会同步和对应客户端有相关性的actor过去。 调用actor上的IsNetRelevantFor来查看它是否与某个连接相关,它会根据actor上配置的策略去查看Owner的同名方法或直接判断输入的viewer是否是自己的Owner,亦或是通过距离(NetCullDistanceSquared)判断。

ReplicationGraph也是一种相关性的优化,它主要是利用actor在场景中空间上的相关性,将场景划分为很多格子,每个actor上设置一个CullDistance,在这个距离内的所有格子都被视为需要同步的区域,当一个连接的viewer进入了某个格子所代表的区域时,对应的actor就需要对其同步。参考知乎文章

Subobject同步

官方文档

要同步UObject及其上面的属性,第一步是创建,以component为例,分为预先创建和动态创建两种,前者通过CreateDefaultSubobject在构造中创建,这种情况下服务器和客户端各自在spawn这个actor时执行,由于标记为bReplicated的actor会在spawn结束后自动同步到客户端,因此两端都会自动有。 预先创建的对象IsNameStableForNetworking会返回true。 而动态则是游戏运行时通过NewObject创建的,它并不会自动同步到客户端上,直到我们使用了下面的两种方式主动进行同步。

第一种是使用subobject的注册列表(FSubObjectRegistry)。首先在持有待同步UObject的actor上设置标记bReplicateUsingRegisteredSubObjectListtrue,然后调用AddReplicatedSubObject将对象加入ReplicatedSubObjects列表,这样在actor同步时会将这个列表中的对象一并写入FOutBunch中。需要注意的是如果要删除同步的对象,要先将其冲列表中移除,因为列表持有的是对象的裸指针,不主动移除可能发生崩溃。

第二种是重写actor上的ReplicateSubobjects方法,它会传入UActorChannel和数据bunch,我们通过调用channel上的ReplicateSubobject方法将要同步的对象写入bunch中。 查看源码可以得知,AActor中就是使用这个方法将ReplicatedComponents数组中的组件进行同步的。

无论使用上面哪种方式进行同步,UObject需要实现IsSupportedForNetworking并返回true,同时也要实现GetLifetimeReplicatedProps并在其中用宏注册需要同步的成员。

属性同步原理(2025.3.15)

参考《Exploring in UE4》网络同步原理深入

和属性同步相关的主要有以下几个类:

  • FRepLayout:每种类型都有一个,包含这个类型需要同步的所有属性,并提供同步所需的接口,如ReplicatePropertiesCallRepNotifies。 其中Parents数组(FRepParentCmd类型)表示所有需要同步的上层(top level)属性,Cmds数组(FRepLayoutCmd类型)表示上层属性所嵌套的子属性,如数组或结构中的属性(为什么要叫cmd?命令模式吗)。 在NetDriver中有一个map可以通过UClass来索引对应的layout。在初次获取时如果没找到就会创建,将对应UClass中所有需要同步的属性加到Parents数组中,如果该属性是数组或结构,则递归的执行加入到Cmds中。
  • FRepState:每个连接的每个对象都有一个,分别储存了接收和发送需要同步的信息,以及当前同步的状态条件
    • ReceivingRepState:缓存了对象的数据(StaticBuffer),用来比较是否发生变化,以及FProperty数组RepNotifies用来在同步发生的时候在客户端调用OnRep函数(FProperty中有一个RepIndex成员,用来在上面提到的Parents中获取数据,再通过和StaticBuffer的偏移获取同步回调函数的参数信息,这里有个问题,OnRep函数应该是没有参数的)
    • SendingRepState:缓存了属性tracker,还有变化的历史
  • FRepChangedPropertyTracker:追踪发生变化的属性以及同步的条件,判断是否需要同步
  • FObjectReplicator:最终执行同步的类,缓存了需要同步的UObject对象指针,与StaticBuffer中的信息比较

GetLifetimeReplicatedProps做了什么

想要使某个成员同步必须重写这个方法进行注册,FRepLayout初始化的时候会拿到传入UClass的CDO调用这个方法,获取到这个类中需要被同步的成员,DOREPLIFETIME系列的宏所作的就是将输入的成员信息(包括同步条件)注册到数组LifetimeProps中,最终被layout储存在Parents数组中。

RPC原理

被标记为RPC的函数在gen.cpp中会生成一个实现,通过生成的参数结构和名字调用ProcessEvent,还会有一个exec开头的静态函数,其中先调用了_Validate结尾的实现,如果通过则调用_Implementation结尾的实现,这就是为什么我们可以只需要实现一个后者执行逻辑。

ProcessEvent执行时会通过GetFunctionCallspace判断是否需要执行RPC,如果是则调用CallRemoteFunction,最终通过NetDriver中的ProcessRemoteFunction找到对应的连接去执行。 客户端收到bunch之后,进行一系列处理和条件判断,最终也调用ProcessEventInvoke,执行UFunction上绑定的exec开头的函数。

可靠传输

预测

参考知乎文章弱网延迟与预测Prediction等和源码(GameplayPrediction.h)。移动组件上也有关于预测的逻辑,需要在移动相关的笔记中整理。

GAS中实现了一套预测回滚机制,在客户端请求释放GA时不等待服务器的验证,本地先走逻辑,当服务器同步下来验证信息后进行校验,如果不通过则将之前的行为回滚,如结束GA、移除GE。 要执行预测,首先NetExecutionPolicy要选择LocalPredicted,先说一下最重要的两个类:

  • FPredictionKey:一次预测中的唯一标识,发生预测时会发送给服务器,服务器经过判断会将预测的结果连带这个key返回给客户端,客户端进行比较,如果能找到key一样的返回结果则说明预测成功,否则说明预测失败,需要回滚。其中包含两个int16,一个是当前预测的key(Current),一个是依赖的key(Base),还包含一个判断预测是否结束的脏标记bIsStale
  • FScopedPredictionWindow:一次预测的作用域窗口,在进行预测的时候定义一个,利用RAII在构造时创建一个新key,保证此次预测与生成的key的唯一对应关系,出作用域之后在析构函数中将key标记为无效,防止后续的操作再使用这个key

GA预测

步骤如下(源码注释):

  1. 客户端调用TryActivateAbility,生成一个新的预测key,然后调用rpc函数ServerTryActivateAbility通知服务器
  2. 客户端继续执行,判断cd、cost是否满足,如果满足则调用ActivateAbility,将预测key缓存在ActivationInfo
  3. 期间任何边际效应(side effect,如GE)都会生成一个新的预测key与之关联
  4. 服务器执行真正的判断,并根据结果用rpc通知客户端成功或失败,同时将预测key数组也同步过去(ReplicatedPredictionKeyMap
  5. 如何预测失败,则立刻结束技能,并通过rejected代理回滚任何边际效应
  6. 如果预测成功,则等待ReplicatedPredictionKey同步下来,通过catchup代理回滚边际效应(之前预测自己本地上的GE)

通过key中包含的依赖信息,如果一个GA又释放了其他GA,在生成新的预测key时会将最早开始预测的key将其进行关联,这样如果验证失败,客户端可以通过这些信息依次回滚所有相关GA。

GE预测

FActiveGameplayEffect中储存了这次GE的预测key,只要这个key有效客户端便会执行预测,服务器也会一起执行并进行同步,客户端收到后与本地的信息进行比对,如果找到了这个key则说明预测成功,由于客户端本地可能已经执行了GC,因此同步下来的GE不需要再执行这些操作。 但ActiveGameplayEffects中此时会有两个GE,一个是客户端预测执行的一个是服务器同步下来的,当ReplicatedPredictionKey也同步下来之后,会执行catch up的delegate,将预测的GE移除。

Attribute预测

只有Instant的GE能够被预测,它在客户端被临时变为Infinite处理,这样改动的就是current值,便于在服务器验证之后回滚。


Comments

Content