UE5.8 Actor Replication 与 Property Replication

📑 目录

UE5.8 Actor Replication 与 Property Replication

一个让策划摔键盘的下午

上个月,我们组的小王用 UE5.8 做了一个四人联机射击 demo。本地单人跑起来丝般顺滑,角色移动、开枪、换弹、受击反馈都很跟手。周五下班前他自信满满地拉上策划和美术一起联机测试,结果开第一局就出事了。

客户端 A 的玩家躲在掩体后面对客户端 B 的玩家清空了一个弹匣,屏幕上血花四溅,对方角色也做了受击后仰。但客户端 B 的屏幕里,自己的血量还是满的,角色稳稳地站在原地,只是偶尔会突然平移一下。更离谱的是,有时候客户端 B 明明已经被打空血条倒地,服务器却判定他还站着,反过来一枪把客户端 A 秒了。

会议室里的气氛瞬间凝固。策划盯着两个屏幕看了半分钟,说了一句话:这不是手感问题,这是游戏根本没法玩。

小王熬了一个周末,把 UE 的网络同步源码翻了一遍。问题看似分散,其实都指向同一个核心:Actor Replication 与 Property Replication。这两个机制决定了服务器上的对象和状态如何分发给各个客户端,也决定了客户端看到的世界到底能有多接近服务器眼中的真相。这篇文章把那个周末的笔记整理出来,按模块拆开讲。

Replication 基础概念

在 UE 的网络模型里,服务器持有唯一权威的世界状态,所有会影响游戏结果的计算都应该在服务器上完成。客户端只是服务器状态的一个观察者,它通过 Replication 接收数据,再用这些数据驱动本地的画面、音效和反馈。

一个 Actor 能否被复制,由 bReplicates 标志位决定。只有服务器上存在且 bReplicates = true 的 Actor,才会被纳入复制系统。服务器会为每个连接到它的客户端维护一份独立的连接状态,其中包含该客户端需要知道的所有 Actor 列表。这个列表不是静态的,UE 会根据 relevance、优先级、距离、可见性等因素动态增删。

下面这张图展示了 UE5.8 网络复制的大致架构。

flowchart TD
    subgraph server ["服务器 Authority"]
    end
    subgraph client_a ["客户端 A"]
    end
    subgraph client_b ["客户端 B"]
        world["UWorld / GameState"]
        rep["Replication Driver"]
        channel_a["Actor Channel A"]
        channel_b["Actor Channel B"]
        proxy_a["本地模拟代理"]
        render_a["渲染与反馈"]
        proxy_b["本地模拟代理"]
        render_b["渲染与反馈"]
    end
    world --> rep
    rep --> channel_a
    rep --> channel_b
    channel_a --> proxy_a
    channel_b --> proxy_b
    proxy_a --> render_a
    proxy_b --> render_b

图中服务器这边有三层:World 里跑的是真正的游戏逻辑,Replication Driver 负责把变化推送到各个 Actor Channel,每个 Channel 再对应到一个客户端的连接。客户端收到数据后,把远程对象以本地代理的形式重建出来,然后交给渲染和输入反馈系统。

理解这个分层很重要,因为后面所有复制相关的问题,几乎都可以定位到其中某一层。服务器没算对,是逻辑层问题;算对了但没发出去,是复制层问题;发出去了但客户端没收到或没解析,是网络或接收层问题。

Actor Replication 解决的是「这个对象要不要让客户端知道」。Property Replication 解决的是「这个对象的哪些字段需要同步,以及同步给谁」。两者配合,才能把服务器的世界快照高效地分发到多台机器上。

Replicated 属性与 RepNotify

小王 demo 里的血量不同步,最直接的原因就是血量属性没有正确配置复制。在 UE 里,要让一个 UPROPERTY 参与网络同步,需要先在属性声明里加上 Replicated 标记,然后在类的 GetLifetimeReplicatedProps 函数里注册这条属性的复制规则。

UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    UPROPERTY(Replicated, BlueprintReadWrite, Category = "Health")
    float CurrentHealth;

    UPROPERTY(ReplicatedUsing = OnRep_MaxHealth, BlueprintReadWrite, Category = "Health")
    float MaxHealth;

    UFUNCTION()
    void OnRep_MaxHealth();

    void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(AMyCharacter, CurrentHealth);
    DOREPLIFETIME(AMyCharacter, MaxHealth);
}

上面的代码里,CurrentHealth 使用普通的 Replicated 标记,服务器端数值变化后会在下一次网络更新时推送到客户端。客户端收到后会直接修改本地值,没有任何回调。MaxHealth 则使用 ReplicatedUsing,并绑定到 OnRep_MaxHealth 函数。这样每次该属性在客户端发生变化时,UE 都会调用这个函数。

RepNotify 是处理表现层同步的关键。比如最大血量变化时,UI 血条的上限需要重绘,护盾条比例需要重新计算,某些 buff 图标可能需要刷新。这些工作放在 RepNotify 里做,比在 Tick 里轮询要干净得多。

不过 RepNotify 只在客户端触发。服务器上修改属性不会调用 OnRep,所以如果你在服务器代码里改了 MaxHealth,需要手动同步更新依赖它的逻辑。一个常见错误是只在 OnRep 里刷新 UI,结果服务器本地运行正常,监听服务器模式下的主机玩家却看不到变化。

另一个容易踩坑的地方是初始值。很多新手以为 Actor 生成时客户端会自动拿到服务器上的初始属性值,但如果 GetLifetimeReplicatedProps 里漏注册了某条属性,客户端就会拿到 C++ 构造函数里的默认值。小王排查时发现,他的 CurrentHealth 在构造函数里默认是 100,服务器生成时改成 80,但客户端因为漏注册一直显示 100。两边一交火,客户端以为自己血很多,其实服务器早就判定残血。

条件复制(Replication Condition)

注册属性时,UE 允许指定一个条件,决定这条属性要同步给哪些客户端。最常用的是 DOREPLIFETIME_CONDITION 宏,第三个参数就是 ELifetimeCondition 枚举。

条件含义
COND_None无限制,所有相关客户端都同步
COND_InitialOnly只在 Actor 首次复制时发送一次
COND_OwnerOnly只同步给拥有者客户端
COND_SkipOwner同步给除拥有者外的所有客户端
COND_SimulatedOnly只同步给模拟代理
COND_AutonomousOnly只同步给自主代理
COND_Custom通过自定义回调判断

条件复制的意义在于减少不必要的流量。不是所有客户端都需要知道所有属性。以第一人称射击游戏为例,玩家的精确鼠标朝向、当前武器后坐力状态、本地预测输入,对拥有者客户端来说已经通过本地输入提前知道,对其它客户端才有必要从服务器同步。

这里有一个容易混淆的点:COND_SimulatedOnlyCOND_SkipOwner 看起来都在排除拥有者,但语义不同。COND_SkipOwner 表示除了拥有者谁都发,而 COND_SimulatedOnly 只发给模拟代理。如果某个 Actor 的 owner 本身是模拟代理(比如 AI 控制的角色),COND_SkipOwner 会跳过它,COND_SimulatedOnly 依然会发送。

小王在修 demo 时给武器后坐力加了 COND_SkipOwner,因为拥有者客户端已经在本地输入系统里模拟过后坐力,不需要服务器再推一次。其它客户端则必须靠服务器同步才能看到枪口上抬。改完后网络流量降了一截,手感也没有变差。

flowchart TD
    A["属性值在服务器发生变化"] --> B{是否满足 NetCondition?}
    B -->|"否"| C["跳过本次同步"]
    B -->|"是"| D{目标客户端是哪些?}
    D -->|COND_OwnerOnly| E["仅发送给 Owner"]
    D -->|COND_SkipOwner| F["发送给非 Owner"]
    D -->|COND_SimulatedOnly| G["仅发送给 Simulated Proxy"]
    D -->|COND_Custom| H["执行 ShouldRep 回调"]
    H --> I{返回 true?}
    I -->|"否"| C
    I -->|"是"| J["加入复制包"]
    E --> J
    F --> J
    G --> J
    J --> K["按优先级和频率发送"]

对于更复杂的场景,可以用 COND_Custom 配合 PreReplicationShouldRep_xxx 回调。比如一个团队竞技游戏里,敌方玩家的具体血量不应该让对手看到,但队友可以看到。这种业务逻辑用内置条件无法表达,就必须写自定义判断。自定义判断里可以访问 Viewer 的 PlayerController、团队 ID、视野关系等数据,决定当前这次复制要不要发送。

Owner Only / Skip Owner

Owner Only 和 Skip Owner 是最常用的一对条件,值得单独展开。

Owner Only 的典型场景是隐藏只应该让操作者知道的信息。卡牌游戏里玩家手牌、MOBA 里英雄的技能冷却精确值、战术竞技里的背包内容,通常都只同步给拥有者客户端。服务器可以发一个简化版给其它客户端,比如「这名玩家持有一把步枪」,但具体弹药数量、配件组合不需要告诉他们。

Skip Owner 则相反,它把拥有者客户端排除在外。这个设计基于一个很朴素的观察:拥有者客户端通常已经通过本地预测知道了状态,服务器再推一次只会造成数据冗余,甚至引发抖动。

小王在 demo 里给角色位置同步加过 COND_SkipOwner,结果出了新问题。他自己客户端上的角色移动反而变得一顿一顿。原因是他把位置属性设成了 Skip Owner,但本地输入预测没有正确插值,客户端失去了服务器的位置修正。后来他把位置同步改回 COND_None,让服务器每帧都推位置,同时把本地输入和服务器快照做混合,手感才稳定下来。

这说明条件复制不是越少越好。Skip Owner 适合那些本地已经精确知道、且服务器不会纠错的属性。对于移动这种需要服务器权威校正的数据,还是让服务器继续推更稳妥。

Lifetime Role 与网络模式

UE 用 ENetRole 描述一个 Actor 或 ActorComponent 在当前进程中的网络身份。这个身份不是全局固定的,而是相对于当前运行环境而言。

graph TD
    A[ENetRole] --> B[ROLE_None]
    A --> C[ROLE_SimulatedProxy]
    A --> D[ROLE_AutonomousProxy]
    A --> E[ROLE_Authority]
    C --> F["客户端上由别人控制的 Actor"]
    D --> G["客户端上由自己控制的 Actor"]
    E --> H["服务器上的真身"]
  • ROLE_None:不参与网络复制,单机对象或默认状态。
  • ROLE_SimulatedProxy:客户端上的模拟代理,服务器说怎么动就怎么动,本地不做预测。
  • ROLE_AutonomousProxy:客户端上的自主代理,玩家可以直接输入,同时把输入发给服务器做权威校验。
  • ROLE_Authority:服务器上的权威对象,拥有最终裁定权。

在 Dedicated Server 架构下,服务器上所有 Actor 都是 Authority,客户端上玩家自己控制的角色是 AutonomousProxy,其它玩家和 AI 是 SimulatedProxy。Listen Server 模式下,主机玩家身上的角色在主机进程里既是 Authority 又是本地输入者,所以主机通常没有延迟补偿的烦恼,但其它客户端的体验仍然取决于主机到它们的网络质量。

网络模式由 ENetMode 描述,四种常见模式分别是:

  • NM_Standalone:纯单机,无网络复制。
  • NM_ListenServer:一个客户端同时充当服务器,适合小规模联机。
  • NM_DedicatedServer:专用服务器,无本地玩家。
  • NM_Client:连接到远程服务器的客户端。

判断当前运行环境时,最常用的是 GetNetMode()HasAuthority()。前者返回整体网络模式,后者判断当前对象是否具有 Authority。需要注意的是,HasAuthority() 为 true 只说明当前进程对这个对象有权威,不代表它一定是 Dedicated Server。Listen Server 里主机玩家的角色 HasAuthority() 也是 true。

小王的 demo 用的是 Listen Server,他自己当主机。这导致他在本地测试时很多网络问题被掩盖了,因为主机上 Authority 和 Autonomous 重合,数据不需要跨机器走。等他换到 Dedicated Server 测试,之前没暴露的问题才一股脑冒出来。建议任何严肃的网络功能都在 Dedicated Server 下验证,Listen Server 只适合原型阶段快速试错。

Actor 的创建与销毁同步

网络对象的生命周期同步和属性同步同样重要。客户端不能凭空创建一个复制的 Actor,它必须从服务器接收 Spawn 通知。服务器上调用 GetWorld()->SpawnActor 并把 bReplicates 设为 true,UE 会自动把创建事件发送给所有相关客户端,客户端会在本地生成对应的 SimulatedProxy 或 AutonomousProxy。

这里有几个细节值得注意。

第一,Spawn 的位置、旋转、Scale 可以在服务器确定后通过复制传给客户端。如果生成逻辑依赖随机数,务必使用 FRandomStream 或种子同步,否则客户端和服务器的随机结果不一致,会出现对象存在但位置不同的奇怪现象。

第二,AActor 的 ReplicatedUsing 事件不能直接写在构造函数里,因为客户端收到 Spawn 通知时构造函数已经执行完毕。需要在 PostNetInitBeginPlay 里做初始化表现,比如播放出生特效、注册 UI。

第三,销毁同步通过 DestroySetLifeSpan 触发。服务器销毁 Actor 后,会通知客户端销毁本地代理。如果客户端提前本地预测销毁,而服务器没有真正销毁,就会出现对象闪现的问题。弹道、爆炸碎片这类临时对象尤其容易中招。

sequenceDiagram
    participant S as "服务器"
    participant C1 as "客户端 A"
    participant C2 as "客户端 B"

    S->>S: SpawnActor(AProjectile)
    S->>S: 设置初始位置/速度
    S->>C1: NetSpawn(AProjectile)
    S->>C2: NetSpawn(AProjectile)
    C1->>C1: 本地创建 SimulatedProxy
    C2->>C2: 本地创建 SimulatedProxy
    loop 每帧
        S->>S: 模拟弹道
        S->>C1: 同步位置/旋转
        S->>C2: 同步位置/旋转
    end
    S->>S: 命中目标 / 超时
    S->>C1: NetDestroy(AProjectile)
    S->>C2: NetDestroy(AProjectile)
    C1->>C1: 本地销毁并触发结束特效
    C2->>C2: 本地销毁并触发结束特效

除了显式 Spawn 和 Destroy,UE 还提供了 Actor Dormancy 机制来控制复制活跃度。一个 Actor 可以进入休眠状态,暂时停止属性同步,直到有客户端靠近或它被显式唤醒。对于大地图、大量静态对象、远处 NPC,Dormancy 能显著降低服务器 CPU 和网络开销。

还有一个相关概念是 NetUpdateFrequency,它决定一个 Actor 每秒最多被复制多少次。高频更新让同步更平滑,但也更吃带宽。对于玩家角色,通常设在 30306060 次每秒;对于远处投掷物,可以降到 1010 次;对于几乎不动的箱子,11 次甚至 Dormant 都可以接受。

可靠性与带宽权衡

网络同步永远是在可靠性和带宽之间找平衡。UE 的复制包默认是可靠的,意味着如果某个包丢失,底层会重传直到送达。这保证了一致性,但代价是延迟抖动。当网络拥塞时,可靠队列会越积越多,反而加剧卡顿。

对于位置、旋转、动画状态这类高频变化的数据,UE 通常使用不可靠复制。丢了一帧位置包没关系,下一帧会补新的位置,玩家感官上只是轻微抖动。但如果丢的是血量变化、技能触发、道具拾取这种离散事件,就必须用可靠通道,否则会造成状态永久不一致。

带宽预算可以用一个简单公式估算:

Bnet=i=1nΔPi×fi1024KB/sB_{net} = \frac{\sum_{i=1}^{n} \Delta P_i \times f_i}{1024} \,\text{KB/s}

其中 ΔPi\Delta P_i 是第 ii 个属性每次更新占用的字节数,fif_i 是它的更新频率,nn 是同时复制的属性总数。这个公式没有算协议头开销,但能快速判断设计方向有没有问题。假设一个角色有 2020 个 float 属性以 3030 Hz 同步,光属性数据就接近 2.42.4 KB/s。 ten 个玩家同时互相可见,单方向带宽就超过 2424 KB/s,再加上位置、旋转、动画、事件,实际流量会轻松翻倍。

所以做网络同步时,必须精打细算。能不发的不发,能用整数压缩的不用 float,能在客户端本地推导的就不传。UE 提供了 NetPriorityNetUpdateFrequencyNetCullDistanceSquared 等杠杆,让开发者按对象重要性分配带宽。

NetPriority 决定当网络包塞不下时,哪些 Actor 优先被发送。玩家角色优先级最高,远处爆炸碎片最低。NetCullDistanceSquared 决定距离多远的 Actor 不再复制。NetUpdateFrequency 则是前面提到的更新频率上限。

小王修 demo 时做了三件事:把武器精确后坐力改成事件驱动而不是每帧同步;把远处角色的 NetUpdateFrequency 降到 1010;给子弹命中加了可靠 RPC,位置同步保留不可靠。改完后在 120ms120\,\text{ms} 延迟、3%3\% 丢包环境下,对射体验基本可玩了。

常见同步问题排查

最后整理一份排查清单,都是小王那个周末踩过的坑。

属性不同步:先检查 bReplicates 是否为 true,再确认 GetLifetimeReplicatedProps 里注册了该属性,最后看条件复制是否把目标客户端排除了。

客户端改了属性但服务器没改: replicated 属性只能由服务器修改,客户端本地修改会被下一次复制覆盖。客户端想改状态必须通过 Server RPC 请求服务器修改。

RepNotify 不触发:检查属性声明里的 ReplicatedUsing 名称和函数名是否一致,函数是否加了 UFUNCTION(),以及当前运行的是不是客户端。

位置抖动或回拉:可能是本地预测与服务器快照冲突,检查移动组件的网络配置,确认是否启用了网络插值和延迟补偿。

对象在客户端凭空出现或消失:检查 Spawn 是否发生在服务器,bReplicates 是否设置,以及是否有 Client 端提前销毁对象。

流量爆炸:用 stat net 查看各 Actor 的复制字节数,定位流量大户。常见原因包括 NetUpdateFrequency 过高、大量无关属性注册了复制、Dormancy 没有启用。

Listen Server 正常 Dedicated Server 异常:重点检查 Authority 判断、本地预测逻辑、以及服务器-only 的初始化代码。

网络延迟高时体验差:区分哪些数据必须可靠、哪些可以不可靠,调整 NetPriority 和更新频率,必要时引入客户端预测和服务器和解。

小王的 demo 最终能跑顺,靠的不是某个神奇开关,而是把每个同步点都理清楚:这个对象是谁在管,这个属性要发给谁,这个事件能不能丢,这个流量值不值得花。UE 的 Replication 系统很庞大,但核心问题始终围绕 Actor 和 Property 两条主线。把主线抓住了,排查时就不会在大海捞针。