UE4的一个帧生命周期的解读
- 先看最外层
FEngineLoop::Tick():一帧的总入口- 为什么引擎不是直接更新一个
UWorld UWorld到底是什么UWorld::Tick()里面的顺序- TickGroup解决什么问题
- 一个Tick不是简单的函数指针
- Tick里新生成的Actor怎么办
- 游戏定时器为什么不等于Actor Tick
- 游戏线程和渲染线程
- 软件限帧和垂直同步
- 把整帧重新串起来
- 总结
- 可以自行阅读的主要源码位置
最近顺着源码看了一遍发现,Actor Tick只是整帧中间的一部分,前后还夹着时间、输入、网络、物理、摄像机、关卡流送和渲染等一大堆工作,故写一篇文章整理一下。
本文基于Unreal Engine 4.24.3源码,不同版本的函数名和细节可能会变化,但整体思路基本相同。
先看最外层
如果把所有细节先藏起来,UE4一帧最重要的调用关系其实只有三层:
FEngineLoop::Tick()
└─ UGameEngine::Tick()
└─ UWorld::Tick()它们分别负责:
FEngineLoop::Tick():处理整个引擎进程的一帧UGameEngine::Tick():找到当前需要运行的游戏世界UWorld::Tick():真正更新世界中的网络、Actor、物理等内容
可以先把它们类比成:
引擎总管
└─ 找到今天要运行的世界
└─ 让这个世界向前走一帧接下来我们从最外面开始慢慢看。
FEngineLoop::Tick():一帧的总入口
FEngineLoop::Tick()位于LaunchEngineLoop.cpp,操作系统主线程会不断调用它。
它每帧大致做下面这些事情:
更新时间并处理限帧
→ 获取系统消息和输入
→ 更新游戏世界
→ 更新界面和渲染相关工作
→ 等待渲染流水线不要落后太远
→ 清理本帧临时内容这里最关键的一步是更新游戏引擎。代码里传入的DeltaTime表示两帧之间经过的时间,下一节会单独解释:
GEngine->Tick(FApp::GetDeltaTime(), bIdleMode);GEngine是当前引擎实例。在普通游戏中,它通常指向UGameEngine,于是流程进入下一层。
DeltaTime是什么
DeltaTime表示上一帧到这一帧实际经过了多少秒。
例如游戏以60帧运行,每帧大约经过:
\[ \frac{1}{60}\approx0.01667秒 \]
Actor移动、动画播放和技能冷却都会使用这个时间。如果一个物体速度是每秒100单位,那么这一帧大约移动:
\[ 100\times0.01667\approx1.667 \]
UE在Windows上使用高精度计时器读取当前时间,再用“本帧时间减去上一帧时间”得到DeltaTime。
为什么引擎不是直接更新一个UWorld
进入UGameEngine::Tick()后,引擎不会直接假设全进程只有一个世界,而是遍历一个叫WorldList的列表。
这是因为编辑器里经常同时存在多个世界:
- 正在编辑的关卡是一个世界
- 点击“在编辑器中运行”(Play In Editor)后,运行中的游戏是另一个世界
- 材质球、角色等工具预览也可能拥有自己的世界
- 无缝切换地图时,旧世界和新世界还可能短暂共存
引擎找到一个有效世界后,大致会执行:
GWorld = Context.World();
Context.World()->Tick(LEVELTICK_All, DeltaSeconds);也就是把GWorld暂时指向当前正在处理的世界,然后调用它的Tick()。
其中LEVELTICK_All表示按照正常游戏方式更新世界中的全部内容。
UWorld到底是什么
UWorld可以理解成一张已经加载并运行起来的地图容器。
它不只保存Actor,还连接着物理、网络、渲染场景和定时器:
UWorld
├─ PersistentLevel:一直存在的主关卡
├─ Levels[]:当前已经加载的所有关卡
│ └─ Actors[]
│ └─ Components
├─ PhysicsScene:物理世界
├─ NetDriver:网络收发
├─ Scene:交给渲染器的场景
└─ TimerManager:游戏定时器一句比较好记的话是:
World装Level,Level装Actor,Actor装Component。
Level更接近磁盘上保存的.umap内容,而UWorld是在运行时把一个主Level、若干流送Level,以及物理、网络、渲染等系统组装起来的“活体”。
这也是为什么跨地图数据通常放在GameInstance而不是UWorld:切图时World可能销毁重建,GameInstance则可以继续存在。
UWorld::Tick()里面的顺序
进入LevelTick.cpp以后,才真正开始更新游戏世界。
把大量细节省略后,顺序大致如下:
flowchart TD
A["接收网络数据"] --> B["推进世界时间"]
B --> C["物理前Tick"]
C --> D["开始物理模拟"]
D --> E["物理模拟期间可并行的Tick"]
E --> F["结束物理并取回结果"]
F --> G["物理后Tick"]
G --> H["游戏定时器、摄像机、关卡流送"]
H --> I["发送网络数据、特效和条件垃圾回收"]这个顺序不是随便排的。例如:
- 网络数据要先收到,本帧逻辑才能使用最新状态
- 角色移动通常要在物理模拟之前提交
- 命中检测如果依赖物理结果,就要放在物理之后
- 摄像机最好等Actor位置更新完再计算
- 网络发送要尽量包含本帧已经完成的逻辑结果
所以UE没有把所有Actor Tick放在同一个大循环里,而是引入了TickGroup。
TickGroup解决什么问题
假设角色、物理和摄像机存在下面的依赖:
角色更新移动意图
→ 物理系统计算最终位置
→ 摄像机跟随最终位置如果摄像机跑在物理之前,它看到的就可能是上一帧位置;如果命中检测跑在物理还没结束的时候,也可能得到不完整结果。
TickGroup就是UE在一帧内部划出的几个阶段:
| 阶段 | 适合放什么 |
|---|---|
TG_PrePhysics | 物理前逻辑,例如角色移动意图 |
TG_StartPhysics | 启动物理模拟 |
TG_DuringPhysics | 可以和物理同时进行的工作 |
TG_EndPhysics | 等待物理结束并取回结果 |
TG_PostPhysics | 需要最新物理结果的逻辑 |
TG_PostUpdateWork | 需要主要更新完成后的工作 |
乍一看它很像“分几组依次遍历”,但源码里实际还多了一层任务调度。
一个Tick不是简单的函数指针
每个可以Tick的Actor或Component,内部都有一个FTickFunction对象。名字里虽然带Function,它实际上更像一张任务卡片,记录:
- 想从哪个TickGroup开始
- 最晚允许在哪个TickGroup结束
- 必须等哪些其他任务先完成
- 能不能放到工作线程执行
例如我们可以声明“摄像机Tick依赖角色Tick”。引擎收集本帧任务时,会根据依赖关系自动调整真正的执行顺序。
UE内部的TaskGraph负责调度这些任务。这里的TaskGraph可以简单理解成引擎自己的任务系统:它把工作分给游戏线程或后台工作线程,并在需要的地方等待任务完成。
流程大致如下:
收集本帧所有Tick任务
→ 分析它们之间的依赖
→ 到达某个TickGroup时放行这一组任务
→ 需要同步时等待它们完成
→ 进入下一组为什么物理期间可以并行
源码中有一处比较特别:
RunTickGroup(TG_DuringPhysics, false);第二个参数为false表示放行这组任务后,不立刻阻塞游戏线程等待它们全部完成。
此时物理系统可能正在其他线程计算,TG_DuringPhysics中的任务也可以同时运行。到了TG_EndPhysics,引擎再等待物理相关工作结束,保证后面的TG_PostPhysics能看到完整结果。
这里要特别注意:不是所有Gameplay Tick都会自动并行。
默认情况下,大部分Actor和Component Tick仍然运行在游戏线程。只有明确允许bRunOnAnyThread,并且代码本身满足线程安全要求时,任务才可能交给工作线程。UE的动画、布料、粒子和物理等内部系统会大量使用并行,但自己写的Actor Tick不会凭空变成多线程。
Tick里新生成的Actor怎么办
本帧任务通常在开始Actor Tick之前统一收集。但如果某个Actor在自己的Tick里又调用了SpawnActor(),新Actor显然不在最开始的任务列表中。
UE使用一个特殊的TG_NewlySpawned阶段处理这种情况。每个主要TickGroup完成后,引擎都会检查本帧是否又出现了新的Tick任务,如果有就继续执行,直到没有新任务为止。
源码还设置了循环上限,防止出现下面这种情况:
Actor A的Tick生成Actor B
→ Actor B的Tick生成Actor C
→ Actor C继续生成……如果一直没有结束,引擎最终会停止继续处理并报告问题,避免单帧陷入无限生成。
游戏定时器为什么不等于Actor Tick
我们平时调用:
GetWorldTimerManager().SetTimer(...);创建的回调由TimerManager统一管理。它不是一个Actor Tick,也不会随意插入某个TickGroup,而是在UWorld::Tick()的固定位置集中更新。
因此定时器回调和某个Actor Tick谁先执行,不能只看代码“感觉应该差不多”,还要看Actor所在的TickGroup以及当前引擎版本中TimerManager的位置。
摄像机、关卡流送和普通TickableObject也有自己的更新位置。理解一帧顺序的意义就在这里:遇到一帧差异时,可以先确认两个系统究竟谁先运行。
游戏线程和渲染线程
游戏逻辑更新完,并不代表画面已经被显卡中的图形处理器(GPU)画出来。
UE把工作大致拆成下面几层:
flowchart LR
GT["游戏线程<br/>更新Actor和世界"] --> RT["渲染线程<br/>整理绘制命令"]
RT --> RHI["渲染硬件接口线程<br/>转换成具体图形接口调用"]
RHI --> GPU["GPU<br/>真正绘制画面"]图中的“渲染硬件接口”英文是Render Hardware Interface,源码里经常缩写为RHI。它把上层渲染代码和Direct3D、Vulkan等具体图形接口隔开。
游戏线程不会等每条绘制命令立刻完成,而是把渲染工作放入队列,然后继续准备下一帧。这样CPU处理游戏逻辑时,渲染线程和GPU也可以同时工作。
但游戏线程也不能无限向前跑。如果它已经算到第100帧,GPU还在画第90帧,渲染命令会越堆越多,画面响应也会越来越慢。
因此FFrameEndSync会在帧末设置一道同步闸门,限制游戏线程相对渲染流水线领先太远。
r.OneFrameThreadLag=1时,通常允许渲染线程相对游戏线程落后一帧,以换取两边重叠执行;关闭后同步更紧,可能减少一部分延迟,但也会损失并行空间。
不同引擎版本和图形接口下,这道同步栅栏具体等待到渲染线程、硬件接口线程还是更后面的阶段会有差别。因此更准确的理解是:
它控制游戏线程和渲染流水线之间的距离,而不是简单地“每帧等待当前GPU全部画完”。
软件限帧和垂直同步
这两个功能都会让帧率降低,但原理完全不同。
软件限帧
t.MaxFPS发生在游戏线程。引擎先计算当前帧已经花了多久,再用目标帧时间减去它:
还要等待的时间 = 目标帧时间 - 本帧已经用掉的时间例如目标60帧,每帧预算约16.67ms。如果逻辑只用了5ms,引擎就等待剩下的约11.67ms。
等待结束后再计算的DeltaTime会包含这段时间,因此游戏世界仍然按照真实经过的约16.67ms推进。
垂直同步
r.VSync则一路传到渲染提交阶段,让交换链按照显示器垂直刷新节奏显示画面。它主要用于减少画面撕裂,不是游戏线程里简单调用一次Sleep。
所以:
| 功能 | 主要发生位置 | 解决什么问题 |
|---|---|---|
t.MaxFPS | 游戏线程 | 限制最高计算帧率、降低功耗 |
r.VSync | 渲染提交和显示阶段 | 对齐显示器刷新、减少撕裂 |
两者可以同时开启,也可以分别使用。
把整帧重新串起来
现在我们再从头走一遍:
FEngineLoop::Tick()读取时间,必要时处理软件限帧。- 引擎获取操作系统消息和输入。
UGameEngine::Tick()遍历当前存在的世界。UWorld::Tick()先接收网络数据并推进世界时间。- 引擎收集Actor和Component的Tick任务,根据TickGroup和依赖安排顺序。
- 物理前任务先执行,然后启动物理;部分工作可以和物理并行。
- 物理结束后,依赖结果的任务继续执行。
- TimerManager、摄像机、关卡流送等系统在各自固定位置更新。
- 网络发送、特效和条件垃圾回收完成本帧世界收尾。
- 游戏线程把绘制工作交给渲染线程,并在帧末限制流水线不要落后太远。
这样看下来,Actor Tick只是第5到第7步中的一部分。
总结
- UE4一帧的主干是
FEngineLoop::Tick → UGameEngine::Tick → UWorld::Tick。 - 一个进程可能同时存在多个World,所以引擎需要遍历
WorldList。 UWorld是已经运行起来的世界容器,连接着Level、Actor、物理、网络和渲染场景。- TickGroup用来表达物理前、物理中、物理后等执行阶段。
FTickFunction还可以声明任务依赖和是否允许工作线程执行,TickGroup并不是几个简单的for循环。- 普通Gameplay Tick默认仍以游戏线程为主,引擎内部系统才会大量使用并行。
- 游戏线程、渲染线程和GPU组成流水线,帧末同步负责限制它们不要相距太远。
- 软件限帧发生在游戏线程,垂直同步发生在渲染和显示阶段,两者不是同一件事。
理解这些顺序后,再遇到“为什么摄像机晚了一帧”“为什么定时器和Actor Tick顺序不对”“为什么物理结果还没更新”等问题,就可以先确定它们分别处于一帧的什么位置,再沿着实际调用顺序查下去。
可以自行阅读的主要源码位置
Launch/Private/LaunchEngineLoop.cppEngine/Private/GameEngine.cppEngine/Private/LevelTick.cppEngine/Private/TickTaskManager.cppEngine/Private/UnrealEngine.cpp
