UE Network Role详解:属性复制、RPC权限与联机排查

发布时间:2026/10/26 4:36:36
UE Network Role详解:属性复制、RPC权限与联机排查

多人网络游戏做多了,你会发现真正把人折磨到半夜的从来不是渲染或者动画,而是那些单机上跑得好好的逻辑,一连上服务器就变成两个角色、三份特效、客户端改了血量服务器不认。绕不开的一个东西,就是 UE 里每个 Actor 都带着的那对属性——Role和RemoteRole,也就是常说的Network Role。它本身不参与任何运算,也不会显示在屏幕上,但它决定了这一刻这台机器上的这个 Actor 到底是“说了算的那个”还是“照着演的那个”,决定了哪些代码该跑、哪些包该发、哪些 RPC 会被引擎悄悄丢掉并且只在日志里留一行警告。

这篇内容聊的就是这套机制。我会从枚举值讲到复制条件,从 RPC 权限矩阵讲到带宽账本,最后给一个能直接跑起来的拾取 Demo,以及一份我自己用了很久的排查速查表。适合已经写过一些 UE 联机代码、但总在“为什么客户端不生效”“为什么服务器收不到”这类问题上反复卡住的朋友;完全没接触过网络同步的新手也能看,前面几节会把概念铺平。

1. Role 和 RemoteRole 到底在描述什么

1.1 三个枚举值,对应三种身份

Role的类型是ENetRole,实际取值里真正会用到的就三个:ROLE_Authority、ROLE_AutonomousProxy、ROLE_SimulatedProxy,外加一个表示“不存在或不相关”的ROLE_None。ROLE_Authority的含义不是“我是服务器”,而是“我对这个 Actor 的状态有最终解释权”;ROLE_AutonomousProxy表示“我不是权威,但我跟权威之间有一条专属通道,我可以本地预测,也能往回发指令”;ROLE_SimulatedProxy则是纯粹的表现层,只能照着收到的数据演,没有发言权。

这三者不是并列的三种类型,而是一条从强到弱的权限阶梯。同一时刻,一个 Actor 在不同机器上的Role完全不同:服务端看到的是Authority,被它拥有的那个客户端看到的是AutonomousProxy,其他所有客户端看到的都是SimulatedProxy。很多人第一次接触时以为Role是 Actor 自身的固有属性,其实它描述的是“本机与这个 Actor 的关系”,换一台机器看同一个 Actor,值就变了。

生活化的类比:一个群里发公告。群主能改公告,普通成员只能看,而“公告”这份数据本身是一样的。Role就是告诉本机代码“你在这个群里是什么身份”,它跟公告内容无关,只跟身份有关。

1.2 为什么是两个变量而不是一个

如果只需要知道“我在这个 Actor 上是什么身份”,一个Role就够了。多出来的RemoteRole描述的是“本机所知道的、对端在这个 Actor 上是什么身份”。两者构成一对镜像:在服务端,Role == Authority、RemoteRole == AutonomousProxy(该 Actor 被某个客户端拥有时)或者SimulatedProxy(没有客户端拥有时);在拥有它的客户端上,Role == AutonomousProxy、RemoteRole == Authority;在旁观客户端上,Role == SimulatedProxy、RemoteRole == Authority。

为什么要知道对端身份?因为很多逻辑的判断依据是“对面能不能改”。比如一个客户端上的道具,本地要不要做预测性的表现、要不要等服务器确认后再播放音效、要不要提前把自己隐藏起来,这些决策都需要同时知道“我能做什么”和“对面能做什么”。RemoteRole就是为这个场景存在的,它是本机视角下对远端能力的建模。

还有一种情况容易被忽略:独立运行(Standalone)时,Role == Authority而RemoteRole == ROLE_None。这个组合在单机调试里完全正常,但如果你在代码里写了“RemoteRole == ROLE_None就认为是客户端”,那单机模式会走进一条你根本没测过的分支。

1.3 HasAuthority 最容易骗人的地方

HasAuthority()的实现就是一句return Role == ROLE_Authority;,简单到没人会去怀疑它,但它有两个非常经典的坑。第一个坑:对于没有开启复制的 Actor,在客户端上Role依然是Authority,因为客户端根本没有从服务器收到过任何角色信息,它就是本地创建的一个普通对象,当然“自己说了算”。所以你在客户端上对一个本地生成的特效用HasAuthority()判断,结果会是true,逻辑直接跑偏。

第二个坑:HasAuthority()不等于“这是服务器”。它只表示“这台机器对这个 Actor 有权威”。在一个纯客户端的本地 UI Actor 上,HasAuthority()同样返回true,但这个结论对网络同步毫无意义。真正想判断“我是不是服务端进程”应该用GetNetMode() == NM_DedicatedServer或者NM_ListenServer,两者语义完全不同,混用会导致一堆莫名其妙的表现问题。

我的习惯是:任何涉及网络分支的代码,第一行先明确用哪个判断。要问“这个 Actor 的状态归谁管”,用HasAuthority();要问“这段逻辑跑在哪个进程角色上”,用GetNetMode();要问“这个 Actor 是不是本地玩家在控制”,用IsLocallyControlled()。三个问题三种写法,写混了一定会出问题。

2. Role 的变化时序:什么时候才能信任它

2.1 从 Spawn 到 BeginPlay 的时序

最容易踩的时机问题,是在构造函数或者PostInitializeComponents里读Role。构造函数在 CDO 和每次实例化时都会执行,那时的网络信息根本没有,读出来的值没有任何参考价值。构造函数里唯一该做的事是设置默认值和组件,任何依赖Role的分支都应该往外挪。

BeginPlay相对安全,但也不是绝对的。客户端收到一个 Actor 的复制数据时,Role往往在BeginPlay之后才被最终确定下来。引擎在角色信息复制到达时会调用PostNetReceiveRole()这个虚函数,你可以重写它来做“角色确定之后”的初始化。稳妥的做法是:表现层的初始化放在BeginPlay,但凡是依赖Role且必须准确的逻辑,要么放在PostNetReceiveRole(),要么在BeginPlay里加一层延迟(比如SetTimerForNextTick)再执行。

还有一点是很多人不知道的:客户端上非拥有者的 Actor,初始Role可能短暂地是Authority,然后被复制数据修正成SimulatedProxy。如果你在BeginPlay里做了一次HasAuthority()判断并缓存了结果,这个缓存可能是错的。所以我的原则很硬:不要缓存 Role 的判定结果,需要的时候现算,这个调用开销低到可以忽略。

2.2 Possess 会让 Role 当场改变

一个 Pawn 在客户端上的Role不是恒定的。在它被本地玩家 Possess 之前,它在本地是SimulatedProxy;Possess 发生的瞬间,它会变成AutonomousProxy。这个切换不会重新触发BeginPlay,如果你在BeginPlay里按SimulatedProxy分支关掉了某些组件,Possess 之后这些组件就再也不会被打开。

正确的做法是把“角色切换时需要重新配置的东西”抽成一个函数,在BeginPlay、OnPossess、OnRep_Controller/OnRep_Owner里都调一次。我在一个项目里就是因为瞄准准星的显示逻辑只在BeginPlay里判断了一次角色,导致主机玩家自己永远看不到准星,而其他客户端看到的都是正常的,排查了两个小时才定位到 Possess 时序上。

反过来,服务端的 Pawn 在Possess前后Role都是Authority,不受影响。所以这个坑只在客户端出现,而客户端出问题时你看到的往往是“只有某一台机器不对”,很容易误判成环境问题而不是代码问题。

2.3 Role 变成 None 的几种情况

ROLE_None出现得比想象中频繁,它是排查时的第一嫌疑人。常见来源有四个:Actor 还没被复制到这个客户端(比如刚 Spawn 还没到);Actor 因为距离超出了网络剔除范围被剔除了;Actor 处于休眠状态(Dormancy)且没有唤醒;Actor 的bNetLoadOnClient为 false 导致服务端存在但客户端不生成。

网络剔除的默认阈值由NetCullDistanceSquared控制,注意它是平方厘米。默认值是 225000000,开方是 15000,也就是 150 米。超过这个距离,服务端就不再向该客户端同步这个 Actor,客户端的Role会回到None,直到玩家走近才恢复。这个“恢复”不是重新 Spawn,而是重新开始复制,所以客户端上的 Actor 实例一直存在,只是没有数据更新。如果你在代码里看到Role == ROLE_None就打日志报警,会在这个边界上刷满日志。

一个实用的判断顺序:先看IsActorBeingDestroyed(),再看GetNetMode(),最后才看Role。因为Role为None的原因太多,光凭它一个值不足以定位问题。

3. 复制条件:让 Role 语义真正落到带宽上

3.1 COND 与 Role 的对应关系

GetLifetimeReplicatedProps里的复制条件是把 Role 语义落地的关键手段,但很多人只会写COND_None,把该省的带宽全省掉了。实际在用的条件有这么几个,每一个都对应一种明确的角色关系:

条件宏发送对象典型用途
COND_None所有相关连接所有人可见的状态,如血量、是否被拾取
COND_OwnerOnly仅拥有该 Actor 的连接自己的背包、自己的技能冷却
COND_SkipOwner除拥有者外的所有连接第三人称才需要的表现数据,如身体朝向
COND_SimulatedOnly仅模拟代理端旁观者需要的特效开关,本人不需要
COND_AutonomousOnly仅自主代理端只在本地玩家身上生效的提示
COND_InitialOnly仅在初始同步时出生时确定、后续不变的数据,如队伍 ID

COND_SkipOwner和COND_SimulatedOnly是我用得最多的一对。第三人称游戏里,角色的上半身旋转对旁观者需要平滑插值,但对本人来说自己的视角旋转是输入直接驱动的,再同步一份过来纯属浪费。把这类属性标成COND_SkipOwner,一个 20 人的房间里每帧能省掉相当可观的出站流量。

3.2 相关性剔除的三个开关

真正决定“某个客户端收不收得到这个 Actor”的,是相关性系统。三个开关按优先级从高到低是:bAlwaysRelevant(永远相关,无视距离)、bOnlyRelevantToOwner(只对 Owner 相关)、NetCullDistanceSquared(距离阈值)。bAlwaysRelevant要慎用,一个被标成永远相关的 Actor,在 64 人的房间里等于每帧给 64 个连接各发一份,量级上去了很容易把上行带宽吃满。

bOnlyRelevantToOwner用在私密对象上非常合适,比如玩家的背包物品、私聊界面背后的数据 Actor。它同时会隐式地让这个 Actor 只对 Owner 触发 RPC,配合COND_OwnerOnly使用语义才完整。

还有一个经常被忘的参数是NetPriority。它不决定发不发,只决定在带宽吃紧时谁先发。默认 1.0,把玩家角色调到 2.0、把场景装饰调到 0.5,在拥挤场景里的体验差别很明显。这个值不是越大越好,所有 Actor 都调高等于都没调高。

3.3 ReplicatedUsing 与 OnRep 的执行端

ReplicatedUsing指定回调函数,这个回调只在客户端执行,服务端不执行。这一点是新手最容易搞错的地方:属性在服务端被修改,服务端不会调用自己的OnRep,只有收到这个属性的客户端才会调。所以如果你把“拾取后播放特效”的逻辑写在OnRep里,主机玩家(listen server 上的服务端)永远看不到这个特效,因为他那边根本没触发回调。

解决办法是在服务端修改属性的同时,显式调用一次那个表现函数;或者在OnRep函数开头判断一下HasAuthority(),手动在服务端也调一遍。我在项目里的做法是把表现逻辑抽成HandleXxxVisuals()这样的无状态函数,OnRep和服务端修改处都调它,保证两端一致。这条规则值得写进团队的代码规范里,因为它造成的 bug 是“部分玩家看不到”,测试时如果只用单机模式或者只用一台客户端测,根本发现不了。

4. RPC 与 Role 的搭配矩阵

4.1 三种 RPC 的合法调用方

RPC 能不能发出去,跟Role的判定是同一套底层规则。ServerRPC 要求调用方是“拥有这个 Actor 的连接”,也就是说这个 Actor 在该客户端上必须是AutonomousProxy且Owner指向该客户端的PlayerController;不满足条件时引擎直接丢弃,只在日志里留一句警告,客户端代码不会收到任何异常。这是“服务器收不到 RPC”这类问题的头号原因,九成以上跟Owner有关。

RPC 类型合法调用方执行方条件
Server拥有该 Actor 的客户端服务端必须设置正确的Owner
Client服务端该 Actor 的 Owner 客户端Owner 为空则不发
NetMulticast服务端所有相关客户端 + 服务端本地受相关性剔除影响

ClientRPC 有一个反直觉的点:在 listen server 上,如果这个 Actor 的 Owner 就是主机玩家本人,服务端调用ClientRPC 会在本地直接执行。这让“主机玩家和客户端玩家表现不同”成为一类非常隐蔽的 bug,因为你在服务端调试时看到一切正常。

NetMulticast也不是“广播给所有人”。它只发给“当前对这个 Actor 相关的连接”。如果 Actor 因为距离被剔除,那个客户端收不到 Multicast,而服务端依然认为发了。所以 Multicast 只适合做本地表现,不能用来做状态同步——状态同步必须靠属性复制。

4.2 用 Role 做前置校验的正确写法

很多人会在ServerRPC 的实现里写一堆if (!HasAuthority()) return;,这些判断在服务端永远是true,等于没写。真正需要校验的是调用者的身份和参数合法性,因为客户端的任何输入都不可信。一个完整的ServerRPC 校验应该包含:调用者是否为空、调用者是否真的是这个 Actor 的合法操作者、参数是否在合理范围内、当前状态是否允许这个操作。

bool AMyWeapon::ServerFire_Validate(const FVector_NetQuantize& AimLocation) { // 只做便宜且确定性的检查,这个函数在服务端执行, // 返回 false 会导致该客户端被断开,所以不要在这里做业务逻辑判断 return FMath::IsFinite(AimLocation.X); } void AMyWeapon::ServerFire_Implementation(const FVector_NetQuantize& AimLocation) { // 业务校验放这里,失败就静默返回,不要用 Validate 去拒绝玩家 if (!HasAuthority() || !IsValid(GetOwner())) { return; } if (!GetOwner()->IsLocallyControlled()) { return; } const float DistSq = FVector::DistSquared(AimLocation, GetOwner()->GetActorLocation()); if (DistSq > FMath::Square(MaxFireRange)) { return; } // 通过了才继续 }

这里有个细节值得强调:_Validate返回false的后果是断开连接,所以它只能用来挡“协议层面的非法数据”,绝对不能用来挡“玩家操作不合法”。把业务判断写进_Validate里,玩家一旦距离超限开了一枪,直接被踢下线,这个体验事故我见过不止一次。

4.3 Reliable 和 Unreliable 不是“重要不重要”的区别

Reliable保证到达,代价是占用可靠通道的缓冲区。引擎的可靠缓冲区是有上限的,一旦堆积溢出,连接会被断开,日志里是Reliable buffer overflow之类的信息。所以在Tick里发ReliableRPC 是一件非常危险的事:网络一抖动,包发不出去,缓冲区迅速堆满,然后玩家掉线,而你在服务端完全看不出原因。

判断标准应该是频率而不是重要性。低频的、状态变更类的事件(拾取、购买、开关门)用Reliable;高频的、可丢的(每帧的瞄准方向、移动输入、语音包)用Unreliable。对于高频但偶尔需要可靠的数据,正确做法是“状态复制 + 事件 RPC”组合:状态用属性复制保证最终一致,事件用Unreliable做即时反馈,丢了就靠下一次状态复制补上。

5. 属性同步还是 RPC:一次带宽账本

5.1 判定标准

新写一个功能时,“该用属性复制还是 RPC”这个问题会反复出现。我的判断标准就三条:这个数据需不需要新加入的玩家也知道?需不需要在丢包后自动恢复?是不是持续变化的状态?三个里有任意一个是“是”,就用属性复制。反过来,如果它是一次性的、不需要重放的、错过了也无所谓的瞬时事件,才用 RPC。

举个具体的:玩家的血量用属性复制,因为新加入的玩家要看到正确血量,丢包了也要自动纠正;玩家开枪这个动作,用 RPC 或者 Multicast,因为它是一次性事件,重放反而会出问题(比如重复播放开火音效)。判断错方向的典型症状是“后加入的玩家看到所有人的血都是满的”,这就是把血量做成了 RPC 而不是复制属性。

5.2 一个 20 人房间的实算

拿一个具体的场景算一下。假设一个 20 人的房间,每个玩家角色同步 3 个属性(位置、朝向、血量),每个属性经过量化和属性头开销后平均按 12 字节算,NetUpdateFrequency设为 30Hz(把默认的 100Hz 降下来对体验影响很小,对带宽影响很大)。

单个角色对单个客户端的流量:12 × 3 × 30 = 1080 B/s。每个客户端要接收其他 19 个角色:1080 × 19 ≈ 20.5 KB/s,折合 164 kbps。服务端的总出站是 20.5 KB/s × 20 ≈ 410 KB/s,折合约 3.3 Mbps。这个量级在局域网没问题,在公网就需要认真对待了,尤其是当角色数量继续上升的时候。

如果这 3 个属性里有一个是“第三人称才需要的身体朝向”,把它改成COND_SkipOwner,每个人少收一份自己的数据看起来不多,但如果房间里有大量 NPC 或载具,省下来的就是线性叠加的量。如果再把NetUpdateFrequency从 30 降到 20,整体再降三分之一,而玩家在 60fps 下的实际感知差异很小,因为位置插值本身就在做平滑。

5.3 客户端预测与服务器校正

AutonomousProxy的核心价值就在于预测。玩家按下移动键,本地立刻动,同时把输入发给服务端,服务端算完再回一个校正。这套机制在 CharacterMovement 里已经封装好了,你要做的是不要去破坏它:不要在客户端直接改Role为Authority的 Actor 的位置,不要在Tick里用客户端算出来的值覆盖复制值,不要在OnRep里做“跳变式”的赋值而不做插值。

对于自己实现的、需要预测的系统(比如技能冷却、弹药数),标准套路是:客户端先本地改,记下“待确认”的状态;服务端处理后回一个ClientRPC,带上权威值和这次操作的序号;客户端收到后比对序号,如果对得上就接受,对不上就回滚。序号这一步很多人会省掉,省掉的后果是延迟高的时候会出现“先扣了弹药又被加回来”的抖动。

6. 动手:一个可验证的拾取 Demo

6.1 类结构与关键成员

这个 Demo 很小,一个可拾取道具加一个玩家角色,但把所有关键点都串起来了:属性复制、OnRep的服务端不触发、ServerRPC 的 Owner 校验、ClientRPC 的结果回执、Multicast的表现广播、以及距离校验必须在服务端做。以 UE5.x 为准,UE4 用户把TObjectPtr换成裸指针即可。

// PickupActor.h #pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "PickupActor.generated.h" UCLASS() class MYNETDEMO_API APickupActor : public AActor { GENERATED_BODY() public: APickupActor(); virtual void BeginPlay() override; // 客户端发起拾取请求 UFUNCTION(Server, Reliable, WithValidation) void ServerTryPickup(APawn* Picker); // 服务端把结果回执给发起者 UFUNCTION(Client, Reliable) void ClientPickupResult(bool bSuccess); // 所有人播放拾取表现 UFUNCTION(NetMulticast, Unreliable) void MulticastPlayPickupFx(); protected: UPROPERTY(VisibleAnywhere, Category = "Pickup") TObjectPtr<UStaticMeshComponent> Mesh; UPROPERTY(ReplicatedUsing = OnRep_Claimed, Category = "Pickup") bool bClaimed = false; UPROPERTY(EditDefaultsOnly, Category = "Pickup") float PickupRadius = 300.f; UPROPERTY(EditDefaultsOnly, Category = "Pickup") float RespawnDelay = 10.f; UFUNCTION() void OnRep_Claimed(); void ApplyClaimedVisuals(); virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; };

6.2 核心代码

// PickupActor.cpp #include "PickupActor.h" #include "Net/UnrealNetwork.h" #include "Components/StaticMeshComponent.h" #include "GameFramework/Pawn.h" APickupActor::APickupActor() { // 构造函数里只做默认值和组件,不碰任何网络判断 PrimaryActorTick.bCanEverTick = false; bReplicates = true; bAlwaysRelevant = false; NetUpdateFrequency = 10.f; Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh")); RootComponent = Mesh; Mesh->SetIsReplicated(false); } void APickupActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); // 拾取状态所有人都要知道,用 COND_None DOREPLIFETIME_CONDITION(APickupActor, bClaimed, COND_None); } void APickupActor::BeginPlay() { Super::BeginPlay(); // 客户端此时 Role 可能还未最终确定,这里只做不依赖 Role 的事 ApplyClaimedVisuals(); } void APickupActor::OnRep_Claimed() { // 注意:这个函数只在客户端执行,服务端修改 bClaimed 不会触发 ApplyClaimedVisuals(); } void APickupActor::ApplyClaimedVisuals() { if (Mesh) { Mesh->SetVisibility(!bClaimed); Mesh->SetCollisionEnabled(bClaimed ? ECollisionEnabled::NoCollision : ECollisionEnabled::QueryOnly); } } bool APickupActor::ServerTryPickup_Validate(APawn* Picker) { return Picker != nullptr; } void APickupActor::ServerTryPickup_Implementation(APawn* Picker) { if (!HasAuthority() || bClaimed) { return; } // 距离校验必须在服务端做,客户端传来的参数一律当不可信处理 const float DistSq = FVector::DistSquared(Picker->GetActorLocation(), GetActorLocation()); if (DistSq > FMath::Square(PickupRadius)) { ClientPickupResult(false); return; } bClaimed = true; // 服务端不会自动触发 OnRep,这里手动补一次表现 ApplyClaimedVisuals(); MulticastPlayPickupFx(); ClientPickupResult(true); } void APickupActor::ClientPickupResult_Implementation(bool bSuccess) { // 只在发起者的机器上执行,可以放 UI 提示、音效等 UE_LOG(LogTemp, Log, TEXT("Pickup result on local client: %s"), bSuccess ? TEXT("Success") : TEXT("Rejected")); } void APickupActor::MulticastPlayPickupFx_Implementation() { // 服务端和所有相关客户端都会执行到这里 // 这里做特效、音效,不要在这里改任何复制属性 }

Demo 里最值得看的其实是ServerTryPickup的调用位置。它必须在客户端上、由玩家输入触发,并且这个 Actor 必须SetOwner(PlayerController),否则引擎收不到。调用前建议加一层if (GetLocalRole() == ROLE_Authority) return;,避免在服务端重复走一遍逻辑——注意这里判断的是ROLE_Authority而不是ROLE_AutonomousProxy,因为在 listen server 的主机上,玩家自己身上的 Actor 也是Authority。

6.3 用控制台命令把问题逼出来

光在局域网跑通不算验证,必须加延迟和丢包。PIE 的 Play 设置里有 Network Emulation 选项,也可以在运行时用控制台:

NetEmulation.PktLag 150 NetEmulation.PktLoss 5 NetEmulation.PktLagVariance 30 stat net ShowDebug Network

NetEmulation.PktLag 150模拟 150ms 单向延迟,PktLoss 5模拟 5% 丢包。这两个一开,很多“本地测没问题”的 bug 立刻现形,尤其是ReliableRPC 的堆积和预测回滚的抖动。stat net看进出流量和包数,ShowDebug Network看连接层面的 ping 和丢包统计。调试完记得用NetEmulation.PktLag 0和NetEmulation.PktLoss 0关掉,不然下一个接手的人会怀疑人生。

想看每个 Actor 的 Role,最快的办法是在BeginPlay或者Tick里打一行日志,把GetLocalRole()、GetRemoteRole()、GetNetMode()、IsLocallyControlled()全打出来。我自己写了一个小函数专门做这件事,用#if !UE_BUILD_SHIPPING包起来,联调期随时开,这行日志救过的次数比我愿意承认的多。

7. 排查速查表与踩坑记录

7.1 常见现象对照表

现象最可能的原因第一步排查
Server RPC 没反应,日志有 warningActor 没设 Owner,或 Owner 不是该玩家打印GetOwner()
客户端看到两个角色客户端也执行了 SpawnActor确认 Spawn 只在HasAuthority()分支
主机玩家看不到特效表现逻辑只写在OnRep里服务端修改处手动补一次调用
客户端改属性后闪回客户端直接改复制属性改为 Server RPC
Role一直是None未开复制或被剔除查bReplicates和NetCullDistanceSquared
玩家莫名掉线ReliableRPC 在Tick里发改Unreliable或合并发送
近距离正常远距离不同步超出网络剔除距离调NetCullDistanceSquared
只有部分人看不到某 Actor相关性剔除,按距离或 Owner 过滤检查bOnlyRelevantToOwner

这张表我基本是照着排查顺序排的,从上往下命中率递减。第一行和第二行能解决我遇到的七成问题,而且都是五分钟内能确认的。

7.2 我踩过的几个坑

第一个坑是把“客户端生成特效”写在了HasAuthority()为真的分支外面。结果是每个客户端都生成了一份,服务端也生成了一份,因为服务端有多个客户端的连接,特效在服务端的默认世界里同时存在,然后在NetMulticast里又被广播一次,画面直接糊掉。这个问题的根源是对“服务端的默认世界只有一个,但客户端的默认世界有 N 个”这件事没有建立直觉。

第二个坑是SetReplicates的调用时机。在运行时动态创建的 Actor,必须在SpawnActor之后立刻SetReplicates(true),如果等到BeginPlay之后才设置,这个 Actor 已经在客户端“初始化过一轮”了,行为会变得很怪。更稳妥的写法是在SpawnActorDeferred里设置完所有网络相关参数再FinishSpawning。

第三个坑跟COND_SkipOwner有关。我把它用在一个第三人称需要、第一人称不需要的属性上,结果发现单机模式下这个属性完全不生效——因为单机时没有“其他连接”,COND_SkipOwner意味着“不给 Owner 发”,而单机只有 Owner 一个视角。这类只在联机下生效的复制条件,测试时必须至少开两个客户端,PIE 里设成 2 个客户端加 1 个专用服务器是最低配置。

第四个坑是NetPriority的误用。我曾经在一个场景里把所有的可交互道具都调到了 3.0,结果玩家角色的移动变得很卡。原因是引擎的优先级调度是相对的,道具的优先级上去了,角色的相对优先级就下来了。带宽紧张时,先发的不是“更重要的”,而是“累积时间 × 优先级最大的”,把次要对象全抬上去等于把主要对象压下去。

7.3 一套可以抄走的判断流程

遇到网络相关的表现问题,我现在固定走这几步:先确认这个 Actor 在出问题的客户端上Role是多少,是None就查复制和相关性,是SimulatedProxy就查“这段逻辑是不是本该在拥有者上跑”,是AutonomousProxy就查“有没有被服务端覆盖”。然后确认Owner是谁,为空的话所有Server和ClientRPC 都是废的。最后才去看具体的属性复制条件和 RPC 参数,因为前两步能过滤掉绝大多数问题,后面的排查才有意义。

至于我个人的体会,这套东西的难点从来不在 API 上,Role就三个值,DOREPLIFETIME就那么几种条件,看一眼文档就懂。难的是脑子里要始终维持“同一时刻有 N 台机器在跑同一份代码,每台机器上的这个 Actor 身份都不一样”这个模型。只要这个模型立住了,写代码的时候自然会问一句“这行跑在谁的机器上”,问题就少了一大半。反过来,如果写的时候没问这句话,那等到联调阶段就只能靠日志一行行猜,效率差十倍不止。