UE5: 探究Actor Tick的注册与执行
1. 前情提要
因工作需要,有在编辑器模式下执行Actor的Tick函数的需求。经过查阅资料,了解到重载Actor::ShouldTickIfViewportOnly函数可以实现在编辑器视口下也可以执行Tick函数。
已知Actor和ActorComponent都有自己的Tick函数,并且进入游戏并执行BeginPlay后才会开始Tick。
出于好奇心,产生了一系列的疑问:
- Actor和组件的Tick函数是由谁管理和统一调用的?
- 在执行BeginPlay后才会开始Tick,但是编辑器模式下默认并不会调用BeginPlay,那么为什么重载了ShouldTickIfViewportOnly()后照样开始了Tick?
以此衍生出新的问题- UE是如何控制Actor的Tick的开始的?
- 有哪些变量或者函数与之相关?
本文将从这些问题出发,简要地探究一下Actor的Tick机制。
2. Actor和ActorComponent Tick的实质
FTickFuntion结构体
众所周知,我们可以通过修改PrimaryActorTick.bCanEverTick
的方式来控制一个Actor是否会被Tick,以此为线索,我们很快就能找到相关的代码,也就是PrimaryActorTick
成员所属的结构体:FActorTickFunction
。
同样的,我们可以在Component中找到类似的结构体FActorComponentTickFunction
,这两个结构体都是继承了FTickFunction
结构体,区别在于FActorTickFunction
保存了指向Actor的指针,FActorComponentTickFunction
保存了指向Component的指针,除此以外也没有显著的区别了。
那么我们当前研究的重点就是FTickFunction
。
以上是这个结构体的主要结构。
关于这个类,网上已经有不少研究了,这里就直接使用知乎网友制作的图片,可以从文末的链接中找到原文。
其中有两个很重要的函数:
ExecuteTick()
该函数是一个虚函数,功能和名字一样,提供了一个统一执行Tick的功能。子类可以对其进行重写,在实际运行时,在注册的地方统一调用所有该类的ExecuteTick
函数,实现Tick的执行与Actor实现的解耦。
例如FActorTickFunction
的实现中,它会调用AActor::TickActor
,再调用Actor的Tick
函数。正如注释所言,它是Actrually execute the tick的,那么说明真正调用Tick函数的其实是Actor中的成员变量PrimaryActorTick
。
要想知道Tick是怎样注册并统一执行的,就得看看另一个主要函数。
RegisterTickFunction(class ULevel* Level)
这个函数很短,主要就是调用了FTickTaskManager::Get().AddTickFunction(Level, this);
,而这个函数则根据传入的Level,拿到Level的一个FTickTaskLevel
类型的成员变量,并调用该变量的AddTickFunction
函数,把FTickFunction
保存到FTickTaskLevel
的一个保存TickFunction的集合AllEnabledTickFunctions
中。
到这里为止,我们就能大致了解Actor的Tick到底是怎样被执行了。
在Actor刚被创建的时候,此时还没有任何一个地方会调用Tick函数,Actor处于静止的状态。
而后在游戏进程的某个阶段中,会将PrimaryActorTick
成员变量的FTickFunction::ExecuteTick
注册到Level中的一个变量里的集合中。例如,游戏运行的过程中,创建Actor会自动调用其BeginPlay
函数,在这个函数中就有注册Tick的操作。
通过这些操作,Level可以获取所有Actor的Tick函数,在World的Tick中,就可以通过遍历Level的方式获取所有已注册的Actor的Tick,并将其一起执行。
点到即止,关于FTickTaskLevel
的运行机制这里就不深究了,接下来我们看看FTickFuntion
有哪些重要参数需要我们注意:
- bCanEverTick:是否允许注册Tick。当这个值为False时,就不会将ExcuteTick函数注册,因此Tick函数将不再被执行。官方的注释还提到,这个值只应该在初始化的时候修改。
- bStartWithTickEnabled:是一个EditDefaultOnly的变量,在蓝图的名字叫做“启用Tick并开始"(UE5.1)。如果其值为false,不管有没有注册,那么Tick函数都不会被执行。这个值可以在运行的时候动态调整。
- TickInterval:设置Tick的间隔时间
- TickGroup:一个枚举变量,它指定该Tick在一次引擎Tick的什么时机执行
顺带一提,Actor
类有一个变量也值得注意:
- bAllowTickBeforeBeginPlay: ”允许开始播放前Tick“,哦我的上帝,看看这蹩脚的翻译。之前在编辑器中看到这个选项总是一头雾水,现在了解过源码后也知道了其含义:是否允许在调用BeginPlay函数前进行Tick。
网上总说Actor会在BeginPlay
函数调用后才会开始Tick,从之前的分析中我们也知道,在Beginplay中会对tick函数进行注册。那么在beginplay之前呢?也有地方调用注册函数吗?
文章的后半段,我会简要地从源码角度探究这个问题,并简要地了解Actor的初始化。
3. Actor的初始化(Tick注册相关)
这是生成(实例)Actor 时的路径。
- SpawnActor 被调用。
- PostSpawnInitialize
- PostActorCreated - 创建后即被生成的 Actor 调用,构建函数类行为在此发生。PostActorCreated 与 PostLoad 互斥。
- ExecuteConstruction:
- OnConstruction - Actor 的构建。蓝图 Actor 的组件在此处创建,蓝图变量在此处初始化
- PostActorConstruction:
- PreInitializeComponents - 在 Actor 的组件上调用 InitializeComponent 之前进行调用。
- InitializeComponent - Actor 上定义的每个组件的创建辅助函数。
- PostInitializeComponents - Actor 的组件初始化后调用。
- OnActorSpawned 在 UWorld 上播放。
- BeginPlay 被调用。
以上内容来自官方文档,这是三条路径的其中一条。
官方文档给出了三种不同的Actor生成方式,通过断点调试测试,发现大多数时候的Actor生成都会走上面的这条路线,包括SpawnActor生成、从文件浏览器拖入场景、PIE等。
可以看出,BeginPlay才是Actor初始化最后的一环,而前面还有很多初始化的环节。
AAcotr::PostSpawnInitialize
Actor初始化的大部分操作都在这个函数里进行,包括初始化Actor的位置、Actor的所有权、组件的初始化等等。上述路径的2-5步骤都在这个函数里进行。
然后在这个函数中的某一行,我们发现了RegisterAllComponents()
函数。很简洁明确的函数名,我们关注的Tick注册就在这个函数里。
RegisterAllComponents()
函数很简单,里面只有个AActor::IncrementalRegisterComponents
函数。
而终于在这个函数里,我们找到了注册Tick函数的入口👇
bool AActor::IncrementalRegisterComponents(int32 NumComponentsToRegister, FRegisterComponentContext* Context)
{
...不关心
// If we are not a game world, then register tick functions now. If we are a game world we wait until right before BeginPlay(),
// so as to not actually tick until BeginPlay() executes (which could otherwise happen in network games).
if (bAllowTickBeforeBeginPlay || !World->IsGameWorld())
{
RegisterAllActorTickFunctions(true, false); // components will be handled when they are registered
}
...不关心
}
从if语句可以看出,当bAllowTickBeforeBeginPlay
为true时或者当前World不是GameWorld时,会执行RegisterAllActorTickFunctions
函数。这就与前面bAllowTickBeforeBeginPlay
的介绍对应上了,解释了为什么该bool选项可以允许Actor在beginplay前进行Tick。
除此以外,如果当前World不是GameWorld,也会对Tick函数进行注册。众所周知,UE包含有多个world,除了GameWorld(worldType == Game)以外,还有EditorWorld、EditorPreviewWorld等等。也就是说,在gameworld之外,UE会帮我们注册好Actor的所有Tick函数。
RegisterAllActorTickFunctions(bool bRegister, bool bDoComponents)
官方注释是这么写的:当被调用时,将调用虚函数调用链来注册Actor和可选的所有组件的所有tick函数。
查看源码,发现调用了两个主要函数RegisterActorTickFunctions
和RegisterAllComponentTickFunctions
void AActor::RegisterAllActorTickFunctions(bool bRegister, bool bDoComponents)
{
if(!IsTemplate())
{
// Prevent repeated redundant attempts
if (bTickFunctionsRegistered != bRegister)
{
...不关心
RegisterActorTickFunctions(bRegister);
bTickFunctionsRegistered = bRegister;
...不关心
}
if (bDoComponents)
{
for (UActorComponent* Component : GetComponents())
{
if (Component)
{
Component->RegisterAllComponentTickFunctions(bRegister);
}
}
}
...不关心
}
..不关心
}
点进去发现,这两个函数实际上就是调用了FTickFuntion的RegisterTickFunction函数。同样的,在if语句中我们也能看到bCanEverTick
的作用,如果这个值为false,无论如何都不会有机会调用Tick函数了,因为注册在这一步就卡住了。
void AActor::RegisterActorTickFunctions(bool bRegister)
{
check(!IsTemplate());
if(bRegister)
{
if(PrimaryActorTick.bCanEverTick)
{
PrimaryActorTick.Target = this;
PrimaryActorTick.SetTickFunctionEnable(PrimaryActorTick.bStartWithTickEnabled || PrimaryActorTick.IsTickFunctionEnabled());
PrimaryActorTick.RegisterTickFunction(GetLevel());
}
}
..不关心
}
BeginPlay
前面探讨了BeginPlay之前是如何注册Tick函数的,那么BeginPlay是否也有类似的逻辑呢?
还真有👇
void AActor::BeginPlay()
{
... 无所谓
SetLifeSpan( InitialLifeSpan );
RegisterAllActorTickFunctions(true, false); // Components are done below.
TInlineComponentArray<UActorComponent*> Components;
GetComponents(Components);
for (UActorComponent* Component : Components)
{
// bHasBegunPlay will be true for the component if the component was renamed and moved to a new outer during initialization
if (Component->IsRegistered() && !Component->HasBegunPlay())
{
Component->RegisterAllComponentTickFunctions(true);
Component->BeginPlay();
ensureMsgf(Component->HasBegunPlay(), TEXT("Failed to route BeginPlay (%s)"), *Component->GetFullName());
}
...不想看
}
...不关心
}
可以看到,beginplay几乎没有进行什么条件判断,果断地对Actor和所有component进行了Tick函数地注册。也就是说,在调用BeginPlay后,Actor及其拥有的所有组件都会注册Tick函数。至于注册tick函数后是否会被执行,那么就是其他bool变量控制的了(如bStartWithTickEnabled变量)。
4. ShouldTickIfViewportOnly的原理
最后解决文章开头的问题,ShouldTickIfViewportOnly是如何在编辑器视口中发挥作用的?
最终通过断点调试找到了它发挥作用的地方。
总之,在World进行Tick的时候,会尝试执行所有已注册的Tick函数,如果ShouldTickIfViewportsOnly返回为true的话,就相当于给tick函数开了一个万能通行证,无论如何该Actor的组件都会被允许tick。
ExecuteTickHelper(UActorComponent* Target, bool bTickInEditor, float DeltaTime, ELevelTick TickType, const ExecuteTickLambda& ExecuteTickFunc)
{
if (Target && IsValidChecked(Target) && !Target->IsUnreachable())
{
... 不关心
if (Target->bRegistered)
{
AActor* MyOwner = Target->GetOwner();
//@optimization, I imagine this is all unnecessary in a shipping game with no editor
if (TickType != LEVELTICK_ViewportsOnly ||
(bTickInEditor && TickType == LEVELTICK_ViewportsOnly) ||
(MyOwner && MyOwner->ShouldTickIfViewportsOnly())
)
{
const float TimeDilation = (MyOwner ? MyOwner->CustomTimeDilation : 1.f);
ExecuteTickFunc(DeltaTime * TimeDilation);
}
}
}
}
参考
UE4中的Tick机制浅析 - 知乎 (zhihu.com)
UE4中的三种Tick方式 - 知乎 (zhihu.com)
[Actor 生命周期 | 虚幻引擎文档 (unrealengine.com)](https://docs.unrealengine.com/4.26/zh-CN/ProgrammingAndScripting/ProgrammingWithCPP/UnrealArchitecture/Actors/ActorLifecycle/