【转载】UE4 中的三种 Tick 方式

3,059 阅读5分钟

原文链接:UE4 中的三种 Tick 方式| Nero

0 前言

每帧更新或定时更新数据是在游戏开发中随处可见的,在 Unity 中我们一般都是在 UpdateLateUpdate 里做处理,而在 UE4 中则是在 Tick 中处理,比如 Actor/ActorComponent 中的 Tick 方法,今天来详细介绍一下 UE4 的几种 Tick 方式

1 主线程循环

在介绍 Tick 之前,有必要先了解 UE4 引擎的主线程(游戏线程)框架,在这里我们着眼于它的运行机制而不会详细介绍其中每个函数的具体实现

归根结底 UE4 是一个应用程序,需要有 main 函数作为启动入口,其代码在源码代码的 Launch 文件夹下:

UE4 封装好了在各个平台下的启动入口,它们最终都调用了 Launch.cpp 文件中定义的 GuardedMain 方法,在这里我们以 Windows 平台为例来介绍

int32 GuardedMain( const TCHAR* CmdLine )
{
    // 开始循环前的初始化,比如设置 GIsEditor 全局变量
    int32 ErrorLevel = EnginePreInit( CmdLine );
    {
#if WITH_EDITOR
        if (GIsEditor)
        {
            ErrorLevel = EditorInit(GEngineLoop);
        }
        else
#endif
        {
            ErrorLevel = EngineInit();
        }
    }
    // 主循环
    while( !IsEngineExitRequested() )
    {
        EngineTick();  //Tick逻辑,调用GEngineLoop.Tick()
    }
#if WITH_EDITOR
    if( GIsEditor )
    {
        EditorExit();
    }
#endif
    return ErrorLevel;
}

上面的代码展示了引擎主线程的整个生命周期,如你所见,当你学会了写 while 语句的时候,你已经有了写引擎的能力了(这当然是开玩笑的 T_T ),一般的引擎主线程代码就如下面代码一样:

int main()
{
    // 开始 while 循环前的一些逻辑处理
 
    while(等待一个结束循环的条件)
    {
        // 每隔一定时间执行一些逻辑,如驱动时间往前走、驱动动画播放、驱动UI界面刷新等
    }
 
    // 结束 while 循环后的一些逻辑处理

    return 0;
}

用一个流程图来展示 UE4 主线程的大致流程(该图参考了一位大神画的图,但是来源找不到了)

其中 while 循环中的 Tick 是我们今天介绍的重点,下面将详细介绍 UE4 中的三种 Tick 方式

2 三种 Tick 方式

2.1 TimerManager

通常我们的普通定时器可以用 TimerManager 来实现,该类实现了一系列 SetTimer,以及一个 ClearTimer ,在 SetTimer 中创建一个用于封装定时器的数据 TimerData,然后在 Tick 中处理这些 TimerData

TimerManager 中的 Tick 是在 World::Tick 中被调用(World::Tick就是在前面介绍的主线程循环的 Tick 中被调用的)

void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
  if (TickType != LEVELTICK_TimeOnly && !bIsPaused)
    {
        GetTimerManager().Tick(DeltaSeconds);
    }
}

用法很简单,下面给个简单示例:

class FTimerMgr
{
public:
    FTimerMgr();
    ~FTimerMgr();
    static FTimerMgr* GetInstance();
    int SetTimer(const TFunction<void(float)>& Callback, float Interval, bool bIsLoop);
    void RemoveTimer(int Index);
private:
    TMap<int, FTimerHandle> _timers;
    int _timerindex;
};

// 设置定时器
int FTimerMgr::SetTimer(const TFunction<void(float)>& Callback, float Interval, bool bIsLoop)
{
    FTimerDelegate timerDelegate;
    timerDelegate.BindLambda([=](const TFunction<void(float)>& func) {
        if (func != nullptr)
        {
            func(Interval);
        }
    }, Callback);
 
    FTimerHandle handler;
    GetTimerManager().SetTimer(handler, timerDelegate, Interval, bIsLoop);
    ++_timerindex;
    _timers.Add(_timerindex, handler);
    return _timerindex;
}

// 移除定时器
void FTimerMgr::RemoveTimer(int Index)
{
    if (!_timers.Contains(Index))
        return;
    FTimerHandle handler = _timers[Index];
    _timers.Remove(Index);
    GetTimerManager().ClearTimer(handler);
}

这段代码在我的另外一篇文章《ue4 lua中的定时器实现》中:

2.2 TickFunction

仔细看 ActorActorComponent 的代码,它们的 Tick(或TickComponent)方法是在 ExecuteTick 中被调用的,而 ExecuteTick 方法实际上是 FTickFunction 中的一个虚方法,在 ActorActorComponent 中都有一个 FTickFunction 的子类 FActorTickFunctionFActorComponentTickFunction 的成员变量

image.png

其实源码中还有很多 Tick 都是通过继承 FTickFunction 来实现的,不一一列举。

这里重点看看 FTickFunction,主要有以下成员变量/函数:

  • bCanEverTick:开启或关闭 Tick
  • TickInterval:设置 Tick 的间隔时间
  • TickGroup:一个枚举变量,它指定该 Tick 在一次引擎 Tick 的什么时机执行
  • RegisterTickFunction:将当前 TickFunction 注册到 Level(关卡)中去

TickGroup 是一个枚举变量,它指定该 Tick 在一帧中的执行时机

enum ETickingGroup
{
    /** 在物理模拟 Tick 前 Tick */
    TG_PrePhysics UMETA(DisplayName="Pre Physics"),
    /** 物理模拟 Tick. */                           
    TG_StartPhysics UMETA(Hidden, DisplayName="Start Physics"),
    /** 和物理模拟同步 Tick. */
    TG_DuringPhysics UMETA(DisplayName="During Physics"),
    /** 在物理模拟 Tick 之后 Tick. */
    TG_EndPhysics UMETA(Hidden, DisplayName="End Physics"),
    /** 刚体、布料等相关的物理模拟. */
    TG_PostPhysics UMETA(DisplayName="Post Physics"),
    /** 在所有 Tick 运行之后. */
    TG_PostUpdateWork UMETA(DisplayName="Post Update Work"),
    /** Catchall for anything demoted to the end. */
    TG_LastDemotable UMETA(Hidden, DisplayName = "Last Demotable"),
    /** Special tick group that is not actually a tick group. After every tick group this is repeatedly re-run until there are no more newly spawned items to run. */
    TG_NewlySpawned UMETA(Hidden, DisplayName="Newly Spawned"),
    TG_MAX,
};

TickFunction 中的 ExecuteTick 是如何被调用的?

首先是在 RegisterTickFunction 方法中调用 FTickTaskManagerAddTickFunction ,将该 TickFunction 注册到指定的关卡中去,具体代码:

void FTickFunction::RegisterTickFunction(ULevel* Level)
{
    if (!IsTickFunctionRegistered())
    {
        const UWorld* World = Level ? Level->GetWorld() : nullptr;
        if(bAllowTickOnDedicatedServer || !(World && World->IsNetMode(NM_DedicatedServer)))
        {
            // 注册到关卡中
            FTickTaskManager::Get().AddTickFunction(Level, this);
        }
    }
}

然后在 World::Tick 中调用 RunTickGroup,如果对 Level 与 World 的关系比较迷糊,可以参考:

Nero:关卡系统一、Level与World

为了避免大篇幅代码和 UE 源码分享不能超过 30 行的约定,这里只列出部分关键代码,感兴趣的可以自行阅读全部源码

void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
    for (int32 i = 0; i < LevelCollections.Num(); ++i)
    {
        if (bDoingActorTicks)
        {
            TickGroup = TG_PrePhysics; // reset this to the start tick group
            SCOPE_CYCLE_COUNTER(STAT_TickTime);
            {
                RunTickGroup(TG_PrePhysics);
            }
            {
                RunTickGroup(TG_StartPhysics);
            }
            {
                RunTickGroup(TG_DuringPhysics, false); // No wait here, we should run until idle though. We don't care if all of the async ticks are done before we start running post-phys stuff
            }
            TickGroup = TG_EndPhysics; // set this here so the current tick group is correct during collision notifies, though I am not sure it matters. 'cause of the false up there^^^
            {
                RunTickGroup(TG_EndPhysics);
            }
            {
                RunTickGroup(TG_PostPhysics);
            }
        }  
    }
    //...,这里省略了 FTickableGameObject 和 FTimerManager 的 Tick 
    if (bDoingActorTicks)
    {
        SCOPE_CYCLE_COUNTER(STAT_TickTime);
        {
            RunTickGroup(TG_PostUpdateWork);
        }
        {
            RunTickGroup(TG_LastDemotable);
        }
    }
}

需要注意RunTickGroup(TG_DuringPhysics, false) 第二个参数 false,表示不需要等待前面的 Tick 完之后再 Tick,即(不需要)同步 Tick

关于 RunTickGroup 的具体执行流程,不详细展开,可以打个断点看看调用堆栈

image.png

TickFunction 用法很简单,下面给出一个示例(也可以参考 Actor 的源码,看看它是如何实现的):

struct FCustomTickFunction : public FTickFunction
{
	FCustomTickFunction()
	{
		TickGroup = TG_PrePhysics;   // 这里可以改为支持外部传入,更灵活
		bTickEvenWhenPaused = false;
	}
	class FTimerMgr* Target;
	void ExecuteTick(float DeltaTime, ELevelTick TickType, ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent) override;
};
template<>
struct TStructOpsTypeTraits<FCustomTickFunction> : public TStructOpsTypeTraitsBase2<FCustomTickFunction>
{
	enum
	{
		WithCopy = false
	};
};

void FCustomTickFunction::ExecuteTick(float DeltaTime, ELevelTick TickType, ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
	check(Target);
        // 调用 FTimerMgr 中的 ExeTick
	Target->ExeTick(DeltaTime);
}

//设置 tick
int FTimerMgr::SetTicker(const TFunction<void(float)>& Callback, bool bIsLoop)
{
	TickFunction.bCanEverTick = true;
	TickFunction.RegisterTickFunction(GAOGameInstance->GetWorld()->PersistentLevel);
	TickFunction.SetTickFunctionEnable(true);
        //todo.
	return 0;
}

void FTimerMgr::ExeTick(float DeltaTime)
{
	//todo.
}

这段代码也在我的另外一篇文章《ue4 lua中的定时器实现》中:

Nero:ue4 lua中的定时器实现2 赞同 · 0 评论文章

2.3 TickableGameObject

FTickableGameObjectTick 原理比较简单,在构造函数中通过调用 AddTickableObject 将自己添加到 TickableObjects 数组中,然后在静态方法 TickObjects 中取出并执行自己的 Tick 方法

TickObjects 方法也是在 World::Tick 中被调用的

void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
    {
        FTickableGameObject::TickObjects(this, TickType, bIsPaused, DeltaSeconds);
    }
}

用法比较简单,可以参考一下 UAISubsystem 相关子类的代码的实现,比如UAIPerceptionSystem

class AIMODULE_API UAISubsystem : public UObject, public FTickableGameObject
{
    GENERATED_BODY()   
public:
    virtual void Tick(float DeltaTime) override {}
};

3 总结

本文从引擎的主线程循环开始,详细介绍了三种 Tick 的实现以及简单使用