属性同步解决"状态怎么追平",RPC 解决"事件怎么通知"。两者在底层共用 actor channel 和 bunch 机制,但打包路径和可靠性语义差别很大。基于 UE5.3 源码(NetDriver.cpp、DataChannel.cpp、RepLayout.cpp 的函数复制部分)走一遍。
从 ProcessEvent 到 bunch
客户端调一个 UFUNCTION(Client, Reliable) 函数,表面上和本地函数调用无异:
UFUNCTION(Client, Reliable)
void ClientReceiveHit(const FHitResult& Hit);
本地调用落在 UObject::ProcessEvent,由 UFunction 的 RPC 标志(FUNC_NetClient/FUNC_NetServer)分派到 AActor::CallRemoteFunction(NetDriver.cpp)。之后的路径:
- 找到该 actor 在本连接上的
UActorChannel(没有就开一个); - 通过
FObjectReplicator的函数序列化路径:写函数 ID(不是名字——和属性 handle 同理,是UFunction在类里的索引,位打包后几个 bit)+ 每个参数的序列化; - 产出 bunch,
SendBunch进 channel 的可靠队列或直接发。
参数序列化按 FProperty 逐个走,可以自定义:结构体实现 NetSerialize 或标 NetSerializeParams 就能做自己的压缩(FHitResult 的位压缩就是教科书)。注意默认走原生内存拷贝,float 不压缩,Vector 全精度——RPC 的默认带宽开销比属性同步大得多,因为属性有 NetQuantize 的存量优化,RPC 参数没有默认优化。
Reliable 队列:什么时候重传
reliable RPC 的本质是排队进 FOutBunch 的可靠流。每个 channel 有两条 bunch 流:可靠和不可靠,序号独立(OutReliableBunch 计数)。可靠 bunch 发出后挂进 UNetConnection::OutAckBits 等 ack:收到 ack 序号之前的全部确认,没确认的下个 tick 原样重传,直到 ack 或 channel 关闭。
这套机制的直接推论,每条都在项目里踩过:
- reliable RPC 有序但不保证时效。前面一个 bunch 丢了,后面所有 reliable bunch 全在等它重传——队头阻塞。高频 RPC(比如每帧一次的状态上报)做成 reliable,一次丢包积累出的延迟肉眼可见。
- 饱和时 reliable 也会丢——不是丢包,是根本没发出去。
IsNetReady为 false 时 reliable bunch 继续排队,缓冲无限增长(5.x 有上限,超了直接断开连接,UNetConnection::Closing)。大面积丢包时你看到掉线,多半是这个缓冲炸了,不是网络物理断了。 - 不可靠 RPC 的语义是"尽力发一次"。bunch 塞进 packet,packet 没凑满等下个 tick;等的过程中如果连接饱和或 channel 被关闭,这个 bunch 可能就没了。所以"玩家扣血"必须 reliable,"播放一个脚步音效"必须 unreliable。
WithValidation 与安全
UFUNCTION(Server, Reliable, WithValidation) 会在打包时额外生成一个 _Validate 函数,服务端反序列化参数后先跑 Validate 再跑实现。这个机制防的是作弊客户端:反序列化层只保证字节流格式合法,不保证语义合法(比如客户端声称"我瞬移了 1 万公里")。Validate 里做范围检查,不通过就丢弃并记日志:
bool AMyCharacter::ServerMove_Validate(FVector_NetQuantize Loc, float Time)
{
const float Dist = FVector::Dist(Loc, GetActorLocation());
return Dist < 2000.f; // 单帧最大位移上界
}
实测里 Validate 漏写等于把信任边界让给了客户端。引擎自带的 ServerMove 校验就是这么做的(CharacterMovement 里),值得抄它的上界思路。
和属性同步的取舍
同一个信息"玩家的血量变了",用 RPC 通知还是属性同步?判断依据是信息的性质:血量是一个持续状态,会再被读(UI、AI 决策、存档),走属性同步(状态以服务端为准,客户端随时可查);受击瞬间的屏幕震动是一次性事件,没有"当前值"概念,走 RPC。混用的典型 bug 是拿 RPC 同步状态——客户端漏收一次就永久不一致,且没有 dormancy 和 relevancy 那套省带宽机制。
实操侧两个命令:net.DebugRPC 日志看 RPC 调用频率(高频 reliable RPC 在带宽统计里很显眼),LogNetTraffic 配合抓包工具分析具体 bunch 内容。