引擎架构与一帧的时序
网络逻辑不是孤立跑的,它嵌在 UE 的一帧里。搞不清「收包在帧的哪一步、发包在哪一步、逻辑在哪跑」,就解释不了复制为什么会延迟一帧、RPC 为什么会乱序。本篇先把引擎骨架和一帧的时序讲清楚,作为整个复制栈的底座。
一句话结论
UE 一帧 = 收包(TickDispatch)→ 游戏逻辑 Tick(分 TickGroup)→ 发包(TickFlush),网络只在 GameThread 跑,这构成了延迟下限。
场景问题
打个比方:引擎一帧像工厂一天的班。上班第一件事是收快递(TickDispatch)——把昨天各地寄来的零件全签收入库;白天照流水线干活(TickGroup),且工序有先后:先粗加工(PrePhysics)、再上机床(物理)、最后质检打包(PostUpdateWork),质检不可能排在粗加工前面;下班前统一发货(TickFlush),不是每做完一个就跑一趟快递站。
由此推出两件事:你今天下午改的图纸,货明天才到对方手里——所以复制至少慢一帧。而整个厂只有一条流水线(GameThread),收货、干活、发货全靠它,一天开工几次(服务器 tick 率)就决定了响应快慢,跟快递跑多快(网络 RTT)无关。
面试常问:「你改了服务器上一个变量,客户端最快什么时候看到?」如果不知道复制发生在一帧的哪一步,只能答「很快」。真实答案是:服务器要等到本帧 TickFlush 才把变化打包发出,客户端要等到下一帧的 TickDispatch 才收到并应用——所以哪怕零网络延迟,也有约一帧的固有延迟。要把这类问题答到点子上,得先有一张「引擎一帧时序图」在脑子里。
实现方案
1. 模块化架构
UE 代码按 Module 组织,每个 Module 一个 *.Build.cs 声明依赖。Engine、Core、CoreUObject、Net 等是引擎模块;游戏项目自己的代码是 Game 模块。Plugin 是一组 Module 的打包,可独立开关。
// MyGame.Build.cs —— 声明本模块依赖
PublicDependencyModuleNames.AddRange(new[] {
"Core", "CoreUObject", "Engine", "InputCore",
"GameplayAbilities", // 用到 GAS 才加
});
关键边界:Engine 模块不能反向依赖 Game 模块(否则引擎无法独立编译)。网络相关代码集中在 Engine 与 Net 模块,NetDriver/NetConnection 等都在这里。
2. 启动流程
进程起来到进入游戏,主线是 FEngineLoop:
main / WinMain
└── FEngineLoop::PreInit() // 加载核心模块、初始化 CoreUObject、GConfig
└── FEngineLoop::Init() // 创建 UEngine 实例(UGameEngine / UEditorEngine)
└── while (!IsRequestingExit)
└── FEngineLoop::Tick() // 每帧调用,驱动 UWorld::Tick
UEngine 是全局单例(GEngine),派生出 UGameEngine(打包运行)与 UEditorEngine(编辑器)。专用服务器用的是 UGameEngine + NetMode == NM_DedicatedServer。
3. 对象容器层次与生命周期
从外到内的包含关系,是理解「谁复制给谁」的地图:
| 层 | 类 | 数量 | 说明 |
|---|---|---|---|
| 引擎 | UEngine (GEngine) | 1 | 进程级单例 |
| 局 | UGameInstance | 1 | 跨关卡存活,登录态/子系统挂这里 |
| 世界 | UWorld | 1(活跃) | 当前世界,含多个 Level |
| 关卡 | ULevel | N | Persistent Level + Streaming Levels |
| 实体 | AActor | 很多 | 可放进世界、可被复制的最小单位 |
| 组件 | UActorComponent | 很多 | 挂在 Actor 上,如移动、网格 |
生命周期钩子(Actor):PostInitializeComponents → BeginPlay(网络就绪后才调)→ 每帧 Tick → EndPlay → BeginDestroy(GC 阶段)。
4. World 类型与 NetMode
UWorld::WorldType:Game(打包运行)/ PIE(编辑器内播放)/ Editor / EditorPreview 等。
NetMode(ENetMode)五值,是所有网络判断的总开关:
| NetMode | 有服务器逻辑 | 有本地玩家 | 场景 |
|---|---|---|---|
NM_Standalone | 是(本地权威) | 是 | 单机 |
NM_ListenServer | 是 | 是 | 房主兼服务器 |
NM_DedicatedServer | 是 | 否(无渲染) | 专用服务器 |
NM_Client | 否 | 是 | 纯客户端 |
GetNetMode() + HasAuthority() 是写网络代码时最常用的两个判断,详见 Gameplay Framework。
5. 一帧的时序(网络视角)
UWorld::Tick 按 TickGroup 分组推进,网络收发夹在两端:
要点:
- 收在最前、发在最后。服务器本帧改的属性,本帧末尾
TickFlush才发出;客户端下一帧TickDispatch才收到。这就是「复制固有延迟约一帧」的来源。 - TickGroup 顺序固定:
TG_PrePhysics → TG_DuringPhysics → TG_PostPhysics → TG_PostUpdateWork。依赖物理最终位置的逻辑(如挂点、相机)要放TG_PostUpdateWork。 AActor::SetReplicates(true)后,发包频率由NetUpdateFrequency控制(默认 100,即每秒最多 100 次考虑),不是每帧都发,详见 Relevancy 与带宽预算。
6. 线程模型
UE 是多线程流水线,但网络逻辑只在 GameThread:
GameThread : 帧 N 帧 N+1 帧 N+2 ← 收发包、Actor Tick、复制都在这
RenderThread: 帧 N 帧 N+1 ← 落后游戏线程约 1 帧
RHIThread : 帧 N ← 再落后 1 帧,提交 GPU 命令
- GameThread:游戏逻辑、网络收发、复制、GC 触发点。所有
UPROPERTY复制、RPC 执行都在这。 - RenderThread:处理渲染指令,落后 GameThread 1~2 帧(这是画面延迟,与网络延迟无关)。
- RHIThread:把渲染指令翻译成图形 API 调用。
- TaskGraph /
AsyncTask:把可并行的活(动画、物理、部分 Tick)派到 worker 线程,但结果要在 GameThread 汇合。
为什么这构成延迟下限:因为收包、逻辑、发包全在一条 GameThread 上串行,服务器的 tick 率(如 30/60Hz)直接决定了「最快多久处理一次输入、多久发一次状态」。服务器降到 20Hz,就意味着输入到响应最坏多等 50ms,与网络 RTT 无关。专用服务器无渲染,省掉 RenderThread/RHIThread,但 GameThread 的 tick 率仍是硬约束。
7. 一句话记住三种「延迟」的差别
这三个东西名字都叫延迟,来源完全不同,别混:
| 一句话 | 来源 | 类比 | |
|---|---|---|---|
| 复制固有延迟(约一帧) | 服务器本帧末发、客户端下帧初收 | 收发夹住逻辑的时序设计 | 今天下班发货、明天签收 |
| Tick 率延迟(1/tickrate) | 两次开工之间的输入攒着不处理 | GameThread 串行 + tick 率 | 工厂一天只开一次工,中午来的单等明天 |
| 渲染延迟(1~2 帧) | 画面比逻辑落后 | RenderThread/RHIThread 流水线 | 车间已做完,展厅橱窗还没换 |
关键区分:前两个影响网络手感,第三个只影响画面、跟网络无关。专用服务器把第三个整个省掉了(无渲染),但前两个躲不开。
为什么这么做
收发夹住逻辑:先把远端状态全部应用到本地(TickDispatch),再跑逻辑,逻辑就能看到最新状态;跑完再统一发出变化(TickFlush),避免一帧内多次发包。这是批处理思想——省 syscall、省带宽。
网络只在 GameThread:复制要遍历 Actor 的 UPROPERTY 做差分(详见 属性复制),这依赖 UObject 的反射与 GC 引用图(详见 UObject/GC/反射),而 UObject 不是线程安全的。放多线程会引入海量锁,得不偿失,所以 UE 选择把网络锁死在 GameThread。
为什么别的选择不行
- 每帧都发全量状态:带宽爆炸,且没必要——大多数属性帧间不变。UE 用差分 + 频率控制,只发变化。
- 网络独立线程:需要给每个 UObject 加锁或做快照,复杂度和开销远超收益;UE5 的 Iris 也只是把「脏状态追踪」优化了,仍在 GameThread 收口。
- 收包放帧末:逻辑就看不到本帧远端更新,响应延迟再加一帧。
沉淀结论
引擎每帧对网络就三件事:收(TickDispatch)→ 算(TickGroup)→ 发(TickFlush)。复制固有延迟约一帧来自「本帧发、下帧收」。网络锁在 GameThread,服务器 tick 率是延迟硬下限。
记忆口诀
一帧三步:Dispatch 收 / TickGroup 算 / Flush 发
TickGroup 序:PrePhysics → DuringPhysics → PostPhysics → PostUpdateWork
三线程:Game 逻辑网络 / Render 落后1帧 / RHI 再落后1帧
延迟下限:网络只在 GameThread / 服务器 tick 率定上限 / 复制固有延迟约一帧
内容来源
综合整理。主要参考:Epic 官方文档 Unreal Engine Networking Overview、Actor Ticking、Engine Loop 源码(LaunchEngineLoop.cpp、LevelTick.cpp)、社区经典分析 Alex Forsythe《How Unreal's Networking Works》与 Cedric Neukirchen《Multiplayer Network Compendium》。数字为引擎默认值或业界数量级估算。
相关专题:反射与 GC 见 UObject/GC/反射;端存在性与 Role 判定见 Gameplay Framework;底层收发的 Bunch/Packet 见 NetDriver/Channel/Bunch。
自测:合上资料能说清楚吗?
服务器改了一个复制属性,客户端最快什么时候能看到?为什么?
参考答案
服务器本帧改属性后,要等到本帧末尾 TickFlush 才把差分打包发出(且受 NetUpdateFrequency 频率约束,不一定当帧发);客户端要到下一帧 TickDispatch 才收到并应用。所以即使零网络延迟,也有约一帧的固有延迟,叠加 RTT 后更大。根因是收发夹住逻辑、网络只在 GameThread 串行处理。
TickGroup 有哪几个、顺序如何?依赖物理结果的逻辑该放哪个?
参考答案
顺序:TG_PrePhysics(大多数逻辑)→ TG_DuringPhysics(物理并行推进)→ TG_PostPhysics → TG_PostUpdateWork。依赖物理最终位置的逻辑(挂点、相机跟随)应放 TG_PostUpdateWork,否则读到的是上一帧的位置。
为什么 UE 把网络复制锁在 GameThread,而不放独立线程加速?
参考答案
复制要遍历 Actor 的 UPROPERTY 做差分比较,依赖 UObject 的反射数据与 GC 引用图,而 UObject 非线程安全。放多线程需给每个对象加锁或做快照,复杂度和开销远超收益。UE5 Iris 优化的是「脏状态追踪」减少遍历量,但仍在 GameThread 收口。