ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UE网络复制与RPC实战:UObject状态同步机制解析

UE网络复制与RPC实战:UObject状态同步机制解析 1. 先搞懂UObject复制的底层逻辑1.1 复制不是“把对象发过去”而是“让两边保持一致”接触UE网络编程的人最开始几乎都会踩同一个坑以为UObject复制就是把一个对象从服务器“传”到客户端。这个理解不能说全错但会严重影响你后续的架构设计。UE的复制Replication本质是一套状态同步机制不是数据传输机制。服务器上有这个对象客户端上也“应该有”这个对象但客户端上的对象不是服务器传过来的拷贝而是客户端自己本地生成、再由服务器持续矫正状态的一个实例。服务器和客户端各自独立运行自己的游戏逻辑复制解决的是“两边状态不一样”的问题。举个例子你在服务器上把一个Actor的Level改成了5客户端上这个Actor的Level如果没同步玩家看到的就是旧的数值。复制要做的就是让服务器上这个Actor的Level变化能自动传到客户端把客户端上的Local Level也改成5。这个机制的核心前提有三个对象必须是Actor或依附于Actor服务器有最终权威Server Authority复制过程由引擎的NetDriver自动调度单独创建一个UObject比如NewObjectUMyData()然后指望它自己复制到客户端是行不通的。UObject本身没有独立的网络通道必须挂在某个Actor身上通过Actor的复制通道一并同步过去。这是很多新手第一个“为什么没复制过去”的原因——你复制的对象根本不在复制体系里。1.2 什么情况下复制会生效什么情况下不会理解复制能不能生效要记住一句话复制是服务器主动推送不是客户端申请拉取。客户端告诉服务器“我要这个对象的状态”这件事在UE里是不存在的。所有复制行为都由服务器发起。具体来说当一个Actor满足以下条件时它的属性复制才会执行bReplicates true在构造函数或BeginPlay里设置属性声明时标记了UPROPERTY(Replicated)或ReplicatedUsing在GetLifetimeReplicatedProps里用DOREPLIFETIME注册了该属性多人在线时服务器确实认为这个Actor需要同步它被Spawn在了游戏世界里缺任何一个条件属性都复制不过去。我见过最典型的失误是只写了UPROPERTY(Replicated)忘了在GetLifetimeReplicatedProps里注册结果服务器上数值变了客户端纹丝不动。卡了一下午检查代码逻辑最后才发现是注册环节漏了。还有一个隐藏条件变化检测。UE引擎默认只有属性值发生变化时才会触发复制。它通过比较当前值和上一次发送值的差异来决定是否推送。也就是说如果你服务器上每帧给同一个属性赋相同的值引擎不会发送任何数据——因为客户端那边的值已经是一样的了。这个特性有好有坏。好处是天然省带宽坏处是如果你依赖“赋值这个动作”本身来通知客户端即使值相同也要触发某些逻辑那么属性复制就做不到了。这时候得用RPC而不是属性复制。1.3 复制条件的额外限制条件里的条件注册复制时DOREPLIFETIME只是最基础的用法。实际操作里很多属性并不需要无脑同步给所有人。比如一个玩家的血量应该同步给所有客户端吗视觉上需要。但一个玩家正在瞄准时开镜的动画状态同步给所有人吗也可以但带宽不划算。更合理的做法是利用复制条件Condition来精细控制。常见的复制条件有条件宏行为COND_InitialOnly只在Actor初次复制时发送一次之后不再同步COND_SkipOwner不发送给拥有者客户端只发给其他客户端COND_OwnerOnly只发送给拥有者客户端COND_None无条件同步给所有客户端COND_SimulatedOnly只发给模拟Actor的客户端COND_AutonomousOnly只发给自主控制的客户端我在做射击游戏时枪械的当前弹药量用的是COND_OwnerOnly因为只有开枪的人需要精确知道自己还剩几发其他客户端看到的是大概的换弹动画状态用的是COND_SkipOwner。这样组合下来每帧复制的数据量降了一大截。有一点要特别注意条件复制只影响谁收到不影响是否执行复制。它不会节省服务器上的计算成本只节省网络传输成本。设计同步策略时优先考虑的是减少不必要的属性变化而不是依赖条件去做筛选。2. 复制对象的分类与网络权威模型2.1 服务器权威为什么客户端改属性几乎总是无效的玩过一段时间UE多人游戏的人都碰上过这种怪事客户端上本地把血量改了显示变了但过了一帧又跳回去了。这个现象背后就是服务器权威模型在发挥作用。在UE的网络架构里服务器是游戏状态的最终裁决者。客户端上的任何属性修改本质上只是本地临时的视觉呈现。一旦服务器认为这个值不符合它记录的权威状态下一次复制就会把客户端的值“矫正”回来。所以如果你的设计是“客户端直接改血量然后复制给所有人看”这个方案从根上就不成立。正确的流程是客户端发起一个请求通常用Server RPC服务器校验请求是否合法比如有没有碰到伤害区域、上次受伤到现在是否过了冷却时间服务器修改血量属性血量属性通过复制同步给所有客户端这个过程看着多绕了一圈但这是保证网络游戏公平性的基础。没有服务器权威玩家就能用修改器随便改血量、改金币、改位置整个游戏环境直接崩坏。纯本地单人游戏不需要这套机制但只要是多人联机不管你是2人合作还是100人大地图服务器权威模型都是默认选择。这不是UE的限制而是对抗作弊和状态冲突的行业共识。2.2 客户端预测与回滚复制之外的补充手段服务器权威模型有个绕不开的痛点延迟。如果每一次操作都要等服务器验证后通过复制返回结果玩家的操作反馈会有明显的滞后感尤其在射击、动作类游戏里这种滞后直接摧毁手感。UE的应对方案是客户端预测Client Prediction。简单说就是客户端在等待服务器确认之前先根据自己的输入把本地状态改了让玩家操作立即生效。等服务器的复制数据到达时如果和客户端预测的一致那就不做任何处理如果不一致就用服务器的权威数据覆盖本地。最典型的场景是角色移动。WASD按下的一瞬间客户端立刻把角色位置往前挪服务器之后通过复制下发真实位置。正常情况下预测是准的玩家感觉不到任何延迟如果因为网络波动导致预测和服务器数据对不上角色会抖一下然后被拉回到服务器确认的位置。这个“抖一下”就是回滚。回滚不完美但比让玩家等100毫秒才看到角色动起来要好得多。做自定义移动组件时你会发现UE帮你把大部分预测逻辑都封装好了。这里我只想强调一个观点复制和预测是两个配套机制不是二选一的关系。属性复制负责最终状态的收敛与同步客户端预测负责体验上的即时反馈两者配合才是完整方案。2.3 自定义UObject的复制把普通对象捎带上网络说了这么多终于回到标题里的UObject复制。前文提到普通UObject不能独立复制但它可以通过Actor的复制系统“顺带”同步。具体有两种方式。第一种是在Actor的属性里保存UObject引用然后把Actor属性标记为可复制。例如UPROPERTY(Replicated) TObjectPtrUMyPlayerData PlayerData;服务器更新PlayerData指向的对象内容后引用本身和对象的状态会随着Actor一起同步。但这里有坑UE默认复制的只是引用本身不会自动把UObject内部的所有属性都同步过去。UObject内部的属性需要你手动在GetLifetimeReplicatedProps里管理或者让它实现IRepChangedProperty接口。第二种是让Actor直接管理UObject的序列化数据。也就是自定义一个复制的数据结构比如USTRUCT() struct FMySyncData { UPROPERTY() int32 Health; UPROPERTY() FVector Position; };把这个结构体作为Actor的属性进行复制。结构体的每个成员都会被复制整个过程由引擎自动处理。这种方式在项目实战中更常用因为它避免了让多个对象实例各自复制时产生的状态错乱。我个人的经验是能用结构体就少用独立的UObject复制。UObject的复制链路更长出问题时排查的成本更高。除非UObject本身承载了复杂的业务逻辑、必须在多个Actor间共享引用否则用结构体打包属性是更稳妥的做法。3. RPC让函数调用跨过网络边界3.1 RPC不是传输函数本身而是触发函数的指令理解了UObject复制是“状态同步”RPC就很好理解了它是事件同步。复制回答的问题是“这个值变成什么了”RPC回答的问题是“发生了什么”。RPC的全称是远程过程调用。在UE里它的表现形式是你在一个进程里调用一个函数这个函数的执行发生在另一个进程上——通常是客户端发起、服务器执行或者服务器发起、某个客户端执行。但要注意RPC不会把函数体本身“传”过去。客户端上定义的Server_Jump()函数在调用时引擎会把函数名和参数序列化成一个网络消息包发给服务器服务器收到消息后解析参数、找到对应函数、在你指定好的那个实例上执行函数体。所以你在RPC函数里写的所有逻辑必须在服务器端也存在一份相同定义的代码。如果客户端和服务器编译出来的代码不一致最常见的是改了函数签名但只打包了其中一个平台调用会失败而且往往不报错只是静默不执行。这也能解释为什么RPC函数一定要用UFUNCTION标记并带上Server、Client、NetMulticast等关键词——这些关键词不只是给开发人员看的它们是引擎用来生成序列化和反序列化代码的线索。3.2 三种RPC类型Server、Client、Multicast怎么选UE的RPC分三大类按调用方和执行方区分Server客户端调用服务器执行。适用于“客户端向服务器发起请求”比如跳跃、开枪、拾取道具。Client服务器调用特定客户端执行。适用于“服务器把结果通知某个客户端”比如把击杀确认发给击杀者本人。NetMulticast服务器调用所有客户端执行。适用于“全世界的玩家都需要知道这件事”比如爆炸特效、全局公告。配合Reliable和Unreliable一共六种组合。可靠ReliableRPC保证消息一定会送达如果中途丢失引擎会重发。适用于不允许丢的请求比如玩家复活。不可靠UnreliableRPC不保证送达适合高频、重复、丢了也无伤大雅的消息比如每帧更新的移动输入。注意一点有些开发者把这个组合思维反过来——把所有RPC都写成Reliable觉得万无一失。这在近战格斗类小规模游戏里还能接受但在有大量玩家同时产生事件的场景里Reliable消息会迅速堆积在网络上导致拥塞、延迟飙升甚至产生连锁的“rpc call failed”类问题。能用Unreliable表达的就不要用Reliable。3.3 RPC参数序列化的隐性规则所有RPC的参数都会被UE序列化后通过网络传输。这意味着参数必须是UE能理解的数据类型基础数据类型、FVector、FRotator、UObject引用、结构体等。有两类参数特别容易出问题。一类是裸指针。在RPC里传一个原生指针只传输了一个Address这个地址在客户端进程里毫无意义。必须传TObjectPtr或者带UPROPERTY标记的UObject引用引擎才会通过网络的NetGUID系统来查找并解析目标对象。另一类是大数组。如果你在一个RPC里传一个TArrayFString并且这个数组有几百个元素整个消息包会非常庞大。UE虽然能处理但它的拆包和重组过程会占用额外的CPU和网络时间。这里更合理的方案是只传数组的引用ID客户端通过ID在本地查询数据。或者直接把数组数据放在一个复制的属性里让引擎用增量同步的方式去传比一次性RPC传完整数据要高效得多。4. 复制与RPC结合使用的实操配置4.1 一个典型的多人游戏同步流程配置为了让你更快上手我直接给一个完整的配置流程结合打击感同步这个常见场景来拆解。假设我们要做一个近战攻击攻击者按下攻击键后服务器确认范围内是否有可伤害目标计算伤害值并把受伤动画同步给所有客户端。步骤一声明属性UCLASS() class AMyCharacter : public ACharacter { GENERATED_BODY() public: AMyCharacter(); // 客户端按下攻击键请求服务器执行攻击逻辑 UFUNCTION(Server, Reliable) void Server_PerformAttack(); // 服务器广播攻击结果所有客户端包括攻击者都执行 UFUNCTION(NetMulticast, Reliable) void Multicast_OnAttackHit(const FHitResult HitResult); // 当前血量需要同步给所有客户端 UPROPERTY(ReplicatedUsing OnRep_Health) float Health; UFUNCTION() void OnRep_Health(); };步骤二注册复制void AMyCharacter::GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMyCharacter, Health); }步骤三实现逻辑void AMyCharacter::Server_PerformAttack() { // 服务器上做碰撞检测 FHitResult Hit; // ... 检测到目标 ... Multicast_OnAttackHit(Hit); } void AMyCharacter::Multicast_OnAttackHit(const FHitResult HitResult) { // 所有客户端播放挥砍动画、受击特效 PlayHitEffects(HitResult); }这个流程结构是UE多人游戏最基础的骨架。客户端不直接修改伤害数值而是发Server RPC请求服务器计算后通过Multicast RPC广播事件血量变化通过属性复制同步。状态同步和事件同步各司其职。4.2 关于“复制过去格式不一样”的问题搜索热词里有一句“复制过去格式不一样”在UE开发的语境里这句话几乎可以和我前面说的“预测回滚”划等号了。但还有一种更隐蔽的情况客户端和服务器浮点数精度不一致。UE在复制浮点数时默认会做量化Quantization处理。FVector的位置信息在网络上传输时会压缩精度一般从32位浮点压缩到20位左右。这个精度的损失在大多数场景下察觉不到比如角色站在地面上偏移1毫米根本不明显。但如果你做的是高精度工业仿真、CAD协同编辑这类应用压缩后的坐标偏差就会变成“格式不一样”的可见问题。解决方案是在配置里调整网络浮点精度// 在DefaultEngine.ini的[/Script/Engine.GameNetworkManager]段 [/Script/Engine.GameNetworkManager] TotalNetBandwidth32000 MinDynamicBandwidth6400 MaxDynamicBandwidth20000另外如果你的“格式不一样”指的是客户端通过RPC拿到数据和服务器上的数据对不上那多半是参数序列化时选择了错误的传输类型。比如把FName当FString传或者传了FDateTime不支持直接复制之类的特殊类型。排查方法很直接在RPC函数入口打印收到的参数逐字段比对。4.3 为什么我用结构体而不是散落的多个属性多人在线游戏发展到中后期属性数量动不动就几十上百个。全用UPROPERTY(Replicated)标记会产生大量复制通道网络包的头部开销直线上升。更优做法是聚类把逻辑相关的属性打包进一个USTRUCT整体复制。USTRUCT() struct FCombatData { GENERATED_BODY() UPROPERTY() float Health; UPROPERTY() float MaxHealth; UPROPERTY() float Stamina; UPROPERTY() int32 ComboCount; };这样做的直接收益一是网络数据包结构更紧凑复用同一个通道的缓冲区二是修改逻辑更清晰——战斗相关的所有状态收在一个结构体里维护起来比散落在类各处轻松得多三是减少GetLifetimeReplicatedProps里的注册行数。代价也很明确任何一个字段变化整个结构体都会重新发送一次。如果结构体里有大字段比如TArray频繁变化的小字段会连带着一起重发带宽反而更差。取舍原则相关性高、变化频率接近的属性放同一个结构体变化频率差异大的拆开分别复制。5. 实战中的高频问题和排查技巧5.1 RPC调用超时报错从Git到UE的全链路排查热词里出现了一个高频报错cannot finish rpc call in 30 seconds: nul和error: rpc failed; curl 56 ...。很多用Git管理UE项目的同学看到这个一定不陌生。这个报错几乎总是出现在提交大型二进制资源时比如提交几百MB的Pak文件、动画缓存、贴图资源。这里的RPC是Git的Remote Procedure Call不是UE的RPC。它指的是Git客户端向远程仓库发送数据时HTTP连接在30秒内没有完成传输。常见原因和对应解法现象原因解决办法大文件提交超时HTTP缓冲区默认太小git config http.postBuffer 524288000加大到500MBSSL连接中断代理/防火墙中断长连接检查代理设置或在Git配置里调整http.lowSpeedLimit和http.lowSpeedTime极慢网络下传输网络抖动导致RPC中断改用SSH协议访问仓库或使用Git LFS管理大文件UE项目尤其容易触发这个因为虚幻的Binaries目录、Saved目录里经常会产生超大的缓存文件。规范的团队一般会用.gitignore排除这些目录只提交源码和必要的资源工程文件。如果你是单人开发且没配置LFS还有一个土办法分多次提交。把资源分批发到远程仓库每次控制在一个合理体积避免单次提交触发超时。5.2 RPC不执行但也不报错静默失败的5个原因比超时更折磨人的是RPC调用后“什么都没发生”既没报错也没执行。这类问题的排查路径我整理成如下顺序按可能性从高到低是否在服务器上调用Client/Multicast客户端调用Client或NetMulticast函数不会执行任何操作也不会报错。只有服务器才有权限发起这些调用。是否在客户端调用ServerServer函数必须在客户端调用服务器调用它是无效的。Actor的bReplicates是否已开启没开启复制的Actor所有网络功能都和普通本地对象一样RPC自然失效。函数的访问权限是否有WithValidation如果声明了WithValidation但服务器端校验函数返回false那么RPC会被丢弃且不产生日志。是否在非GameThread调用网络驱动在异步线程处理数据时不能直接通过引用执行RPC需要借助ENQUEUE_RENDER_COMMAND或使用AsyncTask包装。我遇到最多的是第4种。加了WithValidation之后校验逻辑写得过于严格服务器认为“这个请求不合法”直接丢弃客户端看起来就是“我明明调用了它就是不执行”。如果你不需要强校验就不要用WithValidation如果要用务必在服务器端添加打印否则排查是两眼一抹黑。5.3 排查工具与调试技巧写网络同步代码一个趁手的调试工具能省很多时间。UE官方推荐的方式有三个Use Net PktLane Debugger在项目运行时打开控制台输入stat Net可以实时查看网络包发送/接收的统计数据包括复制属性字节数、RPC数量。这个指标能看到每个Actor每帧发送了多少数据定位哪个Actor是带宽大户。LogNet在DefaultEngine.ini里[Log]部分开启[Log] LogNetVerbose开启后控制台会打印每条网络消息的详细信息。这个日志非常详细但也非常吵我建议只在本地调试网络问题时临时开启定位完就关掉。断点在AActor::OnRep_回调属性复制时OnRep函数在客户端执行。在这里打断点可以校验客户端本地对象和服务器权威对象的状态差。另外RPC_Test和TestActorReplication是引擎自带的自动化测试节点可以直接在编辑器里试用验证特定Actor的复制链路通不通。我写复制相关代码时通常会先摆一个测试Actor单独验证复制链路再接入业务逻辑千万别等到功能做完了才来查为什么没同步。6. 经验沉淀几个坑和契约设计建议6.1 复制的“状态爆炸”问题与对策有一种很隐蔽的性能炸弹你的复制条件没有限制所有客户端每帧都会收到这个Actor的所有复制属性。角色多了属性多了网络带宽迅速爆炸。这个问题的苗头很早就出现但只在人数够多的时候才暴露。我见过一个联机射击demo8人局跑起来网络是很流畅的一到16人就开始丢包32人直接没法玩。一查每个角色每帧同步了全身骨骼的位置数据——明明骨骼移动组件已经开启了本地模拟但属性复制还是照常发送。对策是控制三个关键值NetUpdateFrequencyActor状态更新的频率默认100如果业务允许可以降到20-30NetPriority网络拥塞时谁先被丢低优先级的先被丢适合非核心交互物复制条件能不用COND_None就不用明确区分COND_SkipOwner和COND_OwnerOnly的适用范围还有一个经常被忽略的点只复制需要复制的属性。项目扩张过程中一批一批的UPROPERTY(Replicated)往代码里堆最终形成了“每个属性都在复制、但没有一个属性真正被客户端频繁使用”的局面带宽全浪费在没有消费者的数据上了。定期梳理复制属性清单删除无效项这应该成为多人项目的常态化工作。6.2 写网络代码前的三个契约设计问题写第一行网络代码之前先问自己三个问题能少加一半的班。第一个问题这个属性需要被谁看见所有客户端拥有者客户端还是只有服务器自己答案直接决定复制条件的选择。如果一个属性只有服务器才需要那它压根不需要标记Replicated本地变量就够了。第二个问题这个变化需要即时反馈吗如果是用RPC如果不是用属性复制。RPC适合离散的事件属性复制适合持续的状态。把“某个状态变化了”这种连续性的信息做成RPC事件是很典型的误解——RPC的高频调用会造成消息风潮而属性赋值并不产生任何网络包解决了性能隐患。第三个问题如果这条消息丢失了游戏会怎样如果能接受用Unreliable如果不能用Reliable。同时要明白Unreliable RPC可能会乱序到达因此逻辑上要支持一定程度的乱序语义。我在实际项目中要求所有网络相关代码的PR必须填写这三个问题的答案。否则代码就不合入。这个过程会逼着开发者去想清楚网络同步的边界很多人写着写着就不自觉地把客户端本地逻辑和网络同步逻辑混在一起了边界模糊是最容易出Bug的地方。6.3 最后分享一个复制的自检小技巧写完复制代码后我习惯做一个自检步骤把网络模拟打开就是在PIE设置里启用Network Emulation模拟10%丢包率和50ms延迟然后跑一遍核心流程。这个步骤能快速暴露两个问题第一Reliable RPC顺序是否正确。丢包时消息重发有时候会重复触发逻辑比如一个加血事件触发两次。检查函数里有没有重复执行副作用。第二状态收敛是否正常。丢包后复制数据重发时状态是否还能准确收敛还是说会因为丢了某个关键包而导致两边状态永远无法对齐。这个自检的成本很低但价值极高。网络代码最怕的就是只在理想环境里测一遍觉得没问题就发布了实际上线的网络环境远比你本地测试恶劣得多。提前用模拟弱网环境压一遍能解决很多只在线上出现的灵异问题。写到这里我基本上把UObject复制和RPC从原理到实操的关键点都覆盖了一遍。这个主题在UE开发里算是最核心也最容易被误解的一块不少做了两三年游戏的朋友还会在某些细节上卡壳。我从自己项目的坑里挑出来的这些都是最常见的希望对正在做或准备做多人内容的朋友有点参考价值。
返回列表