首页 / 资讯中心 / 文章详情

UE多人联机Network Role详解:角色判定、Owner链与RPC排查

UE多人联机Network Role详解:角色判定、Owner链与RPC排查 ★ FEATURED ARTICLE
多人联机里最让人抓狂的一类问题是那种单机跑得飞快、一开双人 Play 就全散架的毛病拾取物只有一个人能看见、开火特效别人看不到、角色走两步被拽回原地。这些现象背后八成绕不开Network Role这个属性。它就是 UE 给每个 Actor 贴的一张身份标签告诉引擎“这台机器上这个 Actor 到底该听谁的、该不该预测、该不该收状态”。我在做多人玩法的时候几乎每次新人接手网络模块第一个要讲清楚的就是这个属性因为它决定了后面属性同步、RPC、移动复制能不能正常运转。这篇内容适合已经能写出单机逻辑、正准备把玩法搬到多人环境里的开发者看也适合被联机问题折磨过一阵子、想系统捋一遍的人。看完你应该能说清楚为什么同一个 Actor 在服务端和客户端上的角色不一样、为什么有些 RPC 调了没反应、为什么客户端改的值会被打回去以及怎么用日志把角色状态直接打出来验证。1. 先把 Role 与 RemoteRole 的四种状态拆开1.1 一个 Actor 身上其实挂着两个角色变量打开AActor的头文件你会看到两个成员Role和RemoteRole类型都是ENetRole。它们不是“主角色”和“副角色”的关系而是本地视角和远端视角的关系。Role表示“在这台机器上这个 Actor 由谁来驱动”RemoteRole表示“在对面那台机器上这个 Actor 由谁来驱动”。于是每个 Actor 在任何一台机器上都同时携带着两份身份信息。这个设计初看有点绕但它解决了一个很实际的问题同一帧里服务端需要知道“这个 Actor 在客户端上是不是被玩家亲自驱动的”客户端也需要知道“这个 Actor 在服务端上是不是权威的”。如果只有一个变量服务端和客户端就只能各自维护一套额外的标记代码会立刻变脏。两个变量并排放着判断逻辑就变成了一次枚举比较。日常代码里我们很少直接读Role字段而是用封装好的接口GetLocalRole()、GetRemoteRole()、HasAuthority()、IsLocallyControlled()。这几个接口的语义要分清楚HasAuthority()判断的是GetLocalRole() ROLE_Authority注意它只看本地角色不看远端IsLocallyControlled()在 Pawn 上还会额外检查 Controller 是不是本地玩家控制器。搞混这两个写出来的分支就会在 Listen Server 模式下出现“主机玩家自己打自己”的诡异现象。1.2 四种枚举值分别在说什么ENetRole只有四个值但每一个都对应一种明确的运行状态值得逐一对照ROLE_None这个 Actor 在当前这台机器上不参与网络驱动。要么它压根没复制下来要么它是纯本地生成、bReplicates为 false 的装饰物。服务端上如果看到某个 Actor 的RemoteRole是ROLE_None基本可以断定它不会被发给任何客户端。ROLE_Authority权威端。它拥有最终解释权属性改动的源头在这里碰撞判定、伤害结算、道具消耗都应该跑在这上面。服务端上的本地角色永远是ROLE_Authority除非你手动做了非常规操作。ROLE_AutonomousProxy自主代理。这个身份只出现在拥有该 Actor 的那个客户端上。它意味着“这台机器上的玩家可以亲自驱动它并且允许做客户端预测”人物移动的预测、输入驱动的位移都建立在这个身份之上。ROLE_SimulatedProxy模拟代理。旁观者视角。这个客户端不拥有它只能接收服务端发来的位置和状态然后做插值平滑。你看到的其他玩家角色、远处飞行的子弹在本地都是这个身份。有一个容易被忽略的点ROLE_Authority和ROLE_AutonomousProxy并不互斥它们描述的是不同机器上的同一件事。一个玩家角色在服务端是ROLE_Authority在它自己的客户端是ROLE_AutonomousProxy在其他玩家的客户端是ROLE_SimulatedProxy。三个身份、三个视角指向同一个 Actor。1.3 服务端与客户端视角的完整对照把两个变量的组合穷举一遍其实只有六种有意义的状态。下面这张表建议直接贴在工位上排查问题时对着看比翻源码快得多观察视角LocalRoleRemoteRole实际含义服务端ROLE_AuthorityROLE_AutonomousProxy该 Actor 被某个客户端拥有那个客户端会以自主代理身份驱动它服务端ROLE_AuthorityROLE_SimulatedProxy无主 Actor所有客户端都只做模拟服务端ROLE_AuthorityROLE_None不复制到任何客户端或复制条件未满足客户端拥有者ROLE_AutonomousProxyROLE_Authority我拥有它能预测、能发 Server RPC客户端旁观者ROLE_SimulatedProxyROLE_Authority别人拥有它我只能收状态做插值客户端不相关ROLE_NoneROLE_None该 Actor 尚未被复制到这台机器表里最值得盯的是服务端那一列的RemoteRole。很多“为什么客户端收不到”的问题根因就在服务端这个值上如果它是ROLE_None说明引擎根本没打算把这个 Actor 发给任何人你去客户端翻日志是白费力气。反过来如果服务端显示ROLE_SimulatedProxy而你期望某个玩家能预测它那说明拥有权链没接上八成是SetOwner没调或者调错了对象。另外提醒一句客户端上不存在的 Actor 不会有一个“角色为 None 的实例”让你访问。ROLE_None更多是一个理论状态实际排查时你在客户端拿到的要么是有效指针要么是空指针。所以看到ROLE_None的日志通常意味着这个 Actor 存在于本地但被标记为不参与复制而不是“没复制下来”。1.4 AutonomousProxy 存在的意义预测与纠偏为什么非要单独搞一个AutonomousProxy而不是让所有客户端都SimulatedProxy答案是手感。如果玩家按下 W客户端必须等服务端确认再移动在 60ms 延迟下就是明显的“按键后顿一下”。为了让操作跟手UE 允许拥有者客户端在本地先跑一遍移动逻辑把结果直接呈现在屏幕上同时把移动数据发给服务端。服务端收到之后会自己再算一遍如果两边偏差在容差范围内就认可偏差太大就会下发纠正客户端收到后把角色拉回服务端认为正确的位置。这个“先跑后校”的机制只有在客户端知道“我拥有这个 Actor”时才敢启用而这个判断依据正是ROLE_AutonomousProxy。所以这个身份不只是一个标记它是一整套预测 - 纠正链路的开关。这也解释了一个常见现象你偶尔会看到角色“抖一下”或者“被拽回去”。这通常不是 bug而是预测失败后的正常纠正。要减少它得从移动组件的容差参数和网络质量入手而不是去改角色判定。真正需要警惕的是频繁纠正那说明客户端和服务端的计算结果长期不一致常见原因是两边用了不同的移动参数或者服务端在Tick里偷偷改了位置破坏了预测的一致性。2. 角色是谁写上去的Spawn、Owner 与相关性2.1 构造函数里判断角色没有任何意义新手最容易犯的错是在 Actor 的构造函数里写if (GetLocalRole() ROLE_Authority)。构造函数执行的时候这个类可能正在被 CDO 构造此时网络环境根本不存在角色值恒为默认判断结果毫无参考价值。所有和角色相关的初始化最早也要放到BeginPlay里因为客户端上的 Actor 在BeginPlay触发时已经完成了初始复制角色值是可用的。更稳妥的做法是把角色判断推迟到真正需要它的时刻比如每次交互前判断一次而不是在初始化时缓存一个bool bIsServer。缓存布尔值在绝大多数情况下没问题但一旦涉及无缝切换、Actor 重新归属这类场景缓存值就会过期而重新查询角色的成本极低一次枚举比较而已。我在项目里统一约定需要判断权威性时现场调HasAuthority()不存成员变量。还有一类坑来自编辑器里的预览。在编辑器视口中打开一个 Actor 蓝图此时世界是编辑器世界GetNetMode()返回NM_StandaloneHasAuthority()返回 true一切看起来都很正常。所以千万别靠编辑器预览判断联机行为必须实际启动多客户端。2.2 Owner 链决定谁拿到 AutonomousProxy一个 Actor 能不能在某个客户端上变成ROLE_AutonomousProxy取决于它是否“属于”那个客户端的连接。这个归属关系就是 Owner 链。引擎在建立网络频道时会沿着GetOwner()一路向上追溯直到找到一个由某个玩家连接拥有的对象通常是APlayerController。追溯的结果决定这个 Actor 的频道归谁也决定了哪个客户端能拿到自主代理身份。这条链在实际代码里表现为几种常见形态。玩家角色 Pawn 在Possess时会被设置 Owner 为对应的 Controller所以它天然属于那个客户端。玩家发射的子弹如果SpawnActor时把Params.Owner设成发射者的 Controller 或 Pawn子弹也会继承这条链在发射者客户端上拿到自主代理身份。反过来一个场景里摆着的箱子、地上的道具如果没有 Owner那它在所有客户端上都是ROLE_SimulatedProxy。这里有个细节值得强调SetOwner必须在服务端调用。客户端调SetOwner只会改本地那份副本不会影响服务端的判断也不会同步给其他人。我见过有人为了让道具“属于”拾取者在客户端的拾取逻辑里调SetOwner结果服务端视角完全没变Server RPC 依然被丢弃。正确做法是在服务端的拾取处理里调SetOwner让归属变更随复制下发。2.3 相关性系统会改变你看到的世界即便一个 Actor 设了bReplicates true也不代表它会出现在所有客户端上。UE 有一套相关性Relevancy判定服务端每帧会为每个连接筛选“这个连接需要知道哪些 Actor”。判定不通过的 Actor 不会被发送客户端上根本不存在这个对象自然也就谈不上角色。这套机制是多人游戏能跑起来的基础几百个道具不可能全量广播给每个人。影响相关性的属性有几个关键项。bAlwaysRelevant设为 true 的 Actor 会无视距离始终发送适合全局状态类对象但滥用会直接吃满带宽。bOnlyRelevantToOwner设为 true 的 Actor 只发送给拥有它的连接适合玩家私有数据。NetCullDistanceSquared控制距离裁剪超出这个距离平方的客户端不再收到更新。bNetUseOwnerRelevancy让 Actor 的相关性完全跟随 Owner 的判定结果。这几个属性组合起来会产生一些反直觉的结果。比如一个道具设了bOnlyRelevantToOwner true但它压根没有 Owner那它就不会被发送给任何人所有客户端都看不见。这个组合在逻辑上说得通但在实践中极其容易被写出来因为两个属性是在不同时间点加的。我看到“某个 Actor 只在服务端存在”这类现象时第一反应就是去检查这两个属性的组合。2.4 角色被改写的几个时间点角色不是一次性写死的它在运行过程中会被改写。最常见的改写发生在客户端首次收到某个 Actor 时引擎建立频道、创建 Actor 副本、根据服务端下发的信息设置Role和RemoteRole然后调用PostNetReceiveRole()。这个虚函数是官方留给我们的钩子如果你想在角色确定的第一时间做点什么比如根据角色切换材质、开启或关闭某些组件覆写它比在BeginPlay里判断更精准。第二种改写来自服务端的所有权变更。服务端调SetOwner之后复制系统会把新的归属下发给客户端客户端上这个 Actor 的LocalRole可能从ROLE_AutonomousProxy降级为ROLE_SimulatedProxy也可能反向升级。这个过程伴随一次PostNetReceiveRole()调用。如果代码里缓存了角色值此时就会失效。第三种改写来自 Actor 被销毁重建比如关卡流送、无缝切换。这种情况下客户端上的 Actor 会被销毁再重新创建角色从头走一遍初始化流程。所以任何依赖角色长期稳定的逻辑都要考虑重建场景别把状态挂在 Actor 上却指望它跨关卡存活。3. 搭一个最小可验证场景拾取物与开火3.1 测试环境先配对否则一切结论都不可信在动手写代码之前先把测试环境配好这一步比写代码重要。PIE 默认是单进程单客户端GetNetMode()返回NM_StandaloneHasAuthority()永远为 true所有网络分支都会走“服务端”路径于是你本地测得好好的一上真机全崩。必须把 Play 模式改成Play As Listen Server或Play As Client并把Number of Players设成 2 以上。Listen Server 模式下主机玩家同时是服务端和一个客户端方便调试但容易掩盖问题因为主机的角色判断同时具备两种身份。真正严格的环境是Run Dedicated Server加两个客户端这时没有任何一台机器同时扮演服务和客户端所有角色判断都会被推到极端。我一般先用 Listen Server 快速迭代功能跑通后再用专用服务器加双客户端复测一遍两步都过才算稳。网络延迟也要模拟。编辑器里可以开网络模拟人为加上几百毫秒延迟和一定丢包很多“本地不出现、线上必现”的问题在这个条件下会立刻暴露。特别是预测相关的抖动不加延迟根本测不出来因为零延迟下客户端和服务端的计算结果永远一致。3.2 拾取物属性复制加角色日志先写一个最简单的可拾取道具把复制打开并在关键节点把角色打出来。这个类的目的不是功能完整而是让你亲眼看到角色值在多客户端下的变化UCLASS() class APickupActor : public AActor { GENERATED_BODY() public: APickupActor(); virtual void GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const override; virtual void BeginPlay() override; virtual void PostNetReceiveRole() override; UFUNCTION() void OnRep_IsTaken(); protected: UPROPERTY(ReplicatedUsing OnRep_IsTaken) bool bIsTaken false; UPROPERTY(VisibleAnywhere) TObjectPtrUStaticMeshComponent MeshComp; };构造函数里把复制相关的属性一次设清楚别散落在各个函数里APickupActor::APickupActor() { bReplicates true; SetReplicateMovement(false); // 静态道具不需要高频更新10Hz 足够 NetUpdateFrequency 10.f; MinNetUpdateFrequency 2.f; // 30 米外不再更新按项目尺度调整 NetCullDistanceSquared FMath::Square(3000.f); // 道具对所有人生效不绑定单个 Owner bAlwaysRelevant false; bOnlyRelevantToOwner false; MeshComp CreateDefaultSubobjectUStaticMeshComponent(TEXT(Mesh)); SetRootComponent(MeshComp); MeshComp-SetIsReplicated(false); } void APickupActor::GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(APickupActor, bIsTaken); }然后是角色日志这段代码在排查阶段的价值远超它本身的复杂度。用一个统一的日志格式把 Actor 名、网络模式、本地角色、远端角色、Owner 一次打全static FString RoleToString(ENetRole InRole) { const UEnum* EnumPtr StaticEnumENetRole(); return EnumPtr ? EnumPtr-GetNameStringByValue(static_castint64(InRole)) : TEXT(Unknown); } void APickupActor::BeginPlay() { Super::BeginPlay(); UE_LOG(LogTemp, Log, TEXT([Pickup %s] NetMode%d Local%s Remote%s Owner%s), *GetName(), static_castint32(GetNetMode()), *RoleToString(GetLocalRole()), *RoleToString(GetRemoteRole()), *GetNameSafe(GetOwner())); } void APickupActor::PostNetReceiveRole() { Super::PostNetReceiveRole(); UE_LOG(LogTemp, Warning, TEXT([Pickup %s] Role changed - Local%s Remote%s), *GetName(), *RoleToString(GetLocalRole()), *RoleToString(GetRemoteRole())); }跑起来之后你会看到服务端打印LocalROLE_Authority RemoteROLE_SimulatedProxy两个客户端都打印LocalROLE_SimulatedProxy RemoteROLE_Authority。如果某个客户端压根没打印BeginPlay说明这个 Actor 没被复制过去问题在相关性判定或bReplicates上跟角色无关。3.3 RPC 落点选错就是一整天白干拾取逻辑的 RPC 放在哪个类上是这块最容易翻车的地方。很多人的第一反应是“拾取是道具的事RPC 当然写在道具上”于是写出这样的代码UFUNCTION(Server, Reliable) void ServerTryPickup(APawn* Picker);然后在客户端按键时调用PickupActor-ServerTryPickup(MyPawn)。这段代码在 Listen Server 的主机上能跑通因为主机本地直接执行但在纯客户端上会被静默丢弃服务端一点反应都没有。原因很直接Server RPC 的路由依赖 Owner 链而这个道具没有 Owner服务端无法确定这个调用来自哪个连接只好丢掉。有两种修正方式各有适用场景。第一种是把 RPC 挪到玩家自己的 Pawn 或 PlayerController 上因为它们天然拥有正确的 Owner 链路由绝对没问题。第二种是在服务端先给道具设置 Owner让道具归属于拾取者之后再让道具自己处理 RPC。第二种方式看着更“面向对象”但会带来副作用道具一旦归属某人相关性判定可能跟着变其他客户端可能就看不到它了。我的选择是第一种。拾取行为的发起者是玩家把“我想捡这个”的请求放在玩家身上语义也更自然。道具只负责暴露“我能不能被捡”的查询接口以及在被捡走之后广播表现。这样职责清晰也不会因为归属变更把相关性搞乱// 放在 AMyCharacter 上 UFUNCTION(Server, Reliable, WithValidation) void ServerRequestPickup(APickupActor* Target); bool AMyCharacter::ServerRequestPickup_Validate(APickupActor* Target) { // 服务端校验目标有效、距离合理、未过期 return Target ! nullptr !Target-IsPendingKillPending(); } void AMyCharacter::ServerRequestPickup_Implementation(APickupActor* Target) { if (!Target || Target-IsTaken()) return; const float DistSq FVector::DistSquared(GetActorLocation(), Target-GetActorLocation()); if (DistSq FMath::Square(250.f)) return; // 服务端再校验一次距离 Target-MarkTaken(this); // 改复制属性 MulticastPlayPickupFX(); // 广播表现 }注意_Validate里只做轻量检查别在里面查世界、做射线这个函数会被频繁调用写重了直接影响性能。真正的业务校验放在_Implementation里那里已经在服务端安全。3.4 开火与投射物Owner 传参的正确姿势生成投射物是另一个高频出错点而且错误形式更隐蔽因为它通常表现为“子弹能看见但它的 Server RPC 不生效”。投射物如果需要在命中时调 Server RPC 做伤害结算那它必须有正确的 Owner否则同理会被丢弃void AMyCharacter::Fire() { if (!HasAuthority() !IsLocallyControlled()) return; FActorSpawnParameters Params; Params.Owner GetController(); // 关键接上拥有权链 Params.Instigator this; Params.SpawnCollisionHandlingOverride ESpawnActorCollisionHandlingMethod::AlwaysSpawn; const FVector MuzzleLoc GetActorLocation() GetActorForwardVector() * 80.f; GetWorld()-SpawnActorAProjectile(ProjectileClass, MuzzleLoc, GetActorRotation(), Params); }Params.Owner传 Controller 是最直接的做法引擎会沿着这条链找到玩家的连接。传 Pawn 也可以因为 Pawn 的 Owner 就是 Controller追溯会自然往上走一层。唯一的区别是语义清晰度我倾向传 Controller因为“这个投射物属于哪个连接”这件事答案在连接层面更准确。还有一个常被忽略的点bReplicates必须在生成时就确定运行中途改它不会被复制系统正确识别。所以别在 BeginPlay 里根据角色去设bReplicates那时候已经晚了。要按条件决定是否复制就在 Spawn 之前决定用哪个类或者用SpawnActorDeferred在完成初始化后再调用FinishSpawning。4. 属性同步、移动复制与 RPC 的联动4.1 属性复制只认权威端属性复制的方向是单向的从服务端流向客户端。客户端上修改一个UPROPERTY(Replicated)的值改动只在本地生效引擎不会把它发回服务端而且下一次服务端推送会直接把它覆盖掉。这个行为经常让新人困惑明明本地日志打出来值变了界面却过一会儿又变回去。理解这个机制之后代码结构就清楚了所有改变游戏状态的写入都必须发生在HasAuthority()为 true 的分支里。客户端要做的是“发请求”而不是“改状态”。上面拾取例子里的MarkTaken就只应该在服务端被调用客户端最多在本地做一些乐观的视觉反馈比如先把道具变半透明等服务端确认后OnRep_IsTaken再走正式流程。OnRep函数也有个细节值得注意它在服务端不会因为本地赋值而触发只在客户端收到更新时调用。所以如果你的表现逻辑只写在OnRep里服务端玩家就看不到任何反馈。正确姿势是服务端改完值之后手动调一次表现函数或者用MulticastRPC 广播表现两条路径分开走。我在项目里通常封装一个ApplyTakenVisuals()服务端赋值后直接调OnRep里也调保证两边一致。4.2 CharacterMovement 的预测与纠正角色移动是ROLE_AutonomousProxy价值体现最集中的地方。拥有者客户端在按下输入时立刻本地移动同时把移动数据打包发给服务端服务端模拟一遍把结果和自己的状态比对差异超过阈值就下发纠正。这套逻辑由UCharacterMovementComponent内部完成业务代码基本不用介入但有几个参数会明显影响观感。修正相关的参数包括位置误差阈值、旋转误差阈值、纠正平滑速度等。阈值调太小轻微的网络抖动都会触发纠正角色会一直小幅抽搐阈值调太大客户端跑偏了也不纠正会出现“客户端穿墙、服务端不认”的割裂感。一般按角色胶囊体尺寸的比例来定位置阈值给到几厘米量级比较常见具体数值要结合移动速度实测。另一个容易被忽略的点是服务端的移动校验。默认情况下服务端会接受客户端的移动输入这意味着客户端理论上可以伪造移动数据。对竞技类玩法需要在服务端做速度校验、传送检测、非法位置回退。这些校验的实现位置就在移动组件的服务端路径上而不是在角色蓝图里因为蓝图层的逻辑跑在客户端时无法阻止伪造。旁观者客户端上的角色是ROLE_SimulatedProxy它不做预测只收服务端下发的ReplicatedMovement然后插值。插值带来的效果是“别人比我慢半拍”这是网络游戏的固有代价所有方案都绕不开。想减轻的话只能提高更新频率或者做外推预测但两者都会增加带宽或带来误判需要权衡。4.3 三种 RPC 的执行约束对照RPC 的三种类型和角色是强绑定的很多“RPC 不执行”的问题本质是把类型用错了地方。下面这张表是排查时的第一参照RPC 类型合法调用方实际执行位置典型误用Server拥有该 Actor 的客户端或服务端本地调用只在服务端在没有 Owner 的 Actor 上调用被静默丢弃Client只在服务端调用目标拥有者客户端客户端调用后只在本机执行服务端不知道NetMulticast服务端调用会广播服务端以及所有相关客户端客户端调用只在本机跑别人看不到NetMulticast那一行的误用是最阴险的。因为你在客户端调用 Multicast本机会照常执行屏幕上特效播得好好的于是你以为成功了。直到别人问你“刚才那下是什么效果”你才发现其他客户端一无所知。判断方法很简单任何 Multicast 函数的调用点都必须在HasAuthority()分支里写完顺手检查一遍能省掉大量联调时间。ClientRPC 也有类似陷阱。服务端调ClientRPC 时只有该 Actor 的拥有者客户端会执行其他客户端收不到。如果你的需求是“所有人都要看到”那就该用 Multicast如果是“只有当事人需要知道”才用 Client。这两者的边界在写代码时就要想清楚别等到测试阶段才发现表现不对。4.4 可靠与不可靠的取舍每个 RPC 还要选可靠或不可靠。可靠 RPC 保证送达且顺序正确代价是失败会重传在拥塞时可能堆积不可靠 RPC 直接发丢了就丢了适合高频的表现类调用。判断标准是“丢一次会不会影响游戏逻辑”伤害结算、拾取确认、购买扣费必须可靠开火特效、脚步音效、命中飘字统统不可靠。可靠 RPC 的滥用是新手项目里最常见的带宽黑洞。有人把所有 RPC 都写成Reliable图个心安结果一局游戏里几千次调用网络缓冲区被塞满延迟飙升甚至断线。我见过一个项目把每帧的瞄准方向用可靠 RPC 发送帧率 60 就意味着每秒 60 个可靠包网络稍微差一点立刻雪崩。正确的做法是分层状态类的数据尽量走属性复制让引擎自己做增量和优先级调度表现类的走不可靠 RPC只有关键确认类的才用可靠 RPC。属性复制这套机制本身已经处理了丢包和顺序问题它比手写可靠 RPC 效率高得多能用属性复制的场景优先用属性复制。5. 高频踩坑与排查清单5.1 RPC 静默失败的四种典型原因RPC 调用没有反应是这块最耗时间的问题因为引擎通常只在非常隐蔽的地方打日志。按出现频率排原因大致是四类。第一类是 Owner 链断了。上面反复说过Server RPC 需要服务端知道“这个调用来自哪个连接”判断依据就是 Owner。道具、投射物、临时特效如果没有设置 Owner从客户端调用必然失败。检查方法是在服务端打印GetOwner()看是不是空。第二类是 Actor 压根没复制到客户端。如果客户端上连这个 Actor 都没有调用自然无从谈起。这时要看服务端的RemoteRole如果是ROLE_None回去查bReplicates和相关属性。第三类是调用时机不对。在BeginPlay里立刻发 RPC有可能此时频道还没完全建立好尤其是 Actor 刚生成的那一帧。稳妥做法是延迟一帧或者在PostNetInit之后再发。这类问题在低延迟环境测不出来加了模拟延迟才暴露。第四类是把 Multicast 当 Client 用或者反过来。表现是“有的人看到了有的人没看到”看起来像随机 bug其实是 RPC 类型选错了。排查方法是让所有客户端把自己的角色和收到的 RPC 名打日志对比一下就清楚了。5.2 客户端改属性被回滚与位置抖动的区分“客户端改了值又变回去”和“角色位置抖动”看起来都像同步问题但根因完全不同处理方式也不一样。前者几乎都是服务端权威覆盖导致的客户端不该写这个属性后者通常是预测纠正或者插值参数不当。区分方法很简单改值的场景看这个属性是不是Replicated如果是客户端写入必然被覆盖这是设计如此不是 bug。位置抖动的场景看抖动发生在自己角色还是别人角色上自己角色抖说明是预测纠正去调移动组件的误差阈值和平滑参数别人角色抖说明是插值问题去看更新频率和网络质量。还有一种特殊情况角色在客户端上瞬移。这往往不是抖动而是服务端做了位置修正且幅度很大比如触发了防作弊回退、或者从载具上下来时位置没对齐。这种问题要看服务端的纠正日志客户端侧的插值参数解决不了。5.3 一张按顺序执行的排查表遇到联机问题别乱试按下面的顺序走绝大多数情况能在前三步定位步骤检查项判断依据下一步动作1服务端RemoteRole是否为ROLE_None是则说明不复制查bReplicates、相关性、bOnlyRelevantToOwner2客户端是否存在该 Actor没有BeginPlay日志查距离裁剪、NetCullDistanceSquared、休眠状态3客户端角色是否符合预期拥有者是AutonomousProxy旁观者是SimulatedProxy不符则查 Owner 链4RPC 类型与调用点是否匹配Server只在拥有者客户端调Multicast只在服务端调调整调用位置或类型5属性是否在服务端写入客户端写入会被覆盖把写入逻辑挪进HasAuthority()分支我习惯在项目早期就给每个网络相关的类加一条统一的角色日志开关放在一个控制台变量后面默认关闭排查时打开。这套东西花不了半小时但能在后面省下几十个小时。6. 性能与安全角色判断的位置也影响开销6.1 判断写在哪里调用成本差多少HasAuthority()本身极轻就是一次枚举比较但它背后的问题是这个函数被调用的频率。如果写在Tick里每个 Actor 每帧一次几百个 Actor 就是每帧几百次调用虽然单次很便宜但乘以数量就不容忽视了。更重要的是Tick里的网络判断往往意味着架构设计有问题。我的原则是能在事件触发点判断的绝不放到Tick里能在服务端一次性决定的绝不每帧重复判断。比如“这个 Actor 归谁所有”在SetOwner之后就不会再变判断一次存下来即可“这个道具是否已被拾取”用复制属性表达客户端读属性而不是每帧查状态。另外Actor 的Tick在网络环境下本身就是稀缺资源。大量不会被客户端驱动的 Actor比如纯装饰物根本不需要Tick直接把PrimaryActorTick.bCanEverTick关掉能省下可观的开销。这项优化对帧率的影响往往比调网络参数更直接。6.2 复制频率、休眠与距离裁剪带宽优化的三板斧是降低更新频率、开启休眠、缩小裁剪距离。这三项都直接作用在复制系统上跟角色判定配合使用。NetUpdateFrequency决定这个 Actor 每秒最多被更新多少次。默认值偏高对人物角色合适对静态道具就是浪费。把道具降到 10Hz 甚至更低肉眼几乎看不出区别带宽却能省一大截。MinNetUpdateFrequency是自适应更新时的下限网络拥塞时引擎会自动降频这个值决定了降到哪里为止。休眠Dormancy适合长期不变的 Actor。设成休眠后属性变化不会自动触发更新必须手动FlushNetDormancy()唤醒。用好了能省大量带宽用不好会出现“明明改了值客户端不更新”的问题。我一般只在确实长期静止的对象上开休眠比如已经关闭的门、已经拾取完的道具并且每次状态变更都显式调一次唤醒绝不依赖引擎自动判断。NetCullDistanceSquared按平方设置容易写错数量级。想设 30 米就要写FMath::Square(3000.f)因为 UE 的长度单位是厘米。这个属性只影响更新不影响 Actor 是否被创建所以远处 Actor 可能先创建后停止更新回来时再补齐不会出现对象凭空消失。6.3 服务端权威的边界最后说一条原则性的东西。角色判定的存在本质是为了回答“这件事该由谁说了算”。答案永远是服务端。客户端能做的只有两件事预测表现以及发起请求。任何在客户端算出来的数值只要影响到游戏状态都必须能在服务端重新验证一遍。这条边界的实际含义是位置、血量、背包、冷却、伤害全部由服务端写入客户端只读。客户端可以为了流畅先做个乐观预估但必须准备好被服务端打脸且不能让预估结果泄漏给其他系统。我见过最典型的错误是在客户端算完伤害就直接扣血服务端再算一次导致血量扣两次或者对不上这种问题一旦出现排查成本极高。服务端权威还有一个隐性的好处它是防作弊的地基。客户端可以随意修改本地内存所有本地判断都不可信。把关键逻辑放在服务端配合合理的校验才能挡住大部分低级作弊。这不是说要做到极致而是要在设计阶段就把“哪些必须服务端算”这条线画清楚等到上线前再补代码结构已经不允许了。最后分享一个我自己踩过的坑。早期做拾取玩法时我在道具上写了 Server RPCListen Server 下测了两周都没问题直到第一次拉真机测试两个客户端捡东西全都没反应。那天查了整整一下午最后在服务端日志里看到一行“RPC called on actor which is not owned by the calling client”才反应过来是 Owner 链的问题。从那以后我写任何网络代码的第一件事就是在客户端和服务端各打一遍角色日志确认身份对得上再往下写。这个习惯看起来笨但它把排查从“猜”变成了“看”值得每个人养成。
阅读完成 · 觉得有帮助?
咨询建站