LOADING

加载过慢请开启缓存 浏览器默认开启

UE4的一帧都做了些什么

2026/8/27 GameDev UE 游戏引擎 Tick 多线程
本文总阅读量

UE4的一个帧生命周期的解读

最近顺着源码看了一遍发现,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渲染提交和显示阶段对齐显示器刷新、减少撕裂

两者可以同时开启,也可以分别使用。


把整帧重新串起来

现在我们再从头走一遍:

  1. FEngineLoop::Tick()读取时间,必要时处理软件限帧。
  2. 引擎获取操作系统消息和输入。
  3. UGameEngine::Tick()遍历当前存在的世界。
  4. UWorld::Tick()先接收网络数据并推进世界时间。
  5. 引擎收集Actor和Component的Tick任务,根据TickGroup和依赖安排顺序。
  6. 物理前任务先执行,然后启动物理;部分工作可以和物理并行。
  7. 物理结束后,依赖结果的任务继续执行。
  8. TimerManager、摄像机、关卡流送等系统在各自固定位置更新。
  9. 网络发送、特效和条件垃圾回收完成本帧世界收尾。
  10. 游戏线程把绘制工作交给渲染线程,并在帧末限制流水线不要落后太远。

这样看下来,Actor Tick只是第5到第7步中的一部分。


总结

  1. UE4一帧的主干是FEngineLoop::Tick → UGameEngine::Tick → UWorld::Tick
  2. 一个进程可能同时存在多个World,所以引擎需要遍历WorldList
  3. UWorld是已经运行起来的世界容器,连接着Level、Actor、物理、网络和渲染场景。
  4. TickGroup用来表达物理前、物理中、物理后等执行阶段。
  5. FTickFunction还可以声明任务依赖和是否允许工作线程执行,TickGroup并不是几个简单的for循环。
  6. 普通Gameplay Tick默认仍以游戏线程为主,引擎内部系统才会大量使用并行。
  7. 游戏线程、渲染线程和GPU组成流水线,帧末同步负责限制它们不要相距太远。
  8. 软件限帧发生在游戏线程,垂直同步发生在渲染和显示阶段,两者不是同一件事。

理解这些顺序后,再遇到“为什么摄像机晚了一帧”“为什么定时器和Actor Tick顺序不对”“为什么物理结果还没更新”等问题,就可以先确定它们分别处于一帧的什么位置,再沿着实际调用顺序查下去。

可以自行阅读的主要源码位置

  • Launch/Private/LaunchEngineLoop.cpp
  • Engine/Private/GameEngine.cpp
  • Engine/Private/LevelTick.cpp
  • Engine/Private/TickTaskManager.cpp
  • Engine/Private/UnrealEngine.cpp

本站不开放评论区,如有讨论内容请移步至本站Github讨论区