技术博客

Tick 机制:FTickFunction 的分组与依赖

第 25 / 100 篇 · 2026-05-25 · ue5 · GameFrameworkTick源码分析

"我的逻辑应该 Tick 在哪个时机"是 UE 业务代码里最影响正确性的问题之一:早一点读到的是上一帧的物理结果,晚一点就赶不上下一次物理步进。答案藏在 FTickFunction 的分组里。源码:Engine/Source/Runtime/Engine/Public/TickTaskManagerInterface.h、Private/TickTaskManager.cpp(UE5.3)。

注册:Actor 不是直接进循环

AActor 自己不持有循环句柄,它把 Tick 注册成一条 FTickFunction(ActorTickFunction)。RegisterTickFunction(Actor 在 PostInitProperties/BeginPlay 阶段调用)做的事是把这条记录挂到所属 ULevel 的 FTickTaskLevel 上:

// TickTaskManager.cpp(UE5.3)
void FTickFunction::RegisterTickFunction(ULevel* Level)
{
    FTickTaskManager::Get().AddTickFunction(Level, this);
}

每个 ULevel 持有一个 FTickTaskLevel,每个世界持有一个 FTickTaskManager 统筹所有 Level——这就接上上一篇的结论:流送进来的子关卡 Actor,Tick 记录只是在世界管理器里多了几条,循环本身没有变化。

六个 TickGroup 的真实含义

ETickingGroup 的顺序不是随便排的,它是围绕物理帧的相位定义:

TG_PrePhysics        物理步进之前(默认组)
TG_DuringPhysics     与物理并行(bRunOnAnyThread 的专属)
TG_PostPhysics       物理结果提交之后
TG_PostUpdateWork    相机/骨骼等后置更新
TG_LastDemotable     销毁与降级处理

设计动机要对着物理管线看。Chaos 在 GameThread 上的标准流程是:收集输入 → 步进(或提交异步步进)→ 解算完成。TG_PrePhysics 里移动组件把速度写给物理体;物理步进;TG_PostPhysics 里"读位置"的代码(比如对着物理结果做视线检测的 AI 感知)才拿得到本帧数据。如果你把移动代码放到 PostPhysics,等于用上一帧的碰撞结果推这一帧的位置——肉眼看不出来的延迟,网络同步时会放大成抖动。

TG_DuringPhysics 是最容易被误用的:它允许 Tick 与物理步进并行,前提是函数内不碰物理查询、不碰 GameThread 专属数据。引擎对它做了硬约束——只在 bRunOnAnyThread 且设置了对应标志时才真并行,否则退化成顺序执行。实测在这里加 Sleep 就能观察到自己的 Tick 和 stat physics 的交叠变化。

TG_LastDemotable 的语义最容易被忽视:这个组永远最后执行,且任何组都不能依赖它。原因是销毁安全。Actor 的 Destroy 不是立即执行的,真正的扫尾在帧末的 EndPlay/GC 前驱流程里。如果允许其他组依赖 LastDemotable,依赖方(比如 PrePhysics 的移动)就要等一帧末尾的清理逻辑跑完才能动,等于把全帧的销毁延迟串进了关键路径。反过来让 LastDemotable 依赖别人没有这个问题,因为它本来就最后跑。

Prerequisite:组内的偏序

同一 TickGroup 内的顺序默认"不保证"。要钉死顺序,用依赖:

// 让 Receiver 在 Provider 之后 Tick
ReceiverTick.AddPrerequisite(ProviderActor, ProviderActor->PrimaryActorTick);

AddPrerequisite(TickTaskManager.cpp)做的是双向登记:Provider 记一条 Prereqs(我要通知谁),Receiver 记一条 Prerequisites(我在等谁)。帧开始时调度器对组内任务做拓扑排序再执行,成环会被检测并报错而不是死循环。

注意一个不对称:AddPrerequisite 只管 TickFunction 之间的先后,管不了"我的 BeginPlay 在他的 Tick 之前"——BeginPlay 不属于 Tick 体系,那要靠 Actor 生成顺序或显式调用保证。把初始化塞进依赖链是常见误用。

为什么需要双向登记?单向"我等你"在增量场景会漏:帧中通过 SetTickFunctionEnable 动态启停某条 Tick 时,调度器要快速知道这条的启停影响哪些后继、哪些前驱,双向链表让"断线重排"变成 O(1) 摘除而不是全表扫描。帧内还有 TG_EndOfFrame? 不,六组就够了——帧间通过 FTickTaskSequencer 的 StartFrame/EndFrame 打点。

逐帧成本从哪来

FTickTaskManager::RunTickGroup 每帧对每组执行:收集本组任务 → 按依赖拓扑排序(无增量的帧里是增量维护的)→ 顺序调用 ExecuteTick。所以 Tick 开销不止回调本身,还有排序和遍历。项目里几百个 Actor 每帧 Tick 时,stat game 里 FTickTaskManager::RunTickGroup 占的那几毫秒很大一部分是任务管理开销而非你的逻辑——这也是"别给不需要每帧逻辑的 Actor 开 bCanEverTick"的量化依据。设了 PrimaryActorTick.TickInterval = 0.1f 的任务会被调度器跳过大部分帧,记录还在表里但 Execute 不跑,这就是低开销的关键:关的是执行频率,不是注册。

排查清单

  • 依赖物理结果的逻辑放 TG_PostPhysics 之后,放 PrePhysics 拿的是上帧数据。
  • "Tick 顺序时好时坏":同组无依赖本来就不保证序,加 AddPrerequisite 钉死。
  • 并行 Tick 里访问 GameThread 数据:用 TG_DuringPhysics 前确认 bRunOnAnyThread 与线程安全,不要靠"实测没崩"。
  • 帧末清理逻辑误放 TG_PrePhysics 然后又依赖某 Actor 已销毁——销毁是 LastDemotable 的活儿,依赖关系写反了会被引擎直接拒绝。

一帧内的 TickGroup 相位与依赖关系 TG_PrePhysics TG_DuringPhysics TG_PostPhysics TG_PostUpdateWork TG_LastDemotable 物理步进边界 写速度/输入 读结果/射线 销毁收尾 同组内:Prerequisite 构成偏序,帧首做拓扑排序 A(先) B 等待 A C 等待 A D 无依赖 B、C 可在 D 前后任意交错,只要都在 A 之后——引擎保证偏序,不保证全序 LastDemotable 可被任何人依赖,但不能依赖别人(拒绝成环+防延迟串扰)