深入浅出UE4网络

Posted Leonhard-

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入浅出UE4网络相关的知识,希望对你有一定的参考价值。

UE4中的官方文档结构比较混乱,且有部分错误,不方便学习。笔者试图通过本文,整理出一篇关于UE4网络的文章,方便朋友们对UE4中的网络同步部分的认识,并有进一步理解。如有讲得不清楚明白的地方,还望批评指正。如需转载请注明出处,http://www.cnblogs.com/Leonhard-/p/6511821.html,这是对作者最起码的尊重,谢谢大家。

本文的结构如下:

一、UE4网络综述

二、UE4中的几种同步方式

1.Actor Replication

2.Property Replication

3.Function Call Replication

4.Actor Component Replication

5.Generic Subobject Replication

三、UE4中网络高级用法

1.复制对象引用

2.Role的深层次解读

3.对象归属性

四、UE4中的网络实例分析--Character Movement

五、Further More

1.Detailed Actor Replication

2.网络性能优化

一、UE4中的网络综述

  UE4中的网络模型和一般网络游戏的一样,是C/S模型。但是有些不同的是,UE4最初是用来做FPS的引擎,考虑到FPS的游戏性质,在网络的设计部分,考虑了两种服务器,一种是方便进行局域网本地游戏,在本地机器上搭建服务器,此时本地机器既是服务器又是客户端,即Listen Server。而另外一种则是更为专业的独立服务器,在独立服务器上则不执行渲染任务,只承担服务器的相关职责,即Dedicated Server,默认情况下独立服务器连界面都没有(除非在启动参数中加入-log)。

 

  而说起服务器和客户端,服务器对游戏状态拥有主控权力,机器之间出现数据的差异,都以服务器的为准。而此时的客户端只有服务器的近似值,不难理解,越好的网络环境和网络模型,客户端的游戏状态会接近服务器。但需要注意的是,客户端之间是没有直接连接的,必须通过服务器来进行客户端之间的交互,换句话说,如果没有服务器告知,客户端之间是不知道互相之间的存在的。而且有一个原则,游戏信息只准从服务器向客户端同步,客户端不能向服务器同步,就算客户端发信息给服务器,服务器也当成垃圾丢掉,客户端向服务器发信息的方式只有调用RPC中的Server函数一种形式,这个后面具体会讲。

  如果对服务器和客户端建立连接的过程感兴趣的话,可以参看这幅图(官网上的建立连接过程在顺序上有问题,以这幅图为准)

二、UE4中的几种同步方式

1.Actor Replication

  Actor是UE4中场景中可以显示的核心,在多人网络环境下,Actor也是网络传输的核心元素,甚至可以说Actor是网络同步中的基本单位,后面即将讲到的RPC和属性复制都是在Actor复制中进行的。当Actor的状态发生变换时,引擎会在预设的时间范围内(可以设置同步间隔时间)对该Actor进行网络同步,使得该Actor在应该存在的电脑上(条件复制)得到与服务器的版本。但是默认情况下,UE4不知道是否该对一个Actor执行复制操作,我们需要将Actor::bReplicates变量设置为true。既然Actor是网络同步的基本单位,如果这个变量的Actor::bReplicates为false,那么Actor下的Property Replication,RPC等等,都是白搭。Actor::bReplicates有两种设置方式,一种是在蓝图里,一种是在C++里。

在C++中:

 

或者,在蓝图中:

 

  OK。那么这个时候新问题来了,如果这个Actor服务器只想让Actor的所有者得到同步信息,或者说,不想让整个地图的人都知道Actor的信息,只想让距离Actor的一定范围的人得到同步,又或者说,只想让某些付费玩家得到同步信息,怎么办?这时有一个叫“network relevant”的概念,也就是说,只有当一个Actor对于服务器是net relevant的时候,服务器才会同步信息给他。比如说:只想让Actor的所有者得到信息,我们可以设置Actor::bOnlyRelevantToOwner变量为true。

 

  比如:不想让整个地图的人都知道Actor的信息,只想让距离Actor的一定范围的人得到同步,我们可以设置Actor::NetCullDistanceSquared。当然,这个又会引出新的问题,具体可参看视频,本文为了保持文章的条理性,不进一步讨论这个问题。

  再比如:如果只想让收费玩家得到同步信息,那么此时引擎里没有预设变量来完成这个功能,我们可以Override在Actor::IsNetRelevantFor函数。

2.Property Replication

  当我们将Actor设置为bReplicates = true后,在进行同步时,并不是将所有的属性进行同步,只有将属性标记为需要复制后,同步时才会同步属性。同步属性有两种方式,第一种是当服务器发生变化时,同步到对应客户端,成为Replicated。第二种是当属性发生变化时,同步属性并调用OnRep_函数()。第一种同步方式的例子。蓝图和C++中都可以使用这种方法,而且效果并无差异。

第一种同步方式的C++声明方式如下:

 

第一种同步方式的蓝图声明方式如下:

 

  第二种同步的方式需要特别说明一下。虽然C++和蓝图都可以使用这种方法,但是如果用C++声明此种同步属性,当属性在服务器发生变化时,对应的客户端自动调用OnRep函数,在服务器端,需要手动调用OnRep函数,称作ReplicatedUsing。而在蓝图里声明此种属性时,当属性在服务器发生变化时,服务器和客户端都会自动调用OnRep函数,不需要单独在服务器手动调用,称作RepNotify。

第二种同步方式的C++声明、定义方式如下:

 


 

  蓝图中的除了将Replication中选择RepNotify外,还需要定义如下的函数:

  在C++中实现同步属性,无论是第一种同步方式还是第二种同步方式还需要为属性设置Lifetime。设置的方式就是在类里Override Actor::GetLifetimeReplicatedProps函数。这个函数会在第一个类实例被创建时调用,这里需要注意的是,当有多个实例被创建时,GetLifetimeReplicatedProps函数并不会多次执行。这也就是说,Lifetime的设置是基于类本身还不是基于类实例的,如果属性的同步由某一个状态值来定的话,那么所有的实例都会用第一个实例的状态来定,而不是根据自己实例的状态来定。

 

  那么这个Lifetime有什么用呢。可以用来使用条件复制(Conditional Replication)就可以实现属性只同步给部分客户端的功能,具体的几种类型可以查看链接。那么有的朋友可能会问,那全部属性设成Replicated或者ReplicatedUsing不就行了。那么这样做虽然也可以,但是会有两个问题,一个是实际中的网络流量有限,必须节省带宽。另一个就是为了防止玩家作弊,不该让玩家知道的信息绝对不能让玩家知道。还有个问题,如果Actor的Net Relevant的作用范围小于属性的条件复制的作用范围怎么办,也就是说,如果Actor设置只同步给Owner,还属性设置同步给全部人会怎么样,前面说了,Actor是网络传输的最小单位,属性同步是放在Actor同步包里的,所以,以Actor的限制为准,也就是说这种情况下该属性只能传递给Owner。

 

  那么根据上面说法,条件复制属于“静态”生存期,不能跟随程序进行实时的调整,那么如果我们想根据程序的实时状态来动态调整属性的生存期,是可以的吗?答案是可以的,可以重写Actor::PreReplication函数使用DOREPLIFETIME_ACTIVE_OVERRIDE来实现。需要注意的是,这个限制它是基于Actor的,还不是基于连接的。

3.Function Call Replication

  Function Call Replication也叫作Remote Procedure Calls(RPCs),在蓝图中也称为Event Replication,是一种利用网络手段,将函数调用和执行分开的方式。在正式开始讨论RPC之前,我们先得来认识一下主控(Authority)。我们前面说到“服务器对游戏状态拥有主控权力,机器之间出现数据的差异,都以服务器的为准。”,所以可以说服务器对游戏具有Authority,任何与游戏规则,游戏状态有关的变量以及参与复制的对象(replicated object),都以服务器的为准,客户端的只是一个复制品。但是不是服务器就对所有的Actor拥有Authority呢?答案是:不是。比如说:只出现在客户端的UI,或者说一些本地产生的特效效果。此时的客户端就是这些Actor的Authority,要看谁是这个Actor的Authority,就看服务器是否有这个对象,如果没有,客户端就对这个Actor拥有Authority。但是一般情况下,我们可以把拥有Authority的对象看作是服务器,相反如果没有的话就看作客户端。

  以下是蓝图中区分客户端和服务器的方法。

 

或者在C++中。

 1 void AShooterCharacter::FlyDown()
 2 {
 3     if (this->Role < ROLE_Authority)
 4     {
 5         ServerFlyDown();
 6     }
 7     if (!GetCharacterMovement()->IsMovingOnGround() && !GetCharacterMovement()->IsFalling())
 8     {
 9         GetCharacterMovement()->SetMovementMode(MOVE_Falling);
10     }
11 }

  我们看到C++代码中,使用一个Actor::Role与一个枚举值进行比较。我们进一步看一下枚举值还有什么。

UENUM()
enum ENetRole
{
    /** No role at all. */
    ROLE_None,
    /** Locally simulated proxy of this actor. */
    ROLE_SimulatedProxy,
    /** Locally autonomous proxy of this actor. */
    ROLE_AutonomousProxy,
    /** Authoritative control over the actor. */
    ROLE_Authority,
    ROLE_MAX,
};

  其中的ROLE_NONE表示这个对象不扮演网络角色,不参与同步。ROLE_SimulatedProxy表示它是一个远程机器上的一个复制品,它没有权利来改变一个对象的状态,也不能调用RPC(现在先把RPC理解为下命令让别人去执行)。ROLE_AutonomousProxy表示它既可以完成ROLE_SimulatedProxy的工作(做一个复制品),又可以通过RPC来修改真正Actor的状态。简单来说,两种角色都可以同步服务器角色的信息,并在自己的电脑显示。但是ROLE_AutonomousProxy则可以通过调用RPC,让服务器来执行对应的命令。(这也是前面说的,客户端向服务器发送信息的唯一一种方式,这是RPC中的其中一种,称作Server函数,具体细节后面会介绍。)

  正如前面说说,RPC是一种利用网络手段,将函数调用和执行分开的方式,一共有三种RPCs,分别为Server函数,Client函数,Multicast函数。Server函数是客户端调用,服务器执行。什么意思呢?在网络环境下,一般情况下,所有客户端和服务器都同时拥有一个Actor的实例,服务器的网络角色为ROLE_Authority,而客户端有两种,一种为ROLE_SimulatedProxy,这种角色只能用来接收服务器给它同步的信息,而不能向服务器发送信息。而ROLE_AutonomousProxy角色,不仅可以用来接收服务器给它同步的信息,还可以利用调用Server函数,让服务器自行执行一段预设的代码,这个过程就是Server函数。相反,Client函数就是ROLE_Authority的角色调用,ROLE_AutonomousProxy的角色执行。Multicast函数则是服务器调用,服务器和所有客户端执行。一些特殊情况可以查看下表。下表描述的所有权中:Client-owned actor指的是服务器和客户端的Actor都同属于同一个UNetConnection家族的Actor,而Server-owned actor指的是只存在于Server端的Actor,Unowned actor则指其余Actor(这个说起来比较绕口,但是看完如下的代码可能会好一些)。具体的Client-owned actor可以参考如下的代码。

 1 void UActorChannel::ProcessBunch( FInBunch & Bunch )
 2 {
 3         //......
 4     // Owned by connection\'s player?
 5     UNetConnection* ActorConnection = Actor->GetNetConnection();
 6     if (ActorConnection == Connection || (ActorConnection != NULL && ActorConnection->IsA(UChildConnection::StaticClass()) && ((UChildConnection*)ActorConnection)->Parent == Connection))
 7     {
 8         RepFlags.bNetOwner = true;
 9     }
10         //......    
11 }

 

  从服务器调用RPC:

 
Actor 所有权未复制NetMulticastServerClient
Client-owned actor 在服务器上运行 在服务器和所有客户端上运行 在服务器上运行 在 actor 的所属客户端上运行
Server-owned actor 在服务器上运行 在服务器和所有客户端上运行 在服务器上运行 在服务器上运行
Unowned actor 在服务器上运行 在服务器和所有客户端上运行 在服务器上运行 在服务器上运行

  从客户端调用RPC:

 
Actor 所有权未复制NetMulticastServerClient
Owned by invoking client 在执行调用的客户端上运行 在执行调用的客户端上运行 在服务器上运行 在执行调用的客户端上运行
Owned by a different client 在执行调用的客户端上运行 在执行调用的客户端上运行 丢弃 在执行调用的客户端上运行
Server-owned actor 在执行调用的客户端上运行 在执行调用的客户端上运行 丢弃 在执行调用的客户端上运行
Unowned actor 在执行调用的客户端上运行 在执行调用的客户端上运行 丢弃 在执行调用的客户端上运行

 

  在蓝图中使用RPC时,只需要对函数进行如下设置即可。

  在C++中要将一个函数声明为 RPC,您只需将 ServerClient 或 NetMulticast 关键字添加到 UFUNCTION 声明。

  例如,若要将某个函数声明为一个要在服务器上调用、但需要在客户端上执行的 RPC,您可以这样做:

1 UFUNCTION( Client );
2 void ClientRPCFunction();

  如果要将某个函数声明为一个要在客户端上调用、但需要在服务器上执行的 RPC,您可以采取类似的方法,但需要使用 Server 关键字:

1     UFUNCTION(reliable, server, WithValidation)
2     void ServerFlyUp();

  此外,还有一种叫做多播(Multicast)的特殊类型的 RPC 函数。多播 RPC 可以从服务器调用,然后在服务器和当前连接的所有客户端上执行。 要声明一个多播函数,您只需使用 NetMulticast 关键字:

1 UFUNCTION( NetMulticast,unreliable );
2 void MulticastRPCFunction();

  接下来定义我们的RPC函数。此时需要注意的是,RPC函数的定义需要在函数末尾添加_Impementation,这是跟引擎的具体调用有关,这里不深入探讨,有兴趣的朋友可以参考.generate.h文件。我们直接看例子:

1 void AShooterCharacter::ServerFlyUp_Implementation()
2 {
3     FlyUp();
4 }
1 void AShooterCharacter::FlyUp()
2 {
3      if (this->Role < ROLE_Authority)
4      {
5          ServerFlyUp();
6      }
7       //implement character fly up
8        //.......  
9  }    

  上面两段代码中,第一段是Server函数的具体实现,而下一段是Server函数在客户端的调用和具体飞行的逻辑。这两段代码有点绕,具体含义是这样的:客户端的AutonomousProxy角色响应键盘的输入,本地的Actor执行FlyUp()函数,实现角色的本地飞行,然后客户端调用RPC Server函数,RPC函数在客户端得到调用,因为它是Server函数,所以在Server端进行执行,执行的代码就写在ServerFlyUp_Impementation()里,而执行的代码与客户端本地的FlyUp()相同,只不过,因为服务器Role == Role_Authority,所以,不再调用ServerFlyUp。那么有的朋友可能会有问题,能不能给RPC函数添加参数呢,答案是可以的。UE4会自动帮你同步参数,前提是你的参数类型必须是Replicates的。那么指针参数会不会导致指针失效呢?答案是不会的。具体原因后面会讨论。还有个小问题,能不能把飞行的最终位置作为参数传给Server函数呢?答案可以,但是不好。为什么呢?技术上要实现是可以的,但是因为客户端的数据是不可靠的,为了防止玩家作弊,RPC函数的使用最好以命令的形式传给服务器比较好。

  注意到Server函数的例子中,添加了WithValidation的字段。这是为了防止玩家作弊,添加的一个验证函数,我们可以自行编写验证条件,如果客户端的数据不满足验证条件,则客户端这边会直接放弃RPC的调用,到了服务器那边,服务器在执行Server函数之前还会进行一次判断,防止客户端发送假的包过来,如果判断通过了,才会继续执行。实验环境下,我们默认验证条件都返回true。

1 bool AShooterCharacter::ServerFlyUp_Validate()
2 {
3     return true;
4 }

  上述的一个例子中,还有两个字段是reliable和unreliable。reliable相当于TCP传输,可靠,必达,顺序一定,但是速度慢。unreliable则相当于UDP,不可靠,网络不好时有可能出现不可达,但是好处就是速度快。

  总结一下得到如下的表格。

选项说明
Not Replicated 这个是默认选项,表示该事件不会进行复制。如果它是在客户端上调用,就只能在这个客户端上运行,如果在服务器上调用,就只能在服务器上运行。
Multicast 如果一个多播事件是在服务器上调用,它将不顾哪条连接拥有目标对象,而被复制到所有已连接的客户端。如果客户端调用了一个多播事件,它将被视为没有经过复制,而且只能在调用它的客户端上运行。
Run on Server 如果该事件是从服务器调用,它就只能运行在服务器上。如果是从客户端调用且拥有一个归客户端所有的目标,它将被复制到服务器并在上面运行。“Run on Server” 事件是客户端向服务器发送数据的主要途径。
Run on Owning Client 如果从服务器调用,该事件将运行于拥有目标 actor 的客户端。由于服务器可以拥有 actor,“Run on Owning Client” 事件可能实际运行在服务器上(尽管从名称上看不是这样)。如果是从客户端调用,该事件将被视为没有经过复制,而且只能在调用它的客户端上运行。


4.Actor Component Replication

  (此部分官网已经讲得较为清楚,可点击此处进入链接)UE4支持Actor的组件复制,但是在UE4中运用的却不多,组件的属性修改基本都是通过组件的Server函数来实现,很少情况下才需要在逻辑代码中直接修改Component的属性,或者动态增/删组件。Component作为Actor的一部分进行复制,Actor 仍然掌管角色、优先级、相关性、剔除等方面的工作。一旦复制了 Actor,它就可以复制自身的Component。这些组件可以按 Actor 的方式复制属性和RPC(至于Component为什么能调用RPC函数,后面会有解释)。Component必须以 Actor 的方式来实施::GetLifetimeReplicatedProps函数。

  组件复制中涉及两大类组件。一种是随 Actor一起创建的静态组件。也就是说,在客户端或服务器上生成所属Actor时,这些组件也会同时生成,与组件是否被复制无关。服务器不会告知客户端显式生成这些组件。 在此背景下,静态组件是作为默认子对象在 C++ 构造函数中创建,或是在蓝图编辑器的组件模式中创建。静态组件无需通过复制存在于客户端;它们将默认存在。只有在属性或事件需要在服务器和客户端之间自动同步时,才需要进行复制。动态组件是在运行时在服务器上生成的组件种,其创建和删除操作也将被复制到客户端。它们的运行方式与 Actor 极为一致。与静态组件不同, 动态组件需通过复制的方式存在于所有客户端。另外,客户端可以生成自己的本地非复制组件。这适合于很多种情形。只有当那些在服务器上触发的属性或事件需要自动同步到客户端时,才会出现复制行为。

  在组件上设置属性和 RPC 的过程与 Actor 并无区别。将一个类设置为具有复本后,这些组件的实际实例也必须经过设置后才能复制。在C++中进行组件复制,只需调用 AActorComponent::SetIsReplicated(true) 即可。如果需要复制的组件是一个默认子对象,就应当在生成组件之后通过类构造函数来完成此调用。例如:(注意:真正的ACharacter不是这样实现的)

 1 ACharacter::ACharacter()
 2 {
 3     // Etc...
 4 
 5     CharacterMovement = CreateDefaultSubobject<UMovementComp_Character>(TEXT("CharMoveComp"));
 6     if (CharacterMovement)
 7     {
 8         CharacterMovement->UpdatedComponent = CapsuleComponent;
 9         CharacterMovement->GetNavAgentProperties()->bCanJump = true;
10         CharacterMovement->GetNavAgentProperties()->bCanWalk = true;
11         CharacterMovement->SetJumpAllowed(true);
12         CharacterMovement->SetNetAddressable(); // Make DSO components net addressable
13         CharacterMovement->SetIsReplicated(true); // Enable replication by default
14 
15     }
16 }

  或者是在蓝图中设置:要进行静态蓝图组件复制,只需在组件默认设置中切换 Replicates 布尔变量。同样,只有当组件中拥有需要复制的属性或事件时,才需要 进行此操作。静态组件需要在客户端和服务器上隐式创建。

components_checkbox.png

需要注意的是,并非所有组件都会如此显示,必须要支持某种复制形式才会显示。要通过动态生成的组件来实现这一点,可以调用 SetIsReplicated 函数:

components_function.png

5.Generic Subobject Replication

  其实,UE4还可以复制任意的UObject对象,UObject对象还可以嵌套其他的UObject对象,而且UObject还可以设置RPC和同步属性,功能大致和Actor一致。但是需要注意的是,UObject必须最终被包含在Actor中。只有Actor进行同步时,SubObject才能被同步。首先应该Override UObject::IsSupportedForNetorking。

 1 //ReplicatedSubobject.h
 2 UCLASS()
 3 class UReplicatedSubobject : public UObject
 4 {
 5     GENERATED_UCLASS_BODY()
 6  
 7 public:
 8  
 9     UPROPERTY(Replicated)
10     uint32 bReplicatedFlag:1;
11  
12     virtual bool IsSupportedForNetworking() const override
13     {
14         return true;
15     }
16 };

然后,再Override UObject::UReplicatedSubobject。

 1 //ReplicatedSubobject.cpp
 2 UReplicatedSubobject::UReplicatedSubobject(const class FPostConstructInitializeProperties& PCIP)
 3 : Super(PCIP)
 4 {
 5 }
 6  
 7 void UReplicatedSubobject::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const
 8 {
 9     DOREPLIFETIME(UReplicatedSubobject, bReplicatedFlag);
10 }

最后,在同步的Actor里,我们需要实现AActor::ReplicateSubobjects() 

 1 UCLASS()
 2 class AReplicatedActor : public AActor
 3 {
 4     GENERATED_UCLASS_BODY()
 5  
 6 public:
 7  
 8     virtual void PostInitializeComponents() override;
 9     virtual bool ReplicateSubobjects(class UActorChannel *Channel, class FOutBunch *Bunch, FReplicationFlags *RepFlags) override;
10  
11     /** A Replicated Subobject */
12     UPROPERTY(Replicated)
13     UReplicatedSubobject* Subobject;
14  
15 private:
16 };
 1 #include "ReplicatedActor.h"
 2 #include "UnrealNetwork.h"
 3 #include "Engine/ActorChannel.h"
 4 AReplicatedActor::AReplicatedActor(const class FPostConstructInitializeProperties& PCIP)
 5 : Super(PCIP)
 6 {
 7     bReplicates = true;
 8 }
 9  
10 void AReplicatedActor::PostInitializeComponents()
11 {
12     Super::PostInitializeComponents()
13  
14     if (HasAuthority())
15     {
16         Subobject = NewObject<UReplicatedObject>(this); 
17     }
18 }
19  
20 bool AReplicatedActor::ReplicateSubobjects(class UActorChannel *Channel, class FOutBunch *Bunch, FReplicationFlags *RepFlags)
21 {
22     bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);
23  
24     if (Subobject != nullptr)
25     {
26         WroteSomething |= Channel->ReplicateSubobject(Subobject, *Bunch, *RepFlags);
27     }
28  
29     return WroteSomething;
30 }
31  
32 void AReplicatedActor::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const
33 {
34     DOREPLIFETIME(AReplicatedActor, Subobject);
35 }

三、UE4中网络高级用法

1.复制对象引用

  在UE4中,C++的指针同步是一个不得不处理的对象。如果服务器只是单纯把对象的地址作为信息传给客户端的话,那么客户端会引用无效内存而崩溃。在UE4中,使用FNetworkGUID来作为对象在服务器上的唯一ID。有两种情况服务器会为UObject对象分配GUID,第一种是,第二章里谈及到的所有类型都可以被服务器分配UObject,第二种是具有Stably Named 的UObjects。什么是Stably Named Objects?就是客户端和服务器上名字完全相同的对象(即使这个对象不会被复制,也拥有GUID)。那么什么情况下会存在Stably Named Objects呢?1.从包里直接load出来的对象,比如关卡里放的静态对象。2.通过construction scripts添加的对象。3.通过UActorComponent::SetNetAddressable手动标记的对象。

2.Role的深层次解读

  前面说过,有三种网络角色,分别是ROLE_Autonomous、ROLE_Simulated和ROLE_Authority。其实在UE4的底层代码中,这三个角色不存在实权,三种网络角色其实跟调用RPC函数之间是没有直接关系的。客户端调用RPC Server函数时,服务器端仅仅判断这个客户端Pawn的GetNetConnection是否与这个服务器端的Pawn同属相同的UNetConnection家族,如果相同,则允许Pawn调用RPC Server函数,UE4根本不会判断这个Pawn是什么网络角色。可以做个有趣的实验,我们可以将Actor的Role设为Simulated,发现它还是可以调用RPC,反之如果把Simuate置为Autonomous,是不能调用RPC的。那Role还有什么意义呢?Role是UE4中帮助我们理解和认识UE4网络框架的一个概念,可以反映UE4的网络架构。在一般情况下,我们就可以认为具有Autonomous角色的Pawn就可以调用RPC Server函数。如果只是作为了解,那么这一节就不用再往下读了,如果你是一个有强迫症的人,想要弄清楚他们之间的关系。OK。接下来的图,可以帮助你了解PlayerController、UNetConnection、Role和RPC之间的关系。

 

  此时,我们知道了,Role只是一个“巧合”,它与是否调用RPC没有直接关系。

3.对象归属性

  官网抛出了Client-owned actorServer-Owned actor两个概念。根据上下文,Client-owned actor指的是服务器和客户端的Actor都同属于同一个UNetConnection家族的Actor,而Server-owned actor指的是存在于Server端的Actor。其实,这两个概念叫connection-owned和Server-only actor更加贴切。每个连接都有一个专门为其创建的PlayerController。每个出于此原因创建的PlayerController都归这个连接所有。要确定一个 actor 是否归某一连接所有,可以查询这个 actor 最外围的所有者,如果所有者是一个 PlayerController,则这个 actor 同样归属于拥有 PlayerController 的那个连接。类似的例子包括 Pawn actor 归 PlayerController 所有的情形。它们的所有者将是其所属的 PlayerController。在此期间,它们归属于 PlayerController 的连接。在连接归属于 PlayerController 期间,Pawn 只能由该连接所有。所以,一旦 PlayerController 不再拥有这个 Pawn,后者就不再归连接所有。另一个例子就是道具栏物品归 Pawn 所有的情况。这些道具栏物品归属于可能拥有该 Pawn 的同一连接(如存在)。在确定所属连接方面,组件有一些特殊之处。这时,我们要首先确定组件所有者,方法是遍历组件的“外链”,直到找出所属的 actor,然后确定这个 actor 的所属连接,像上面那样继续下去。

连接所有权是以下情形中的重要因素:

  • RPC 需要确定哪个客户端将执行运行于客户端的 RPC

  • Actor 复制与连接相关性

  • 在涉及所有者时的 Actor 属性复制条件

  连接所有权对于 RPC 这样的机制至关重要,因为当您在 actor 上调用 RPC 函数时,除非 RPC 被标记为多播,否则就需要知道要在哪个客户端上执行该 RPC。它可以查找所属连接来确定将 RPC 发送到哪条连接。连接所有权会在 actor 复制期间使用,用于确定各个 actor 上有哪些连接获得了更新。对于那些将bOnlyRelevantToOwner设置为true的actor,只有拥有此actor的连接才会接收这个 actor 的属性更新。默认情况下,所有 PlayerController 都设置了此标志,正因如此,客户端才只会收到其拥有的 PlayerController的更新。这样做是出于多种原因,其中最主要的是防止玩家作弊和提高效率。对于那些要用到所有者的需要复制属性的情形来说,连接所有权具有重要意义。例如,当使用 COND_OnlyOwner 时,只有此 actor 的所有者才会收到这些属性更新。最后,所属连接对那些作为自治代理的 actor(角色为 ROLE_AutonomousProxy)来说也很重要。这些 actor 的角色会降级为 ROLE_SimulatedProxy,其属性则被复制到不拥有这些 actor 的连接中。

四、UE4中的网络实例分析--Character Movement

  设想在游戏中,角色在向前走,当把这个角色同步到其他机器上时,由于网络传输的延迟和网络带宽的限制,我们不可能实时的同步每一个角色每一瞬间的位置和动作,这样的话,我们的角色在其他电脑上的移动并不是很平滑,看起来是一直在瞬移的,这是一个问题;还有,UE4中是采用RPC同步的方式将客户端玩家的输入传给服务器,让服务器进行处理,那么,如果我们移动函数的参数设置为具体的速度值或者位移值,那么这样的话,玩家就可以通过给服务器传假的包来作弊;即使我们不传输具体数值,这样的话,我们传递命令给服务器,让服务器自行移动,仔细一想,这样也会有问题,那么通过加快客户端运行速度来实现客户端频繁的向服务器发生移动命令,也可以实现作弊……幸运的是,UE4已经帮我们解决了角色的移动问题。就是CharacterMovementComponent组件。这一节分析一下UE4对于角色移动的所做的考虑,作为一次网络实例的分析。

(1)UE4解决了玩家的瞬移问题

  为了解决玩家在两次同步之间的缝隙造成的瞬移,UE4采取本地对角色的移动进行模拟的策略,在UCharacterMovementComponent::SimulateMovement和MoveSmooth中,UE4实现了对SimulatedProxy的移动模拟,而且后者更加的节约性能。

(2)处理Speed Hack问题

  在以前的CS里,可以利用加速齿轮加快本地机器的运行速度,使得调用RPC的频率增加,进而让自己的角色移动速度和开枪速度加快。这个就是Speed Hack问题。那么为了不让玩家作弊,UE4加入了一种Speed Hack Protection的方法。在每次客户端调用移动的RPC Server函数时,加入一个TimeStamp的变量,服务器会根据这个变量,计算客户端本次调用RPC函数与上一次调用的时间差,如果这个差值过于小的话,则RPC函数调用失败。此时,客户端因为先执行了移动的函数,造成了客户端和服务器角色的位置不同步。此时服务器会重新发包给Actor所在客户端进行数据的更新,如果客户端的Actor的位置与服务器发来的位置偏差较大时,客户端会在调用PerformMovement进行移动之前,先调用ClientUpdatePostion进行位置的调整与更新。其中,两次RPC函数之间的调用阈值可以在GameNetworkManager中设置。

(3)RPC Server函数是设计为reliable还是unreliable

  我们想一下,涉及玩家移动的RPC Server函数,是设计为reliable好还是unreliable妙呢?如果是reliable的话,函数必然会执行,但是,这样执行速度太慢,会造成玩家体验感不好。而且如果网速卡的情况下,还会出现新的问题,比如:玩家看到了boss,点击了必杀,结果网络卡了半天,boss已经被消灭,此时玩家丢出去的必杀收不回来,又原地放了个必杀,这就很尴尬了。所以玩家行动的RPC函数,设计成unreliable会比较好一些。但是unreliable也会有问题,比如说:玩家点击了一下瞬移,网络卡了,包没发过去,此时客户端进行了移动,但是等服务器的同步包过来时,客户端又被拉到了移动前,看起来就像按键失灵了一样。一个比较好的办法是在客户端保持一个行动队列,每一次客户端的动作都加到队列里,当服务器确认了一个动作之后,就把动作从行动队列里移除,如果服务器没有确认该动作,则客户端重新发包,客户端这边就不会有“按键失灵”的感觉。

五、Further More

1.Detailed Actor Replication

  具体的细节,官网上有比较详细的文档。大多数 actor 复制操作都发生在 UNetDriver::ServerReplicateActors 内。在这里,服务器将收集所有被认定与各个客户端相关的 actor,并发送那些自上次(已连接的)客户端更新后出现变化的所有属性。

这里还定义了一个专门流程,指定了 actor 的更新方式、要调用的特定框架回调,以及在此过程中使用的特定属性。其中最重要的包括:

  • AActor::NetUpdateFrequency - 用于确定 actor 的复制频度

  • AActor::PreReplication - 在复制发生前调用

  • AActor::bOnlyRelevantToOwner - 如果此 actor 仅复制到所有者,则值为 true

  • AActor::IsRelevancyOwnerFor - 用于确定 bOnlyRelevantToOwner 为 true 时的相关性

  • AActor::IsNetRelevantFor - 用于确定 bOnlyRelevantToOwner 为 false 时的相关性

相应的高级流程如下:

  • 循环每一个主动复制的 actor(AActor::SetReplicates( true )

    • 确定这个 actor 是否在一开始出现休眠(DORM_Initial),如果是这样,则立即跳过。

    • 通过检查 NetUpdateFrequency 的值来确定 actor 是否需要更新,如果不需要就跳过

    • 如果 AActor::bOnlyRelevantToOwner 为 true,则检查此 actor 的所属连接以寻找相关性(对所属连接的观察者调用 AActor::IsRelevancyOwnerFor)。如果相关,则添加到此连接的已有相关列表。

      • 此时,这个 actor 只会发送到单个连接。

    • 对于任何通过这些初始检查的 actor,都将调用 AActor::PreReplication

      • PreReplication 可以让您决定是否针对连接来复制属性。这时要使用 DOREPLIFETIME_ACTIVE_OVERRIDE

    • 如果同过了以上步骤,则添加到所考虑的列表

  • 对于每个连接:

    • 对于每个所考虑的上述 actor

      • 确定是否休眠

      • 是否还没有通道

        • 确定客户端是否加载了 actor 所处的场景

          • 如未加载则跳过

        • 针对连接调用 AActor::IsNetRelevantFor,以确定 actor 是否相关

          • 如不相关则跳过

    • 在归连接所有的相关列表上添加上述任意 actor

    • 这时,我们拥有了一个针对此连接的相关 actor 列表

    • 按照优先级对 actor 排序

    • 对于每个排序的 actor:

      • 如果连接没有加载此 actor 所在的关卡,则关闭通道(如存在)并继续

      • 每 1 秒钟调用一次 AActor::IsNetRelevantFor,确定 actor 是否与连接相关

      • 如果不相关的时间达到 5 秒钟,则关闭通道

      • 如果相关且没有通道打开,则立即打开一个通道

      • 如果此连接出现饱和

        • 对于剩下的 actor

          • 如果保持相关的时间不到 1 秒,则强制在下一时钟单位进行更新

          • 如果保持相关的时间超过 1 秒,则调用 AActor::IsNetRelevantFor 以确定是否应当在下一时钟单位更新

      • 对于通过了以上这几点的 actor,将调用 UChannel::ReplicateActor 将其复制到连接

UChannel::ReplicateActor 将负责把 actor 及其所有组件复制到连接中。其大致流程如下:

  • 确定这是不是此 actor 通道打开后的第一次更新

    • 如果是,则将所需的特定信息(初始方位、旋转等)序列化

  • 确定该连接是否拥有这个 actor

    • 如果没有,而且这个 actor 的角色是 ROLE_AutonomousProxy,则降级为 ROLE_SimulatedProxy

  • 复制这个 actor 中已更改的属性

  • 复制每个组件中已更改的属性

  • 对于已经删除的组件,发送专门的删除命令

2.网络性能优化

  一般的网络优化手段基本都是基于上述的某些手段,官网已经将这些方法总结成了一篇短文,具体可以参看官网。复制 actor 是一件耗费时间的工作。引擎会尽量让这个过程变得更有效率,但您也可以做一些额外的工作来简化这个过程。在收集 actor 用于复制时,服务器将检查一些事项,如相关性、更新频度、休眠情况等。您可以调整这些检查项以改善性能。要最大限度提升这一过程的效率,最好是遵循以下优先顺序:

  • 关闭复制(AActor::SetReplicates( false )

    • 当 actor 未进行复制时,它最初不会出现在列表中,我们可以充分利用这一点,确保那些无需复制的 actor 会有相应标记。

  • 减少 NetUpdateFrequency 值

    • actor 的更新次数越少,更新所用的时间就越短。最好是尽量压低这个数值。该数值代表了这个 actor 每秒复制到客户端的频度。

  • 休眠情况

  • 相关性

  • NetClientTicksPerSecond

  • 能在客户端生成的就不要在服务器生成(爆炸,特效等统一用一个函数来让客户端生成)

  如果属性并非是绝对必需,则不要将其标记为复制。如果可以,最好能尝试从现有的已复制属性中派生状态。尝试利用已有的量化函数,如 FVector_NetQuantize。这样能大大减少向客户端复制此状态时所需的大小,如果使用得当,就不会导致任何明显的偏差。FName 一般不会被压缩,所以在使用它们作为 RPC 的参数时,请记住它们通常会向字符串发送所有调用。这会产生很大的资源消耗。

以上是关于深入浅出UE4网络的主要内容,如果未能解决你的问题,请参考以下文章

UE4 C++复刻AdvancedLocomotionSystemV动画高级系统---更新中

UE4 ShooterGame Demo的开火的代码

UE4配置文件详解

「游戏引擎 浅入浅出」4.3 片段着色器

「游戏引擎 浅入浅出」4.3 片段着色器

「游戏引擎 浅入浅出」4.3 片段着色器