为什么属性同步比 RPC 更省带宽?------UE 网络选型的本质判断
这其实是 UE 网络设计里一个反直觉但很关键的认知。
很多人会觉得 RPC 是"点对点直接喊一嗓子",应该比"每帧 diff 整个 Actor"更便宜------但事实恰好相反。
一、先给结论:核心差距在哪一句话里
属性同步性能更好,不是因为它"做得少",而是因为它做得集中、可去重、可批处理、可省略。
- 属性同步 :不管你这一帧改了几次,帧末只发一次最终结果。
- RPC :开发者喊一次就发一次,喊一百次就是一百个包。
更进一步,可以浓缩成一句选型原则:
属性同步是"按结果同步"(State),RPC 是"按动作同步"(Event)。结果可以合并去重,动作不行。
这一句话,几乎决定了 UE 网络层所有的设计取舍。下面把背后的四个层面拆开讲。
二、四个层面的性能差距
层面 1:合并去重 ------ 同一帧改 100 次只 diff 一次
核心:属性同步天然是"幂等"的,可以攒到帧末尾只算最终值;RPC 每次调用都是独立事件,不能合并。
举个最简单的例子:
cpp
// 场景:每帧子弹移动
// ------ 属性同步版本 ------
BulletLocation = NewLoc; // 一帧赋值 1 次(哪怕你写 10 次,也只 diff 最后那个)
// 实际带宽:1 次差异
// ------ RPC 版本 ------
MulticastUpdateLocation(NewLoc); // 调一次发一次
// 万一逻辑里被多调了 → 调 N 次发 N 次
为什么属性同步可以这么干?因为它同步的是 "状态"------同样的最终值,发多少次结果都一样,所以 UE 可以放心地"攒到帧末再算一次"。
而 RPC 同步的是 "事件" ------Fire() 调两次和调一次语义就是不一样(一次是开了一枪,两次是开了两枪),引擎不敢替你合并。
这是最底层的差距:幂等性决定了能不能合并,能不能合并决定了带宽下限。
层面 2:懒发 ------ 没变化整条流水线 0 字节
核心:属性同步"没变就不发",根本不进流量;RPC 一旦调用,就一定会进 SendBuffer。
两者的判断逻辑完全不同:
text
[属性同步]
this 帧 Health 没变? → 整条流水线跳过,0 字节
变了? → diff、序列化、发送
[RPC]
你调了 ServerXXX? → 100% 进 Bunch 排队发出
举个射击游戏的例子:玩家可能 5 秒不掉血,这 5 秒里属性同步 Health 是一字节都不发的。
但如果你把它写成 MulticastUpdateHealth(100) 在 Tick 里调用,每秒该发还是发------哪怕值根本没变。
属性同步天生就有"惰性",RPC 天生就"勤快"------在大部分游戏状态变化稀疏的场景下,惰性才是省带宽的王道。
层面 3:调度 ------ 属性同步走全套筛选体系,RPC 几乎不受约束
核心:属性同步可以被 UE 的"相关性 / 优先级 / 频率 / Dormant"全套调度机制管住;RPC 调了就发,绕开了大部分优化。
UE 在发属性同步包之前会过四道筛子:
| 筛子 | 属性同步 | RPC |
|---|---|---|
| Dormant(休眠) | 可跳过整个 Actor | 不受影响,照发 |
| NetUpdateFrequency(限频) | 受限,比如 10Hz | 立即发 |
| Relevant(相关性) | 不相关的客户端不发 | 看 Multicast 范围,几乎广播 |
| Priority(优先级) | 带宽不够时排队 | 不排队 |
这是属性同步和 RPC 最大的性能差距来源 ------如果你把所有状态都用 RPC 同步,相当于绕开了 UE 全套带宽优化体系。
举个极端例子来感受一下:
MMO 里 1000 个玩家在地图上跑。
- 属性同步 靠
NetCullDistance直接把 950 个不相关的剔除掉,每个客户端只处理身边 50 个;- Multicast RPC 则会广播给所有 1000 个连接,谁离得近谁离得远它不管------直接爆带宽。
同一份逻辑,性能差一两个数量级,差就差在这套调度上。
层面 4:优化生态 ------ 后续所有引擎升级都在给属性同步加 buff
核心:UE 这些年加的网络优化,几乎全部是为属性同步服务的,RPC 这边十年没什么变化。
简单列一下属性同步这边的"优化历代记":
- PushModel(4.x 引入):跳过 diff 反射开销,由开发者主动标脏。
- FastArraySerializer:数组增量同步,只发变化的元素,不重发整个数组。
- Replication Graph(4.20+):把"哪些 Actor 同步给哪些客户端"从 O(N²) 优化到接近 O(1)。
- Iris(5.4+):重写整个属性同步框架,进一步压缩带宽、提升可扩展性。
而 RPC 这边?基本就是十年前那套------丢进 SendBuffer 等发。
结论:越往后 UE 引擎升级,属性同步的性能优势越大 。这是一个会随版本拉开差距 的优势,不是静态的。
(合理推论:从 UE 官方 Roadmap 和 Iris 的设计目标来看,未来若干个大版本属性同步仍是优化重心,RPC 不会有大的架构调整。)
三、本质原因:状态 vs 事件
把上面四层归纳一下,本质只有一句话:
属性同步同步的是"状态",RPC 同步的是"事件"。
- 状态:幂等、可合并、可丢中间值、可懒发;
- 事件:每次调用语义不同,不能合并、不能丢、必须按序送达。
正是因为"状态"这个特性,才让 UE 敢做合并、敢做懒发、敢做调度筛选、敢做 PushModel 这类深度优化。
而 RPC 因为是"事件"------每一次调用都不可替代------引擎必须老老实实把每个调用都送出去,所有优化空间都被堵死。
所以选型的判断标准非常简单:
状态走属性同步,事件走 RPC。
四、对照表:什么场景该选什么
也不是说 RPC 一无是处。在"低频 + 一次性事件"场景下,RPC 反而更便宜(因为它不需要持续维护一个属性槽位)。
| 场景 | 选 RPC | 选属性同步 |
|---|---|---|
| 玩家按下了某个按钮 | ✅ 一次性事件,没"状态"概念 | ❌ 要硬塞成属性,反而要加 Counter / bool 反复翻转 |
| 一段聊天文字 | ✅ 一次性事件 | ❌ 属性同步聊天历史会不停增长 |
| 关键剧情触发 | ✅ Reliable RPC | ❌ |
| 玩家的位置 | ❌ 每帧都改,RPC 太贵 | ✅ 属性同步 |
| 玩家的血量 | ❌ 变化频繁 | ✅ 属性同步 |
| 装备列表 | ❌ | ✅ FastArray |
通俗讲:
RPC 是为"事件"准备的,属性同步是为"状态"准备的。用错了场景,性能都崩。
五、一个最常见的"性能反模式"
新人很容易写出这种代码------把状态当事件发:
cpp
// ❌ 反模式:把状态当事件发
void AMyChar::SetHealth(float NewHealth)
{
Health = NewHealth;
MulticastUpdateHealth(NewHealth); // 每次改血都广播
}
这段代码同时踩中了前面四个层面的所有坑:
- 没有合并:一帧改 3 次(吃药 / 中弹 / 回复),就发 3 次包;
- 没有懒发:哪怕值没变,调一次发一次;
- 没有调度 :Multicast 强制全员广播,没用
NetCullDistance剔除远处玩家; - 没有优化生态:走不到 PushModel / Iris 这些路径上。
正确写法是把它还原回"状态":
cpp
// ✅ 正解:状态用属性同步
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
void AMyChar::SetHealth(float NewHealth)
{
Health = NewHealth;
MARK_PROPERTY_DIRTY_FROM_NAME(AMyChar, Health, this); // PushModel 标脏
}
void AMyChar::OnRep_Health()
{
// 客户端反应:刷 UI、播音效、播血条动画......
}
这样一改,立刻拿到四个收益:
- 一帧改 N 次只算 1 次(合并);
- 值没变就不发(懒发);
- 自动走相关性 / 优先级筛选(调度);
- 走 PushModel,跳过反射 diff(优化生态)。
六、一句话收尾
如果你只想记住一句话来指导日常写网络代码,那就是:
属性同步 = "Server 按帧总结结果",RPC = "Server 转发每个动作"。
总结可以合并、去重、调度、跳过;转发不行------这是两者性能差距的本质。
下一次纠结"这个东西到底用属性同步还是 RPC"的时候,先问自己一个问题:
它是一个"状态",还是一个"动作"?
答案出来了,选型也就出来了。