委托与 Tick 机制
Delegates & Tick
UE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。
委托的三种类型
委托以类型安全的方式在将来调用任意对象的成员函数,调用方无需知道对象的类型。
| 类型 | 声明宏 | 绑定数 | 返回值 | 蓝图 | 特点 |
|---|---|---|---|---|---|
| 单播 (Single) | DECLARE_DELEGATE... | 1 | 支持 | 否 | 支持载荷 (Payload) |
| 多播 (Multicast) | DECLARE_MULTICAST_DELEGATE... | 多个 | 不支持 | 否 | Broadcast() 调用全部 |
| 动态 (Dynamic) | DECLARE_DYNAMIC_[MULTICAST_]DELEGATE... | 1 或多个 | 单播支持 | 是 | 可序列化,按函数名查找,比普通委托慢,不支持载荷 |
声明规则:签名可带返回值、const、最多 4 个载荷变量和最多 8 个参数;宏后缀如 _OneParam、_TwoParams、_RetVal。声明可在全局、命名空间或类声明内,不能在函数体内。UDELEGATE(...) 可给委托加 UFunction 说明符,例如 BlueprintAuthorityOnly。
绑定方式与安全性
| 单播绑定 | 多播添加 | 目标 | 安全性 |
|---|---|---|---|
BindUObject | AddUObject | UObject 成员函数 | 保存弱引用,对象销毁后可用 IsBound/ExecuteIfBound 处理 |
BindSP | AddSP | 共享指针对象 | 弱引用;AddSP 快但非线程安全 |
BindRaw | AddRaw | 原生 C++ 对象 | 无引用,对象删除后调用不安全 |
BindStatic | AddStatic | 全局/静态函数 | — |
BindLambda | AddLambda | Lambda | 多播版用于不捕获指针的 lambda |
| — | AddWeakLambda / AddSPLambda | 带弱引用的 Lambda | 目标 UObject / 共享指针仍有效时才调用 |
BindDynamic | AddDynamic / RemoveDynamic | 动态委托 | 辅助宏自动生成函数名字符串,目标须为 UFUNCTION |
执行
- 单播:
Execute()不检查绑定;未绑定时调用会触发断言,在某些情况下甚至可能写坏内存。应先IsBound(),无返回值时可用ExecuteIfBound()(仍要小心未初始化的输出参数)。 - 多播:
Broadcast()即使没有绑定也总是安全,会跳过已失效的对象。调用顺序未定义,不一定按添加顺序。Remove()为 且可能打乱顺序;RemoveAll(对象)移除绑定到该对象的全部函数,但不会移除未绑定对象指针的 Raw 委托。 - 委托可以复制;按值传递会在堆上分配内存,应尽量按引用传递。
实际项目中的用法
// 头文件:声明一个可在蓝图中绑定的事件
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth);
UPROPERTY(BlueprintAssignable)
FOnHealthChanged OnHealthChanged;
// 改变生命值处
OnHealthChanged.Broadcast(Health);
// 订阅方(处理函数必须是 UFUNCTION)
HealthComp->OnHealthChanged.AddDynamic(this, &AMyHUDActor::HandleHealthChanged);
- 需要蓝图绑定的事件(即蓝图里的事件分发器)用动态多播 +
UPROPERTY(BlueprintAssignable);纯 C++ 内部回调用普通单播/多播,更快且支持载荷。 - 引擎自身的大量事件就是委托,例如组件重叠事件
OnComponentBeginOverlap.AddDynamic(...),见 蓝图与 C++ 协作。 - UFunction 可以绑定委托,常用于把操作绑定到用户输入。
Tick 机制
Tick 指在 Actor 或组件上按固定间隔(通常每帧一次)运行代码或蓝图脚本。可设置为每帧、按最小间隔或完全不 Tick。普通 UObject 没有内置更新能力,需要时可继承 FTickableGameObject 并实现 Tick()。
Tick 组顺序:每个 Tick 组中的所有 Actor 和组件都 Tick 完,下一组才开始。下图按一帧内的时间顺序排列。
flowchart LR A["TG_PrePhysics<br/>帧开始;物理数据是上一帧"] --> B["TG_DuringPhysics<br/>物理模拟已开始,数据可能是上一帧或本帧"] B --> C["TG_PostPhysics<br/>本帧物理已完成"] C --> D["引擎:潜在动作、定时器管理器、<br/>更新相机、关卡流送"] D --> E["TG_PostUpdateWork<br/>相机已更新"] E --> F["引擎:处理延迟生成的 Actor,<br/>结束帧并渲染"]
| Tick 组 | 适合放什么 |
|---|---|
TG_PrePhysics | 要与物理对象交互的 Actor(含基于物理的附着),让其移动参与本帧物理模拟 |
TG_DuringPhysics | 不关心物理数据或能容忍一帧延迟的逻辑,如背包界面、小地图;与物理并行,互不等待 |
TG_PostPhysics | 需要物体最终位置的武器/移动射线检测,如激光瞄准器(差一帧会很明显) |
TG_PostUpdateWork | 依赖相机最终朝向的效果、给粒子系统喂最后时刻的数据、帧内最后裁决(如格斗游戏中两个角色同一帧互抓) |
Tick 依赖:AddTickPrerequisiteActor / AddTickPrerequisiteComponent 让本对象等指定对象 Tick 完再执行。与其把一批对象挪到更晚的组,不如只对真正依赖的一两个对象设依赖——同组内的对象可以并行更新,这样更高效。不同组之间天然有序,无需再设依赖。
官方例子:玩家角色瞄准激光、准星落在命中点、HUD 显示充能条。
| 对象 | 安排 | 理由 |
|---|---|---|
| 动画角色 | PrePhysics | 先动画,物理对象才能正确跟随和交互 |
| HUD | DuringPhysics | 不用物理数据,没必要互相等待;代价是显示晚一帧 |
| 准星 | PostPhysics | 对最终场景做检测,正好落在表面上 |
| 激光粒子 | PostUpdateWork,或 PostPhysics + 依赖准星 | 后者只等准星,不等其他 PostPhysics 对象,更高效 |
设置方式(通常在构造函数中,确保 BeginPlay 前生效;Actor 在 BeginPlay 中注册自身及组件的 Tick 函数):
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bTickEvenWhenPaused = true;
PrimaryActorTick.TickGroup = TG_PrePhysics;
// 组件用 PrimaryComponentTick,对应 TickComponent()
- 运行时开关:
SetActorTickEnabled/SetComponentTickEnabled。 - 一个对象可以有多个 Tick 函数:继承
FTickFunction,重写ExecuteTick和DiagnosticMessage;重写AActor::RegisterActorTickFunctions,调用SetTickFunctionEnable与RegisterTickFunction;用AddPrerequisite设依赖。可参考EngineBaseTypes.h中的FActorTickFunction、FComponentTickFunction。 - 组件可以和所属 Actor 处于不同的 Tick 组。
取舍:事件、定时器还是 Tick
| 方式 | 优点 | 缺点 |
|---|---|---|
| 委托 / 事件 | 只在发生时执行,零空闲开销;解耦发送方与接收方 | 多播顺序未定义;Raw 绑定有悬空风险;动态委托较慢 |
| 定时器 | 适合延时、周期性动作(如拾取道具后无敌 10 秒、毒气每秒伤害) | 世界定时器管理器在 PostPhysics 之后、PostUpdateWork 之前统一处理,不能任意选择执行阶段 |
| Tick | 适合连续变化、需与物理和相机精确对齐的逻辑 | 每帧开销,实例多时累积明显(尤其蓝图) |
官方建议:蓝图中用定时器或委托代替 Tick;Tick 依赖类且实例很多时是蓝图与 C++ 性能差异最大的场景之一。整体帧预算见 帧时间预算与渲染性能。
常见坑
- 默认 Tick 组下读取物理结果却在 PrePhysics 中:拿到的是上一帧数据,表现为跟随、瞄准“慢一帧”。
- 依赖多播委托的调用顺序实现逻辑:顺序未定义,随时可能改变。
- 绑定 Raw 委托后忘记解绑而对象已删除:回调时崩溃;UObject 优先用
AddUObject/AddDynamic(弱引用)。 - 动态委托按函数名查找绑定的函数,
AddDynamic的目标应是 UFunction(官方示例中的处理函数均为 UFUNCTION)。 - GC 只在帧间运行,因此一帧内解引用一次弱指针后使用是安全的,见 垃圾回收与对象生命周期。
GEA 的视角:分阶段、分桶与 Tick 组(卷 II 17.6.3、17.7.4)
GEA 第 17 章描述的更新调度可以和本页的 Tick 机制逐项对照,详见 游戏对象的实时更新:
- 分阶段更新:Naughty Dog 引擎每帧在动画混合前、混合后(最终姿势前)、最终姿势后三次更新对象;UE 的 Tick 组则以物理模拟为参照划分阶段(PrePhysics、DuringPhysics、PostPhysics、PostUpdateWork)。两者都是"在帧内固定位置运行对象逻辑"(对比归纳)。
- 回调优于遍历:GEA 认为与其遍历所有对象调用每个阶段的钩子,不如只让需要的客户注册回调。本页记录的"能用定时器或委托就不用 Tick,可以关闭不需要的 Tick"体现了同一原则。
- 分桶 vs Tick 依赖:GEA 按依赖森林的层级把对象分成固定的几个桶(载具/平台 → 角色 → 手持物),逐桶完整更新;UE 用
AddTickPrerequisiteActor/Component为单个对象设依赖,并建议不要为此把一批对象挪到更晚的组,以保留同组内的并行。GEA 自己也说桶切换会引入同步点,是该方案的主要不足之一。 - 一帧延迟:GEA 的规则是"对象状态在更新循环前后一致、循环中可能不一致",读取尚未更新的对象就会出现一帧延迟;本页 HUD 放在 DuringPhysics、"代价是显示晚一帧"的官方例子,就是有意接受这种延迟的一个选择。
出处
- Delegates UE 5.8 文档
- Multi-cast Delegates UE 5.8 文档
- Dynamic Delegates UE 5.8 文档
- Actor Ticking UE 5.8 文档
- Objects in Unreal Engine UE 5.8 文档
- Coding in UE: Blueprint vs. C++ UE 5.8 文档
- UFunctions UE 5.8 文档
- Properties (UPROPERTY) UE 5.8 文档
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.6.3 节
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.7.4 节