2570 字
13 分钟
UE 网络复制——从属性同步到 Replication Graph

一、复制不是“把变量发过去”#

很多人第一次接触 UE 多人游戏时,会把 Replication 理解成:

服务器上的变量变了
→ 引擎把新值发给所有客户端
→ 客户端的变量也变了

这只描述了表面现象。真正的网络复制是在回答三个问题:

  1. 哪些对象需要被这个客户端知道?
  2. 哪些状态已经变化,值得占用带宽发送?
  3. 这个客户端应该收到完整状态、部分状态,还是一个事件

所以复制更像一个状态分发系统:

服务器权威状态
↓ Relevancy / Owner / Condition
这个客户端应该知道的状态子集
↓ Delta Serialization
只发送和上次不同的部分
↓ Replication / RPC
客户端重建自己的视图

服务器是事实来源,客户端是事实的缓存和表现层。 客户端可以预测输入、提前播放动画,但不能因为客户端说“我已经拿到奖励了”,服务器就直接相信它。


二、一个 Actor 怎样进入复制系统#

最小配置通常是:

AMyWorldItem::AMyWorldItem() {
bReplicates = true;
SetReplicateMovement(true);
}

bReplicates 只是告诉 UE“这个 Actor 有资格被复制”。它还需要满足其他条件:

Actor 被服务器 Spawn
→ Actor 对当前 Connection 是 Relevant
→ Actor 有对应的 ActorChannel
→ 复制属性发生变化,或有 RPC 要发送
→ 序列化后写入网络包

1 ActorChannel 是什么#

服务器和每个客户端之间,会为需要通信的 Actor 建立一个逻辑通道——ActorChannel。通道负责:

  • 告诉客户端这个 Actor 的 NetGUID 和基本信息
  • 发送 Actor 的初始状态
  • 发送后续的属性差异
  • 转发这个 Actor 发出的 RPC
  • 在 Actor 变得不相关时关闭或暂时休眠

这意味着同一个 Actor 对不同客户端可以处于不同状态。玩家 A 已经进入副本,能看到任务目标;玩家 B 还在大厅,暂时看不到副本里的敌人。服务器仍然只有一个世界,但每条 Connection 的复制工作不同。

2 常用的复制调度参数#

bAlwaysRelevant = false; // 是否无视距离始终相关
NetUpdateFrequency = 10.0f; // 最高每秒尝试复制多少次
MinNetUpdateFrequency = 2.0f; // 自适应更新时的最低频率
NetDormancy = DORM_Awake; // 是否允许进入休眠
NetCullDistanceSquared = ...; // 距离相关性阈值

这些参数不是“越大越好”:

参数太小的结果太大的结果
NetUpdateFrequency状态延迟明显CPU 和带宽浪费
NetCullDistance远处 Actor 消失客户端收到太多无关 Actor
bAlwaysRelevant适合全局状态很容易广播过量
Dormancy唤醒不及时静态对象浪费复制检查

三、Replicated 属性:只同步变化的状态#

1 声明和注册#

UCLASS()
class ACoopSessionState : public AActor {
GENERATED_BODY()
public:
UPROPERTY(ReplicatedUsing = OnRep_ObjectiveProgress)
int32 ObjectiveProgress = 0;
UPROPERTY(Replicated)
int32 RemainingSeconds = 0;
UPROPERTY(Replicated)
FGameplayTag SessionState;
protected:
UFUNCTION()
void OnRep_ObjectiveProgress(int32 OldProgress);
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
void ACoopSessionState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const {
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACoopSessionState, ObjectiveProgress);
DOREPLIFETIME(ACoopSessionState, RemainingSeconds);
DOREPLIFETIME(ACoopSessionState, SessionState);
}

UPROPERTY(Replicated) 解决“把值送到客户端”,OnRep_ 解决“值到了以后客户端要做什么”。例如 ObjectiveProgress 到达后,客户端 UI 应该刷新,而不是把 UI 刷新逻辑塞进每一个修改任务进度的地方。

2 OnRep 只在客户端有意义吗#

通常,OnRep_ObjectiveProgress 是客户端收到新值时触发。服务器自己直接写入属性时,不会因为这个写入自动触发同一个 OnRep。如果服务器也需要执行相同的表现逻辑,应该把逻辑提取成普通函数:

void ACoopSessionState::SetObjectiveProgress(int32 NewProgress) {
const int32 OldProgress = ObjectiveProgress;
ObjectiveProgress = NewProgress;
RefreshObjectivePresentation(OldProgress, ObjectiveProgress);
}
void ACoopSessionState::OnRep_ObjectiveProgress(int32 OldProgress) {
RefreshObjectivePresentation(OldProgress, ObjectiveProgress);
}

不要在 OnRep 里再次修改这个 Replicated 属性。 否则很容易产生客户端和服务器逻辑分叉,或者在表现逻辑中意外触发下一轮状态变化。

3 复制条件#

不是所有属性都需要发给所有人:

UPROPERTY(Replicated)
int32 PublicActionCount = 0;
UPROPERTY(Replicated)
int32 MyPrivateQuestHint = 0;
DOREPLIFETIME(ACoopPlayerState, PublicActionCount);
DOREPLIFETIME_CONDITION(
ACoopPlayerState,
MyPrivateQuestHint,
COND_OwnerOnly);

常用条件包括:

条件含义例子
COND_OwnerOnly只发给 Owner私人情报、输入相关状态
COND_SkipOwner除 Owner 之外都发其他玩家可见、自己本地预测
COND_InitialOnly初始复制时发一次阵营、角色类型
COND_AutonomousOnly只发给自主代理本地玩家专属状态
COND_Never暂时不复制调试开关或实验字段

权限不是 UI 的隐藏。 如果服务器把玩家的私密任务提示复制给所有客户端,再用 UI 把它遮住,数据仍然已经泄露;真正的隐私必须在复制条件或 RPC 目标层面解决。


四、RPC:同步事件,而不是同步状态#

属性复制适合表达“现在是什么”,RPC 适合表达“刚刚发生了什么”。UE 里最常见的三种 RPC:

UFUNCTION(Server, Reliable)
void ServerSubmitAction(FGameplayTag ActionTag);
UFUNCTION(Client, Reliable)
void ClientShowPrivateHint(const FHintData& Hint);
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayActionEffect();

1 Server RPC#

客户端通过 Server RPC 请求服务器做事:

客户端按下技能键
→ ServerSubmitAction(ActionTag)
→ 服务器检查玩家身份、当前阶段、资源、动作是否合法
→ 服务器修改 ObjectiveProgress
→ ObjectiveProgress 通过属性复制同步给相关客户端

客户端传来的 ActionTag请求参数,不是事实。永远不要因为客户端请求了一个技能,就直接替它扣除资源或生成奖励。

2 Client RPC#

服务器可以只通知某一个客户端:

void ACoopPlayerController::SendPrivateQuestHint(const FHintData& Hint) {
if (IsValid(GetPawn())) {
ClientShowPrivateHint(Hint);
}
}

Client RPC 必须有明确的 Connection 归属。把它放在一个没有 Owner 的全局 Actor 上,通常会发现 RPC 根本没有到达目标玩家。

3 NetMulticast RPC#

Multicast 适合“所有当前相关客户端都播放一个短暂效果”:

void AWorldItem::ConfirmPickup() {
// 服务器确认结果后通知表现层
MulticastPlayPickupEffect();
}

它不适合保存关键状态:

错误:MulticastMissionFinished()
→ 客户端靠收到这个事件才知道结果
更稳:Replicated SessionState = Finished
→ Multicast 只负责播放特效和音效

因为晚加入的客户端可能错过已经发送过的 Multicast。可恢复的事实放进 Replicated 状态,不可恢复的瞬时表现才用事件。

4 Reliable 不是“更快”#

Reliable 的含义是可靠送达和重传,不是低延迟。大量 Reliable RPC 堵住队列,会让后面的重要消息也被拖住,严重时甚至断开连接。

适合 Reliable适合 Unreliable
任务完成、章节开始、奖励结算鼠标瞄准、移动输入、粒子表现
不到达就无法继续的控制事件下一帧会被新值覆盖的状态
数量少、顺序重要的事件高频、允许丢失的通知

五、Dormancy:静止的 Actor 不需要每次检查#

一个已经摆好的任务目标,可能几分钟都不变。如果服务器每次 NetUpdate 都检查它的所有属性,结果只会得到“没有变化”,这也是开销。

Awake
→ 属性持续变化,正常参与复制
→ 所有初始状态送完,进入 Dormant
→ 有新事件发生,FlushNetDormancy()
→ 发出变化后重新进入 Dormant
void AWorldCheckpoint::OnItemChanged() {
FlushNetDormancy();
MarkItemDirty();
ForceNetUpdate();
}

Dormancy 的关键不是“永远睡眠”,而是明确什么事件会唤醒它。如果业务代码只改了属性,却忘了唤醒 Actor,客户端就会一直看到旧状态。


六、从 Relevancy 到 Replication Graph#

小规模项目里,距离相关性和 NetCullDistanceSquared 通常够用。人数和 Actor 数量增长后,问题会变成:服务器每个网络更新周期都要对每个 Connection 检查大量 Actor。

Replication Graph 的思路是提前把 Actor 放进不同的节点:

Connection A 的观察位置
Replication Graph
├── Grid Spatialization Node → 只取附近格子
├── Always Relevant Node → GameState、全局事件
├── Dormancy Node → 静止 Actor 不重复计算
└── Custom Node → 按队伍、权限、玩法筛选
class UMyReplicationGraphNode_AlwaysRelevantForWorldEvent
: public UReplicationGraphNode {
public:
virtual void GatherActorListsForConnection(
const FConnectionGatherActorListParameters& Params) override;
};

它不是“开启一个性能开关”,而是把项目的相关性规则显式建模。你需要先回答:某种 Actor 按空间分发,按队伍分发,还是对所有玩家始终相关?

UE5 的 Iris / Replication System 也在继续演进复制的序列化与调度方式,但基础问题没有变:减少候选对象、减少重复状态、减少不必要的 Connection 广播。


七、合作副本的复制设计#

可以把一个回合拆成三层:

ACoopSessionState
├── Replicated:SessionState、RemainingSeconds、ObjectiveProgress
├── OwnerOnly:自己的任务提示、个人冷却
└── Server RPC:UseAbility、Interact
AWorldItem
├── Replicated:物品 ID、是否已拾取、交互状态
└── Multicast:拾取 VFX、音效
ACoopPlayerState
├── Replicated:等级、职业、队伍状态
└── OwnerOnly:私人任务提示、个人技能冷却

这样设计的好处是:

  • 新客户端加入后,仍然能从属性得到完整的回合状态
  • 技能和交互请求必须经过服务器验证
  • 私人信息不会因为“所有人都收到同一个状态”而泄露
  • 表现事件丢失时,下一次状态同步仍能修正画面

复制系统的目标不是让所有客户端拥有一模一样的内存,而是让每个客户端拥有它有权知道、足以正确表现的那部分状态。


八、总结#

概念一句话
ActorChannel服务器和客户端之间管理 Actor 生命周期与状态的逻辑通道
Replicated 属性表达可恢复的当前状态,默认只发送变化部分
OnRep客户端收到新状态后刷新表现层的入口
RPC表达事件或请求;Server RPC 仍然必须经过服务器验证
Owner / Condition决定一个属性或事件应该发给谁
Dormancy没有变化的 Actor 暂停复制检查,事件发生时显式唤醒
Replication Graph把相关性规则组织成节点,减少大规模复制的候选集

网络复制真正难的地方不是 API,而是状态边界。 哪些是事实,哪些是请求,哪些是私人信息,哪些只是一次性的表现事件——这些边界划清楚,UE 的复制工具才能发挥作用。

UE 网络复制——从属性同步到 Replication Graph
https://www.m4doka.xyz/posts/ue/ue-9-replication/
作者
m4doka
发布于
2026-08-18
许可协议
CC BY-NC-SA 4.0