UE5: 探究Actor Tick的注册与执行

发布时间 2023-11-09 09:43:42作者: 仇白

1. 前情提要

因工作需要,有在编辑器模式下执行Actor的Tick函数的需求。经过查阅资料,了解到重载Actor::ShouldTickIfViewportOnly函数可以实现在编辑器视口下也可以执行Tick函数。

已知Actor和ActorComponent都有自己的Tick函数,并且进入游戏并执行BeginPlay后才会开始Tick。

出于好奇心,产生了一系列的疑问:

  1. Actor和组件的Tick函数是由谁管理和统一调用的?
  2. 在执行BeginPlay后才会开始Tick,但是编辑器模式下默认并不会调用BeginPlay,那么为什么重载了ShouldTickIfViewportOnly()后照样开始了Tick?
    以此衍生出新的问题
    1. UE是如何控制Actor的Tick的开始的?
    2. 有哪些变量或者函数与之相关?

本文将从这些问题出发,简要地探究一下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 时的路径。

  1. SpawnActor 被调用。
  2. PostSpawnInitialize
  3. PostActorCreated - 创建后即被生成的 Actor 调用,构建函数类行为在此发生。PostActorCreated 与 PostLoad 互斥。
  4. ExecuteConstruction
    • OnConstruction - Actor 的构建。蓝图 Actor 的组件在此处创建,蓝图变量在此处初始化
  5. PostActorConstruction
    1. PreInitializeComponents - 在 Actor 的组件上调用 InitializeComponent 之前进行调用。
    2. InitializeComponent - Actor 上定义的每个组件的创建辅助函数。
    3. PostInitializeComponents - Actor 的组件初始化后调用。
  6. OnActorSpawned 在 UWorld 上播放。
  7. 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函数。

查看源码,发现调用了两个主要函数RegisterActorTickFunctionsRegisterAllComponentTickFunctions

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/