UE 网络同步机制避坑:从 BeginPlay、OnRep 到 SubObject、FastArray、Reliable 与 ReplicationGraph

本文面向已经写过 UE 网络玩法、但经常被复制时序、RPC 路由、Owner、Relevancy、Dormancy、SubObject、FastArray 搞混的开发者。
适用范围以 UE4.26 / UE4.27 到 UE5.x 的传统 Generic Replication / ReplicationGraph 为主。UE5 Iris 在 SubObject、FastArray、ReplicationFragment 等细节上有差异,文中会单独标注。

UE 的网络同步最容易误解的一点是:它不是把服务端和客户端的对象自动变成完全一样。UE 真正做的是:

flowchart TB
    A["服务端拥有权威状态"]
    B["NetDriver 按连接筛选 Actor"]
    C["ActorChannel 负责
“某个 Actor 对某个连接”的复制"] D["属性 / RPC / Component / SubObject
被序列化成 bunch"] E["客户端接收 bunch"] F["创建 Actor / 解析对象引用
写入属性 / 执行 RPC / 触发 OnRep"] A --> B --> C --> D --> E --> F

所以,玩法开发时比“这个变量怎么同步”更重要的问题是:

1
2
3
4
5
6
7
8
这个数据是谁拥有?
谁有权修改?
谁能看到?
它是长期状态还是瞬时事件?
它是否需要服务端校验?
它是否只给 Owner?
它是高频还是低频?
它是否需要持久化?

只要这几个问题没有想清楚,后面无论用 ReplicatedRepNotifyRPCFastArraySubObject 还是 ReplicationGraph,都很容易写出“本机 PIE 正常、Dedicated Server 一跑就炸”的网络 Bug。

总体心智模型:状态、事件、权限、可见性不要混在一起

UE 网络玩法里最常见的错,不是 API 拼错,而是概念边界混乱。

Authority、Owner、Relevancy、Dormancy 是四件事

概念 解决的问题 常见误解
Authority 谁能最终决定状态 以为 Owner 就是 Authority
Owner / Owning Connection Client RPC、OwnerOnly 条件复制发给谁 以为 Actor 在服务端创建,所以 Owner 就是服务器
Relevancy 某个连接当前是否应该看到这个 Actor 以为 bReplicates=true 就所有人一定收到
Dormancy Actor 是否暂时跳过复制检查 休眠后改属性却不唤醒

状态和事件必须分开

属性复制适合长期状态

1
2
3
4
5
6
7
Health
Ammo
TeamId
CurrentWeapon
bIsDead
DoorState
BuildingLevel

这些数据的特点是:客户端最终需要收敛到服务端最新值,中间过程可以丢。

RPC 更适合瞬时事件或请求

1
2
3
4
5
PlayMuzzleFX
ShowErrorMessage
Client_PlayHitReact
Server_RequestUseItem
Server_RequestOpenChest

这些数据的特点是:它发生一次就是一次,错过了不应该靠“属性最终值”来推断。

一个实用分层

flowchart LR
    subgraph 玩法网络分层
        direction TB
        A["Client Request
客户端请求服务端做事
Server RPC(必须服务端校验)"] B["Authoritative State
服务端权威状态
Replicated Property / FastArray / SubObject"] C["Transient Event
瞬时表现
Unreliable RPC / GameplayCue / EventId"] D["Private View
私有数据
COND_OwnerOnly / Client RPC"] E["Public View
所有人可见的简化状态
普通属性复制 / ReplicationGraph Relevancy"] end

如果你正在设计一个背包、技能、建筑、任务、UGC 运行时系统,优先用这个分层去拆,而不是一上来就问“这个字段加不加 Replicated”。


BeginPlay 和 OnRep 顺序为什么容易踩坑

很多人会下意识认为客户端初始化顺序是:

flowchart TB
    A["BeginPlay"]
    B["UI / 组件 / 引用都准备好了"]
    C["之后才会执行 OnRep"]
    A --> B --> C

这个理解在网络环境里非常危险。

动态复制 Actor 的初始属性通常会先于 BeginPlay 被读入

对动态 Spawn 的 Replicated Actor 来说,客户端第一次收到它时,会通过 ActorChannel 的初始 bunch 创建 Actor,并读入初始复制属性。官方 API 对 PostNetInit 的描述是:Actor 被生成并读入 replicated properties 后调用。

也就是说,对于动态复制 Actor,初始复制属性并不是一定晚于 BeginPlay

但是,这并不意味着你可以放心在 BeginPlay 里假设一切都准备好了。因为真实项目里还有:

1
2
3
4
5
6
PlayerState / Pawn / Controller 互相引用
Replicated Actor 指针延迟解析
Replicated Component / SubObject 初始化
UI 创建时机
AnimInstance / Mesh 初始化时机
Seamless Travel / Late Join / Reconnect

所以最安全的心智模型不是“BeginPlay 一定先”或“OnRep 一定先”,而是:

1
2
BeginPlay 和 OnRep 都只是若干初始化入口之一。
真正的业务初始化应该做成幂等 TryInit / TryRefresh。

案例:OnRep_CurrentWeapon 里访问 UI 崩溃

错误写法:

1
2
3
4
5
6
7
UPROPERTY(ReplicatedUsing = OnRep_CurrentWeapon)
TObjectPtr<AWeapon> CurrentWeapon;

void AMyCharacter::OnRep_CurrentWeapon()
{
WeaponWidget->SetWeapon(CurrentWeapon);
}

问题是 OnRep_CurrentWeapon 执行时:

1
2
3
4
WeaponWidget 可能还没创建
HUD 可能还没初始化
PlayerController 可能还没 Possess 完
CurrentWeapon 指针可能还没完全解析

更安全的写法是把每个入口都收敛到一个 TryRefresh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void AMyCharacter::OnRep_CurrentWeapon()
{
bPendingRefreshWeapon = true;
TryRefreshWeaponView();
}

void AMyCharacter::BeginPlay()
{
Super::BeginPlay();

bActorBeginPlayReady = true;
TryRefreshWeaponView();
}

void AMyCharacter::NotifyUIReady()
{
bUIReady = true;
TryRefreshWeaponView();
}

void AMyCharacter::TryRefreshWeaponView()
{
if (!bPendingRefreshWeapon)
{
return;
}

if (!bActorBeginPlayReady)
{
return;
}

if (!bUIReady)
{
return;
}

if (!IsValid(CurrentWeapon))
{
return;
}

WeaponWidget->SetWeapon(CurrentWeapon);
bPendingRefreshWeapon = false;
}

核心思想:

1
2
OnRep 只表示“网络数据到了”
不表示“所有业务依赖都准备好了”

案例:Pawn BeginPlay 里拿不到 PlayerState

错误写法:

1
2
3
4
5
6
7
void AMyCharacter::BeginPlay()
{
Super::BeginPlay();

AMyPlayerState* PS = GetPlayerState<AMyPlayerState>();
TeamId = PS->GetTeamId();
}

客户端上可能出现:

1
2
3
Character BeginPlay 执行了
但 PlayerState 还没复制到这个 Pawn
GetPlayerState() 返回 nullptr

更安全写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void AMyCharacter::BeginPlay()
{
Super::BeginPlay();
TryInitFromPlayerState();
}

void AMyCharacter::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
TryInitFromPlayerState();
}

void AMyCharacter::TryInitFromPlayerState()
{
AMyPlayerState* PS = GetPlayerState<AMyPlayerState>();
if (!IsValid(PS))
{
return;
}

CachedTeamId = PS->GetTeamId();
RefreshTeamVisual();
}

结论:

1
2
Pawn BeginPlay 不是 PlayerState Ready 的保证。
PlayerState Ready 应该监听 OnRep_PlayerState。

多个 OnRep 之间没有确定顺序

一个非常重要的官方规则:不同 replicated 变量的 OnRep 调用顺序没有确定性。它不保证按声明顺序、内存顺序,也不保证按服务端标脏顺序。

错误写法:

1
2
3
4
5
6
7
8
9
10
11
UPROPERTY(ReplicatedUsing = OnRep_Weapon)
TObjectPtr<AWeapon> CurrentWeapon;

UPROPERTY(ReplicatedUsing = OnRep_WeaponState)
EWeaponState WeaponState;

void AMyCharacter::OnRep_WeaponState()
{
// 假设 OnRep_Weapon 已经执行过
CurrentWeapon->ApplyState(WeaponState);
}

更安全:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void AMyCharacter::OnRep_Weapon()
{
TryApplyWeaponState();
}

void AMyCharacter::OnRep_WeaponState()
{
TryApplyWeaponState();
}

void AMyCharacter::TryApplyWeaponState()
{
if (!IsValid(CurrentWeapon))
{
return;
}

CurrentWeapon->ApplyState(WeaponState);
}

如果多个字段必须以严格顺序整体生效,优先考虑:

1
2
3
1. 把它们合并到同一个 replicated struct。
2. OnRep 里只缓存,最终在 PostRepNotifies 里统一处理。
3. 使用显式版本号 / EventId 做收敛。

Component 的 ReadyForReplication

UE5 中 UActorComponent::ReadyForReplication() 是一个很重要的时机:它表示 owning actor 已经正式 ready for replication,适合在 replicated component 里注册 subobject。

例如:

1
2
3
4
5
6
7
8
9
10
void UMyInventoryComponent::ReadyForReplication()
{
Super::ReadyForReplication();

if (GetOwner() && GetOwner()->HasAuthority() && !InventoryObject)
{
InventoryObject = NewObject<UInventoryObject>(this);
AddReplicatedSubObject(InventoryObject);
}
}

不要把 Actor 的 BeginPlay、Component 的 BeginPlay、SubObject 的复制注册时机混成一件事。


Replicated Actor 指针为什么可能先是 null,之后又能指向正确对象

客户端收到一个 replicated Actor 指针时,对方 Actor 可能还没创建完成。如果 OnRep_Target 的时候 Target == nullptr,那当 Target 创建完成后,Target 指针怎么指向正确 Actor?

答案是:UE 复制的是对象引用的 NetGUID,不是内存地址。

对象引用复制的本质:FNetworkGUID

服务端复制 Actor 指针时,可以理解为发送:

1
TargetActor -> FNetworkGUID

客户端收到后通过 PackageMap 查询:

1
FNetworkGUID -> 本地 UObject / AActor

如果 Target Actor 此时还没在客户端创建:

1
2
3
NetGUID 暂时 unmapped
属性可能暂时表现为 null
系统记录这个未解析引用

等后续 Target Actor 的创建 bunch 到达:

flowchart TB
    A["客户端创建 Target Actor"]
    B["NetGUID 映射到本地 Actor 实例"]
    C["之前 unmapped 的引用可以被修正"]
    A --> B --> C

例子:Marker 先到,Enemy 后到

服务端:

1
2
3
4
AEnemy* Enemy = GetWorld()->SpawnActor<AEnemy>(EnemyClass);
AMyMarker* Marker = GetWorld()->SpawnActor<AMyMarker>(MarkerClass);

Marker->Target = Enemy;

客户端可能收到:

sequenceDiagram
    participant S as Server
    participant C as Client
    S->>C: Marker bunch(含 Marker.Target NetGUID → Enemy)
    Note over C: Enemy 还没创建
NetGUID 暂时未映射 C->>C: Marker.Target == nullptr C->>C: OnRep_Target 执行(Target 为 null) S->>C: Enemy bunch C->>C: Enemy 创建,NetGUID 映射成功 C->>C: Marker.Target 自动修正为 Enemy

业务层不要依赖第一次 OnRep 必然有效

稳妥写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void AMyMarker::OnRep_Target()
{
TryBindTarget();
}

void AMyMarker::BeginPlay()
{
Super::BeginPlay();
TryBindTarget();
}

void AMyMarker::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);

if (bPendingBindTarget)
{
TryBindTarget();
}
}

void AMyMarker::TryBindTarget()
{
if (!IsValid(Target))
{
bPendingBindTarget = true;
return;
}

bPendingBindTarget = false;
Target->OnDestroyed.AddDynamic(this, &ThisClass::HandleTargetDestroyed);
RefreshMarkerVisual();
}

RPC 参数里的未映射引用更危险

属性引用可以被后续 remap,但 RPC 参数里的对象引用要更加谨慎。若 RPC 到达时对象还没有映射,某些情况下会以 null 参数执行;是否延迟未映射 RPC 还受 net.DelayUnmappedRPCs 等配置影响。

所以,不要把关键业务只写成:

1
Client_PlayLockOn(TargetActor);

更稳的方式是:

1
2
3
1. 同步 TargetActor 指针。
2. 同步 TargetRuntimeId / GameplayObjectId 作为稳定 ID。
3. 客户端在 TryBindTarget 中等待指针或 ID 解析完成。

PlayerState 上 COND_OwnerOnly 的属性会不会同步给非 Owner

PlayerState 上一个复制属性,只设置 COND_OwnerOnly。这个 PlayerState 同步到非 owner 客户端上时,这个属性的初始值会不会同步到其他客户端?会不会触发 OnRep?

结论:

1
不会。

更准确地说:

1
2
3
4
PlayerState 这个 Actor 本身会同步到所有相关客户端。
但是这个属性带了 COND_OwnerOnly。
对非 Owner 连接来说,该属性在初始复制和后续复制时都会被过滤掉。
所以非 Owner 客户端不会收到它的初始值,也不会因此触发 OnRep。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UPROPERTY(ReplicatedUsing = OnRep_PrivateInventory)
FInventoryList PrivateInventory;

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

DOREPLIFETIME_CONDITION(
AMyPlayerState,
PrivateInventory,
COND_OwnerOnly
);
}

客户端 A 是这个 PlayerState 的 Owner:

1
2
3
4
客户端 A:
- 收到 PrivateInventory 初始值
- 后续变化也收到
- OnRep_PrivateInventory 会触发

客户端 B 不是 Owner:

1
2
3
4
5
客户端 B:
- 能看到这个 PlayerState
- 但不会收到 PrivateInventory 初始值
- 后续变化也不会收到
- OnRep_PrivateInventory 不会因为网络复制触发

客户端 B 上这个字段通常只是:

1
2
3
C++ 构造默认值
Blueprint 默认值
本地代码曾经写过的值

但不会是服务端真实值。

私有数据和公开数据要分开

推荐:

1
2
3
4
5
6
7
// 所有人可见:等级、队伍、击杀数、当前装备外观
UPROPERTY(ReplicatedUsing = OnRep_PublicStats)
FPublicPlayerStats PublicStats;

// 只有自己可见:背包完整内容、任务私密进度、私有 UI 数据
UPROPERTY(ReplicatedUsing = OnRep_PrivateInventory)
FInventoryList PrivateInventory;

不要在非 Owner 客户端读取 OwnerOnly 字段做 UI 或玩法判断。


RPC、Client RPC、NetMulticast 与 Dedicated Server

Client RPC 只能发给拥有者

Client RPC 的目标不是“所有客户端”,而是:

1
拥有这个 Actor 的那个 UNetConnection

在 Dedicated Server 下:

flowchart TB
    A["Server 调用 Client RPC"]
    B["UE 查找这个 Actor 的
Owning Connection"] C["只把 RPC 发给该连接
对应的客户端"] A --> B --> C

例子:

1
2
UFUNCTION(Client, Reliable)
void Client_ShowInventoryError(const FString& Reason);

服务端:

1
PlayerAController->Client_ShowInventoryError(TEXT("背包满了"));

结果:

1
2
3
客户端 A 收到
客户端 B 收不到
客户端 C 收不到

这正是 Client RPC 的用途:私有通知

如果 Actor 没有 Owning Connection 呢?

如果服务端在一个没有 owning client 的世界 Actor 上调用 Client RPC:

1
WorldTipActor->Client_ShowTip();

它不会神奇地发给所有客户端。Client RPC 不是广播。如果想广播,应该使用:

1
2
3
1. NetMulticast:瞬时表现广播。
2. 复制公开属性:长期公开状态。
3. 遍历 PlayerController 逐个发 Client RPC:每个玩家内容不同的私有通知。

NetMulticast 也不是万能广播

NetMulticast 只有服务端调用时,才会在服务端以及该 Actor 当前 relevant 的客户端执行。

错误理解:

1
Client 调用 Multicast -> 所有人收到

实际:

1
2
Client 调用 Multicast -> 通常只在本地执行
Server 调用 Multicast -> 服务端和相关客户端执行

适合用 NetMulticast 的场景:

1
2
3
4
爆炸特效
枪口火焰
普通受击表现
短生命周期表现事件

不适合:

1
2
3
4
5
门是否打开
比赛阶段
玩家是否死亡
建筑等级
背包内容

这些应该用 replicated state。

Dedicated Server 没有本地玩家、UI、音频、渲染

Dedicated Server 是纯服务端进程:

1
2
3
4
5
6
7
8
没有 LocalPlayer
没有 Viewport
没有 UMG
没有本地输入
没有本地相机
不应该播放音效
不应该渲染 Niagara / 粒子
不应该访问客户端 UI

但它有:

1
2
3
4
5
6
7
8
World
GameMode
GameState
PlayerController(服务端表示远端连接)
PlayerState
Pawn / Character
Actor
NetDriver

错误写法:

1
2
3
4
5
6
void AMyCharacter::Die()
{
PlayDeathSound();
SpawnDeathNiagara();
ShowDeathUI();
}

更安全:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void AMyCharacter::Die()
{
if (HasAuthority())
{
bIsDead = true;
OnRep_IsDead();
}
}

void AMyCharacter::OnRep_IsDead()
{
if (GetNetMode() == NM_DedicatedServer)
{
return;
}

PlayDeathSound();
SpawnDeathNiagara();

if (IsLocallyControlled())
{
ShowDeathUI();
}
}

Reliable RPC 的真实含义:可靠但不是万能

Reliable 的含义不是:

1
2
3
4
一定立刻执行
永远不会失败
网络断了还能到
适合每帧发送

更准确的理解是:

1
2
3
在连接和 Channel 仍然有效的前提下,
Reliable RPC 会被重发直到收到 ACK,
并保证相关范围内的执行顺序。

Reliable 的 Head-of-line Blocking

假设同一个 ActorChannel 上:

1
2
Reliable RPC A 丢包
Reliable RPC B 到了

由于可靠顺序要求,B 不能越过 A 执行。

结果:

1
2
3
A 堵住
B 延迟
后续可靠消息继续堆积

所以不要这样:

1
2
3
4
5
6
void AMyCharacter::Tick(float DeltaSeconds)
{
Server_UpdateAimRotation(AimRot);
Server_UpdateMoveInput(Input);
Server_UpdateMouseDelta(MouseDelta);
}

如果这些都是 Reliable,高延迟或丢包下很快就会把可靠队列堆起来。

Reliable 适合什么

适合:

1
2
3
4
5
6
7
背包使用请求
购买物品
聊天消息
确认类 UI 操作
开始匹配
任务领取
关键玩法请求

不适合:

1
2
3
4
5
6
7
每帧瞄准
每帧输入
脚步声
普通枪口火焰
普通受击飘字
持续移动状态
大量频繁 UI 提示

建议:

1
2
3
重要低频请求:Reliable
高频状态:压缩属性 / CharacterMovement / Unreliable 限频
普通表现:Unreliable RPC / 本地预测

Reliable 的顺序边界

可以依赖:

1
2
同一 Actor 上 Reliable RPC 的相对顺序。
Actor 与其 SubObject 上的 RPC 顺序也有相应保证。

不要依赖:

1
2
3
4
5
不同 Actor 的 RPC 顺序。
Reliable 与 Unreliable 混用时的顺序。
RPC 与其他 Actor 属性复制的顺序。
RPC 与对象引用创建的顺序。
RPC 与 UI 初始化的顺序。

Replicated SubObject:复制 UObject 的正确方式

普通 UObject 默认不是独立网络对象。UE 原生网络复制的主角是:

1
2
3
AActor
UActorComponent
ActorChannel

但玩法系统经常需要轻量对象:

1
2
3
4
5
6
背包对象 UInventoryObject
技能对象 UAbilityInstance
Buff 对象 UBuffInstance
任务对象 UQuestState
装备槽 UObject
UGC 脚本实例 UObject

如果每个都做成 Actor,成本很高。SubObject 的意义就是:

1
2
3
4
把 UObject 作为某个 Actor 或 ActorComponent 的 replicated subobject 复制。
SubObject 没有自己的 ActorChannel。
SubObject 通过 Owning Actor 的 ActorChannel 复制。
SubObject 可以有自己的 replicated properties。

复制引用和复制对象本体是两件事

这是 SubObject 最容易错的点。

1
2
UPROPERTY(Replicated)
TObjectPtr<UInventoryObject> InventoryObject;

这只是复制“指向它的引用”。它不等于复制这个 UObject 本体。

如果对象本体没有通过:

1
AddReplicatedSubObject

或旧路径:

1
ReplicateSubobjects / Channel->ReplicateSubobject

注册进入复制流程,客户端不会自动拥有它的完整复制状态。

UObject 自身需要支持网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
UCLASS()
class UInventoryObject : public UObject
{
GENERATED_BODY()

public:
virtual bool IsSupportedForNetworking() const override
{
return true;
}

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

public:
UPROPERTY(ReplicatedUsing = OnRep_Items)
FInventoryFastArray Items;

UFUNCTION()
void OnRep_Items();
};

void UInventoryObject::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UInventoryObject, Items);
}

UE5 推荐:Registered SubObject List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
UCLASS()
class AMyPlayerState : public APlayerState
{
GENERATED_BODY()

public:
AMyPlayerState();

UPROPERTY(ReplicatedUsing = OnRep_InventoryObject)
TObjectPtr<UInventoryObject> InventoryObject;

UFUNCTION()
void OnRep_InventoryObject();

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

void CreateInventoryOnServer();
void DestroyInventoryOnServer();
};

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
AMyPlayerState::AMyPlayerState()
{
bReplicates = true;
bReplicateUsingRegisteredSubObjectList = true;
}

void AMyPlayerState::CreateInventoryOnServer()
{
if (!HasAuthority())
{
return;
}

if (IsValid(InventoryObject))
{
RemoveReplicatedSubObject(InventoryObject);
}

InventoryObject = NewObject<UInventoryObject>(this);
AddReplicatedSubObject(InventoryObject);

ForceNetUpdate();
}

void AMyPlayerState::DestroyInventoryOnServer()
{
if (!HasAuthority())
{
return;
}

if (IsValid(InventoryObject))
{
RemoveReplicatedSubObject(InventoryObject);
InventoryObject = nullptr;
}

ForceNetUpdate();
}

void AMyPlayerState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyPlayerState, InventoryObject);
}

注意:删除或替换已经注册的 SubObject 前,先 RemoveReplicatedSubObject 注册列表里保留的是原始指针,忘记移除后再 GC,可能造成悬空指针或崩溃。

UE4/兼容路径:ReplicateSubobjects

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool AMyPlayerState::ReplicateSubobjects(
UActorChannel* Channel,
FOutBunch* Bunch,
FReplicationFlags* RepFlags
)
{
bool bWroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);

if (IsValid(InventoryObject))
{
bWroteSomething |= Channel->ReplicateSubobject(
InventoryObject,
*Bunch,
*RepFlags
);
}

return bWroteSomething;
}

UE5 Generic Replication / ReplicationGraph 仍可用这条兼容路径,但 Iris 只支持 registered subobject list。所以新项目建议优先走 registered list。

SubObject RPC 要额外处理

Replicated SubObject 默认不等于“UObject RPC 自动可用”。如果 SubObject 需要自己发 RPC,通常要重写:

1
2
3
4
5
6
7
8
9
10
11
virtual int32 GetFunctionCallspace(
UFunction* Function,
FFrame* Stack
) override;

virtual bool CallRemoteFunction(
UFunction* Function,
void* Parms,
FOutParmRec* OutParms,
FFrame* Stack
) override;

简单项目里更推荐:SubObject 不直接发 RPC,通过 Owner Actor / Component 转发。


FastArray:列表型状态的增量同步

普通 TArray 复制的问题:

1
2
UPROPERTY(ReplicatedUsing = OnRep_Items)
TArray<FInventoryItem> Items;

如果数组有 100 个元素,只改了 1 个:

1
2
3
普通数组复制可能造成较大带宽浪费。
客户端也不知道哪个元素是新增、删除、修改。
OnRep_Items 只能拿到最终数组。

FastArray 的目标是:

1
2
3
只同步变化的元素。
让客户端知道哪些元素 Added / Changed / Removed。
适合背包、Buff、技能、任务、状态列表。

基本结构

Item:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
USTRUCT()
struct FInventoryItemEntry : public FFastArraySerializerItem
{
GENERATED_BODY()

UPROPERTY()
int32 SlotIndex = INDEX_NONE;

UPROPERTY()
int32 ItemId = 0;

UPROPERTY()
int32 Count = 0;

void PreReplicatedRemove(const struct FInventoryList& InArraySerializer);
void PostReplicatedAdd(const struct FInventoryList& InArraySerializer);
void PostReplicatedChange(const struct FInventoryList& InArraySerializer);
};

Container:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
USTRUCT()
struct FInventoryList : public FFastArraySerializer
{
GENERATED_BODY()

UPROPERTY()
TArray<FInventoryItemEntry> Entries;

bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
{
return FFastArraySerializer::FastArrayDeltaSerialize<
FInventoryItemEntry,
FInventoryList
>(Entries, DeltaParms, *this);
}

void AddItem(int32 SlotIndex, int32 ItemId, int32 Count)
{
FInventoryItemEntry& Entry = Entries.AddDefaulted_GetRef();
Entry.SlotIndex = SlotIndex;
Entry.ItemId = ItemId;
Entry.Count = Count;

MarkItemDirty(Entry);
}

void ChangeCount(int32 SlotIndex, int32 NewCount)
{
if (FInventoryItemEntry* Entry = FindBySlot(SlotIndex))
{
Entry->Count = NewCount;
MarkItemDirty(*Entry);
}
}

void RemoveAtIndex(int32 Index)
{
Entries.RemoveAt(Index);
MarkArrayDirty();
}

FInventoryItemEntry* FindBySlot(int32 SlotIndex)
{
return Entries.FindByPredicate(
[SlotIndex](const FInventoryItemEntry& Entry)
{
return Entry.SlotIndex == SlotIndex;
}
);
}
};

Traits:

1
2
3
4
5
6
7
8
9
template<>
struct TStructOpsTypeTraits<FInventoryList>
: public TStructOpsTypeTraitsBase2<FInventoryList>
{
enum
{
WithNetDeltaSerializer = true
};
};

Owner:

1
2
UPROPERTY(Replicated)
FInventoryList InventoryList;

FastArray 的本质

FastArray 不是“魔法数组”,它的本质是:

1
2
3
4
每个连接维护一份上次发送 / 接收状态。
服务端对数组元素做版本追踪。
本次只序列化变化的元素和删除信息。
客户端按 ReplicationID 合并到本地数组。

可以类比:

1
2
普通 TArray:发整张表。
FastArray:发变更日志。

必须 Mark Dirty

新增或修改元素:

1
2
Entry.Count += 1;
MarkItemDirty(Entry);

删除元素:

1
2
Entries.RemoveAt(Index);
MarkArrayDirty();

这不是风格问题,而是 FastArray 增量复制能否正确生效的前提。

不要把数组下标当稳定身份

不要依赖:

1
Entries[3]

作为长期身份。更稳的是显式存:

1
2
int32 SlotIndex;
FGuid ItemGuid;

FastArray 依赖 ReplicationID 做元素身份,业务层也应该有自己的稳定 ID。


网络同步顺序不要过度假设

常见错误假设:

1
2
3
4
5
服务端先设置属性,再发 RPC,客户端一定先收到属性。
Actor A 先 Spawn,Actor B 后 Spawn,客户端一定先收到 A。
Target 指针 OnRep 时,Target Actor 一定已经创建。
BeginPlay 后所有 replicated 属性都可用。
多个 OnRep 按声明顺序触发。

这些都不能作为强业务前提。

典型错误代码

服务端:

1
2
CurrentWeapon = NewWeapon;
Client_PlayEquipAnimation();

客户端:

1
2
3
4
5
void AMyCharacter::Client_PlayEquipAnimation_Implementation()
{
CurrentWeapon->AttachToComponent(GetMesh(), AttachRules, SocketName);
PlayAnimMontage(CurrentWeapon->EquipMontage);
}

风险:

1
2
3
4
Client RPC 先执行。
CurrentWeapon 属性还没更新。
或者 NewWeapon Actor 还没创建。
于是 CurrentWeapon == nullptr。

稳定写法:事件 ID + 状态收敛

服务端:

1
2
CurrentWeapon = NewWeapon;
EquipEventId++;

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void AMyCharacter::OnRep_CurrentWeapon()
{
TryPlayEquip();
}

void AMyCharacter::OnRep_EquipEventId()
{
bPendingEquipEvent = true;
TryPlayEquip();
}

void AMyCharacter::TryPlayEquip()
{
if (!bPendingEquipEvent)
{
return;
}

if (!IsValid(CurrentWeapon))
{
return;
}

if (!GetMesh())
{
return;
}

bPendingEquipEvent = false;
AttachWeapon();
PlayEquipMontage();
}

核心思想:

1
2
不要依赖“网络消息到达顺序”。
依赖“条件满足后执行”。

大量 Actor 同步为什么会爆性能

如果有:

1
2
3
4
5
5000 个建筑
2000 个资源点
500 个 NPC
10000 个掉落物
100 个玩家

并且每个 Actor 都:

1
2
3
bReplicates = true;
bAlwaysRelevant = true;
NetUpdateFrequency = 100.f;

服务器每帧可能要做:

1
2
3
4
5
6
7
每个连接遍历大量 Actor。
判断 relevancy。
判断优先级。
比较属性 dirty。
序列化数据。
维护 ActorChannel。
发包。

复杂度接近:

1
Actor 数量 × 连接数量

爆的不是只有带宽,还有:

1
2
3
4
5
服务器 CPU
连接上的 ActorChannel 数量
每连接复制状态缓存
包大小
客户端反序列化成本

生存 / UGC / 建造类游戏的对象分层

对象类型 推荐策略
玩家角色 正常复制,高优先级,移动交给 CharacterMovement
NPC 距离相关,远处低频或聚合
建筑 大部分时间 Dormant,状态变化时唤醒
资源箱 / 采集物 默认休眠,只同步剩余量、是否被采集、刷新时间
掉落物 距离相关,数量多时聚合
纯装饰 不复制,客户端根据地图数据或种子本地生成

常用优化手段

1
2
3
4
5
6
7
8
9
10
NetCullDistanceSquared:距离裁剪。
NetUpdateFrequency:降低低频对象更新。
Dormancy:静态对象休眠。
bOnlyRelevantToOwner:私有对象只给 Owner。
COND_OwnerOnly:私有属性只给 Owner。
ReplicationGraph:大规模 Actor 分区。
FastArray:列表增量同步。
SubObject:减少不必要的 Actor 数量。
聚合复制:多个对象合成一个状态包。
客户端预测 / 本地生成表现。

ReplicationGraph 原理与实现方法

传统复制大致是:

1
2
3
对每个连接
遍历大量 Actor
判断这个 Actor 是否应该复制给这个连接

大量 Actor + 大量连接时,这会让服务器 CPU 成为瓶颈。

ReplicationGraph 的思想是:

1
2
3
用持久化节点提前组织 Actor。
每个连接按需从节点拿“候选复制列表”。
避免每帧每连接从零开始遍历所有 Actor。

ReplicationGraph 不是替代属性复制 / RPC

ReplicationGraph 主要优化的是:

1
“哪些 Actor 需要考虑复制给这个连接”

它不是替代:

1
2
3
4
5
属性复制
RPC
FastArray
SubObject
ActorChannel

常见节点划分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
全局状态 Actor
-> AlwaysRelevantNode

PlayerController / PlayerState / 自己 Pawn
-> AlwaysRelevantForConnectionNode

玩家角色 / NPC / 掉落物 / 建筑
-> GridSpatialization2DNode

只给 Owner 的对象
-> Owner 相关节点 / bOnlyRelevantToOwner

休眠静态对象
-> DormancyNode / Grid + Dormancy

启用 ReplicationGraph

DefaultEngine.ini

1
2
[/Script/OnlineSubsystemUtils.IpNetDriver]
ReplicationDriverClassName="/Script/MyGame.MyReplicationGraph"

或者用代码绑定:

1
2
3
4
5
6
UReplicationDriver::CreateReplicationDriverDelegate().BindLambda(
[](UNetDriver* ForNetDriver, const FURL& URL, UWorld* World) -> UReplicationDriver*
{
return NewObject<UMyReplicationGraph>(GetTransientPackage());
}
);

基础结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
UCLASS(Transient, Config = Engine)
class UMyReplicationGraph : public UReplicationGraph
{
GENERATED_BODY()

public:
virtual void InitGlobalActorClassSettings() override;
virtual void InitGlobalGraphNodes() override;
virtual void InitConnectionGraphNodes(
UNetReplicationGraphConnection* RepGraphConnection
) override;

virtual void RouteAddNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo,
FGlobalActorReplicationInfo& GlobalInfo
) override;

virtual void RouteRemoveNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo
) override;

private:
UPROPERTY()
TObjectPtr<UReplicationGraphNode_GridSpatialization2D> GridNode;

UPROPERTY()
TObjectPtr<UReplicationGraphNode_ActorList> AlwaysRelevantNode;
};

初始化节点:

1
2
3
4
5
6
7
8
void UMyReplicationGraph::InitGlobalGraphNodes()
{
GridNode = CreateNewNode<UReplicationGraphNode_GridSpatialization2D>();
AddGlobalGraphNode(GridNode);

AlwaysRelevantNode = CreateNewNode<UReplicationGraphNode_ActorList>();
AddGlobalGraphNode(AlwaysRelevantNode);
}

路由 Actor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void UMyReplicationGraph::RouteAddNetworkActorToNodes(
const FNewReplicatedActorInfo& ActorInfo,
FGlobalActorReplicationInfo& GlobalInfo
)
{
AActor* Actor = ActorInfo.Actor;

if (Actor->IsA<AMyGlobalStateActor>())
{
AlwaysRelevantNode->NotifyAddNetworkActor(ActorInfo);
return;
}

if (Actor->IsA<APlayerController>())
{
// 通常不放全局节点,走连接私有逻辑。
return;
}

GridNode->AddActor_Dormancy(ActorInfo, GlobalInfo);
}

ReplicationGraph 和 Iris 的关系

需要特别注意:ReplicationGraph 和 Iris 是两套独立系统,不是叠加关系。 一个 NetDriver 不能同时把 ReplicationGraph 当作传统复制筛选层、又使用 Iris 作为同一路复制系统的替代实现。是否启用 Iris、是否使用 ReplicationGraph,需要按项目版本和目标平台统一规划。


Actor 网络复制调用栈:属性、RPC、OnRep 到底怎么走

服务端发送路径

简化调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
UWorld::Tick

UNetDriver::TickFlush

UNetDriver::ServerReplicateActors

按连接遍历 UNetConnection

收集该连接需要复制的 Actor

如果使用 ReplicationGraph:
UReplicationGraph::ServerReplicateActors
GatherActorListsForConnection

找到 / 创建 UActorChannel

UActorChannel::ReplicateActor

AActor::PreReplication

FObjectReplicator::ReplicateProperties

复制 Actor 属性

复制 Component 属性

复制 SubObject 属性

发送 RPC / queued RPC bunch

UNetConnection Flush

Socket send

不同 UE 版本函数名和内部拆分会有差异,但主路径基本是:

1
TickFlush -> ServerReplicateActors -> UActorChannel::ReplicateActor

TickDispatch 和 TickFlush

可以简化理解:

1
2
TickDispatch:收包,处理远端发来的 packet。
TickFlush:发包,把本帧要复制的 Actor/RPC flush 出去。

服务器:

1
2
3
4
5
6
7
8
TickDispatch
处理客户端输入 / Server RPC

World Tick
运行业务逻辑,修改权威状态

TickFlush
复制 Actor 属性 / RPC 给客户端

客户端:

1
2
3
4
5
6
7
8
9
TickDispatch
接收服务端复制
创建 Actor
执行 RPC
写入属性
触发 OnRep

World Tick
客户端表现 / 预测 / UI

属性复制流程

Health 为例:

1
2
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

服务端修改:

1
Health = 50.f;

复制时:

flowchart TB
    A["Actor 被判定 relevant"]
    B["ActorChannel 存在"]
    C["FObjectReplicator 比较
当前对象数据和 Shadow State"] D["发现 Health 变化"] E["序列化 Health 到 bunch"] F["更新该连接的
发送历史 / shadow"] A --> B --> C --> D --> E --> F

重点:属性是否发送是按连接判断的。 同一个 Actor 的同一个属性,可能发给 A,不发给 B。这取决于:

1
2
3
4
5
6
Relevancy
Dormancy
Owner
条件复制
NetUpdateFrequency
连接是否饱和

客户端属性接收和 OnRep

客户端收到属性 bunch:

flowchart TB
    A["UActorChannel 解析 bunch"]
    B["FObjectReplicator::ReceivedBunch"]
    C["FRepLayout 反序列化属性"]
    D["把新值写入对象内存"]
    E["记录需要触发的 RepNotify"]
    F["处理 unmapped object references"]
    G["调用 OnRep 函数"]
    A --> B --> C --> D --> E --> F --> G

所以在 OnRep_Health() 中读到的是新值。

如果需要旧值,可以使用带参数 OnRep:

1
2
3
4
5
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

UFUNCTION()
void OnRep_Health(float OldHealth);

具体支持和签名细节以项目 UE 版本为准。

服务端不会自动触发 OnRep

C++ 中服务端修改 RepNotify 属性,不会自动执行客户端式 OnRep

推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void AMyCharacter::SetHealth(float NewHealth)
{
const float OldHealth = Health;
Health = NewHealth;

if (HasAuthority())
{
HandleHealthChanged(OldHealth);
}
}

void AMyCharacter::OnRep_Health(float OldHealth)
{
HandleHealthChanged(OldHealth);
}

把真正逻辑放到 HandleHealthChanged,而不是在服务端硬调 UI 逻辑。


玩法开发最容易出错清单:逐条举例

Actor 是否 bReplicates = true

错误:

1
2
3
4
AChest::AChest()
{
// 忘了 bReplicates
}

正确:

1
2
3
4
AChest::AChest()
{
bReplicates = true;
}

是否由 Server Spawn

错误:

1
2
// Client 本地 Spawn
GetWorld()->SpawnActor<AWeapon>(WeaponClass);

正确:

1
Server_RequestEquipWeapon(WeaponId);

服务端:

1
2
3
AWeapon* Weapon = GetWorld()->SpawnActor<AWeapon>(WeaponClass);
Weapon->SetReplicates(true);
CurrentWeapon = Weapon;

Server RPC 是否在 Owner Actor 上调用

错误:

1
Chest->Server_Open();

如果 Chest 不属于这个客户端,Server RPC 可能被丢弃或不按预期执行。

正确:

1
MyCharacter->Server_RequestOpenChest(Chest);

服务端校验:

1
2
3
4
if (!CanInteractWith(Chest))
{
return;
}

Client RPC 是否有正确 Owner

错误:

1
WorldTipActor->Client_ShowTip();

正确:

1
PlayerController->Client_ShowTip();

Multicast 是否由 Server 调用

错误:

1
2
// Client 调用
Explosion->Multicast_PlayFX();

正确:

1
2
3
4
Server_RequestExplode();

// Server 内部
Multicast_PlayFX();

Reliable 是否被高频调用

错误:

1
2
3
4
void Tick(float DeltaSeconds)
{
Server_UpdateAim(AimRot);
}

正确:

1
2
3
4
压缩 Rotator。
限频 10~20 Hz。
用 Unreliable 或属性同步。
关键开火由 Server 校验。

RPC 参数是否包含不可信数据

错误:

1
Server_AddGold(999999);

正确:

1
Server_RequestClaimReward(RewardId);

服务端:

1
2
3
4
5
6
if (!RewardSystem.CanClaim(PlayerState, RewardId))
{
return;
}

RewardSystem.Claim(PlayerState, RewardId);

属性是否注册到 GetLifetimeReplicatedProps

错误:

1
2
3
4
UPROPERTY(Replicated)
int32 Ammo;

// 忘了 DOREPLIFETIME

正确:

1
2
3
4
5
6
7
void AMyWeapon::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps
) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyWeapon, Ammo);
}

OnRep 是否 null-safe

错误:

1
2
3
4
void OnRep_Target()
{
Target->DoSomething();
}

正确:

1
2
3
4
5
6
7
8
9
10
void OnRep_Target()
{
if (!IsValid(Target))
{
bPendingBindTarget = true;
return;
}

BindTarget();
}

OnRep 是否依赖 UI 初始化

错误:

1
2
3
4
void OnRep_Health()
{
HealthBar->SetPercent(Health / MaxHealth);
}

正确:

1
2
3
4
void OnRep_Health()
{
OnHealthChanged.Broadcast(Health);
}

UI 创建后主动拉当前状态:

1
HealthBar->SetPercent(Character->GetHealthPercent());

Dormant 时是否先唤醒再改属性

错误:

1
2
SetNetDormancy(DORM_DormantAll);
bIsOpen = true;

正确:

1
2
3
FlushNetDormancy();
bIsOpen = true;
ForceNetUpdate();

是否大量 Actor AlwaysRelevant

错误:

1
bAlwaysRelevant = true;

然后所有建筑、资源、掉落物都这样写。

正确:

1
2
3
4
全局少量对象 AlwaysRelevant。
空间对象走距离 / ReplicationGraph。
低频对象 Dormancy。
私有对象 OwnerOnly。

推荐的网络玩法分层架构

背包示例

sequenceDiagram
    participant Client
    participant Server
    participant Others as 其他客户端
    Client->>Client: 玩家点击使用物品
    Client->>Server: Server_UseItem(SlotIndex, ClientPredictedItemId)
    Note over Server: 校验:Slot 是否存在 / ItemId 匹配
数量足够 / 是否在冷却 / 状态允许 alt 校验通过 Server->>Server: 修改 Inventory FastArray Server-->>Client: OwnerOnly 同步给自己 Server-->>Others: 公开装备变化同步 Client->>Client: OnRep / FastArray 刷新成功状态 else 校验失败 Server-->>Client: Client RPC 返回失败原因 end

建筑示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
建筑 Actor:
bReplicates = true
NetDormancy = DORM_DormantAll
不 AlwaysRelevant
走 RepGraph 空间节点

建筑状态:
HP
Level
OwnerId
bCompleted
VisualState

状态变化:
FlushNetDormancy
修改 replicated 属性
ForceNetUpdate
必要时重新休眠

UGC 大世界对象示例

不要每个装饰物都复制 Actor。可以:

1
2
3
4
5
6
服务端同步 UGC Cell 数据版本。
客户端按 CellId / Seed 本地生成装饰。
只有可交互对象才是 replicated Actor。
大量静态对象使用聚合状态。
变化对象用 FastArray 增量同步。
空间相关对象走 ReplicationGraph。

调试与测试建议

常用命令

优先使用当前官方网络模拟命令:

1
2
3
4
NetEmulation.PktLag 100
NetEmulation.PktLoss 5
NetEmulation.PktDup 2
NetEmulation.PktOrder 1

常用查看:

1
2
3
4
5
6
7
8
stat net
stat netpkt
net.ListActorChannels
net.DumpRelevantActors
net.RPC.Debug 1
net.Reliable.Debug 1
net.ForceOnePacketPerBunch true
net.SubObjects.CompareWithLegacy 1

部分旧命令或 RepGraph 专用命令在不同 UE 版本中可能存在差异,建议在目标版本控制台自动补全或源码中确认。

推荐测试矩阵

1
2
3
4
5
6
7
8
9
10
11
Dedicated Server + 2 Clients
Listen Server + 1 Client
延迟 100ms
丢包 5%
客户端中途加入
Actor 进入 / 离开 relevancy
Dormant 后唤醒
地图切换
Seamless Travel
重连
大量 Actor 压力测试

不要只在本机 0 延迟 PIE 里测。


总结

UE 网络同步最容易错的不是 API,而是边界混乱:

1
2
3
4
5
6
7
8
把客户端请求当成权威结果。
把属性同步当成立即调用。
把 RPC 当成全局有序消息。
把 OnRep 当成初始化完成通知。
把 UObject 当成自动可复制对象。
把 Reliable 当成无限可靠队列。
把 PlayerState 公开复制和 OwnerOnly 私有属性混为一谈。
把大量世界对象全部 AlwaysRelevant。

正确做法:

1
2
3
4
5
6
7
8
9
10
Client 负责输入、预测、表现。
Server 负责校验、裁决、修改权威状态。
Replicated Property 负责长期状态。
RPC 负责瞬时事件和请求。
FastArray 负责列表增量。
SubObject 负责轻量 UObject 状态。
OwnerOnly 负责私有数据。
ReplicationGraph 负责大规模 Actor 筛选。
Dormancy 负责低频静态对象。
OnRep 只做数据变化响应,不做初始化假设。

最后给一句最适合写进团队 Code Review 的话:

网络玩法不要依赖“某条消息先到”。应该让所有状态最终收敛到服务端权威结果,让所有客户端逻辑在依赖条件满足后再执行。


参考资料

  1. Unreal Engine Documentation - Replicated Object Execution Order
    https://dev.epicgames.com/documentation/unreal-engine/replicated-object-execution-order-in-unreal-engine

  2. Unreal Engine Documentation - Detailed Actor Replication Flow
    https://dev.epicgames.com/documentation/unreal-engine/detailed-actor-replication-flow-in-unreal-engine

  3. Unreal Engine Documentation - Remote Procedure Calls
    https://dev.epicgames.com/documentation/unreal-engine/remote-procedure-calls-in-unreal-engine

  4. Unreal Engine Documentation - Object Replication / Replicating UObjects
    https://dev.epicgames.com/documentation/unreal-engine/replicating-uobjects-in-unreal-engine

  5. Unreal Engine Documentation - Replicating Object References
    https://dev.epicgames.com/documentation/unreal-engine/replicating-object-references-in-unreal-engine

  6. Unreal Engine API - FFastArraySerializer
    https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/NetCore/FFastArraySerializer

  7. Unreal Engine Documentation - Replication Graph
    https://dev.epicgames.com/documentation/unreal-engine/replication-graph-in-unreal-engine

  8. Unreal Engine Documentation - Actor Network Dormancy
    https://dev.epicgames.com/documentation/unreal-engine/actor-network-dormancy-in-unreal-engine

  9. Unreal Engine Documentation - Actor Relevancy
    https://dev.epicgames.com/documentation/unreal-engine/actor-relevancy-in-unreal-engine

  10. Unreal Engine Documentation - Console Commands for Network Debugging
    https://dev.epicgames.com/documentation/unreal-engine/console-commands-for-network-debugging-in-unreal-engine