从”普通程序员能听懂”到”能改 UE 网络源码”——基于源码逐行考证,六架构全覆盖:
网络 / 同步 / RPC / 通信 / 分发 / 网关,附移动端优化 / 大世界分发 / 调试工具链三大专题
- 文档版本:1.0(2026-08-26)
- 源码快照:本地仓库
d:\Project\GameDevelop\UnrealEngine,UE 5.9 移动优化 fork,HEAD6cea9bd20f8f - 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以
路径:函数名为准 - 姊妹文档:本文是《Nanite 虚拟几何体完全教程》与《Virtual Shadow Maps 完全教程》的第三篇——同一写作体系,但网络是 CPU 侧、跨进程的世界(前两篇是 GPU 侧单进程),读者无需先读前两篇
- 符号说明:
Replication=复制,Bunch=束,Packet=数据报,NetGUID/RepGraph/Changelist/Beacon/Iris不翻译,Dormancy=休眠,Relevancy=相关性
第 0 章 导读:这份文档怎么读
TL;DR|这份文档把 UE 网络架构拆成六个子系统讲透:网络(邮局:连接与通道)、
同步(信件抄送:属性复制)、RPC(电话:远程函数调用)、通信(安检流水线:
PacketHandler 与包格式)、分发(分区快递员:谁该收到什么)、网关(门卫与换乘站:
登录/Beacon/旅行)。三个深度档位:30 秒看懂、半天学会调参、1 天读源码。
0.1 目标读者与前置知识
目标读者是普通程序员:写过 C++、用蓝图或代码摆弄过 Actor、知道”服务器和客户端”是什么但没深挖过 UE 的网络层。每个网络术语第一次出现都配一句直觉解释。
前置知识三个:
- UDP 与 TCP 的区别:UDP 快但会丢包乱序,TCP 可靠但慢(重传+拥塞控制)。UE 用 UDP 打底、自己实现可靠性——理解这一点是全文档的钥匙;
- 权威服务器:多人游戏里”谁说了算”——服务器是唯一真相,客户端只能建议;
- Actor 与蓝图:UE 的场景对象与脚本基础。
与姊妹文档的关系:Nanite/VSM 讲的是”一帧怎么画出来”,本文讲的是”多台机器怎么玩同一个游戏”——但思维方式一脉相承:理解分层、理解数据结构、理解决策链。
0.2 三条阅读路径
| 通道 | 读什么 | 耗时 | 读完能做什么 |
|---|---|---|---|
| 🚀 30 秒通道 | 每章 TL;DR + 第 2 章六类比 + 图 F1 | 10 分钟 | 跟人讲清六架构分工、复制与 RPC 的区别、C/S 权威模型 |
| 📚 标准通道 | TL;DR + 正文 + 全部图 + 第 9~12 章 | 6~8 小时 | 完整数据流;会调 CVar 优化带宽;能设计属性复制方案;会用 NetTrace 排障 |
| 🔬 源码考古通道 | 全部 + 源码考古块 + 附录 A 对照 | 1~2 天 | 敢打开 Engine/Private/ 对照着读,能定位任意子系统入口,能改网络源码 |
0.3 块标记约定
与姊妹文档完全一致:
- **
TL;DR**(每章开头):30 秒读完本章,3~5 句、必须含类比名、不含源码路径; - **
类比实验室**(正文小节):用普通程序员已懂的东西讲陌生概念; - **
源码考古**(每章末尾,折叠块):贴真实源码(≤25 行/段),逐行翻译。引用格式:路径:行号(函数名); - **
避坑**(正文穿插):版本差异、公开资料与当前代码不一致、易错点。
0.4 一张图看懂全局
读图方法:一帧的服务器旅程——① 收到客户端的包(网络层认路)→ ② 拆包验包(通信层安检)→ ③ 游戏逻辑跑起来(世界观)→ ④ 决定”谁该收到什么”(分发层决策)→ ⑤ 把变化序列化成束(同步+RPC)→ ⑥ 出厂发回客户端(通信+网络)。底部回环是下一帧的燃料:客户端的 ACK 与输入。六块 = 六章,图会随阅读展开。
0.5 章节地图
| 篇 | 章节 | 一句话 |
|---|---|---|
| 开篇 | 第 0~1 章 | 怎么读 + UE 网络世界观(权威服务器、两大手段) |
| 概念篇 | 第 2 章 | 六架构速览(每个架构一个类比) |
| 机制篇 | 第 3~8 章 | 网络 / 通信 / 同步 / RPC / 分发 / 网关 六大架构逐章拆解 |
| 专题篇 | 第 9~11 章 | 移动端优化 / 大世界分发 / 调试工具链 |
| 调优篇 | 第 12~13 章 | CVar 速查与常见坑 / 局限与展望 |
| 附录 | A~D | 源码地图、术语表、参考资料、自测题 |
第 1 章 网络世界观:权威服务器与两大手段
TL;DR|UE 网络的世界观是”服务器说了算“:一切游戏状态以权威服务器为准,客户端只能
“建议”(发输入)并”先斩后奏”(本地预测)。四种运行模式本质只有两类——服务器与客户端。
同步靠两大手段:属性复制 = 信件抄送(状态定期送达)与 RPC = 电话(事件即时触发)。
一句话:服务器是真相,客户端是建议者。
1.1 两种世界观:状态同步 vs 帧同步
多人游戏同步有两种主流哲学:
- 帧同步(Lockstep):所有客户端跑同一套确定性模拟,只同步”输入”——大家吃同样的输入、算同样的结果。优点是带宽极小;缺点是任何非确定性都会导致分歧(浮点差异、随机数、物理),且 RTT 高的玩家拖慢所有人(要等输入)。
- 状态同步(State Sync):服务器跑权威模拟,把状态变化广播给客户端;客户端自行渲染(可预测)。带宽更高,但鲁棒:客户端掉帧、作弊、非确定性都不影响服务器。
UE 选的是状态同步(官方文档措辞:”The server is where the game actually happens”)。理解这个选择就理解了 UE 网络的一切设计:服务器是真相源,客户端是”远程操控的视图”。
P2P vs 权威 C/S:UE 引擎层没有原生 P2P——所谓”P2P 游戏”(如大厅邀请制)是某客户端以 ListenServer 身份建主机,配合 OnlineSubsystem 会话实现的。权威服务器仍然是唯一真相源。
1.2 权威模型:服务器是唯一真相
权威服务器(Authoritative Server)的含义:
- 服务器运行”真正的游戏”——伤害计算、移动结果、拾取判定全在服务器;
- 客户端发来的东西是建议(Request),不是事实——客户端说”我跑到 (100,50) 了”,服务器验证后说”行”或”不对,你在 (98,50)”;
- 客户端为了手感预测(Prediction)——先本地模拟,服务器纠偏时再改正(第 8.7 章)。
类比实验室:先斩后奏
想象你向老板汇报工作:你先按自己的判断把活干了(预测),老板每周五核对一遍(服务器纠偏),
错了就改(纠偏/回滚),对了就记下(确认)。老板是唯一权威——你的判断只是建议。
UE 的角色移动就是这样:客户端本地先跑(手感顺滑),服务器每帧校验(真相),
不一致时发”纠偏包”把客户端拽回正确位置(ClientAdjustPosition)。
1.3 四种模式:本质只有两类
ENetMode(EngineBaseTypes.h:978-996),枚举注释原话:”every mode less than NM_Client is a kind of server“(小于 NM_Client 的都是服务器变体):
| 模式 | 是什么 | 谁在跑游戏逻辑 |
|---|---|---|
NM_Standalone |
单机(无网络) | 本机(唯一真相) |
NM_DedicatedServer |
专用服务器 | 无渲染的服务器进程 |
NM_ListenServer |
主机兼玩家 | 服务器进程 + 本地玩家 |
NM_Client |
客户端 | 只跑本地预测/渲染 |
关键推论:UNetDriver::IsServer()(NetDriver.cpp:2594)= ServerConnection == NULL——没有”连出去的连接”就是服务器。Dedicated 服务器没有本地玩家、NetServerMaxTickRate 限帧(NetDriver.h:875-888);Listen 服务器同时是客户端(有本地玩家);Standalone 根本没有 NetDriver。
1.4 Actor 的网络角色
不是所有 Actor 都参与网络——Actor 的网络身份由几个属性决定(Actor.h:593-914):
| 属性 | 含义 |
|---|---|
bReplicates |
参与复制(服务器→客户端) |
bAlwaysRelevant |
对所有连接始终相关(不看距离) |
bOnlyRelevantToOwner |
只发给拥有者(PlayerController/Pawn 常用) |
bNetTemporary |
一次性复制(生成后不更新,如子弹特效) |
bNetStartup |
关卡加载时存在(Level 里的静态 Actor) |
NetUpdateFrequency |
每秒最多复制几次(默认 100) |
NetPriority |
带宽紧张时谁优先(默认 1.0) |
NetDormancy |
休眠策略(第 7.8 章) |
避坑:
NetUpdateFrequency/NetPriority从 UE 5.5 起公开访问已废弃
(Actor.h:903-914 带 UE_DEPRECATED 注释),必须用SetNetUpdateFrequency()/GetNetUpdateFrequency()访问器。旧教程直接赋值会编译告警。
复制的最小单位是属性:UPROPERTY(Replicated) 标记的属性,配合 DOREPLIFETIME 宏注册——服务器变了它,客户端自动收到。这是第 5 章的主线。
1.5 两大手段对比:什么时候用哪个
| 属性复制(Replication) | RPC(远程函数调用) | |
|---|---|---|
| 类比 | 信件抄送 | 电话 |
| 语义 | 状态(”这个值现在是 X”) | 事件(”这件事发生了”) |
| 方向 | 服务器 → 相关客户端 | 双向(Server/Client/Multicast) |
| 频率 | 按 NetUpdateFrequency 节流 | 每次调用立即发送(可攒批) |
| 可靠性 | 天然可靠(最新值覆盖旧值) | 默认不可靠,bReliable 修饰 |
| 数据量 | 只发变化的(changelist) | 参数全量序列化 |
| 适用 | 位置、血量、装备、游戏状态 | 开火、开门、换武器、聊天 |
决策口诀:**”是状态还是事件?”**——血量是状态(复制);”被打了一枪”是事件(RPC)。二者可以配合:RPC 触发瞬间事件,属性复制跟进持续状态。
1.6 一帧的生命周期:收 → 想 → 发
服务器一帧: TickDispatch(收包) ← 处理客户端发来的输入/请求(LevelTick 早期) Actor Tick(想) ← 游戏逻辑跑起来(移动/伤害/AI) TickFlush(发包) ← ServerReplicateActors 决定并发出复制(LevelTick.cpp:1930 附近)客户端一帧:
TickDispatch(收包) ← 收到服务器的复制(属性/RPC)
Actor Tick(想) ← 本地预测 + 渲染
TickFlush(发包) ← 发出输入(移动上报)/ Server RPC帧序保证:收包永远在发包之前 → 服务器先看到上一帧的输入,再决定本帧的真相
UNetDriver::TickFlush(NetDriver.cpp:1097)里:Iris 优先(InternalIrisUpdateTransactional,:1141),否则 legacy ServerReplicateActors(:1159)。第 3~7 章全部围绕”TickFlush 里发生了什么”展开。
1.7 小结
这一章记住三句话:
- 服务器是真相,客户端是建议者(状态同步 + 权威模型);
- 四种模式本质两类:服务器(没有 ServerConnection)与客户端;
- 两大手段:属性复制传状态、RPC 传事件。
下一章,六个架构各配一个类比,建立全书地图。
第 2 章 六架构速览(每个架构 = 一个类比)
TL;DR|UE 网络拆成六个子系统,每个配一个你早就懂的东西:网络 = 邮局体系、
通信 = 安检流水线、同步 = 信件抄送、RPC = 电话、分发 = 分区快递员、
网关 = 门卫与换乘站。这一章只立类比不给代码——每个架构在源码里的落点
在第 3~8 章展开,末尾给”概念 → 源码”预告表。
2.1 网络架构 = 邮局体系
定义:连接与通道体系——UNetDriver(邮局总局)管着所有 UNetConnection(邮路),每条邮路上若干 UChannel(邮袋:控制袋/语音袋/数据袋/Actor 袋),数据以 Bunch(信件)为单位流动,NetGUID(门牌号)标识收件对象。
邮局总局 UNetDriver(服务端一份,管所有邮路) ├─ 邮路 UNetConnection(每玩家一条;客户端只有一条 ServerConnection) │ ├─ 邮袋 UChannel#0 Control(握手/控制消息) │ ├─ 邮袋 UChannel#1 Voice(语音) │ ├─ 邮袋 UChannel#2 DataStream(Iris 数据流,本 fork 特性) │ └─ 邮袋 UChannel#N Actor(每个被复制的 Actor 一个,动态开/关) │ └─ 信件 Bunch(属性变化 / RPC / 生成信息) │ └─ 门牌号 NetGUID(标识"这封信发给哪个对象") └─ 限速:令牌桶 QueuedBits(每帧恢复带宽配额,超额等下一帧)
它在这条流水线里的位置:一切的骨架——数据从哪进(ReceivedPacket)、往哪出(FlushNet)、走哪条邮袋(Channel 分发)、多快(令牌桶)。
2.2 通信架构 = 安检流水线
定义:PacketHandler 体系——每个连接一条”安检流水线”(组件链:无状态握手 → 加密 → 压缩 → Oodle),出包时依次”盖章、封装、称重”,进包时反向”验真、解密、拆封”。连接建立前还有一道”验明正身”:无状态握手用 cookie 挑战应答防 UDP 放大攻击。
类比实验室:机场安检
出包 = 行李托运:安检(StatelessConnect 验身份)→ 盖章(加密)→ 装箱(压缩)→ 称重(限速)。
进包 = 到达提取:拆箱 → 验章 → 过检。所有行李走同一条传送带(PacketHandler 流水线),
顺序不能乱——这就是”组件链”的含义。
它在这条流水线里的位置:网络层(第 3 章)与游戏层之间的一层”翻译与安检”——第 4 章。
2.3 同步架构 = 信件抄送 + 改动日记
定义:属性复制体系——服务器为每个类型建一张 FRepLayout(”这封信格式怎么排”的类型蓝图),为每个对象记一本 changelist 改动日记(只记”哪个属性变了”,不记值),为每个连接备一份 FRepState 收件档案(上次发到哪)。只在变化时发差值,首帧发全量,客户端收完触发 OnRep 通知。
类比实验室:公司周报抄送
服务器每周给所有分公司(连接)发周报:周报只写”这周改了什么”(changelist),
不重发全文;新分公司入职第一周收到”全部历史”(首帧全量);分公司收到周报后
通知各科室(CallRepNotifies 触发 OnRep)。只发差异,是带宽的生命线。
它在这条流水线里的位置:第 5 章——“⑤ 序列化出包”的前半(状态部分)。
2.4 RPC 架构 = 电话
定义:远程函数调用——ProcessRemoteFunction 是总机:先查”这个号能不能打”(销毁/撕裂/拦截),再分流 Iris 新线路或 legacy 老线路;四种线路(Server 上行 / Client 下行 / Multicast 广播)+ Reliable 可靠修饰。Server RPC 在服务器本地调用时直接本地执行(不绕电话线)。
类比实验室:电话与总机
你拨号(调用 RPC)→ 总机接线员(ProcessRemoteFunction)查三件事:
① 这个号存在吗(Actor 活着吗)② 这个号打得通吗(Channel 开了吗,没开先”强制复制”
把 Actor 通道建起来)③ 走哪条线(本地直拨 / 专线 / 广播)。电话可以挂
(不可靠 RPC 丢了就算了)也可以要求回执(bReliable 可靠 RPC 保证送达)。
它在这条流水线里的位置:第 6 章——“⑤ 序列化出包”的后半(事件部分)。
2.5 分发架构 = 分区快递员
定义:复制决策体系——服务器每帧跑四步决策链(准备连接 → 按频率筛候选 → 按优先级排序 → 按相关性派件);Replication Graph 把 O(N×M) 挨个检查换成预建的空间网格(每个 viewer 只查自己所在格子);不动的 Actor 休眠(Dormancy)停止派件,动了再唤醒。
类比实验室:分区快递员
传统方式:每个快递员每天挨家挨户问”这家人要不要收件”(O(N×M))。
RepGraph 方式:城市划成网格,每个快递员只负责自己格子里的住户(空间网格),
长期没快递的住户标记”休眠”(Dormancy)——不问了,有包裹了再叫醒(FlushNetDormancy)。
它在这条流水线里的位置:第 7 章——“④ 复制决策”整块。
2.6 网关架构 = 门卫与换乘站
定义:入口与旅程体系——门卫是 GameMode(PreLogin → Login → PostLogin 验票发门卡),带外专线是 Beacon(游戏服务器与匹配/大厅服务的旁路连接),大巴换乘是 SeamlessTravel(无缝换图),进门后 PlayerController 是玩家在服务器上的替身,角色移动靠先斩后奏(客户端预测 + 服务器纠偏)。
类比实验室:机场航站楼
安检(StatelessConnect 握手)→ 值机(GameMode Login)→ 登机口(PlayerController 生成)
→ 转机(SeamlessTravel 无缝换图)→ 飞机晚点纠偏(客户端预测被服务器纠正)。
还有一条”员工通道”(Beacon):机场与航空公司内部系统之间的专线,不走旅客流程。
它在这条流水线里的位置:第 8 章——从”连上服务器”到”进游戏玩起来”的全部旅程。
2.7 六架构串起来:一帧的旅程 + 落点预告
对照图 F1 走一遍:客户端进门(网关,Ch8)→ 每帧收包拆包(网络+通信,Ch3/4)→ 游戏逻辑(Ch1)→ 服务器决策发什么(分发,Ch7)→ 序列化状态与事件(同步+RPC,Ch5/6)→ 出厂(通信+网络,Ch4/3)→ 客户端应用状态触发 OnRep(Ch5)→ ACK 与输入回流(Ch3 拥塞控制)。
| 架构 | 类比 | 核心类/函数 | 章节 |
|---|---|---|---|
| 网络 | 邮局体系 | UNetDriver / UNetConnection / UChannel / FNetPacketNotify | Ch3 |
| 通信 | 安检流水线 | FPacketHandler / HandlerComponent / StatelessConnectHandlerComponent | Ch4 |
| 同步 | 信件抄送 | FRepLayout / FRepChangelistState / FRepState / CallRepNotifies | Ch5 |
| RPC | 电话 | ProcessRemoteFunction / FObjectReplicator / QueueRemoteFunctionBunch | Ch6 |
| 分发 | 分区快递员 | ServerReplicateActors / UReplicationGraph / Dormancy | Ch7 |
| 网关 | 门卫与换乘站 | AGameModeBase / AOnlineBeacon / SeamlessTravel / 客户端预测 | Ch8 |
第 3 章 网络架构:连接与通道
TL;DR|UE 网络的骨架:NetDriver=邮局总局管着所有 Connection(邮路),每条邮路上
若干 Channel(邮袋),数据以 Bunch(信件) 为单位流动。客户端连服务器要过
六关握手(Hello→Challenge→Login→Welcome→Netspeed→Join);之后每条邮路用
14 位包序号 + ACK 历史位图保证可靠,用令牌桶限速。
3.1 UNetDriver:邮局总局
UNetDriver(NetDriver.h:809)是网络的总管家,头文件自带官方架构文档(:34-324 的注释)。核心职责:
1 | // NetDriver.h 头注释(节选翻译): |
- 连接管理:服务端
ClientConnections(每玩家一条)+FConnectionMap(IP→连接,:980);客户端单个ServerConnection(:970); - 通道工厂:
ChannelDefinitions/ChannelDefinitionMap(可配置通道类型表,:1038-1044,来自 BaseEngine.ini 的+ChannelDefinitions); - 五个职责虚函数(贯穿全书的钩子):
InitConnect/InitListen—— 客户端/服务端启动;TickDispatch—— 收(每帧最早);TickFlush—— 发(每帧最晚,内部 Iris 优先否则 ServerReplicateActors);ServerReplicateActors—— 复制决策(第 7 章);ProcessRemoteFunction—— RPC 总机(第 6 章)。
3.2 连接:UNetConnection
UNetConnection(NetConnection.h:283,继承 UPlayer——连接天然关联玩家)是一条端到端邮路。核心成员(:283-380 区域):
| 成员 | 职责 |
|---|---|
Channels[] |
通道表(每槽一个 UChannel,槽号 = ChIndex) |
OutReliable[] / InReliable[] |
每通道的可靠序列计数 |
PacketNotify |
包级可靠/ACK(FNetPacketNotify) |
SendBuffer |
发送缓冲(攒包) |
PackageMap |
对象↔NetGUID 映射 |
Handler |
PacketHandler 流水线(第 4 章) |
QueuedBits |
令牌桶(带宽限速) |
InitBase(NetConnection.cpp:488)里创建 UPackageMapClient 并 InitHandler()——一条连接建好时,网络身份(PackageMap)与通信管道(Handler)同时就位。
3.3 通道体系:UChannel
UChannel(Channel.h:63)是双向通道基类:InRec/OutRec 可靠收/发队列、InPartialBunch 部分束重组缓冲。
通道槽位(EChannelType,Channel.h:25-36;注册于 BaseEngine.ini:1841-1844):
| ChIndex | 通道 | 谁开 | 用途 |
|---|---|---|---|
| 0 | Control | 客户端开(bClientOpen) | 握手与控制消息(NMT_*) |
| 1 | Voice | 双方 | VoIP 语音 |
| 2 | DataStream | 双方 | Iris 数据流(本 fork 特性) |
| 3+ | Actor | 服务器开(bServerOpen) | 每个被复制 Actor 一个,动态分配 |
CHTYPE_MAX = 8(本 fork,上游 7)——多出的 1 位为 Iris 预留。Actor 通道是服务器主动开的(服务器决定谁值得复制),这呼应第 7 章的”分发架构”。
3.4 连接状态机与握手协议
连接状态(EConnectionState,NetConnection.h:94-101):USOCK_Invalid → Closed → Pending → Open → Closing。握手期间处于 Pending,完成后 Open。
游戏层握手六关(消息定义 DataChannel.h:173-248,官方流程注释 NetDriver.h:80-183):
客户端(UPendingNetGame) 服务器(UWorld::NotifyControlMessage)
│ │
│ ① NMT_Hello(版本/特性位/加密token) │
│ ──────────────────────────────────────▶ │
│ │ 版本/特性校验
│ ② NMT_Challenge(防重放 cookie) │
│ ◀────────────────────────────────────── │
│ ③ NMT_Login(URL/玩家ID/平台) │
│ ──────────────────────────────────────▶ │
│ │ GameMode::PreLogin → WelcomePlayer
│ ④ NMT_Welcome(地图名/游戏名) │
│ ◀────────────────────────────────────── │
│ ⑤ NMT_Netspeed(上报带宽) │
│ ──────────────────────────────────────▶ │
│ 加载地图 → TravelCompleted │
│ ⑥ NMT_Join │
│ ──────────────────────────────────────▶ │
│ │ SpawnPlayActor → 生成 PlayerController
│ │ → 进入 Actor 通道复制阶段
避坑:这里有两层握手,别混:① 底层 StatelessConnect 无状态握手(第 4 章,
cookie 挑战应答防放大攻击)在 PacketHandler 里;② 上层游戏层握手(本节的 NMT_* 六关)
走 ControlChannel。老教程经常把两层混为一谈——记住”先过安检(无状态握手),再走门卫
(游戏层握手)”。
服务端用 EClientLoginState(NetConnection.h:116-155)+ IsClientMsgTypeValid 校验消息顺序——客户端不能跳关(没 Hello 就 Login 直接拒绝)。
3.5 包级可靠性:14 位序号 + ACK 历史
UE 用 UDP,可靠性自己造。FNetPacketNotify::WriteHeader(NetPacketNotify.cpp:203)的打包头:
UDP 数据报(packet) ┌──────────┬──────────┬──────────┬──────────────────────┬──────────────┐ │ Seq 14bit │ AckedSeq 14bit │ HistWC 4bit │ ACK 历史位图(每 word 16 位,1~16 word)│ 数据(bunch 流)│ └──────────┴──────────┴──────────┴──────────────────────┴──────────────┘ 本包序号 确认到哪号 历史 word 数 哪些包被确认(滑动窗口) (乱序检测用) (丢包检测用)
- Seq(14 位):本包序号,接收方检测乱序/重复(配合
PacketOrderCache乱序缓存修正,net.DoPacketOrderCorrection); - AckedSeq + 历史位图:告诉对端”你的包我收到哪了、哪些丢了”——丢包由发送方重传可靠数据;
- 另有扩展包头:1 bit 是否有 JitterClockTime + 10 bit 抖动时间、1 bit 服务端帧耗时(供 ping 计算)。
束(Bunch)层可靠性:FOutBunch/FInBunch(DataBunch.h:29-204)带 bReliable(可靠束,保证送达且有序)、bPartial(大束分片)等标志。部分束重组在 ReceivedNextBunch(DataChannel.cpp:769-948)——可靠束必须连续序号到达(ChSequence),不可靠束可以跳。
3.6 带宽管理:令牌桶与自适应频率
令牌桶(NetConnection.cpp:5110-5145):每帧按 NetSpeed(默认 2600 bps,下限 1800,InitBase 按 URL LAN/Internet 选项)恢复配额 DeltaBits = NetSpeed * DeltaTime * 8;发包时 QueuedBits += 包字节数*8;IsNetReady() 检查饱和——饱和时服务器停止继续发(bIgnoreSaturation 参数)。
自适应更新频率:FNetworkObjectInfo(NetworkObjectList.h:34-109)的 OptimalNetUpdateDelta 根据实际发包间隔动态调整(net.UseAdaptiveNetUpdateFrequency)——不动的 Actor 自动降频。
拥塞控制(本 fork 已含):FNetworkCongestionControl(TrafficControl.h:78-98)基于 BDP(带宽×RTT) 计算在途字节上限,net.EnableCongestionControl(默认 0)开启后由 IsReadyToSend 门控发包。
3.7 源码考古:NetDriver 骨架与 TickFlush 的帧位置
源码考古:想看真东西的人进。
① 帧循环锚点,
LevelTick.cpp:1926-1938:
1
2
3
4 // Update net and flush networking.
BroadcastPreTickFlush(RealDeltaSeconds);
BroadcastTickFlush(RealDeltaSeconds); // note: undilated time is being used here
BroadcastPostTickFlush(RealDeltaSeconds);这段代码在干什么:
UWorld::Tick在所有 Actor Tick 组跑完之后广播 TickFlush——UNetDriver::InitBase(NetDriver.cpp:2434-2435)把自己绑到这些事件上。收包(TickDispatch)
在帧前、发包(TickFlush)在帧后——服务器总是”先看输入、再想逻辑、后发真相”。② TickFlush 的双路径,
NetDriver.cpp:1097-1159:
1
2
3
4
5
6
7
8
9
10
11
12
13 void UNetDriver::TickFlush(float DeltaSeconds)
{
...
if (ReplicationSystem)
{
InternalIrisUpdateTransactional(DeltaSeconds); // Iris 新复制系统(本 fork 已带骨架)
}
else if (ClientConnections.Num() > 0)
{
ServerReplicateActors(DeltaSeconds); // legacy 复制(第 5/7 章主角)
}
...
}这段代码在干什么:Iris 优先,legacy 兜底——
ReplicationSystem存在就走新系统,
否则走ServerReplicateActors。本 fork 的 Iris 是”骨架已装、默认不启用”(见第 13 章),
本文后面所有机制章讲的都是 legacy 路径——读懂 legacy 是读懂 UE 网络的必修课。③ 通道注册表,BaseEngine.ini:1841-1844(节选):
1
2
3
4 +ChannelDefinitions=(ChannelName=Control, ClassName=/Script/Engine.ControlChannel, StaticChannelIndex=0, bServerOpen=false, bClientOpen=true, ...)
+ChannelDefinitions=(ChannelName=Voice, ClassName=/Script/Engine.VoiceChannel, StaticChannelIndex=1, ...)
+ChannelDefinitions=(ChannelName=DataStream, ClassName=/Script/Engine.DataStreamChannel, StaticChannelIndex=2, ...)
+ChannelDefinitions=(ChannelName=Actor, ClassName=/Script/Engine.ActorChannel, StaticChannelIndex=-1, bServerOpen=true, bClientOpen=false, ...)这段代码在干什么:通道配置化——类型、槽位、谁主动开都在 ini 里声明,引擎启动时
由LoadChannelDefinitions(NetDriver.cpp:801)建成映射表。注意Actor通道StaticChannelIndex=-1(动态分配)且bServerOpen=true(服务器主动开)——
“服务器决定谁值得被复制”从这里就定死了。
第 4 章 通信架构:PacketHandler 与包格式
TL;DR|通信架构管”数据怎么编”:每个连接一条 PacketHandler 安检流水线——出包时
压缩、加密、打包;进包时验真、解密、重组。无状态握手(StatelessConnect)用 cookie
挑战应答”验明正身”,防 UDP 放大攻击;DDoS 检测在外围兜底。可靠性不在这里——
它在连接层(第 3.5 章),别被旧教程带偏。
4.1 分层模型:数据报 → 束 → 消息 → 游戏语义
| 层 | 单位 | 谁持有 | 可靠性 |
|---|---|---|---|
| 传输 | UDP 数据报(Packet) | Socket / UNetConnection | 无(UDP) |
| 通信 | PacketHandler 组件链 | UNetConnection::Handler | 部分组件可加(已弃用) |
| 通道 | Bunch(束) | UChannel(InRec/OutRec) | 束级可靠(bReliable) |
| 控制 | NMT_* 控制消息 | UControlChannel | 可靠 |
| 游戏 | 属性/RPC | UActorChannel / FObjectReplicator | 属性天然可靠、RPC 可选 |
关键理解:UE 用 UDP 是因为游戏数据”最新的才重要”(旧位置包可以丢),但”命令类”数据(开火、切换状态)必须可靠——所以可靠性做在束层而不是包层:FOutPacketTraits(数据报元数据)与 FOutBunch(束标志)是两套东西。
4.2 PacketHandler:安检流水线
FPacketHandler(PacketHandler.h)是发送/接收两向的组件流水线,每个连接一个。组件基类 HandlerComponent(:739)的四个虚接口:
| 接口 | 方向 | 含义 |
|---|---|---|
Incoming / Outgoing |
连接内 | 正常收发数据(握手完成后) |
IncomingConnectionless / OutgoingConnectionless |
连接外 | 无连接数据(握手前的包!) |
关键机制:握手完成前(组件 RequiresHandshake),所有包缓冲在 BufferedPacket 里,FPacketHandlerHandshakeComplete 委托触发后才放行——没验明正身,数据不进门。
配置装配:UPacketHandlerProfileConfig(PacketHandlerProfileConfig.h,PerObjectConfig)按 NetDriver 从 ini 读取组件列表([<NetDriverName> PacketHandlerProfileConfig] Components=...)。
避坑:老教程里的
FPacketHandlerProfileManager(4.x 静态管理器)在 5.x 已删除,
被上面的UPacketHandlerProfileConfig取代。见到旧类名按”旧资料”处理。
4.3 无状态握手:验明正身
StatelessConnectHandlerComponent(StatelessConnectHandlerComponent.cpp)是连接建立的第一道门——基于 DTLS 思想的挑战应答:
客户端 服务器(无状态——不记录任何连接!) │ ① InitialPacket(请求连接) │ │ ───────────────────────────────────▶ │ │ │ 服务器不分配内存,只发 cookie │ ② Challenge(cookie = HMAC(客户端IP+secret))│ │ ◀─────────────────────────────────── │ │ ③ Response(回显 cookie) │ │ ───────────────────────────────────▶ │ │ │ 校验 cookie 通过 → 才创建连接 │ ④ Ack │ │ ◀─────────────────────────────────── │ │ 握手完成 → 数据放行(BufferedPacket 清空)│
为什么”无状态”:服务器在验证通过前不分配任何连接资源——攻击者伪造源 IP 发一堆连接请求,服务器只回 cookie 不回内存(64 字节 secret + 20 字节 cookie),UDP 放大攻击(DDos)被掐死在门口。握手版本到 v4(EHandshakeVersion,含 SessionClientId / CL 版本早期拒绝)。
4.4 加密与压缩
- FEncryptionComponent(
EncryptionComponent.h):加密组件抽象,EnableEncryption/DisableEncryption/SetEncryptionData;驱动流程在控制通道的NMT_EncryptionAck消息; - Oodle 压缩插件:本 fork 开关是
net.OodleServerEnableMode/net.OodleClientEnableMode(OodleNetworkHandlerComponent.cpp:169-174,默认空串)+ ini 段bEnableOodle; - 可靠性组件已弃用:
ReliabilityHandlerComponent带UE_DEPRECATED(5.3)标记——可靠性由束层与连接层承担,别在 PacketHandler 里找可靠传输。
避坑:上游 UE 5.x 曾宣传的
r.ServerUseOodleCVar 在本 fork 不存在(grep 全库无匹配)——
Oodle 配置一律走net.OodleServerEnableMode/ClientEnableMode+ ini。
4.5 安全兜底:DDoS 与 RPC 滥用检测
Net/Core 模块提供外围防护(Net/Core/Misc/DDoSDetection.h):按时间窗口统计包速率,超限丢包/断连;RPCDoSDetection 专门防”客户端疯狂调 Server RPC”;违规触发 NMT_SecurityViolation 控制消息。
4.6 源码考古:PacketHandler 的骨架
源码考古:想看真东西的人进。
PacketHandler.h:739 附近(HandlerComponent 基类,节选):
1
2
3
4
5
6
7
8
9
10
11 class HandlerComponent
{
public:
// 以下四个虚接口是组件挂进流水线的"接口位"
virtual void Incoming(FBitReader& Packet) = 0; // 入向:收到数据
virtual void Outgoing(FBitWriter& Packet) = 0; // 出向:发出数据
virtual void IncomingConnectionless(FBitReader& Packet) = 0; // 无连接入向(握手期)
virtual void OutgoingConnectionless(FBitWriter& Packet) = 0; // 无连接出向(握手期)
virtual bool RequiresHandshake() const { return false; } // 是否需要握手完成才放行
...
};这段代码在干什么:组件 = 实现了四个接口的”传送带工位”——入向加工(解密/验签/解压),
出向加工(压缩/加密/打包)。RequiresHandshake()返回 true 的组件(如加密)会让
PacketHandler 在握手完成前缓冲所有数据——这就是”没验明正身不进门”的实现。
组件链顺序由 Profile 配置决定,加工顺序固定。② 无状态握手的 cookie,
StatelessConnectHandlerComponent.cpp(要点):
1
2
3 // 服务器不保存握手状态,一切验证都基于 cookie 本身:
// cookie = HMAC(客户端地址 + ServerSecret),客户端必须原样回显
// 服务器校验通过后才真正创建连接(此前零资源分配)这段代码在干什么:防放大攻击的全部秘密——验证前零状态、零分配。攻击者伪造一万个
源 IP 发 InitialPacket,服务器回一万个 cookie(每包 ~20 字节),但不创建任何连接对象,
攻击者的资源消耗 = 服务器消耗,放大攻击失效。这是 UE 网络的第一道安全闸。
第 5 章 同步架构:属性复制
TL;DR|同步架构管”状态怎么抄送”:服务器为每个类型建一张 FRepLayout(类型蓝图)、
为每个对象记一本 changelist 改动日记、为每个连接备一份 FRepState 收件档案——
只在属性变化时发差值,首帧发全量,客户端收完触发 OnRep 通知。
类比:周报抄送——只写”这周改了什么”,不重发全文。
5.1 问题与心智模型
服务器的状态要复制给所有客户端,三个问题:复制什么(类型知道有哪些属性)、复制哪些(对象知道哪些变了)、复制给谁(连接知道上次发到哪)。FRepLayout 体系用三张表回答。
5.2 三张表(本章核心)
FRepLayout(每类型一份,全局共享) FRepChangelistState(每对象一份,全局共享)
┌────────────────────────────┐ ┌────────────────────────────┐
│ Parents:顶层属性 │ │ ChangeHistory[64]:环形日记 │
│ Health(COND_None) │ │ [0]: {Health, Ammo} │
│ Ammo(COND_InitialOnly) │ │ [1]: {Health} │ ← 只记"哪个
│ Inventory(动态数组) │ │ [2]: {} │ 属性变了"
│ Cmds:叶子命令(展开子树) │ │ StaticBuffer:shadow 数据 │
│ BaseHandleToCmdIndex: │ │ CompareIndex:本帧比较标记 │
│ handle → cmd 索引表 │ └────────────────────────────┘
└────────────────────────────┘ │ 每连接从
│ HistoryEnd 拉取
FRepState(每对象 × 每连接一份) ▼
┌────────────────────────────┐ ┌────────────────────────────┐
│ FSendingRepState: │ │ FReceivingRepState: │
│ LastChangelistIndex │ │ StaticBuffer(shadow) │
│ LastCompareIndex │ │ RepNotifies(待触发OnRep)│
│ ChangeHistory[32] │ │ GuidReferencesMap │
└────────────────────────────┘ └────────────────────────────┘
- FRepLayout(
RepLayout.h:1880-1909):属性树——Parents是 DOREPLIFETIME 注册的顶层属性,Cmds是展开后的叶子命令(结构体成员递归展开、动态数组按元素展开、有原生 NetSerialize 的结构体只生成单条命令),BaseHandleToCmdIndex是”属性句柄 → 命令索引”的译码表。每类型一份,所有对象共享(FRepLayout::CreateFromClass/CreateFromStruct/CreateFromFunction,:1189); - FRepChangelistState(:433):每对象一份、跨连接共享——
ChangeHistory[64]环形 changelist 缓冲(只记 handle 集合)、StaticBuffer(上次比较的 shadow 数据)、CompareIndex(本帧比较过的标记,防重复比较); - FRepState(:671):每对象 × 每连接——发送侧(
FSendingRepState:LastChangelistIndex、ChangeHistory[32])与接收侧(FReceivingRepState:StaticBuffer、RepNotifies)。
优化机制:FReplicationChangelistMgr(:501)用 LastReplicationFrame 保证同帧多连接只比较一次(第一个连接比较,其余连接从共享 changelist 拉取)——这就是”服务器 100 连接复制同一个 Actor 只花一次比较成本”的由来。
5.3 服务器侧入口链:三条跳板
1 | UActorChannel::ReplicateActor(DataChannel.cpp:3599) ← 通道级:打包一个 Actor 的全部更新 |
ReplicateProperties_r(DataReplication.cpp:2007)里两件关键事:
1 | const ERepLayoutResult UpdateResult = FNetSerializeCB::UpdateChangelistMgr( |
5.4 变化追踪:只记 handle,不记值
FRepLayout::CompareProperties(RepLayout.cpp:1777)——头注释点破关键(RepLayout.h:1142-1148):changelist 只记录”哪些 handle 变了”,不含值(值在 shadow 里,发的时候才读)。逐属性比较在 CompareParentProperties(:1514):
- PushModel 分支(
WITH_PUSH_MODEL):只遍历MARK_PROPERTY_DIRTY标记过的属性(游戏代码主动声明”我改了”),不逐字节比较——省 CPU 但要求代码纪律; - 非 PushModel:逐属性
IsPropertyDirty比较 shadow 与当前值——服务器每帧全量比较所有复制属性(CPU 成本换代码简单)。
首帧全量:UActorChannel::ReplicateActor 中 RepFlags.bNetInitial = true(DataChannel.cpp:3800)→ bForceInitialDirty(:3825)→ 所有属性强制入 changelist;COND_InitialOnly 属性只在首帧比较(之后不再比较)。
避坑:FRepChangedPropertyTracker 的位置变了——它在 NetCore 模块
(Net/Core/PropertyConditions/RepChangedPropertyTracker.h),且已退化为
“条件激活状态跟踪器”(脏属性跟踪改由 changelist 承担)。旧教程(5.0 时代)
把脏跟踪讲成它的职责——以本仓库为准。
5.5 序列化与共享:只发差异,还只序列化一次
SendProperties_r(RepLayout.cpp:2767)逐属性写入:
1 | // This property changed, so send it |
共享序列化(Shared Serialization):同帧多个连接复制同一个 Actor 时,第一个连接序列化后的字节缓存复用(GNumSharedSerializationHit 统计命中),后续连接直接拷贝共享缓冲——序列化成本 O(1) 次/帧/对象,不管多少连接。
接收侧:FRepLayout::ReceiveProperties_r(:3789)按流中内嵌的 handle 逐条反序列化到对象内存,同时对比 shadow,把变化的属性压入 RepState->RepNotifies(供 OnRep 触发)。
5.6 自定义序列化设施
NetSerialization.h 提供官方序列化工具(:72-127 头注释给出模板约定):
| 设施 | 用途 |
|---|---|
NetSerialize / NetDeltaSerialize |
自定义序列化(TStructOpsTypeTraits::WithNetSerializer=true 声明) |
FVector_NetQuantize* |
位置量化(例:FVector_NetQuantize10 = 10 位尾数 ≈ 0.1 精度) |
SafeNetSerializeTArray_* |
带长度上限的数组序列化(防恶意客户端撑爆内存) |
FFastArraySerializer |
数组 delta 同步:只发增删改的元素,不发整个数组(NetDeltaSerialize 驱动) |
类比实验室:快递称重压缩
位置数据是”大件”——FVector_NetQuantize 像真空压缩袋(32 位 float → 量化定点,
精度换体积);FFastArraySerializer 像”只寄新买的书,不重寄整柜书”(数组 delta)。
这两个是带宽优化的第一梯队武器(第 9 章专题再用)。
5.7 客户端接收链:从包到 OnRep
1 | ReceivedBunch(DataChannel.cpp:3111) |
GUID 未映射:属性引用了客户端还没加载的对象(NetGUID 未解析)→ 进 UpdateUnmappedObjects 队列(DataReplication.cpp:2461),等映射后补 PostNetReceive(:2525)——**复制可以”迟到但不缺席”**。
5.8 源码考古:三张表的关键结构
源码考古:想看真东西的人进。
① changelist 只记 handle,
RepLayout.h:1142-1148(头注释):
1
2
3 // 改动追踪器(FRepChangedPropertyTracker)与 changelist 的职责划分:
// changelist 只记录"哪些属性句柄在本帧发生了变化",
// 不记录变化前后的值——值在 shadow 数据里,发送时读取。这段代码在干什么:注释直接回答”为什么带宽小”——日记只写”谁变了”,值不重复存。
一帧 100 个属性只有 3 个变了 → changelist 3 个 handle(每 handle ~1-2 字节),
发送时只序列化这 3 个属性的值。② 变化比较的 PushModel 分支,
RepLayout.cpp:1514-1638(要点):
1
2
3
4
5
6
7
8
9
10
11 if (bUsePushModel)
{
// PushModel:只检查游戏代码显式标记"脏"的属性(MARK_PROPERTY_DIRTY)
PushProperties = PushModelState->GetDirtyProperties();
...
}
else
{
// 非 PushModel:逐属性比较 shadow 与当前值(CPU 全量扫描)
CompareProperties_r(SharedParams, StackParams);
}这段代码在干什么:两种脏追踪策略的岔路口——PushModel 靠”主动申报”(省 CPU,贵在
代码纪律:每次改属性都要 MARK_PROPERTY_DIRTY),默认路径靠”被动比较”(省心,贵 CPU)。
大世界服务器属性多时 PushModel 收益显著(第 10 章专题)。③ OnRep 触发,
RepLayout.cpp:4661(要点):
1
2
3
4 // 对 RepNotifies 里每个变化的属性:
// 查属性的 RepNotifyFunc(UPROPERTY(ReplicatedUsing=OnRep_X) 声明的函数名)
// → Object->FindFunction(RepNotifyFunc) → ProcessEvent 调用
// 这就是客户端"收到属性变化后自动跑一段代码"的机制这段代码在干什么:
ReplicatedUsing=OnRep_Health的魔力来源——属性落到客户端后,
引擎查函数、调用它。OnRep 是客户端钩子(服务器自己改属性不触发 OnRep),
常用来做”属性变化后的表现”(血条更新、声音播放)。
第 6 章 RPC 架构:远程函数调用的全链路
TL;DR|RPC 是电话:四种线路(Server 上行 / Client 下行 / Multicast 广播)× Reliable
可靠修饰。总机是ProcessRemoteFunction:先查”能不能打”(销毁/撕裂/拦截),再分流
Iris 新线路或 legacy 老线路;参数按函数级 RepLayout 序列化,不可靠多播先攒批再限流发送。
Server RPC 在服务器本地调用时直接本地执行——不绕电话线。
6.1 RPC 与属性复制的分工
第 1.5 章的决策口诀:”状态用复制,事件用 RPC“。RPC 的声明方式:
1 | UFUNCTION(Server, Reliable) void ServerFireWeapon(); // 客户端→服务器(可靠) |
四种类型由 UFunction 的 FunctionFlags 决定:FUNC_NetServer | FUNC_NetClient | FUNC_NetMulticast | FUNC_NetReliable。
6.2 四种类型矩阵
| Server(上行) | Client(下行) | Multicast(广播) | |
|---|---|---|---|
| 方向 | 客户端 → 服务器 | 服务器 → 拥有者 | 服务器 → 所有相关客户端 |
| 谁执行 | 服务器 | 拥有该 Actor 的客户端 | 所有客户端 + 服务器本地 |
| 典型用途 | 移动上报、开火请求 | 击杀回执、UI 通知 | 爆炸、开门动画 |
| 权限校验 | 服务器验证(防作弊) | 拥有者校验 | 无(只收) |
| 本地行为 | 服务器本地调用 = 直接执行 | 服务器调用不本地执行 | 服务器调用立即本地执行 |
关键特殊性:Multicast 在服务器上立即本地执行(调用者端直接 ProcessEvent,不绕网络)——服务器不需要”广播给自己”;Client RPC 只发给拥有者连接(Actor->GetNetConnection())。
6.3 发送链:ProcessRemoteFunction 的总机逻辑
UNetDriver::ProcessRemoteFunction(NetDriver.cpp:8053)是 RPC 的唯一总机,顺序检查:
1 | ProcessRemoteFunction(每步处理掉就 return) |
通道级发送 ProcessRemoteFunctionForChannelPrivate(:3152)的四个关键动作:
- Channel 未开 → 先强制复制(:3179-3220):
Ch->SetForcedSerializeFromRPC(true); Ch->ReplicateActor();—— RPC 能”逼”服务器先开 Actor 通道(连 Actor 都没复制过就收到它的 RPC?不可能——所以先复制一遍,把通道建起来); - 可靠性:
Bunch.bReliable = (Function->FunctionFlags & FUNC_NetReliable)(:3236); - 参数序列化:
GetFunctionRepLayout(Function)->SendPropertiesForRPC(...)(:3303)——函数参数也走 RepLayout(每个 UFunction 一份函数级布局); - 队列 vs 立即发(:3326-3341):不可靠的 Multicast 走
QueueRemoteFunctionBunch(攒批),其余直接Ch->SendBunch。
6.4 Multicast 排队与限流
FObjectReplicator::QueueRemoteFunctionBunch(DataReplication.cpp:2293):不可靠多播 RPC 攒进 RemoteFunctions 队列,下次属性复制时随 bunch 发出;限流 net.MaxRPCPerNetUpdate(DataReplication.cpp:39,默认值写作时核实)——同一 RPC 每帧调用太多次直接丢弃多余的(”开火音效每秒 100 次?发 20 次够了”)。
避坑:UE 5.x 已没有独立的 UFunctionChannel(老教程的核心概念)——RPC 直接在
ActorChannel 的属性流里传输(ReadFieldHeaderAndPayload 循环里用 Cast
功能没变,架构变了:**RPC 是”特殊字段”,不是”特殊通道”**。
6.5 接收链:从束到执行
1 | FObjectReplicator::ReceivedBunch(DataReplication.cpp:984) |
安全边界:客户端收到 FUNC_NetServer 的 RPC 直接拒绝(:1351-1361)——Server RPC 只能在服务器执行,这保证了”客户端只能建议,不能命令”。
6.6 陷阱与边界
- RPC 先于属性到达:客户端收到”捡起武器”的 RPC 时武器对象可能还没复制过来(NetGUID 未映射)——引擎的解法是延迟队列(PendingLocalRPCs),映射后补执行;
- 不可靠 RPC 会丢:不修饰 bReliable 的 RPC 丢了就丢了(游戏逻辑不能依赖它);”命令类”(换弹、交易)必须 Reliable;
- Server RPC 的验证:
Server_Validate函数(WithValidation)让服务器在执行前先验证参数——防作弊的第一道闸(”你说你开了一枪,射程内真有敌人吗?”)。
6.7 源码考古:ProcessRemoteFunction 全分支
源码考古:想看真东西的人进。
NetDriver.cpp:8053-8198(要点结构):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 void UNetDriver::ProcessRemoteFunction(...)
{
// ① 前置检查
if (Actor->IsActorBeingDestroyed()) return;
if (Actor->GetTearOff()) return;
if (SendRPCDel && !SendRPCDel(RPCCallInfo, ...)) return;
// ② Iris 新路径
if (ReplicationSystem && ReplicationSystem->SendRPC(...)) { return; }
// ③ RepDriver(RepGraph)拦截
if (ReplicationDriver && ReplicationDriver->ProcessRemoteFunction(...)) { return; }
// ④ Multicast 广播(服务器→所有相关客户端)
if (Function->FunctionFlags & FUNC_NetMulticast)
{
for (UNetConnection* Connection : ClientConnections)
{
if (Actor->IsNetRelevantFor(...) || (bReliable && Channel->OpenAcked))
{
InternalProcessRemoteFunctionPrivate(...); // 每连接发送
}
}
return;
}
// ⑤ 单播(服务器→拥有者 / 客户端→服务器)
UNetConnection* Connection = Actor->GetNetConnection();
InternalProcessRemoteFunction(...);
}这段代码在干什么:RPC 的”总机接线表”——五步分流。注意 ④ 的过滤条件:
不可靠多播只发给相关连接,可靠多播可以发给”通道还开着但已不相关”的连接
(保证”开门”这类命令不漏发)。SendRPCDel委托是游戏代码的拦截点
(做自定义 RPC 过滤/统计)。② Channel 未开先强制复制(:3179-3220,要点):
1
2
3
4
5
6
7 if (Channel == nullptr)
{
// 通道不存在(Actor 还没复制过)→ 强制先复制一次,把通道建起来
Ch->SetForcedSerializeFromRPC(true);
Ch->ReplicateActor();
...
}这段代码在干什么:RPC 的”先遣队”——服务器要给客户端发 RPC,但对应 Actor 的通道
还不存在(比如刚生成的对象立刻收到 RPC)——引擎强制复制一遍把通道建好,RPC 随后
到达。这就是”RPC 能创建通道”的机制,也是为什么 RPC 参数里引用的对象必须是
“可能已经复制过”的(否则 NetGUID 没映射,参数进延迟队列)。
第 7 章 分发架构:决策链、Replication Graph 与 Dormancy
TL;DR|分发架构回答”谁该收到什么、什么时候“:服务器每帧跑四步决策链(准备连接 →
按频率筛候选 → 按优先级排序 → 按相关性派件)。Replication Graph 把 O(N×M) 挨个检查换成
预建的空间网格(每个 viewer 只查自己格子);休眠(Dormancy)让不动的 Actor 停止派件,
动了再唤醒。类比:分区快递员——只派自己区的件,休眠包裹睡仓库。
7.1 问题定义:决策与序列化的分工
第 5 章解决”怎么编”(序列化),本章解决”给谁发、什么时候发“(决策)。Legacy 路径的决策在 ServerReplicateActors 里,RepGraph 是它的可插拔替代品——两者二选一(ReplicationDriver 接口统一)。
7.2 Legacy 决策链:四步
UNetDriver::ServerReplicateActors(NetDriver.cpp:6205):
ServerReplicateActors(每帧,服务器)
│
├─ ① PrepConnections(:5126)
│ 按 NetClientTicksPerSecond 决定本帧 tick 哪些连接(分摊)
│ 没 tick 到的连接:候选 Actor 标记 bPendingNetUpdate(下帧强制补发)
│
├─ ② BuildConsiderList(:5231)
│ 遍历活跃 Actor:NetUpdateFrequency 门控(没到 NextUpdateTime 跳过)
│ → CallPreReplication(游戏代码预处理钩子)
│ → 初始休眠的 Actor 直接移除
│
├─ ③ PrioritizeActors(:5456)
│ 每个 Actor × 每个连接算 Priority(GetNetPriority:距离/朝向/重要度)
│ → 排序(越重要越先发)
│
└─ ④ ProcessPrioritizedActorsRange(:5615)
逐 Actor × 逐连接:
IsActorRelevantToConnection(:5681)相关性判定(距离/可见/bAlwaysRelevant)
→ 无通道则创建(:5722 CreateChannelByName(NAME_Actor))
→ Channel->ReplicateActor()(:5758,进入第 5 章序列化)
→ 带宽饱和(IsNetReady)→ 停止本帧继续发(ForceNetUpdate 下帧补)
→ 不再相关 → Channel->Close(Relevancy/TearOff)
相关性判定(AActor::IsNetRelevantFor,ActorReplication.cpp:388)的优先级:bAlwaysRelevant / 是 ViewTarget 或拥有者 / bNetUseOwnerRelevancy 委托 / bOnlyRelevantToOwner → 最后按距离(IsWithinNetRelevancyDistance,DistSq < GetNetCullDistanceSquared())。
带宽饱和:Connection->IsNetReady()(NetConnection.cpp:2731)检查令牌桶——饱和时停止继续发(这是”优先级排序”的意义:重要的一定先发出去)。
7.3 Replication Graph:分区快递员
动机:Legacy 每帧对每个 Actor × 每个连接做相关性检查 = **O(N×M)**——Fortnite 5 万 Actor × 100 连接 = 500 万次检查/帧。RepGraph 把”检查”变成”预建的数据结构查找“。
位置:插件(Engine/Plugins/Runtime/ReplicationGraph/,本 fork 引擎版已移除)。
类体系(ReplicationGraph.h):
| 节点类 | 职责 |
|---|---|
UReplicationGraphNode(:69) |
基类:NotifyAdd/RemoveNetworkActor + GatherActorListsForConnection |
UReplicationGraphNode_ActorList(:188) |
通用列表节点(手工分组用) |
UReplicationGraphNode_GridSpatialization2D(:579) |
2D 空间网格:动态/静态/休眠三张表 |
UReplicationGraphNode_GridCell(:535) |
每个格子一个(DynamicNode + DormancyNode) |
UReplicationGraphNode_DormancyNode(:485) |
格子休眠表 |
UReplicationGraphNode_AlwaysRelevant(:771) |
始终相关节点(PlayerController 等) |
UReplicationGraphNode_TearOff_ForConnection(:888) |
撕裂节点 |
空间网格分发(ReplicationGraph.cpp:6238):每个 viewer 算出自己所在格子 (Location - SpatialBias) / CellSize,只 gather 那个格子的 Actor 列表——远处格子的 Actor 根本不进考虑列表。格子 TTL 过期时通知客户端销毁休眠动态 Actor。
主循环(ReplicationGraph.cpp:1112 UReplicationGraph::ServerReplicateActors):
1 | PrepareForReplication → 每连接 GatherActorListsForConnection(全局节点+连接节点) |
帧驱动:ReplicationPeriodFrame = NetServerMaxTickRate / NetUpdateFrequency(:1083)——RepGraph 按帧而非时间调度,服务器 tick 率 30 时 NetUpdateFrequency 100 的 Actor 每 3 帧发一次。
避坑:RepGraph 头注释明说”用 RepGraph 时
IsNetRelevantFor/GetNetPriority
不再被使用“(ReplicationGraphTypes.h)——决策完全由图结构承担。
另外本 fork 的开关是 **Net.RepDriver.Enable**(NetDriver.cpp:7999),
上游的net.EnableReplicationGraph不存在。
7.4 Dormancy:休眠的 Actor 停止派件
五状态(EngineTypes.h:3645):DORM_Never(永不休眠)/ DORM_Awake(默认,随时可睡)/ DORM_DormantAll(全连接休眠)/ DORM_DormantPartial(按连接)/ DORM_Initial(初始休眠,等唤醒)。
Legacy 链路:
1 | ShouldActorGoDormant(NetDriver.cpp:5434):条件检查(休眠设置/无通道/时间) |
唤醒:AActor::FlushNetDormancy(Actor.cpp:3120)——DORM_Initial → DORM_DormantAll、重新注册、走 RepDriver 或 legacy 逐连接清休眠标志。**唤醒条件是”对象变了”**(游戏代码主动调 FlushNetDormancy,或 RepDriver 检测到属性脏)。
RepGraph 侧:bDormantOnConnection 优先跳过(:1523);DormancyNode 按需把休眠 Actor 拉回列表(ConditionalGatherDormantDynamicActors)。
7.5 启用与切换
1 | DefaultEngine.ini: |
Net.RepDriver.Enable=1(NetDriver.cpp:7999)+ 自定义 RepGraph 类 → 替换 legacy 决策链。
7.6 源码考古:决策链与休眠判定
源码考古:想看真东西的人进。
① 相关性 + 建通道,
NetDriver.cpp:5681-5758(要点):
1
2
3
4
5
6
7
8
9
10
11
12
13
14 const bool bIsRelevant = Actor->IsActorRelevantToConnection(...); // 相关性判定
...
if (bIsRelevant)
{
UActorChannel* Channel = ActorInfo->Channel;
if (Channel == nullptr)
{
// 相关但没通道 → 创建(这就是 Actor 通道动态分配的时机)
Channel = Connection->CreateChannelByName(NAME_Actor, EChannelCreateFlags::None);
Channel->SetChannelActor(Actor, ...);
}
Channel->ReplicateActor(...); // 进入第 5 章序列化
}
...这段代码在干什么:决策链的”派件动作”——相关 → 建通道 → 复制三步。
注意通道是服务器主动创建的:客户端永远不会主动申请 Actor 通道(它只能被动收)。
这也是”服务器权威”在分发层的体现。② 休眠判定,
NetDriver.cpp:5434-5560(要点):
1
2
3
4
5 if (IsActorDormant(ActorInfo, WeakConnection)) continue; // 已休眠(此连接)
if (ShouldActorGoDormant(Actor, Channel, ...))
{
Channel->StartBecomingDormant(); // 进入休眠流程(等最后一包发完)
}这段代码在干什么:休眠的两个判定点——已休眠的直接跳过(省掉相关性检查),
该休眠的开始休眠(等所有连接都收到最后一包后真正关通道)。Dormancy 是
“停止派件”的开关,能省的不只是带宽,还有每帧的检查成本。③ RepGraph 的网格 gather,
ReplicationGraph.cpp:6238-6290(要点):
1
2
3
4
5
6
7
8
9 for (const FNetViewer& CurViewer : Params.Viewers)
{
int32 CellX = (ClampedViewLoc.X - SpatialBias.X) / CellSize; // 格子坐标
int32 CellY = (ClampedViewLoc.Y - SpatialBias.Y) / CellSize;
if (UReplicationGraphNode_GridCell* CellNode = GetCell(CellX, CellY))
{
CellNode->GatherActorListsForConnection(Params); // 只 gather 自己格子!
}
}这段代码在干什么:O(N×M)→O(格子内 Actor × viewer) 的核心——每个 viewer 只查询
自己所在格子的 Actor 列表。网格大小(CellSize)是 RepGraph 最重要的调参:
格子太小 → 每帧跨格抖动(Actor 在边界反复进出);太大 → 网格退化回全量检查。
第 8 章 网关架构:登录、Beacon、旅行与预测
TL;DR|网关架构管”怎么进门、换乘、验票“:门卫是 GameMode(PreLogin→Login→PostLogin),
带外专线是 Beacon(游戏服务器与匹配/大厅服务的旁路连接),大巴换乘是 SeamlessTravel
(无缝换图)。进门后 PlayerController 是玩家在服务器上的替身,角色移动靠先斩后奏
(客户端预测 + 服务器纠偏)。注意:**UE 引擎层没有”网关服务器”**——入口职责分散在
GameMode/Beacon/Session/旅行四件套里。
8.1 网关的定位:引擎里没有”网关服务器”
搜索整个引擎:没有名为 Gateway 的服务器代码。UE 的”网关”是职责分散的入口体系:
| 职责 | 承担者 | 章节 |
|---|---|---|
| 验明正身(防放大攻击) | StatelessConnect 无状态握手 | Ch4 |
| 验票进门(游戏层登录) | GameMode / GameSession | 8.2 |
| 带外专线(与匹配/大厅通信) | Beacon | 8.3 |
| 找房间(会话匹配) | Session(插件化) | 8.4 |
| 换乘(换图/无缝旅行) | ServerTravel / SeamlessTravel | 8.5 |
| 进门后的身份 | PlayerController(替身) | 8.6 |
| 进门后的行为 | 客户端预测 + 纠偏 | 8.7 |
8.2 登录链:GameMode 验票
AGameModeBase(GameModeBase.h:47,头注释:”只在服务器上实例化,客户端不存在“)的四个登录函数(:286-338):
1 | 客户端 NMT_Login 到达(第 3.5 章第③关) |
AGameSession(GameSession.h:78/155/183):ApproveLogin(选项/唯一 ID 校验)、KickPlayer、RegisterServer(向在线服务注册)。
避坑:登录有两层:连接层握手(NMT_Hello→Join,第 3.5 章,
UWorld::NotifyControlMessage
处理)与游戏层登录(GameMode 四个函数,8.2 节)。写PreLogin时你处理的是
游戏层——连接层的版本校验早就在NMT_Hello时做完了。
8.3 Beacon:带外专线
AOnlineBeacon(OnlineBeacon.h:67,AActor + FNetworkNotify):游戏服务器与专用服务(匹配、大厅、QoS)之间的旁路连接——不走玩家登录流程。
Beacon 握手(DataChannel.h 内注释的官方流程):NMT_BeaconWelcome → NMT_BeaconJoin → NMT_BeaconAssignGUID → NMT_BeaconNetGUIDAck——Beacon 有自己独立的控制消息流。
Lobby 大厅范例(Engine/Plugins/Online/OnlineFramework/Source/Lobby/):ALobbyBeaconHost(:24)/ ALobbyBeaconClient(:58)/ ALobbyBeaconState(:171)——Epic 官方的”分房间/大厅”参考实现:玩家先进大厅(Beacon 连接),凑满人后一起旅行进游戏服务器。
8.4 Session:找房间(已插件化)
IOnlineSession 接口(Create/Find/Join/DestroySession)位于插件(OnlineSessionInterface.h);引擎侧 UOnlineSession(OnlineSession.h)已退化为空壳基类(StartOnlineSession 等均为空虚函数)。
避坑:旧教程(UE4 时代)以
UOnlineSession为主讲会话——现在会话功能全部在
OnlineSubsystem 插件里(Steam/EPIC/自定义平台各自实现)。引擎只管”给会话留接口”。
8.5 旅行与关卡分发:换乘与下车
- 硬旅行:
UWorld::ServerTravel(World.h:4226)——服务器换图,所有客户端断开重连(清空世界); - 无缝旅行:
SeamlessTravel(:4238)+FSeamlessTravelHandler(:194)——预加载新图、保留指定 Actor(玩家/控制器),换图不闪断; - 关卡流送:
ULevelStreaming的bShouldBeLoaded/Visible通过 RPC 下发(APlayerController::ClientUpdateLevelStreamingStatus,PlayerController.h:1450)——服务器说”你该加载 3 号关卡了”; - World Partition:服务器
IsServerStreamingEnabled(WorldPartition.cpp:1377-1401)做权威校验,客户端用UWorldPartitionStreamingSourceComponent(WorldPartitionStreamingSourceComponent.h:16)按自己位置自主流式加载——大世界分发(第 10 章专题)的伏笔。
8.6 PlayerController:玩家的替身
APlayerController 是玩家在服务器上的化身(PlayerController.h:489 NetConnection 持有者):
- 唯一性:头注释(:841)”PlayerController 是唯一能承载 RPC 的 Actor 类型“——实际含义:它天然
bNetOwner,是玩家输入与服务器之间的官方通道; - Pawn 绑定链:
ClientRestart(:1153,客户端重建 Pawn)→ServerAcknowledgePossession(:1481,客户端确认”我控制它了”)→ClientRetryClientRestart(:1470,失败了重试); - SetPawn 三件套(
Controller.h:246-249):SetPawn(本地/服务器逻辑变更)、SetPawnFromRep(复制到达时执行 + RepNotify)、SetPawn_Direct(无条件直设)——**”服务器决定你控制谁,客户端跟随”**。
8.7 客户端预测:先斩后奏
角色移动是 UE 网络里最精妙的机制(CharacterMovementComponent.h):
客户端 服务器 │ ① 玩家按 W(本地立即响应) │ │ ReplicateMoveToServer 打包输入 │ │ SavedMoves 记录"我这样动过"(:3181) │ │ ───────────── ServerMove(输入) ─────────────▶ │ │ │ ② ServerMove_Implementation(:10106) │ │ 权威模拟 → 算出正确位置 │ ◀──────────── ClientAdjustment(纠偏包) ──────── │ ③ 位置不一致才发纠偏 │ ④ ClientAdjustPosition(:11242) │ │ OnClientCorrectionReceived(:11419) │ │ 截断 SavedMoves:确认的部分删掉 │ │ 不一致的部分回滚重放 │ │ ⑤ 若一致:什么都不发生(预测成功,丝滑) │
关键数据:FNetworkPredictionData_Client_Character(:3160)的 SavedMoves(未确认的移动缓冲)+ LastAckedMove;FSavedMove_Character(:2988)是最小移动单元(输入+时间戳)。纠偏频率受 NetworkMinTimeBetweenClientAdjustments(:855-868)控制——别每帧纠偏,给客户端一点容错。
为什么这样设计:预测 = 延迟补偿——不预测的话,客户端按 W 到画面动之间有整整一个 RTT 的延迟(100ms 以上),手感像踩泥。先斩后奏让”本地立即动、服务器确认对错”。
8.8 源码考古:GameMode 登录四函数
源码考古:想看真东西的人进。
GameModeBase.h:286-338(节选):
1
2
3
4
5
6
7
8
9
10 /** 玩家尝试进入时调用(同步校验:人数上限/黑名单) */
virtual void PreLogin(const FString& Options, const FString& Address, ...);
/** 异步校验版本(调后端 API 验证,必须回调完成) */
virtual void PreLoginAsync(const FString& Options, const FString& Address,
const FUniqueNetIdRepl& UniqueId, const FOnPreLoginComplete& OnComplete);
/** 通过校验后创建 PlayerController */
virtual APlayerController* Login(UPlayer* NewPlayer, ENetRole InRemoteRole,
const FString& Address, const FString& UniqueId, FString& ErrorMessage);
/** 玩家完全进入后调用(通知其他玩家/初始化个人状态) */
virtual void PostLogin(APlayerController* NewPlayer);这段代码在干什么:登录四函数的顺序就是”门卫流程”——先拦(PreLogin)→ 异步验票
(PreLoginAsync,可对接账号服务)→ 发门卡(Login 建 PlayerController)→ 欢迎入住
(PostLogin)。注意PreLoginAsync是必调回调的(”must call the delegate”)——
异步流程下服务器会挂起等待,写错会导致玩家永远卡在”进入中”。
第 9 章 专题一:移动端网络优化
TL;DR|移动网络的敌人是延迟、抖动、带宽、弱网。四板斧:量化压缩(FVector_NetQuantize
等)、条件复制(COND_* 只发需要的人)、频率分层(重要对象高频、装饰对象低频)、
服务器降 tick(NetServerMaxTickRate)。另外移动端弱网场景(切后台/重连)有专门应对。
9.1 移动网络的账本
| 维度 | 桌面 | 移动 | 对策 |
|---|---|---|---|
| 延迟 | 20-60ms | 60-200ms(蜂窝网络波动大) | 客户端预测 + 服务器纠偏 |
| 抖动 | 小 | 大(信号切换/拥塞) | 缓冲、纠偏容错 |
| 带宽 | 富裕 | 受限(流量套餐) | 量化压缩、条件复制 |
| 连接稳定性 | 高 | 弱(切后台/隧道/地铁) | 重连机制、NetPing |
9.2 分层调参:频率分层
1 | 角色(自己) NetUpdateFrequency = 100 每帧,不可省 |
移动端原则:能少发就少发——同一场景,桌面 100Hz 的 Actor 在移动端 20Hz 视觉几乎无差(加上插值),带宽省 80%。
9.3 带宽压缩三板斧
- 量化:
FVector_NetQuantize10/100(10/100 位精度)替代 float 向量——位置精度 0.01/0.001 级足够游戏使用,体积从 96 位降到 30-50 位; - FastArray:
FFastArraySerializer只发数组增删改(掉落物列表、玩家列表)——否则整个数组每帧全量; - 条件复制:
COND_InitialOnly(只首帧)、COND_OwnerOnly(只拥有者)、COND_SimulatedOnly(只模拟端)——**每个属性问一遍”真的需要发给所有人吗”**。
9.4 服务器 Tick 率
NetServerMaxTickRate(NetDriver.h:875-888,BaseEngine.ini [OnlineSubsystemUtils.IpNetDriver] NetServerMaxTickRate=30):服务器每帧的复制决策上限。30 tick 的服务器 = 每秒 30 次复制决策(不是 60)——移动端对延迟敏感度低(玩家在手机上玩),30 是常见选择;竞技类可以 60。
避坑:网上资料说的
r.DedicatedServerMaxTickRateCVar 在本 fork 不存在——正确配置
是NetServerMaxTickRate(UNetDriver 属性 + BaseEngine.ini)。见到前者按旧资料处理。
9.5 实战调优路线图(五步法)
1 | ① 看数字 stat net(带宽/包数统计)、NetTrace 抓复制明细 |
9.6 移动端避坑清单
- 切后台:App 进后台 UDP 断流——需要重连逻辑(
NMT_NetPing心跳检测 + 服务器保留连接); - 弱网纠偏风暴:抖动大时服务器频繁发纠偏——调大
NetworkMinTimeBetweenClientAdjustments; - 流量敏感:不要用可靠 RPC 发高频数据(音量/位置),可靠 RPC 的重传在弱网下会积压——高频用属性复制(最新值覆盖),低频命令才用可靠 RPC;
- Ping 显示:
net.PlayerFacingPingType(NetConnection.cpp:66)控制玩家可见 ping 的计算方式(RoundTripExclFrame 默认)。
第 10 章 专题二:大世界多人分发实战
TL;DR|大世界多人分发的账:Legacy O(N×M) 检查在 100 人 × 5 万 Actor 下爆掉。解法:
RepGraph 空间网格(viewer 只查自己格子)+ Dormancy 分层休眠(远物/静态物睡仓库)+
AlwaysRelevant 清单(PlayerController 等始终相关)+ World Partition 配合(客户端
自主流式加载)。本章给出一套从”能跑”到”能扛”的落地路线。
10.1 成本模型:O(N×M) 的账
Legacy:每帧对每个活跃 Actor × 每个连接做相关性检查。100 连接 × 5 万 Actor = 500 万次检查/帧——即使每次检查 100ns 也要 500ms(远超帧预算)。RepGraph 把检查变成格子查找:每个 viewer 只看自己格子(几十~几百个 Actor)→ 检查量降 2-3 个数量级。
10.2 RepGraph 落地
1 | 自定义 UReplicationGraph 子类(MyReplicationGraph): |
网格尺寸调参:CellSize = 网络相关性距离(通常 100-500m)。太小 → Actor 在格子边界反复进出(抖动+重复复制);太大 → 格子内 Actor 太多(退化)。
10.3 Dormancy 策略
| 对象类型 | 建议 Dormancy | 理由 |
|---|---|---|
| PlayerController/Pawn | DORM_Never | 永远要同步 |
| 静态道具(箱子/门) | DORM_DormantAll | 不动就睡 |
| 动态但低频(资源点) | DORM_DormantPartial | 按连接睡 |
| 大世界远景装饰 | DORM_Initial | 等玩家靠近再唤醒(FlushNetDormancy) |
唤醒时机:游戏代码在”对象被交互/状态变化”时调 FlushNetDormancy——别在 Tick 里无条件调(那等于永不休眠)。
10.4 与 World Partition 配合
- 服务器
IsServerStreamingEnabled权威校验;客户端WorldPartitionStreamingSourceComponent按位置自主加载(第 8.5 章); - 流送边界上的 Actor:进入流送区的 Actor 触发”新 Actor 复制”(首次全量)——流送高峰帧会有复制洪峰,配合 Dormancy 让新区域的对象”先休眠后唤醒”;
- HLOD 替代:远处用简化网格(视觉),但网络复制的是逻辑对象——HLOD 不参与复制(网络只看逻辑 Actor)。
10.5 实测调参路线图
1 | ① 抓基线 NetTrace 抓 60 秒,统计每连接带宽/包数/最大 Actor 数 |
第 11 章 专题三:调试工具链
TL;DR|网络排障四件套:NetTrace(复制明细追踪,抓”谁发了什么”)、stat net
(实时带宽/包统计)、net 控制台命令(连接/通道/休眠状态)、包捕获(Wireshark 解密
后的完整包流)。最后一节给”症状 → 定位”决策树。
11.1 NetTrace:复制明细追踪
FNetTrace(Net/Core/Trace/NetTrace.h):Unreal Insights 的网络追踪——抓取每个属性/RPC 的序列化明细(谁、什么时候、多少字节)。用法:-trace=net 启动或运行时开启,然后在 Insights 里看 Network 视图——**”这个属性为什么每帧都在发?”的直接答案**。
11.2 控制台与统计
| 命令/统计 | 看什么 |
|---|---|
stat net |
实时带宽、包数、复制调用次数 |
net.ShowConnections |
各连接状态(模式/延迟/带宽) |
net.ShowChannels |
各通道状态(开放/队列深度) |
net.Dormancy 相关 |
休眠统计 |
net.EnableCongestionControl |
拥塞控制开关(第 3.6 章) |
11.3 症状 → 定位表
| 症状 | 大概率原因 | 检查项 |
|---|---|---|
| 高带宽 | 高频复制对象太多 / 未量化 / FastArray 未用 | NetTrace 找字节大户 → 9.3 三板斧 |
| 高延迟(服务器视角) | 服务器 tick 率低 / 逻辑重 | stat net + 服务器帧耗时 |
| 抖动(位置回跳) | 预测/纠偏频率不匹配 / 复制频率过低 | 客户端预测调试(p.VisualizeCorrections) |
| RPC 丢失 | 不可靠 RPC 在网络抖动下丢弃 | 确认语义(丢了可接受?)或加 Reliable |
| 对象不同步 | NetGUID 未映射 / 相关性判定错误 | NetTrace 看是否发送;客户端日志看 PendingLocalRPCs |
| 卡登录 | PreLoginAsync 未回调 / 版本不匹配 | 服务器日志 NMT_* 顺序 |
11.4 包捕获:终极手段
Wireshark 抓 UDP 包(端口 7777 默认)——但默认加密+Oodle 压缩后内容不可读。调试环境做法:关闭加密(net.AllowEncryption=0)与压缩,用 Wireshark 的 UE 解析器看结构;生产环境用 NMT_SecurityViolation 日志定位恶意客户端。
第 12 章 平台支持与性能调优
TL;DR|CVar 速查与常见坑。核心旋钮:net.EnableCongestionControl(BDP 拥塞控制)、
NetServerMaxTickRate(服务器决策频率)、Net.RepDriver.Enable(RepGraph 开关)、
net.UseAdaptiveNetUpdateFrequency(自适应频率)。调优三板斧:看统计 → 抓 NetTrace → 单旋钮调参。
12.1 CVar 速查表(写作时以 grep 核实默认值)
连接与包(NetConnection.cpp):
net.EnableCongestionControl= 0(BDP 拥塞控制,:105)net.DisableBandwithThrottling= 0(:101,关掉令牌桶——测试用)net.DoPacketOrderCorrection= 1(乱序修正,:88)net.PlayerFacingPingType= RoundTripExclFrame(:66)
复制与分发:
Net.RepDriver.Enable= 1(RepGraph 开关,NetDriver.cpp:7999)net.UseAdaptiveNetUpdateFrequency(NetDriver.cpp:452)net.MaxRPCPerNetUpdate(DataReplication.cpp:39)net.DormancyHysteresis(DataChannel.cpp:114)
安全与通信:
net.AllowEncryption(NetDriver.cpp:457)net.OodleServerEnableMode/net.OodleClientEnableMode(Oodle 插件,默认空)net.MagicHeader/net.VerifyMagicHeader(StatelessConnect 握手,:235/:303)
服务器:NetServerMaxTickRate(BaseEngine.ini [IpNetDriver] 默认 30,注意这不是 CVar 而是配置属性)
12.2 调优工作流
1 | 1. 看数字 stat net + NetTrace(带宽构成/字节大户/复制频率) |
12.3 常见坑清单
- 属性忘注册:
UPROPERTY(Replicated)了但没DOREPLIFETIME——属性永远不复制; - RPC 忘加 Reliable:”换弹”这类命令不可靠 = 偶尔不换弹;
- NetUpdateFrequency 设 0:Actor 永不复制(除非 ForceNetUpdate)——
bNetTemporary的 Actor 尤其容易踩; - 客户端改复制属性:客户端本地改
Replicated属性会被服务器覆盖——“改了等于没改”的经典 bug; - OnRep 在服务器也跑:误以为 OnRep 服务器也触发(不触发——只在客户端)——服务器逻辑写在函数本体;
- RepGraph 与 Legacy 混用:开了
Net.RepDriver.Enable但没配ReplicationDriverClassName——空转; - 预测量大:预测逻辑与非预测逻辑混写,纠偏时回滚错对象——预测代码只改”预测专用”状态。
第 13 章 局限、替代方案与展望
TL;DR|UE 网络的局限:权威服务器的延迟代价(每个动作都过服务器)、UDP 的不可靠
(靠自研可靠性补)、序列化 CPU 成本(大世界属性多时显著)、带宽天花板(量化只能缓解)。
方向:Iris(Epic 重写的复制系统,本 fork 已带骨架但默认关闭)、帧同步/回滚(不适合
RPG 但适合格斗/即时策略)、第三方平台(Photon/PlayFab 提供托管与大厅)。
13.1 局限与对策
| 局限 | 表现 | 对策 |
|---|---|---|
| 权威延迟 | 每个动作 RTT 后才被承认 | 客户端预测(移动);”乐观执行+回滚”模式 |
| UDP 不可靠 | 丢包丢状态 | 束层可靠 + ACK 重传(引擎已做);关键数据 Reliable |
| 序列化 CPU | 大世界属性多时服务器 CPU 高 | PushModel(只序列化脏的)、共享序列化、量化 |
| 带宽天花板 | 100 人 × 高频对象超限 | RepGraph + Dormancy + 条件复制 + 频率分层 |
| 单服务器上限 | 一张地图一个服务器进程 | 分地图/分频道(Beacon+旅行)、世界分区 |
13.2 Iris:Epic 的重写
为什么重写:legacy 复制系统是”历史包袱”——属性比较与序列化耦合、通道与复制耦合、难以做全量化状态快照。Iris(Engine/Source/Runtime/Net/Iris/,官方文档标 Experimental):
- 全量化:属性先 Quantize 再 Serialize(量化管线 NetSerializers);
- PushModel 原生:脏标记是设计核心而非可选优化;
- ReplicationBridge:复制与通道解耦(DataStreamChannel 承载数据流,本 fork 的 CHTYPE_MAX=8 与 DataStream 通道(BaseEngine.ini:1842)就是为它预留的);
- 本 fork 现状:
UNetDriver::ReplicationSystem成员存在(NetDriver.h:2546 附近)、TickFlush的InternalIrisUpdateTransactional分支(:1141)、ProcessRemoteFunction的 Iris 分支(:8112)——骨架已装、默认不启用(-UseIrisReplication或项目设置切换)。
避坑:本文所有机制章(第 3~8 章)讲的都是 legacy 路径——它是当前默认且文档/社区
主体;Iris 只在必须提到的分支出场。读 Iris 教程请认准”Experimental”标记。
13.3 替代路径
| 方案 | 适合 | 不适合 | UE 支持 |
|---|---|---|---|
| 状态同步(本文) | RPG/射击/开放世界 | 确定性要求极高的竞技 | ✅ 原生 |
| 帧同步/回滚 | 格斗/即时策略 | 大世界/非确定性内容 | ❌ 无原生(社区方案) |
| 第三方平台(Photon/PlayFab) | 快速原型/托管大厅 | 深度定制 | 通过 OnlineSubsystem 对接 |
13.4 演进时间线与阅读进阶
| 时间 | 里程碑 |
|---|---|
| 2004 | UE2 网络层(当前架构的祖先:Driver/Connection/Channel 概念) |
| UE4 (2014) | 属性复制成熟、RPC 体系、Replication Graph 引入(Fortnite 驱动) |
| 5.0~5.3 | RepGraph 插件化、PushModel、网络改造(DataChannel 合并、FunctionChannel 移除) |
| 5.4~5.9 | Iris 实验性集成(本 fork 已带骨架)、拥塞控制(BDP)、打包头无条件化 |
| 未来 | Iris 转正(官方路线图)、全量化状态复制 |
进阶路线:Epic 官方文档(Networking and Multiplayer → Programming Network Multiplayer Games → Replication Graph → Iris)→ 知乎/CSDN 中文深挖(见附录 C)→ 本仓库源码(附录 A 指路)→ Squanch 博客 UE4 Networking 系列(未能访问核实,仅线索)。
附录 A 源码地图
全部路径基于本仓库快照(2026-08-26,HEAD
6cea9bd20f8f)。行号只用于定位;代码演进后以函数名为准。
阅读顺序建议:先NetDriver.h(架构文档+总入口)→NetDriver.cpp(每帧主循环)→ 按六架构分组深入。
A.1 网络架构(连接与通道)
| 文件 | 关键内容 |
|---|---|
Classes/Engine/NetDriver.h |
UNetDriver :809(架构注释 :34-324)、ChannelDefinitions :1038、ClientConnections :974、ServerConnection :970 |
Private/NetDriver.cpp |
LoadChannelDefinitions :801、TickFlush :1097(Iris 优先 :1141)、ServerReplicateActors :6205、ProcessRemoteFunction :8053、Net.RepDriver.Enable :7999 |
Classes/Engine/NetConnection.h |
UNetConnection :283、EConnectionState :94、EClientLoginState :116、Channels/OutReliable |
Private/NetConnection.cpp |
InitBase :488、令牌桶 Tick :5110、FlushNet :2412、WritePacketHeader :2870、ReceivedPacket :3247、DispatchPacket :3609、net.* CVar 区 :60-248 |
Classes/Engine/Channel.h |
UChannel :63、EChannelType :25-36(CHTYPE_MAX=8) |
Private/DataChannel.cpp |
UChannel/UControlChannel/UActorChannel 实现(本 fork 合并):ReceivedNextBunch :769、ReceivedBunch :3111、ReplicateActor :3599、ProcessBunchInternal :3270、StartBecomingDormant :4632、ReadyForDormancy :4604、BecomeDormant :4595 |
Public/Net/DataChannel.h |
控制消息定义 NMT_* :173-248(DEFINE_CONTROL_CHANNEL_MESSAGE) |
Public/Net/DataBunch.h |
FOutBunch/FInBunch :29-204(bReliable/bPartial 标志) |
Public/Net/NetPacketNotify.h + Private/Net/NetPacketNotify.cpp |
包级可靠:WriteHeader :203(14-bit Seq + AckedSeq + 历史位图) |
Classes/Engine/GameNetworkManager.h |
Listen 服动态带宽 :54-71 |
Public/Net/TrafficControl.h |
FNetworkCongestionControl :78-98(BDP 拥塞控制) |
Classes/Engine/NetworkObjectList.h |
FNetworkObjectInfo :34-109(NextUpdateTime/OptimalNetUpdateDelta) |
A.2 通信架构(PacketHandler)
| 文件 | 关键内容 |
|---|---|
Runtime/PacketHandlers/PacketHandler/Public/PacketHandler.h |
FPacketHandler、HandlerComponent :739(Incoming/Outgoing/IncomingConnectionless/OutgoingConnectionless/RequiresHandshake) |
PacketHandler/Classes/PacketHandlerProfileConfig.h |
UPacketHandlerProfileConfig(按 NetDriver 装配组件) |
PacketHandler/Public/EncryptionComponent.h |
FEncryptionComponent(Enable/Disable/SetEncryptionData) |
Engine/Public/PacketHandlers/StatelessConnectHandlerComponent.h/.cpp |
无状态握手(cookie 挑战应答、EHandshakePacketType 六种包型、net.MagicHeader :235) |
Runtime/PacketHandlers/ReliabilityHandlerComponent/ |
已 UE_DEPRECATED(5.3) |
Net/Core/Public/Net/Core/Misc/DDoSDetection.h |
DDoS 防护 |
Plugins/Compression/OodleNetwork/Source/Private/OodleNetworkHandlerComponent.cpp |
net.OodleServerEnableMode/ClientEnableMode :169-174 |
A.3 同步架构(属性复制)
| 文件 | 关键内容 |
|---|---|
Engine/Public/Net/RepLayout.h |
FRepLayout :1880(Parents/Cmds/BaseHandleToCmdIndex)、FRepChangelistState :433、FReplicationChangelistMgr :501、FRepState :671、树生成规则注释 :1091-1152 |
Engine/Private/RepLayout.cpp |
CompareProperties :1777、CompareParentProperties :1514、ReplicateProperties :1972、SendProperties_r :2767、ReceiveProperties :3789、CallRepNotifies :4661 |
Engine/Private/DataReplication.cpp |
FObjectReplicator:ReplicateProperties_r :1927、ReceivedBunch :984、ReceivedRPC :1323、QueueRemoteFunctionBunch :2293、PostReceivedBunch :1592、net.MaxRPCPerNetUpdate :39 |
Net/Core/Public/Net/Core/PropertyConditions/RepChangedPropertyTracker.h |
FRepChangedPropertyTracker(本 fork 位置,条件激活跟踪) |
Classes/Engine/NetSerialization.h |
NetSerialize/NetDeltaSerialize 模板、FVector_NetQuantize、SafeNetSerializeTArray_* |
Net/Core/Classes/Net/Serialization/FastArraySerializer.h |
FFastArraySerializer :408(数组 delta 同步) |
CoreUObject/Public/UObject/CoreNet.h |
UPackageMap :190、FNetBitWriter/FNetBitReader :383-439 |
A.4 RPC 架构
| 文件 | 关键内容 |
|---|---|
Private/NetDriver.cpp |
ProcessRemoteFunction :8053(Iris 分支 :8112 / RepDriver 拦截 :8141 / Multicast :8148 / 单播 :8191)、ProcessRemoteFunctionForChannelPrivate :3152(SetForcedSerializeFromRPC :3179) |
Private/DataReplication.cpp |
ReceivedRPC :1323、ReceivePropertiesForRPC :1415、ForwardRemoteFunction :1442、校验拒绝 :1351-1361 |
Private/PackageMapClient.cpp |
UPackageMapClient(对象↔NetGUID) |
A.5 分发架构(决策链 + RepGraph + Dormancy)
| 文件 | 关键内容 |
|---|---|
Private/NetDriver.cpp |
ServerReplicateActors_PrepConnections :5126 / BuildConsiderList :5231 / PrioritizeActors :5456 / ProcessPrioritizedActorsRange :5615、IsActorRelevantToConnection :5386、ShouldActorGoDormant :5434、FlushActorDormancyInternal :4894 |
Private/ActorReplication.cpp |
IsNetRelevantFor :388、GetNetPriority :48、IsWithinNetRelevancyDistance :383 |
Plugins/Runtime/ReplicationGraph/Source/Public/ReplicationGraph.h |
UReplicationGraphNode :69 / ActorList :188 / GridSpatialization2D :579 / GridCell :535 / DormancyNode :485 / AlwaysRelevant :771 / UReplicationGraph :920 |
Plugins/Runtime/ReplicationGraph/Source/Private/ReplicationGraph.cpp |
ServerReplicateActors :1112、ReplicateActorListsForConnections_Default :1449(bDormantOnConnection :1523 / StarvationFactor :1614)、网格 Gather :6238、ReplicationPeriodFrame :1083 |
Plugins/Runtime/ReplicationGraph/Source/Public/ReplicationGraphTypes.h |
FGlobalActorReplicationInfo :983、FConnectionReplicationActorInfo :1395 |
Classes/Engine/ReplicationDriver.h |
UReplicationDriver :47-60(CreateReplicationDriver 工厂)、ReplicationDriverClassName 注释 :13-14 |
Classes/Engine/EngineTypes.h |
DORM_* 枚举 :3645 |
A.6 网关架构(登录/Beacon/旅行/预测)
| 文件 | 关键内容 |
|---|---|
Classes/GameFramework/GameModeBase.h |
AGameModeBase :47、PreLogin/PreLoginAsync/Login/PostLogin :286-338 |
Classes/GameFramework/GameSession.h |
ApproveLogin :78、KickPlayer :155、RegisterServer :183 |
Plugins/Online/OnlineSubsystemUtils/Public/OnlineBeacon.h |
AOnlineBeacon :67(AActor + FNetworkNotify) |
Plugins/Online/OnlineFramework/Source/Lobby/ |
ALobbyBeaconHost :24 / ALobbyBeaconClient :58 / ALobbyBeaconState :171(大厅范例) |
Plugins/Online/OnlineSubsystem/Source/Public/Interfaces/OnlineSessionInterface.h |
IOnlineSession(Create/Find/Join/DestroySession,插件化) |
Classes/Engine/World.h |
ServerTravel :4226、SeamlessTravel :4238、FSeamlessTravelHandler :194 |
Classes/GameFramework/PlayerController.h |
NetConnection :489、ClientRestart :1153、ServerAcknowledgePossession :1481、ClientUpdateLevelStreamingStatus :1450、”唯一 RPC Actor”注释 :841 |
Classes/GameFramework/Controller.h |
SetPawn/SetPawnFromRep/SetPawn_Direct :246-249 |
Classes/GameFramework/CharacterMovementComponent.h |
FNetworkPredictionData_Client_Character :3160、FSavedMove_Character :2988、NetworkMinTimeBetweenClientAdjustments :855-868 |
Private/Components/CharacterMovementComponent.cpp |
ReplicateMoveToServer :8925、ServerMove_Implementation :10106、SendClientAdjustment :2337、ClientAdjustPosition :11242、OnClientCorrectionReceived :11419 |
A.7 Iris 与 NetCore 支撑
| 文件 | 关键内容 |
|---|---|
Runtime/Net/Iris/ |
ReplicationSystem(Experimental,本文不展开) |
Private/Net/Experimental/Iris/DataStreamChannel.h |
UDataStreamChannel(fork 新增,StaticChannelIndex=2) |
Net/Core/Public/Net/Core/ |
PushModel(PushModel.h)、NetTrace(Trace/NetTrace.h)、量化序列化(Serialization/QuantizedVectorSerialization.h)、RPCDoSDetection |
Core/Public/Misc/EngineNetworkCustomVersion.h |
FEngineNetworkCustomVersion(AcksIncludedInHeader=8、JitterInHeader=14、Latest=44) |
附录 B 术语表(中英对照)
| 英文 | 中文(本文用词) | 一句话解释 | 首次出现 |
|---|---|---|---|
| NetDriver | (不译) | 网络总局:管理所有连接与共享数据 | Ch2/3 |
| NetConnection | (不译) | 一条端到端连接(邮路) | Ch2/3 |
| Channel | 通道 | 连接内的通信槽(邮袋):Control/Voice/DataStream/Actor | Ch2/3 |
| Bunch | 束 | 通道内传输的数据单元(信件) | Ch2/3 |
| Packet | 数据报 | UDP 传输单元(含包头+多束) | Ch3 |
| NetGUID | (不译) | 对象的网络门牌号(对象↔ID 映射) | Ch2 |
| PackageMap | (不译) | NetGUID 映射表(对象身份译码器) | Ch3 |
| Replication | 复制 | 属性状态从服务器同步到客户端 | Ch1 |
| RPC | (不译) | 远程函数调用(电话) | Ch1 |
| Changelist | (不译) | 改动日记(只记”哪个属性变了”) | Ch5 |
| RepLayout | (不译) | 类型的复制蓝图(属性树) | Ch5 |
| RepState | (不译) | 每对象×每连接的复制状态 | Ch5 |
| CallRepNotifies | (不译) | 客户端收到属性后触发 OnRep 通知 | Ch5 |
| OnRep | (不译) | ReplicatedUsing 声明的客户端回调 | Ch5 |
| PushModel | (不译) | 主动脏标记(MARK_PROPERTY_DIRTY)替代逐属性比较 | Ch5 |
| FastArraySerializer | (不译) | 数组 delta 同步(只发增删改) | Ch5 |
| NetSerialize | (不译) | 自定义序列化(量化等) | Ch5 |
| FVector_NetQuantize | (不译) | 位置量化压缩 | Ch5 |
| Server/Client/Multicast RPC | (不译) | 三种 RPC 方向 | Ch6 |
| Reliable | 可靠 | bReliable 修饰:保证送达与顺序 | Ch6 |
| Relevancy | 相关性 | “这个连接该不该收到这个 Actor” | Ch7 |
| NetUpdateFrequency | (不译) | 每秒最大复制次数 | Ch1 |
| NetPriority | (不译) | 带宽紧张时的发送优先级 | Ch1 |
| NetSpeed | (不译) | 连接带宽上限(令牌桶) | Ch3 |
| QueuedBits | 令牌桶 | 带宽配额记账 | Ch3 |
| RepGraph | (不译) | Replication Graph:可插拔分发框架 | Ch7 |
| GridSpatialization2D | (不译) | RepGraph 的空间网格节点 | Ch7 |
| Dormancy | 休眠 | 停止复制的状态(DORM_* 五态) | Ch7 |
| FlushNetDormancy | 唤醒 | 对象变化时解除休眠 | Ch7 |
| PacketHandler | (不译) | 每连接的组件流水线(安检) | Ch4 |
| HandlerComponent | (不译) | 流水线组件基类 | Ch4 |
| StatelessConnect | 无状态握手 | cookie 挑战应答(防放大攻击) | Ch4 |
| Handshake | 握手 | 连接建立前的验证流程 | Ch3/4 |
| NMT_* | (不译) | 控制通道消息(Hello/Challenge/Login/…) | Ch3 |
| Beacon | (不译) | 游戏服务器与专用服务的带外连接 | Ch8 |
| Session | 会话 | 找房间/进房间(插件化) | Ch8 |
| SeamlessTravel | 无缝旅行 | 换图不断开连接 | Ch8 |
| ServerTravel | 服务器旅行 | 服务器换图(客户端重连) | Ch8 |
| LevelStreaming | 关卡流送 | 按需加载关卡(RPC 下发状态) | Ch8 |
| World Partition | (不译) | 大世界分区流送 | Ch8 |
| Prediction | 预测 | 客户端先本地模拟(先斩后奏) | Ch8 |
| SavedMove | (不译) | 未确认的移动缓冲单元 | Ch8 |
| Correction | 纠偏 | 服务器把客户端拉回正确位置 | Ch8 |
| ClientAdjustPosition | (不译) | 纠偏 RPC | Ch8 |
| Iris | (不译) | Epic 的新复制系统(Experimental) | Ch13 |
| NetTrace | (不译) | 复制明细追踪工具 | Ch11 |
| DDoS | 分布式拒绝服务 | 放大攻击防护 | Ch4 |
附录 C 参考资料
Epic 官方(dev.epicgames.com):
- Networking and Multiplayer in Unreal Engine —— 网络体系总览(Actor 复制/RPC/RepGraph/Iris 导航),有官方中文版;
- Programming Network Multiplayer Games for Unreal Engine(5.2)—— C++ 网络编程权威指南:C/S 权威模型、可靠与不可靠取舍;
- Actor Replication —— 复制两大手段(属性 vs RPC)的基础页;
- Replication Graph in Unreal Engine(5.7)—— RepGraph 节点体系与配置方法(注意:文档基于 5.7,本 fork 的 RepGraph 在插件目录);
- Introduction to Iris in Unreal Engine(5.8)—— Iris 新复制系统(仍标 Experimental)。
中文社区:
- 知乎《使用虚幻引擎4年,我想再谈谈他的网络架构【经验总结】》(zhuanlan.zhihu.com/p/105040792)—— 717 赞高赞长文:状态同步本质、”同步数据也许会迟到但永远不会缺席”;
- 知乎《UE4网络同步思考(一)—经典同步方案》(p/56548096)—— C/S vs P2P、UDP 融合 TCP 优点、带宽四板斧(相关性/优先级/成员标记/合包);
- 知乎《UE5-Iris 网络复制系统技术分析(架构层次)》(p/1996687209709991513)—— Iris 三层架构与
-UseIrisReplication切换; - 知乎《UE5中的网络同步能力和同构服务器框架(上)》(p/621339344)—— 同构服务器框架概念;
- CSDN《UE4网络架构学习笔记》(blog.csdn.net/qqQQqsadfj/article/details/123256433)—— 四模式与属性复制三步曲;
- CSDN《解析虚幻联网机制和游戏中如何实现网络同步》(blog.csdn.net/X_Bai01/article/details/146400016)—— WithValidation 防作弊、NetDebug 调试。
博客:
- Squanch(squanch.blog)UE4 Networking 系列(Phil Kauffold)—— 写作时未能访问核实,仅线索;内容基于 UE4/5.0,类名有差异。
注意:以上社区资料基于 UE4/UE5.0~5.2,与当前源码的差异(如 FunctionChannel 移除、
RepGraph 插件化、NetServerMaxTickRate 命名)请以本文正文的避坑块为准。
附录 D 自测练习
入门级:
- 用三句话 + 一个类比向同事解释 UE 网络架构(要求用上”服务器权威”和”UDP”)。
- 属性复制和 RPC 的区别是什么?”玩家掉血”和”玩家开火”分别该用哪个?
- 四种运行模式(Standalone/Dedicated/Listen/Client)中,哪些是”服务器”?
进阶级:
- 画出客户端连接服务器的完整握手时序(六关 NMT_*),标注每一关是谁发给谁、干什么。
- 解释”changelist 只记 handle 不记值”为什么能省带宽,并说明首帧为什么必须全量。
- 一个 50 人服务器 × 2 万活跃 Actor 的场景,Legacy 决策链每帧做多少次相关性检查?RepGraph 空间网格(每格子 100 个 Actor)后大约多少次?
- RPC 的 Multicast 在服务器本地调用时会发生什么?为什么这样设计?
- 客户端预测的”先斩后奏”流程是什么?SavedMoves 在纠偏时被怎样处理?
源码级:
- 在
NetDriver.cpp:8053(ProcessRemoteFunction)找到五个分支,说出每个分支的进入条件。 - 在
RepLayout.h:1880(FRepLayout)找出 Parents/Cmds/BaseHandleToCmdIndex 三个成员,解释它们的关系。 - 在
NetPacketNotify.cpp:203(WriteHeader)对照图 F5,推导一个纯 ACK 包(无数据)的最小头部大小。 - 在
NetDriver.cpp:5434(ShouldActorGoDormant)找到休眠的三个条件,解释”无新属性”为什么是必要条件。 - 实验:开一个 ListenServer + 两个客户端,用
stat net观察带宽,把某个 Actor 的NetUpdateFrequency从 100 调到 10,观察stat net与行为变化。