Tick在计算机领域并没有很好的中文翻译,英汉词典里的解释是很短的一段时间,或者时钟的一次滴答。
在引擎里,tick对应的是引擎循环(EngineLoop)的单次执行,也可以认为是服务器的一次滴答;时钟的滴答是一秒滴答一次,但服务器一秒会滴答很多次。每秒的tick数越多,一般来说代表了服务器对客户端的响应可以更及时,一般意味着服务器的性能也就更高。
和Tick非常相关的一个概念是帧(Frame)。帧,最开始是衡量画面的刷新频率的;后面在帧同步的技术中,用于逻辑同步,一个逻辑帧是游戏逻辑执行的最小时间单位。在Unreal Engine的Dedicated Server中,由于和客户端共用了代码(比如Tick的计数器变量被命名为GFrameCounter),所以每次Tick执行,很多时候也被称为一帧。但为了突显Unreal原生使用的状态同步方式,我们用每秒128 tick,会比每秒128帧更准确。
我们看下Unreal Engine中的Tick:可以看到进程的主函数在完成初始化后,就会进入所谓的引擎循环,只要引擎没有被要求退出,就会一直执行Tick。
int32 GuardedMain( const TCHAR* CmdLine )
{
<初始化代码>
BootTimingPoint("Tick loop starting");
DumpBootTiming();
while( !IsEngineExitRequested() )
{
EngineTick();
}
TRACE_BOOKMARK(TEXT("Tick loop end"));
<其他代码>
}
EngineTick执行过程的调用层级如下:
GuardedMain(const wchar_t * CmdLine)
EngineTick()
FEngineLoop::Tick()
UGameEngine::Tick(float DeltaSeconds, bool bIdleMode)
UWorld::Tick(ELevelTick TickType, float DeltaSeconds)
针对于本文要讲的内容,我们需要关注下调用层级中的第三层,FEngineLoop层的Tick函数。
void FEngineLoop::Tick()
{
<其他代码>
// set FApp::CurrentTime, FApp::DeltaTime and potentially wait to enforce max tick rate
{
QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_UpdateTimeAndHandleMaxTickRate);
GEngine->UpdateTimeAndHandleMaxTickRate();
GEngine->SetGameLatencyMarkerStart(CurrentFrameCounter);
}
<其他代码>
// main game engine tick (world, game objects, etc.)
GEngine->Tick(FApp::GetDeltaTime(), bIdleMode);
<其他代码>
// Increment global frame counter. Once for each engine tick.
GFrameCounter++;
<其他代码>
}
这里主要关注如下三个执行逻辑即可:
1. UEngine::UpdateTimeAndHandleMaxTickRate() 顾名思义,就是更新引擎中使用的时间变量,并根据设置的MaxTickRate来休眠一定时间。
2. 调用UEngine::Tick()触发Gameplay中的Tick执行。
3. 在FEngineLoop::Tick()的最后,自增GFrameCounter。
对于Gameplay来讲,最重要的一步,则是调用层级中的第五层,调用当前游戏世界的Tick函数驱动游戏逻辑(UWorld::Tick)。
Engine的Tick函数提供了Tick运行的框架逻辑,Tick的主要运行开销还是集中在UWorld的Tick函数。
探究UWorld的Tick函数之前,需要先对Unreal Gameplay的相关类(Actor,Componen等)的作用有所了解,读者可以自行探究,这里不再赘述。如有有需要可以阅读我的另一篇文章,johnyao:UE网络通信(一) 概述。
UWorld Tick函数主要的开销有三处。
void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
<其他代码>
{
SCOPE_CYCLE_COUNTER(STAT_NetWorldTickTime);
CSV_SCOPED_TIMING_STAT_EXCLUSIVE(NetworkIncoming);
CSV_SCOPED_SET_WAIT_STAT(NetworkTick);
SCOPE_TIME_GUARD(TEXT("UWorld::Tick - NetTick"));
LLM_SCOPE(ELLMTag::Networking);
// Update the net code and fetch all incoming packets.
BroadcastTickDispatch(DeltaSeconds);
BroadcastPostTickDispatch();
if( NetDriver && NetDriver->ServerConnection )
{
TickNetClient( DeltaSeconds );
}
}
<调用Actor Component等的Tick函数>
// Update net and flush networking.
// Tick all net drivers
{
SCOPE_CYCLE_COUNTER(STAT_NetBroadcastTickTime);
LLM_SCOPE(ELLMTag::Networking);
BroadcastTickFlush(RealDeltaSeconds); // note: undilated time is being used here
}
在我们先从第二部分,Gameplay层的TickFunction开始介绍。
我们首先从Actor下面这个成员变量说起。
UPROPERTY(EditDefaultsOnly, Category=Tick)
struct FActorTickFunction PrimaryActorTick;
FActorTickFunction是从FTickFunction继承的一个类,FTickFunction及其派生类负责TickFunc的配置和运行调度。大部分的配置和调度相关的成员变量都在基类FTickFunction中定义; 在派生类里一般只有一个Target成员变量,它是指向TickFunc所属的类对象的指针。(TickFunc在这里特指Gameplay层面,某个具体的Gameplay对象在引擎某一Tick中需要完成的任务)
下面是Actor初始化的部分代码。这里展示了FTickFunction部分成员变量的使用:
void AActor::InitializeDefaults()
{
PrimaryActorTick.TickGroup = TG_PrePhysics;
// Default to no tick function, but if we set 'never ticks' to false (so there is a tick function) it is enabled by default
PrimaryActorTick.bCanEverTick = false;
PrimaryActorTick.bStartWithTickEnabled = true;
PrimaryActorTick.SetTickFunctionEnable(false);
<other-init-code>
除此之外,还需要重点了解的配置项有如下:
1. bAllowTickOnDedicatedServer是否在Dedicated Server中运行该Tick逻辑
2. TickInterval Tick运行的时间间隔,小于引擎Tick的运行时间, 则为每引擎Tick都执行
我们可以在蓝图内对如上属性进行配置:
上述两个配置项是DS上Tick优化的关键。很多的Tick逻辑只需要跑在客户端,甚至不需要跑tick,但由于unreal的代码生成工具生成的代码是把tick默认打开的(如下面代码所示),可能导致产生无谓的开销。
AMyActor::AMyActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
}
这里就引出了Tick常规优化的第一个常见做法:关闭不必要的Tick逻辑。
个人建议可以生成如下形式的代码,在必要的时候才打开客户端或者DS上的Tick执行。
#if !UE_SERVER
PrimaryActorTick.bCanEverTick = false; // client tick. default disable
#else
PrimaryActorTick.bCanEverTick = false; // ds tick. default disble
#endif
Tick常规优化的另外一个做法是,设置合理的Tick间隔。
很多Tick逻辑并不需要每帧都需要运行,比如AI补充刷新逻辑,我们可以将这部分Tick的TickInterval设置为秒级,甚至更长。
在Actor的BeginPlay函数中,会调用RegisterActorTickFunctions将TickFunc向FTickTaskManager进行注册。
void AActor::RegisterActorTickFunctions(bool bRegister)
{
check(!IsTemplate());
if(bRegister)
{
if(PrimaryActorTick.bCanEverTick)
{
PrimaryActorTick.Target = this;
PrimaryActorTick.SetTickFunctionEnable(PrimaryActorTick.bStartWithTickEnabled || PrimaryActorTick.IsTickFunctionEnabled());
PrimaryActorTick.RegisterTickFunction(GetLevel());
}
}
else
{
if(PrimaryActorTick.IsTickFunctionRegistered())
{
PrimaryActorTick.UnRegisterTickFunction();
}
}
FActorThreadContext::Get().TestRegisterTickFunctions = this; // we will verify the super call chain is intact. Don't copy and paste this to another actor class!
}
在World的Tick中,会调用FTickTaskManager的RunTickGroup,经过一系列调用,会执行到FActorTickFunction的ExecuteTick函数,这里调用Target的TickActor函数,最终会调用Tick函数。
void FActorTickFunction::ExecuteTick(float DeltaTime, enum ELevelTick TickType, ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
if (Target && !Target->IsPendingKillOrUnreachable())
{
if (TickType != LEVELTICK_ViewportsOnly || Target->ShouldTickIfViewportsOnly())
{
FScopeCycleCounterUObject ActorScope(Target);
Target->TickActor(DeltaTime*Target->CustomTimeDilation, TickType, *this);
}
}
}
void AActor::TickActor( float DeltaSeconds, ELevelTick TickType, FActorTickFunction& ThisTickFunction )
{
//root of tick hierarchy
// Non-player update.
// If an Actor has been Destroyed or its level has been unloaded don't execute any queued ticks
if (!IsPendingKill() && GetWorld())
{
Tick(DeltaSeconds); // perform any tick functions unique to an actor subclass
}
}
Component的TickFunc逻辑和Actor 90%类似,这里不再赘述,感兴趣的可以阅读相关代码。
我们继续上一小节,UWorld的Tick函数调用Actor Component等的TickFunc这部分内容。在本节中,我们看下从UWorld的Tick函数如何完成对Gamplay层各游戏对象TickFunc的调度。
上一节,我们已经介绍了一部分内容。大家可以回忆下这两点:
1. 每个Tick函数会指定一个TickGroup
2. Tick函数会向FTickTaskManager注册自身
关于TickGroup, 读者可以简单的理解为Tick执行的优先级。列举如下:
UENUM(BlueprintType)
enum ETickingGroup
{
/** Any item that needs to be executed before physics simulation starts. */
TG_PrePhysics UMETA(DisplayName="Pre Physics"),
/** Special tick group that starts physics simulation. */
TG_StartPhysics UMETA(Hidden, DisplayName="Start Physics"),
/** Any item that can be run in parallel with our physics simulation work. */
TG_DuringPhysics UMETA(DisplayName="During Physics"),
/** Special tick group that ends physics simulation. */
TG_EndPhysics UMETA(Hidden, DisplayName="End Physics"),
/** Any item that needs rigid body and cloth simulation to be complete before being executed. */
TG_PostPhysics UMETA(DisplayName="Post Physics"),
/** Any item that needs the update work to be done before being ticked. */
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,
};
在Gameplay层面, 关于Tick我们只需要关注到FTickFunction。我们使用它进行Tick函数的配置。
但在底层,完成Actor,Component TickFunc的执行,还需要如下几部分功能的支持。其中一部分,负责TickFunc的管理调度,主要有如下类组成:FTickTaskManager,FTickTaskLevel,FTickTaskSequencer;另一部分,负责TickFunc的依赖关系梳理以及TickFunc的执行:主要由TGraphTask,FTaskGraphCompatibilityImplementation等组成。再有就是更底层的任务队列和执行线程。
在TickFunction加入游戏世界时,主要是任务管理调度类参与,具体时序如下:
简单讲,就是调用TickTaskManager的AddTickFunction,最终添加到TickTaskLevel中。
Tick的函数执行要复杂的多,但可以梳理为如下时序:
在world的Tick函数中,会遍历自己的LevelCollections,对于LevelCollections中的每个colletion执行后续操作
1. StartFrame阶段
2. RunTickGroup阶段
这一步按本节开头给出的TickGroup的顺序,依次调用TickTaskManager的RunTickGroup。该函数的参数即具体的TickGroup。 这里最终会由FTaskGraphCompatibilityImplementation来调用具体的线程,来依次执行之前已经添加到队列中的Task(TickFunc)。
3. EndFrame阶段
该步会再次调用各个level的ScheduleTickFunctionCooldowns,将有TickIntervel,且刚执行完的TickFunc设置为CoolingDown状态。
本文介绍了UE4中的Tick, TickFunction等功能,以及TickFunction的调度,希望对正在研究学习这块的朋友有帮助。