1 网络同步机制
UE 提供了强大的网络同步机制:
- RPC :可以在本地调用,对端执行
- 属性同步:标记一个属性为
UPROPERTY(Replicated)
就可以自动将其修改后的值同步到客户端 - 移动复制:Actor 开启了移动复制后会自动复制位置,旋转和速度
- 创建和销毁:Server 创建 Actor 时根据其权限会在所有连接客户端生成远程代理
UE 基本上都是基于 Actor 进行同步的。Actor 同步的前提需要标记 Actor 为bReplicated
的 。首先来了解下如何应用 UE 中的属性同步。
2 Actor 同步
2.1 如何同步一个 Actor
首先思考一下,如何创建一个 Actor 然后让他同步到各个客户端?
- 在哪里创建?创建 Actor 的操作显然需要在服务端执行,如果在客户端执行,这个 Actor 只会在这个客户端可见。
- 如何让 Actor 同步? 标记 Actor 的
bReplicated
为 True。
2.2 如何同步 Actor 的属性
创建并同步完 Actor 之后,下一步是能够支持 Actor 的数据能够正常同步到客户端,首先在应用层如何支持这一操作?
假设我们有一把武器,需要同步武器的弹药数量,那么需要进行如下定义
/** weapon.h **/ | |
class AWeapon : public { | |
UPROPERTY(replicatedUsing=OnRep_Ammo) // 可选属性,当 Ammo 成功同步后会调用该函数 | |
int32 Ammo; // 弹药数量 | |
UFUNCTION() | |
virtual void OnRep_Ammo(); | |
virtual void GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const override; // 属性复制条件控制 | |
} | |
/** weapon.cpp **/ | |
void AWeapon::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const { | |
Super::GetLifetimeReplicatedProps(OutLifetimeProps); | |
DOREPLIFETIME(AWeapon, Ammo); // 具体的复制属性 | |
} |
上述定义主要有如下特点:
- Actor 支持同步时,如果有自定义需要同步的属性,需要重写 GetLifetimeReplicatedProps 函数,并在其中标注要复制的具体属性
- 同步属性时,可以通过
URPOPERTY
宏中replicatedUsing
属性来指定同步后要执行的回调函数
2.3 Actor 同步流程
现在我们需要考虑一下,Actor 的属性在什么情况下会被复制?通常来说我们只需要在 Actor 属性被修改时就需要同步到客户端,但是什么时候会被修改我们并不知道,因此引擎中会根据 Actor 复制频率 来做同步检查。参考后文中 4.4 优先级和复制频率 的内容。我们可以梳理出如下流程:
基本上每帧都需要检查有哪些 Actor 需要同步,显然这种检查也是比较耗时的,由此 UE 也引入了 PushModel 技术,手动标记 Actor 哪些属性已修改需要更新,从而节约检查属性的消耗。
2.4 小结
如何创建,同步一个 Actor 的应用层流程基本梳理完毕,但是显然需要知道其后面的原理,由此引出如下问题在后续的文章中解决:
- UE 中网络同步的整体框架是如何驱动的?
- UE 中如何利用这个网络同步的框架同步一个 Actor?其从服务端执行 SpawnActor 开始到客户端接收到请求并创建出 Actor 的流程及调用堆栈是怎样的?
- 属性同步中 replicatedUsing 是如何实现的?这个宏是如何作用到属性上的?
- 如何检查属性是否修改?如何对比?
Actor 同步只能从 Server 同步到 Client,Client 唯一向 Server 发送请求的方式只有 RPC,属性同步是单向的
3 RPC 使用分析
3.1 什么是 RPC
RPC(Remote Procedure Call,远程过程调用)是一种用于实现分布式应用程序的技术。通过 RPC,可以使分布式应用程序中的各个部分像本地代码一样交互,即使它们不在同一台计算机或在不同的网络上。
在 RPC 中,一个应用程序可以调用另一个应用程序中的函数或方法,就像调用本地函数一样。这些函数和方法在不同的进程或计算机上执行,但对调用方来说,它们是透明的。调用方不需要了解远程代码的具体实现细节,只需要知道如何调用它们并处理返回值。
RPC 的使用有一些前提准则,必须满足这些条件才能调用
- 它们必须从 Actor 上调用。
- Actor 必须被复制。
- 如果 RPC 是从服务器调用并在客户端上执行,则只有实际拥有这个 Actor 的客户端才会执行函数。
- 如果 RPC 是从客户端调用并在服务器上执行,客户端就必须拥有调用 RPC 的 Actor。
- 多播 RPC 则是个例外:
- 如果它们是从服务器调用,服务器将在本地和所有已连接的客户端上执行它们。
- 如果它们是从客户端调用,则只在本地而非服务器上执行。
- 现在,我们有了一个简单的多播事件限制机制:在特定 Actor 的网络更新期内,多播函数将不会复制两次以上。按长期计划,我们会对此进行改善,同时更好的支持跨通道流量管理与限制。
3.2 RPC 的种类
UE 中有 3 种 RPC :
- Server : 仅在 Server 上调用
- Client :仅在 Client 上调用
- NetMulticast :在与服务器连接的所有客户端及服务器本身上调用
这三种 RPC 只需要在函数调用的声明中加上对应的标记即可。
3.2.1 如何确定 RPC 在哪里被执行
当 RPC 函数在服务器上调用时,有如下情况:
Actor 所有权 | 未复制 | NetMulticast | Server | Client |
---|---|---|---|---|
Client Owned Actor | 在服务器上运行 | 在服务器和所有客户端上运行 | 在服务器上运行 | 在 actor 的所属客户端上运行 |
Server Owned Actor | 在服务器上运行 | 在服务器和所有客户端上运行 | 在服务器上运行 | 在服务器上运行 |
Unowned Actor | 在服务器上运行 | 在服务器和所有客户端上运行 | 在服务器上运行 | 在服务器上运行 |
当 RPC 函数在客户端上调用时,如下:
Actor 所有权 | 未复制 | NetMulticast | Server | Client |
---|---|---|---|---|
Owned By Invoking Client | 在执行调用的客户端上运行 | 在执行调用的客户端上运行 | 在服务器上运行 | 在执行调用的客户端上运行 |
Owned By a different client | 在执行调用的客户端上运行 | 在执行调用的客户端上运行 | 丢弃 | 在执行调用的客户端上运行 |
Server Owned Actor | 在执行调用的客户端上运行 | 在执行调用的客户端上运行 | 丢弃 | 在执行调用的客户端上运行 |
Unowned Actor | 在执行调用的客户端上运行 | 在执行调用的客户端上运行 | 丢弃 | 在执行调用的客户端上运行 |
事实上最终判断 RPC 在哪里被执行,主要根据如下三个条件:
- 调用端是谁(Client/Server)
- 调用的 Actor 属于哪个连接
- RPC 的类型(Server/Client/NetMulticast)
举一个例子,有两个客户端 c1 和 c2 各自有 Pawn p1 和 p2,c1 的客户端上能够获取到 p2 这个对象,但是无法利用 p2 调用 RPC,因为在 c1 上 p2 只是一个普通的 Pawn,其没有对应的 c2 的 PlayerController(参考 [[总体框架#3. PlayerController|PlayerController 定义]])。也没有对应的 Connection,因此无法执行 RPC。
- 实际上是否会调用到对端,主要根据
UObject::GetFunctionCallspace
这个接口返回的枚举来判定的。- 其次根据 Actor 所属的 Connection,如果 Actor 不属于任何一个 Connection(Owner 递归查找找不到 PlayerController),那么也是无法调用 RPC 的。
3.3 RPC 的使用
UE 中,一个 RPC 函数的声明和定义如下(以 Client 调用 Server 执行的 RPC 为例):
/** weapon.h **/ | |
class AWeapon : public { | |
UFUNCTION(Server) | |
void Fire(); | |
} | |
/** weapon.cpp **/ | |
void AWeapon::Fire_Implementation() { | |
/** do weapon fire **/ | |
} |
此时只需要在 Client 端使用如下操作:
AWeapon* Weapon = GetWeapon(); | |
Weapon->Fire(); |
就能直接调用 Server 端的 Fire 接口了。关于其背后实现的原理,可以参考 [[原理#4. QA#4.5 RPC 函数如何执行的|RPC函数执行原理]]。
这里需要注意一点,UE 的 RPC 是没有返回值的,统一都是 void。个人如果需要获取返回值,那么就需要一个类似协程的概念,来获取返回值,否则只能阻塞等待或者异步等待,后者显然代码可读性也不是很好。
3.4 小结
RPC 与属性同步有些不同,RPC 可以 Server To Client 也可以 Client To Server,是一种双向的通信方式,而属性同步只能 Server To Client,属于单向同步。对于 RPC 的实现,有如下问题可以再进行深究:
- 如何调用对端的函数?RPC 的前提是利用 Actor 去调用,Actor 本身存有反射信息,因此问题简化为,告诉对端哪一个 Actor 要执行名称为 xxx 的函数
- 函数的参数如何封装好并传递到对端?
- NetMulticast 如何转发函数?
4 Actor 同步概念
4.1 NetRole
每个 Actor 都有一个 LocalRole 和 RemoteRole 的概念,分别对应于 Actor 在本地和在对端的 Role,Role 主要分为 3 种:
- ROLE_SimulatedProxy
- ROLE_AutonomousProxy
- ROLE_Authority
通常LocalRole=Authority
只存在于服务器(但是客户端也有可能存在,比如 Spawn 一个 Actor 但是不标记为 Replicated)。关于各种 Role 常见的设置可以参考下图:
4.1.1 AutonomousProxy 和 SimulatedProxy 的区别
- AutonomousProxy 和 SimulatedProxy 基本只存在于客户端,ROLE_AutonomousProxy 用于处理本地玩家的输入,并将这些输入发送到服务器进行处理,而 ROLE_SimulatedProxy 用于处理其他玩家的输入,并在客户端上模拟 Actor 在服务器上的运行。因此通常 AutonomousProxy 只存在于 PlayerController 和其 Possess 的 Pawn。
- SimulatedProxy 是标准的模拟途径,通常是根据上次获得的速率对移动进行推算。当服务器为特定的 actor 发送更新时,客户端将向着新的方位调整其位置,然后利用更新的间歇,根据由服务器发送的最近的速率值来继续移动 actor。
- AutonomousProxy 通常只用于 PlayerController 所拥有的 actor。这说明此 actor 会接收来自真人控制者的输入,所以在我们进行推算时,我们会有更多一些的信息,而且能使用真人输入内容来补足缺失的信息(而不是根据上次获得的速率来进行推算)。
4.1.2 小结
那么这个 Role 有什么用呢?个人认为有如下用处:
- 在 C/S 模式下,基本可以认为 LocalRole 为 Authority 的 Actor 当前就是处于服务器环境下,用来区分服务器还是客户端
- 引擎对于 AutonomousProxy 和 SimulatedProxy 做了区分,用来更好的模拟玩家输入
就目前而言,只有服务器能够向已连接的客户端同步 Actor (客户端永远都不能向服务器同步)。始终记住这一点, 只有 服务器才能看到
Role == ROLE_Authority
和RemoteRole == ROLE_SimulatedProxy
或者ROLE_AutonomousProxy
。
4.2 关联连接
UE 中 Actor
有关联连接的概念,即这个 Actor
属于哪个连接。在传统的 C/S 服务器中,每个客户端和服务器会有一条连接,在 UE 中会为每个连接创建一个 PlayerController
,这样这个 PlayerController
就归这条连接所有。
而如果一个 Actor
的 Owner 为 PlayerController
或者为 Pawn
并且这个 Pawn
拥有一个 PlayerController
,那么这个 Actor
就归属于拥有这个 PlayerController
的连接。
这里的关联连接有什么用呢?
考虑如下三种情况:
- 需要确定哪个客户端将执行运行于客户端的 RPC
- Actor 复制与连接相关性(比如 bOnlyRelevantToOwner 为 True 的 Actor,只有拥有这个 Actor 的 Connection 才会收到这个 Actor 的属性更新,比如 PlayerController)
- 涉及 Owner 的 Actor 属性复制条件(比如 COND_OnlyOwner 只能复制给 Owner)
4.3 相关性
相关性是用于判断 Actor 是否需要进行同步的重要依据。其主要判断相关性的接口为 AActor::IsNetRelevantFor
。个人认为相关性最重要的一点是可以有效的节约带宽和同步操作所带来的 CPU 消耗。
比如场景的规模可能比较大,玩家特定时刻只能看到关卡中部分 Actor。被服务器认为可见或者能够影响客户端的 Actor 组会被是为该客户端的相关 Actor 组,服务器只会让客户端知道其相关组内的 Actor。
- 如果 Actor 是
bAlwaysRelevant
、归属于 Pawn 或 PlayerController、本身为 Pawn 或者 Pawn 是某些行为(如噪音或伤害)的发起者,则其具有相关性。 - 如果 Actor 是
bNetUseOwnerRelevancy
且拥有一个所有者,则使用所有者的相关性。 - 如果 Actor 是
bOnlyRelevantToOwner
且没有通过第一轮检查,则不具有相关性。 - 如果 Actor 被附加到另一个 Actor 的骨架模型,它的相关性将取决于其所在基础的相关性。
- 如果 Actor 是不可见的 (
bHidden == true
) 并且它的 Root Component 并没有碰撞,那么则不具有相关性,- 如果没有 Root Component 的话,
AActor::IsNetRelevantFor()
会记录一条警告,提示是否要将它设置为bAlwaysRelevant=true
。
- 如果没有 Root Component 的话,
- 如果
AGameNetworkManager
被设置为使用基于距离的相关性,则只要 Actor 低于净剔除距离,即被视为具有相关性。
Pawn 和 PlayerController 将覆盖
AActor::IsNetRelevantFor()
并最终具有不同的相关性条件。
4.4 优先级和复制频率
4.4.1 优先级
每个 Actor 都有一个名为 NetPriority
的浮点变量。这个变量的数值越大,Actor 相对于其他"同伴"的带宽就越多。和优先级为 1.0 的 Actor 相比,优先级是 2.0 的 Actor 可以得到两倍的更新频度。唯一影响优先顺序的就是它们的比值。
计算 Actor 的当前优先级时使用了函数 AActor::GetNetPriority
。为避免出现饥荒(starvation),AActor::GetNetPriority
使用 Actor 上次复制后经过的时间去乘以 NetPriority。同时,GetNetPriority
函数还考虑了 Actor 与观察者的相对位置以及两者之间的距离。
4.4.2 复制频率
Actor 不是每一帧都进行复制的,每个 Actor 有个自己的每秒复制频率 NetUpdateFrequency,每次检查 Tick 的 DeltaTime > 1/NetUpdateFrequency,满足条件才可以进行下一步复制检查。
比如默认 PlayerState 每秒更新 1 次,而 Pawn 每秒更新 100 次(默认情况下服务器 30 fps 运行,基本上每帧都会做复制检查)。