前言

要构建多人的网络游戏,理解UE4的网络框架和原理是十分必要的。所以在这里
对 UE4 的网络框架做一个简单总结,一些内容来源于自己的理解,还有一些来源于对官方文档和引用文章的翻译。

UE4 的网络框架与许多常见框架一样采用了 C/S 的客户端 – 服务器模型架构 ,这就意味着服务器有绝对 权威(authoritative) 决定关键的行为,客户端一般将动作请求发送到服务器由服务器进行校验,然后服务器处理数据变化,再将结果同步回客户端进行展示和模拟。

Actor的作用

在实现网络通信和同步上,UE4 操作的基本对象就是 Actor, 所以参与同步的类不是直接或者间接继承自 Actor C++ 基类 AActor, 就是作为某个 Actor 的组成部分。

在通信机制上,UE4 对 Actor 提供了两种基本机制,一种是同步 (Replication) , 一种是远程过程调用RPCs(Remote Procedure Calls)。同步是 UE4 自动管理进行的,用于频繁发生变化的属性和数据的同步;RPCs 由程序主动调用,类似于函数调用,只不过调用和执行的机器并不是在同一个地方。之后会详细介绍这两种机制。

网络模式 Network Modes

UE4 定义了几种网络模式,都有专门的的枚举来表示,表示当前运行程序在网络同步中的角色或者作用,它们分别是:

  • NM_Standalone:表示服务器运行在单机状态,不接受任何其他连接客户端的请求。可以看做是没有网络功能的单机程序,一般用于单人游戏或者本地游戏。

  • NM_DedicatedServer:专用服务器 (Dedicated Server),类似于传统中心 C/S 网络结构中的服务器角色,它本身不包含任何客户端。只运行服务器的逻辑,所以省略可以不运行不必要的声音,画面,用户输入等客户端专有的处理逻辑。

  • NM_ListenServer:包含了本地 Player 的服务器模式。这个模式既包含服务器部分的功能又包含客户端部分的功能。但是由于本身也是客户端,所以在此机器上运行的逻辑和决策并不是完全可信的。一般这种模式用于几个人联机点对点的服务,比如以前的魔兽RPG的开房间模式,其中一个客户端作为服务器管理中转其他客户端的信息。

  • NM_Client: 纯客户端的网络模式,当处于这个模式,客户端是连接到远程的 Dedicated Server 专用服务器或者 Listen Server上,不会运行服务器端的逻辑。

下面会详细列列举 UE4 网络相关的一些基础知识。

UE4服务器类型

就像之前提过的,服务器运行方式分为2种类型,分别是 Listen Server 和 Dedicated Server 。

Dedicated Server

Dedicated Server 是专用服务器,是作为一个 独立运行 的服务器,不需要与任何客户端绑定。Dedicated Server与客户端分离运行,一般作为一个中心服务器让客户端加入或者离开。它可以被编译在 Windows 和 Linux 平台运行,也可以运行在 Virtual Server 虚拟服务器,让客户端通过固定的 IP 地址连接。Dedicated Server 不需要显示部分,所以不需要 UI,不需要 PlayerController。也没有专属的角色在游戏中。

ListenServer

ListenServer 是客户端和服务器端结合的一种服务器类型,所有至少会有一个 Client 连接到这个服务器,当客户端关闭,服务器也会关闭。ListenServer需要有 UI 和 PlayerController,可通过 PlayerContoller[0] 来获得。

ListenServer 的 IP 地址就是运行客户端的地址,所以可能会产生没有固定的静态 IP 的问题。这个问题可以由 OnlineSubsystem 子系统来解决。

服务器交互机制

Actor是网络交互中的第一类对象 ,所以要进行网络信息的同步传输,首先要找到需要同步的Actor。为了使用网络功能,必须把 Actor 的 bReplicates 属性设置为 True,这个属性在 C++ 中或者蓝图中都可以使用。

在 Actor 的网络交互机制上,主要分为2种类型,分别是 Replication 同步和 Remote Procedure Calls(RPCs) 远程过程调用。

Replication 同步,主要用于进行属性的自动同步。一般用于一些经常变化的属性,比如角色的 HP 血量,移动位置等。

RPCs 远程过程调用,由调用者主动发起。一般用于一些通知事件的发生,或者发起一些行为和动作。比如使用武器开火射击,或者释放了一个技能

Replication 同步

Replication 属性同步一般是指服务器将数据传递到客户端的动作(反之不行)。进行同步的方式分为两种,Property Replication属性同步 和 Component Replication 组件同步 。

顾名思义, Property Replication 是以对象属性作为基本同步单位的同步机制;而 Component Replication 组件同步是以 Actor 组件为基本管理单位的同步机制,相对于属性同步属于更粗粒度的管理。

下面会具体介绍两种机制和使用方式。

Property Replication

Property Replication 是以 Actor 的属性为单位进行网络同步的机制。每个 Actor 会维护一个包含了所有需要同步的属性列表。当其中有属性发生变化的时候,服务器就会把更新的属性值发送到所有客户端进行同步,客户端收到同步的属性值后,就会更新本地对应的属性为新的值。这种同步机制的方向 只会从服务器Server发送到客户端Client客户端永远不会同步属性到服务器。如果强行在客户端修改了这个属性,那么这个值可能会在下次服务器同步的时候被覆盖,或者永远与其他客户端不同步,导致一些错误。

使用 Property Replication

Property Replication 的使用主要分为三步:

  • 首先要保证 Actor 已经把 Replicates 标记开启了,这个是所有网络机制启用的基本保证。设置方式既可以通过蓝图勾选,也可以同步代码设置;
  • 接下来,要将声明的属性设为 Replicated,同样也可以通过 C++ 和蓝图两种方式;
  • 接下来,在属性所在的Actor类中,需要声明GetLifetimeReplicatedProps 函数,并调用UE4内置宏添加这个属性同步的调用语句;

使用 C++ 的设置过程如下:

.h 文件中设置属性为 Replicated,声明 GetLifetimeReplicatedProps 函数的重写。

1
2
3
4
5
6
7
8
9
10
11
12
class TEST_API ATestCharacter : public ACharacter
{
GENERATED_BODY()

private:
UPROPERTY(Replicated)
float Health;

public:
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

.cpp 文件中设置 bReplicates = true; 并且将需要同步的属性注册进 GetLifetimeReplicatedProps 中。

1
2
3
4
5
6
7
8
9
10
11
ATestCharacter::ATestCharacter()
{
bReplicates = true;
}

void ATestCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME(ATestCharacter, Health);
}

Replication Condition

除了默认的同步调用,我们也可以使用 条件同步 调用,使用的是另一个宏 DOREPLIFETIME_CONDITION,如:

1
DOPEPLIFETIME_CONDITION(ATestCharacter, Health, COND_OwnerOnly);

当使用条件同步时,只有满足了条件所对应的情况才会进行属性同步,这个例子中 COND_OwnerOnly 对应其中一种条件值得枚举值,表示属性只会同步给Actor 的拥有者(Onwer)。此外还有以下其他条件枚举值可以选择:

条件说明
COND_InitialOnly属性只会在 第一次 连接时候同步,之后不再进行同步
COND_OwnerOnly属性只会同步给Actor的 拥有者(Owner)
COND_SkipOwner属性会同步给 除了 Actor 拥有者 之外其他的所有连接
COND_SimulatedOnlyActors 属性只同步给 **模拟(simulated)** 的Actor
COND_AutonomousOnly属性只同步给 Autonomous自治 类型的Actor
COND_SimulatedOrPhysics属性会同步给模拟(Simulated)类型和 **物理类型(设置了bRepPhysics标记的)** 的Actor
COND_InitialOrOwner属性会在 第一次连接 时候同步 或者 是同步给 Actor的拥有者
COND_Custom自定义同步条件,使用 SetCustomIsActiveOverride 来开关同步

RepNotify

有时候可能想在属性 同步之后 得到通知,或者调用一个回调函数,UE4 提供了 RepNotify 机制来实现这个功能。这个机制能让用户提供一个回调函数,在当属性值发生更新之后,就会调用这个回调函数,这个机制对 所有同步的实例 都起作用。C++ 中的使用流程如下:

1
2
3
4
5
6
// .h
UPROPERTY(ReplicatedUsing=OnRep_Health)
float Health

UFUNCTION()
void OnRep_Health();

这个例子在 Health 属性同步声明中,指定了 OnRep_Health 函数作为其 RepNotify 的函数,要注意这个函数必须添加 UFUNCTION 的宏声明,即使这个宏里是空的声明。

当 Health 属性更新后,就会调用 OnRep_Health 通知函数。在实现中可以添加特殊的逻辑,比如如果 Health 小于 0,则播放死亡动画。

Component Replication

除了使用同属性同步,UE4也支持以功能**组件(Component)**的方式进行同步。一般情况下,大部分组件是不需要同步的,需要同步的属性一般都在 Actor 下通过属性同步方式完成。但有一些特殊 Component 包含了需要网络同步的属性,这种情况下就需要使用组件同步的功能。

组件同步的方式与 Actor 的方式基本一致,组件也可以使用属性同步,或者调用 RPCs 来进行网络交互。组件可以看作是 Actor 的一部分,当 Actor 发生同步时,就会递归调用到组件的同步。组件同步属性时,也需要调用相应的 ::GetLifetimeReplicatedProps (…) 函数像 Actor 一样添加同步属性的设置。

当使用组件同步时,组件可以分为两个大类,分别是 静态组件(Static Component) 和 动态组件(Dynamic Component)。

  • Static Component

    静态组件是 Actor 创建时候就会生成的组件,无论是否进行组件同步,都会创建这个组件。服务器也不会通知客户端创建这类组件,客户端创建 Actor 的时候就会直接生成,一般这种组件是在 C++ 构造函数 Constructor 或者作为默认的子物体 DefaultSubObject 在蓝图里创建。一般只有这类组件的属性发生变化,才需要进行服务器和客户端之间的同步。

  • Dynamic Component

    动态组件是指在运行时动态创建的组件,在服务器创建的动态组件会自动同步到所有客户端实例然后分别进行创建,之后也会在属性变化时候自动同步属性给所有客户端。

    客户端虽然也可以创建自己本地的动态组件,但是这种情况下组件并不会同步到服务器,也不会同步给其他客户端,所以在服务器和其他客户端是看不到新创建的组件的。

使用组件同步的方式基本和 Actor 一样,都是先开启同步的标记属性,然后在 GetLifetimeReplicatedProps 函数添加具体要同步属性的规则宏。

RPCs远程过程调用

RPCs 是 (Remote Procedure Cal)远程过程调用 的缩写,表示一个函数在本地进行调用,但是在远端执行。

RPCs 既可以是从 服务器调用到客户端 ,也可以是 从客户端调用到服务器。在使用上,一般用于调用一个事件或者动作,比如服务器告诉客户端某个 NPC 释放了一个技能,客户端进行特效,声音的播放。或者客户端告诉服务器自己更换了一个武器,服务器处理物品的更换,属性变化,以及同步给其他客户端的一些逻辑等。

RPCs 类型

RPCs 分为三种类型,分别是:

  • Server:这类函数一般由客户端进行调用,会在服务器端的实例执行。
  • Client: 这类函数一般由服务器调用,会在这个 Actor 的拥有者 Owner 的客户端实例执行 。
  • NetMulticast:多播类型的 RPC,一般用于在服务器调用,然后在所有客户端对应 Actor 的实例执行,同时也会在服务器端执行。虽然函数也可以在客户端进行调用,但是效果和执行一个非 RPC 的函数是一样的,不会在服务器产生任何效果。

RPC的一些需求和缺陷

在使用RPCs进行网络通信,必须满足一些特定的需求和注意事项:

  • RPCs 必须从 Actor 进行调用。
  • 进行 RPCs 的 Actor 必须是开启了同步属性( Replicated )的。
  • 如果 RPCs 是从服务器调用到客户端的( Client调用类型 ),只有拥有这个 Actor 的客户端( Owner )会执行这个函数。
  • 如果 RPCs 是从客户端调用到服务器的( Server调用类型 ),这个客户端必须拥有这个 Actor 才可以。
  • NetMulticast RPCs 是一个特例:
    • 如果从服务器调用这类RPC,不仅服务器会本地执行这个 RPCs 的函数,并且其他所有已经连接的客户单实例也会执行。
    • 如果从客户端进行调用,则只会在这个客户端本地执行,不会在服务器和其他客户端执行。
    • 目前UE4还有一个机制会限制RPCs的调用:一个多播类型的RPCs函数在一个 更新周期(Update Period)只会同步一次,也就是在更新周期内即使频繁调用多次,最终也只会执行一次。根据官网描述,以后UE4可能会改进这个限制。

不同类型的RPCs调用表

为了更清晰,不同类型 RPCs 在不同角色的机器上和不同的拥有关系下的执行效果,下边列出了两种情景下的调用效果表。第一种是在服务器调用的效果;第二种是在客户端调用的效果。

表的不同列表示 RPCs 的不同类型,表的行表示被调用 Actor 对应不同的拥有关系。

from Server

Actor拥有关系非同步NetMulticastServerClient
客户端拥有在服务器执行在服务器和所有客户端执行只在服务器执行在拥有者的客户端执行
服务器拥有在服务器执行在服务器和所有客户端执行只在服务器执行在服务器执行
未被拥有在服务器执行在服务器和所有客户端执行只在服务器执行在服务器执行

from client

Actor拥有关系非同步NetMulticastServerClient
调用客户端拥有在调用客户端执行在调用客户端执行在服务器执行在调用客户端执行
其他客户端拥有在调用客户端执行在调用客户端执行丢弃在调用客户端执行
服务器拥有在调用客户端执行在调用客户端执行丢弃在调用客户端执行
未被拥有在调用客户端执行在调用客户端执行丢弃在调用客户端执行

在 C++ 使用 RPCs

声明 RPCs 函数

声明 RPCs 很简单,只需要在 C++ 头文件对应的函数增加 UFUNCTION 宏,里边写入函数的RPCs类型关键字,如下:

1
2
3
4
5
6
7
8
UFUNCTION(Client)
void func1();

UFUNCTION(Server)
void func1();

UFUNCTION(NetMulticast)
void func2();

Reliability 可靠性

RPC有一个隐含的属性 —— Reliability。默认情况下,RPCs是不可靠的,也就是无法保证肯定会在目标机器上执行,可能会丢失。但是也可以手动开启关键字 Reliable 的标签,保证函数肯定会执行,如下:

1
2
UFUNCTION(Server, Reliable)
void func3();

也可以添加 Unreliable 的关键字标签,但是因为默认就是Unreliable的,所以一般并不需要。

一般情况下,Reliable 用于实现一些关键函数事件,尤其是会对逻辑流程产生依赖影响的函数。而 Unreliable 的函数适合播放一些无关紧要的效果,声音等,即使偶尔没有执行也不会产生严重影响。Unreliable 的 RPCs 在执行上会有更高的性能效率。

RPCs 实现函数

要在 C++ 实现 RPCs 函数,需要实现一个 FuncName_Implementation 的函数体,加后缀 _Implementation 是 UE4 默认的规则(见 UE 知识点汇总),如:

1
2
3
4
5
6
7
8
9
// .h
UFUNCTION(Server, Reliable)
void func3();

// .cpp
void ATestCharacter::func3_Implementation()
{
//
}

Validation验证

有时候开发者希望在调用 RPCs 函数之前进行一些条件检查,并不是每次都必然会执行这个函数实现,或者在检测到一些特定的输入时候执行一些特定的操作。UE4 提供了这样的机制,就是 Validation 验证机制。

要对一个RPCs函数使用验证,只需要在声明时候在 UFUNCTION 宏中额外添加 WithValidation 关键字,然后需要找一个地方添加验证函数,函数名默认是 FuncName_Validate,如下:

1
2
3
4
5
6
7
8
9
// .h
UFUNCTION(Server, Reliable, WithValidation)
void func3();

// .cpp
bool ATestCharacter::func3_Validate()
{
//
}

验证函数会返回一个布尔值,如果为真则继续执行 PRCs 函数,否则不会执行。可以用于检测作弊以及约束数值区间等等。

RPC与属性同步的区别

下面我们来看看RPC和属性同步之间的差异。

首先,RPC本质上是一次简单的请求,即一次性的数据传输。RPC具有主动性,可以在客户端和服务器之间发起主动通信。相比之下,属性同步只能从服务器向客户端同步数据,客户端上的属性最终将反映服务器上的值,这意味着它是单向的。另一个区别在于同步频率:属性同步在每一帧的特定时机进行,客户端版本的属性最终会反映服务器上的值,但客户端不一定会收到服务器上属性发生的每一个变化的通知。例如,如果一个整数属性的值从1迅速变为2,然后又变为3,客户端最终会收到一个值为3的更新,但不能保证客户端会知道2的变化。并且从1变为2,然后又变回1,客户端只能看到该属性的第一个值。在复制角色时,角色的阴影状态与帧上的属性相同,用于比较属性是否不同,从而判断是否需要同步,因此不会检测到变化,也不会同步到客户端(当然也还有别的操作让其强制更新)。而RPC则不同,每次执行时都会立即发送数据,注意只有可靠的RPC调用才是立马发送。

因此,在选择属性同步或RPC时,需要考虑以下问题:你是否真的需要关注每次值的变化,还是只需要最新值来完成某些操作;以及这个数据是否可以接受可能会丢失的情况,因为不可靠的RPC可能会丢包。属性同步是可靠的(虽迟但到)。当然,你也可以使用可靠的RPC调用来保证数据一定会到达,但显然开销会增加。因此,一般来说,RPC应用于效果提示和类似的非关键网络信息传递,而属性同步则用于其他一切。无论如何,属性同步都会在Actor身上发生。如果你真的只需要触发一个一次性事件,而且你不在乎该事件是否会被丢弃,或者你需要从客户端向服务器发送数据,那么使用RPC是合适的。

参考资料

本文转载自 UE4入门学习-网络基础(一)