一、复制不是“把变量发过去”
很多人第一次接触 UE 多人游戏时,会把 Replication 理解成:
服务器上的变量变了 → 引擎把新值发给所有客户端 → 客户端的变量也变了这只描述了表面现象。真正的网络复制是在回答三个问题:
- 哪些对象需要被这个客户端知道?
- 哪些状态已经变化,值得占用带宽发送?
- 这个客户端应该收到完整状态、部分状态,还是一个事件?
所以复制更像一个状态分发系统:
服务器权威状态 ↓ 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() → 发出变化后重新进入 Dormantvoid 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 的复制工具才能发挥作用。