UE5.8 Server、Client 与 NetMulticast RPC:从一次走火的霰弹枪说起
引子:那把不受控的霰弹枪
三月的一个晚上,我们项目组在跑多人射击 Demo 的内网测试。地图是一张仓库混战图,八名玩家分成两队抢包点。测试开始不到五分钟,美术同学老周在语音里喊:「我刚才明明没按鼠标,怎么枪自己响了?」
更诡异的是接下来的伤害判定。老周客户端里霰弹枪喷出一团火光,对面两人应声倒地;可服务器日志里只记录到一人受伤,另一人毫发无损。又过了半分钟,策划小夏在公屏打字:「谁一直在空地上开枪?我这看他在打空气。」
三个症状指向同一个病根:RPC 调用方向搞混了。老周本地的开枪逻辑走了 Client RPC,枪口火光在自己屏幕上立刻播放,但服务器根本没收到授权请求。伤害计算在客户端本地跑了一份,又被服务器覆写。空地上的玩家则是另一台客户端擅自调用了 NetMulticast,把本不该广播的效果发给了所有人。
修完这个 bug 后,我把 UE5.8 里 RPC 的机制重新整理了一遍。RPC 是联机游戏里最常用的工具之一,也是最容易写出隐蔽 bug 的地方。方向搞反、权限没验、频率失控、参数超限,都会让项目在线上线出各种各样的事故。这篇文章从霰弹枪事故出发,把 Server、Client、NetMulticast 三种 RPC 以及围绕它们的设计原则讲清楚。
一、RPC 调用方向:谁调用,谁执行,谁收到
UE 的 RPC(Remote Procedure Call)本质上是一条跨网络的函数调用消息。它不关心函数返回值,只负责把「某对象上调用某函数」这件事同步到远端。根据调用者与执行者的关系,UE 把 RPC 分成三类:Server、Client、NetMulticast。
flowchart TD
subgraph client_a ["客户端 A:老周"]
input_a["输入 / 开火"]
pc_a["PlayerController"]
end
subgraph server ["服务端:权威世界"]
gm["GameMode"]
world["World / 命中判定"]
end
subgraph client_b ["客户端 B:小夏"]
pc_b["PlayerController"]
fx_b["特效 / 音效"]
end
input_a --> pc_a
pc_a --> world
world --> pc_a
world --> fx_b这张架构图对应一次正确实现的霰弹枪开火。玩家输入在本地处理,通过 Server RPC 把请求发到服务端;服务端做命中判定,把结果用 Client RPC 回给开火者,再用 NetMulticast 把视觉与音效广播给所有相关玩家。
1.1 Server RPC:客户端请求,服务端执行
Server RPC 只能由客户端调用,在服务端执行。它的用途是把玩家的输入或请求提交给服务器做权威裁定。开枪、换弹、拾取、使用技能、发送聊天消息,这些操作的起点通常是 Server RPC。
老周的霰弹枪事故里,真正该走的逻辑是:客户端检测到鼠标左键按下,调用 Server_Fire()。服务端收到后做射线检测或弹道模拟,计算伤害,再决定下一步同步。如果反过来,把命中判定放在客户端本地,然后用 Server RPC 只通知结果,就给了修改器很大的作弊空间。
UE 在编译期通过 UFUNCTION(Server, Reliable) 这样的宏声明 Server RPC。 Reliable 表示这条 RPC 必须送达,引擎会在 UDP 之上做确认与重传;去掉 Reliable 就是 Unreliable,适合频繁但不关键的事件。
1.2 Client RPC:服务端调用,指定客户端执行
Client RPC 只能由服务端调用,在执行对象的拥有客户端上执行。它的典型用途是反馈结果、触发本地特效、播放音效、打开 UI。由于服务端知道每个连接对应哪个 PlayerController,它能精确地把消息推给目标客户端。
霰弹枪开火的后坐力震屏、命中提示、弹药 UI 变化,都适合用 Client RPC。服务端对老周的 PlayerController 调用 Client_Recoil(),只有老周自己的屏幕会抖,其他玩家不需要这个信息。
Client RPC 有一个常见陷阱:如果你在非 Owned Actor 上调用 Client RPC,消息不知道该发给谁,会直接被丢弃。只有 Network Role 为 ROLE_Authority 且拥有有效 NetOwner 的对象,才能正确路由 Client RPC。
1.3 NetMulticast RPC:服务端调用,所有相关客户端执行
NetMulticast RPC 由服务端调用,广播给所有将该 Actor 标记为 Relevant 的客户端。它适合需要 everyone sees the same thing 的场景:爆炸特效、全屏公告、技能范围提示、角色死亡动画。
仓库混战里,老周开枪的火光与枪声应当由 NetMulticast 广播。但注意一个细节:如果开枪请求还没经过 Server RPC 到达服务端,客户端不能自己先调用 NetMulticast。否则会出现老周本地和其他玩家看到的效果不同步,也就是小夏看到的「他在打空气」。
NetMulticast 还有个限制:它只能从服务端发起。客户端擅自调用 NetMulticast 会被引擎忽略或记录警告。正确的流程永远是客户端先请求,服务端确认后再广播。
二、Reliable 与 Unreliable:要不要等确认
UDP 不保证送达,但游戏里的不同消息对可靠性的要求差别很大。UE 让开发者在每个 RPC 上显式选择 Reliable 或 Unreliable。
Reliable RPC 走引擎自己实现的确认重传。它的代价是占用网络通道:丢包时会重发,连续丢包会让后续 Reliable 消息排队,形成队头阻塞。Unreliable RPC 发出去不追踪,丢了就丢了,适合高频低重要性的信息。
一次完整的霰弹枪开火可以这样分配:
flowchart LR
A["玩家按下开火"] -->|Unreliable| B["本地预测播放枪口上跳"]
A -->|Reliable| C["Server RPC 请求开火"]
C --> D{服务端判定}
D -->|"命中"| E["NetMulticast 命中特效"]
D -->|"未命中"| F["NetMulticast 弹着点火花"]
D -->|Reliable| G["Client RPC 更新弹药与命中提示"]这里 Reliable 只保留两处:真正改变世界状态的 Server RPC,以及必须反馈到 UI 的 Client RPC。本地预测动画和命中特效都可以用 Unreliable,即使丢一帧,下一帧的状态会自然覆盖,玩家几乎无感。
Reliable 的滥用有一个经典场景:把角色移动也做成 Reliable RPC。早期做联机的团队容易犯这个错,结果高延迟或丢包时移动包堆积,玩家感觉角色被绳子拴住。UE 的 CharacterMovementComponent 使用专门的网络同步通道,混合 Unreliable 状态包和少量 Reliable 校正,而不是把每次移动都走 Reliable RPC。
Reliable 与 Unreliable 的选择不能只看功能,还要看频率与重要性。频率高的事件如果走 Reliable,会把通道里的其他 Reliable 消息一并拖慢。重要性低的事件如果走 Unreliable,丢包后可以通过后续状态自然修正。聊天消息、交易请求、技能结算适合 Reliable;枪口火光、弹着点火花、脚步声、命中飘字适合 Unreliable。
三、RPC 权限检查:不要相信客户端
Server RPC 是客户端进入服务端的入口,这个入口必须做权限检查。常见的检查项包括:
- 调用者是否是该 Actor 的 Owner;
- 当前是否处于允许执行该操作的状态;
- 输入参数是否在合法范围内;
- 目标对象是否在调用者的交互范围内。
霰弹枪例子里,服务端收到 Server_Fire() 后,应当检查:
其中 是武器 Actor, 是发起调用的 PlayerController。如果 不成立,直接丢弃这条 RPC。这一步能挡住大部分简单的伪造请求。
更严格的检查还包括射线可命中性验证。客户端可以谎报命中坐标,服务端必须用自己的世界状态重新做命中判定,而不是把客户端上报的伤害直接扣血。公式可以写成:
也就是说,只有当客户端上报的射击起点与服务器记录的玩家位置距离不超过阈值 时,才认可这次开火;伤害值由服务端根据服务器上的弹道重新计算。
sequenceDiagram
participant A as "客户端 A"
participant S as "服务端"
participant B as "客户端 B"
A->>S: Server_Fire(origin, dir)
Note right of S: 检查 Owner、弹药、CD
S->>S: 服务端弹道判定
S->>A: Client_HitResult(damage, ammo)
S-->>B: NetMulticast_MuzzleFlash
S-->>A: NetMulticast_MuzzleFlash这张时序图展示了开枪的完整消息流。所有影响世界状态的判定都在服务端完成,客户端只负责表现与输入。
四、RPC 参数限制:能传什么,不能传什么
RPC 的参数会被序列化到网络包中,因此受到 UObject 引用、网络带宽和类型的限制。
UObject 指针在 RPC 中只能传递已经复制到目标客户端的对象。如果目标客户端还没收到某个 Actor 的创建包,RPC 里引用这个 Actor 会得到空指针。老周的霰弹枪如果要把命中目标传给 Client RPC,最好传 Actor 的 NetID 或一个已经复制的弱引用,而不是裸指针。
大型结构体也不宜直接当 RPC 参数。例如一次技能释放要传十几个浮点、数组、变换矩阵,最好打包成一个单独的 UStruct,或者拆成多次属性复制。每次 RPC 参数序列化后的大小直接影响带宽预算。
字符串参数尤其要小心。聊天消息如果直接作为 RPC 参数,必须在服务端限制长度,否则一个恶意玩家可以塞几千字的文本导致单包膨胀。自定义 UObject 参数则要确认目标端已经收到该对象的初始化包,否则序列化会失败或得到空引用。
下面是一段简化的 Server RPC 声明示例,注意参数类型选择:
UFUNCTION(Server, Reliable, WithValidation)
void Server_FireWeapon(FVector_NetQuantize Origin, FVector_NetQuantize Dir);
UFUNCTION(NetMulticast, Unreliable)
void Multicast_PlayMuzzleFlash(FVector_NetQuantize Location);
UFUNCTION(Client, Reliable)
void Client_UpdateAmmo(int32 NewAmmo);FVector_NetQuantize 是 UE 提供的网络压缩向量,用 19 位表示每个分量,比完整 FVector 省很多带宽。WithValidation 让服务端在执行前调用验证函数,验证失败会断开作弊客户端的连接。三个函数加起来不到 15 行,却已经覆盖了开枪所需的核心 RPC。
五、常见 RPC 模式
不同游戏行为对应不同的 RPC 组合。下面用几个具体场景说明。
5.1 拾取物品
玩家按 F 拾取地上的医疗包。客户端先调用 Server_TryPickup(itemID),服务端检查距离与背包空间,若通过则修改玩家属性并把物品从世界移除。结果用 Client RPC 刷新 HUD,物品消失用 NetMulticast 广播给所有玩家。
5.2 聊天消息
聊天输入框在本地,调用 Server_SendChat(text)。服务端做敏感词与长度过滤,再用 NetMulticast 把消息发给所有客户端。注意聊天通常走 Reliable,因为文本不能丢。
5.3 技能释放
MOBA 里法师释放火球术。客户端调用 Server_CastSkill(slot),服务端检查蓝量、冷却、距离,确认后生成投射物 Actor。投射物的命中用 Server RPC 或自身 Authority 判定,命中后的爆炸与伤害用 NetMulticast 广播。
5.4 角色死亡
服务端判定血量归零后,调用 NetMulticast 播放死亡动画,同时用 Client RPC 通知死亡玩家打开观战界面。死亡状态本身应当用属性复制(Replicated Health)维护,而不是每次靠 RPC 通知。
这些模式可以归纳成一张关系图:
graph TD
A["玩家输入"] --> B{是否改变世界状态}
B -->|"是"| C["Server RPC"]
B -->|"否"| D["本地预测或表现"]
C --> E{是否需要反馈给特定玩家}
E -->|"是"| F["Client RPC"]
E -->|"否"| G{是否需要所有人看见}
G -->|"是"| H["NetMulticast RPC"]
G -->|"否"| I["仅服务端处理"]这张决策图能帮你在写新功能时快速选对 RPC 类型。核心原则是:先判定谁拥有真相,再决定消息往哪走。
六、RPC 与属性复制的选择
很多新手会把所有同步都做成 RPC,结果代码里到处是函数调用,状态管理混乱。UE 提供了两条同步通道:RPC 用于事件,属性复制用于状态。两者不是替代关系,而是互补。
属性复制适合持续变化的数据:血量、位置、弹药、buff 层数。它的语义是「最新值覆盖旧值」,丢一两个包没关系。RPC 适合一次性事件:开枪、拾取、死亡、切换武器。它的语义是「这件事发生了」,需要被处理一次。
老周的霰弹枪弹药数量,最好走属性复制。每次开枪后服务端修改 CurrentAmmo,客户端自动收到更新,UI 绑定这个变量即可。如果每次都用 Client RPC 通知弹药变化,网络波动时容易出现 UI 与服务端不一致。
但也不是所有状态都适合属性复制。一次性事件如果走属性复制,会出现「事件被重复触发」的问题。例如老周的开枪请求,如果用一个 Replicated bool 表示「是否开火」,客户端每一帧都会检测这个 bool 变化,容易误判多次。这种一次性的脉冲事件用 Server RPC 更合适。
选择原则可以这样记:状态用复制,事件用 RPC。如果需要两者都表达,比如技能冷却,可以用属性复制维护剩余冷却时间,用 Client RPC 触发冷却开始的 UI 动画。
七、RPC 频率控制
RPC 没有内置的全局冷却,但放任客户端高频调用会迅速烧穿带宽并拖垮服务端。常见控制手段包括:
- 服务端对同一 RPC 做时间窗口限制;
- 客户端输入做采样或节流,避免每帧都发;
- 高频事件合并成批量包;
- 对 NetMulticast 做距离衰减,远处玩家收低频或简化版。
霰弹枪如果是连发的霰弹枪,每秒可能打出 3 到 4 发。每发都走 Reliable Server RPC 会带来可观的重传开销。方案之一是把每发都设为 Unreliable,但服务端记录最后开火时间,丢弃过于频繁的请求;同时客户端本地用预测动画填充,玩家手感不会受影响。
带宽估算可以用一个简单公式。假设一局 8 人对战,每人每秒开火 4 次,每发 Reliable Server RPC 加上 NetMulticast 平均 40 字节:
这还只是开枪事件。如果所有交互都按这个密度发,服务器出口压力会迅速上升。把其中一部分改成 Unreliable,并把 NetMulticast 的广播范围按距离裁剪,能把这个数字压到可接受范围。
八、安全性与作弊防范
RPC 的安全性设计可以分三层:入口校验、执行沙盒、事后审计。
入口校验在前面提过,重点是 WithValidation 与 Owner 检查。执行沙盒指服务端不要把客户端传入的参数直接用于伤害或状态变更,而是用自己的副本重新计算。事后审计则是在日志里记录异常频率、异常参数、异常来源 IP,便于后续封禁。
服务端重新计算听起来多了一次开销,但这是防止作弊的最低成本方案。客户端可以伪造命中坐标、伪造伤害倍率、伪造技能范围,服务端如果只按客户端给的数字扣血,修改器作者会笑出声。把判定逻辑放在服务端,客户端只提交意图,是联机游戏的基本纪律。
霰弹枪事故里,如果老周本地走了 Client RPC 开枪,服务器没做权限检查,那么一个修改器可以直接伪造 Server_Fire() 并以每秒 30 发的频率调用。 WithValidation 函数里加上最近开火时间检查,就能把这类请求挡掉:
bool AMyWeapon::Server_FireWeapon_Validate(FVector_NetQuantize Origin, FVector_NetQuantize Dir)
{
if (GetWorld()->TimeSeconds - LastFireTime < FireInterval * 0.9f)
{
return false;
}
return true;
}验证失败返回 false 后,引擎会断开该客户端连接。这个函数只有 8 行,却能把高频射击作弊直接拒之门外。
另一层防范是参数范围检查。客户端传来的 Origin 不能离角色当前位置太远,Dir 应当是单位向量,弹药数不能为负。任何一项异常都应当触发审计日志,而不是静默修正。
结语:RPC 是刀,方向是刃
修完霰弹枪 bug 的那个周末,我在代码里加了三条注释:Server RPC 是世界入口,Client RPC 是个人反馈,NetMulticast 是公共舞台。这三句话后来成了组里做联机功能时的口头禅。
UE5.8 的 RPC 机制本身不复杂,宏加几行就能让函数跨网络调用。真正复杂的是设计阶段的方向判断:哪个动作走 Server,哪个效果走 Multicast,哪些数据应该交给属性复制,哪些校验必须做在服务端。方向错了,表现层再华丽也会在线上暴露出千奇百怪的同步问题。
老周后来没再遇到过枪自己响的情况。小夏也没再见过有人在空地上对着空气喷子弹。那把霰弹枪的故事成了我们项目的一个反面教材,每次新人写联机功能前,我都会让他先看一眼那把枪的提交记录。