LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

UE4中Game线程的最外层Tick

2022/5/6

在印象里面,这篇文章只用了两天时间就完成了

也就是在入职的第二天,在别人都还在配环境拉项目的时候

我已经速度拉满的根据profiler和源码弄懂了最外圈的引擎循环!

FengineLoop::Tick

前言

我们从启动入口launch.cpp中的流程来看

引擎在完成PreInitPreStartupScreen,PreInitPostStartupScreen,init

等一些初始化操作完成之后,便调用进入FengineLoop::Tick,这也是我们这次分析的主体

本次分析会以game线程所执行的流程来理解

由于UE的运行代码涉及到许多宏,插入性能测试,还有一些可能写了注释也看不懂的玩意

所以希望按图索骥先逐步摸清game线程中引擎所做的事情,而后再进一步分析game线程是怎么和渲染线程结合的

有些比较简单的流程会稍微深入查看1或者2层Depth看看他们的细节(具体怎么跑的,有什么需要注意的)


总体流程

GameThread是引擎运行的心脏,承载游戏逻辑、运行流程的职责

在FEngineLoop::Tick函数执行每帧逻辑的更新

宏观来看,GameThread的流程包括以下部分

WaitFPS

如果上一帧没有达到最小的MaxFPS所需的时间(这里理解最大帧率也就是最小帧耗时没有达到最小帧耗时就是这一帧处理的太快了)

会在此处进行Sleep让这一帧耗时更久点(可能是为了更新尽可能稳定,同时也方便其他线程和GameThread通信)

PS:Stat显示的FrameTIme其实是下一帧WaitFPS结束减去这一帧的WaitFPS结束

因为其实把WaitFPS连在结尾更符合直觉,Stat也是这么算的

SlateInput:

Slate UE4自带的自定义与平台无关的用UI框架

SlateInput阶段主要用于接收slate收到的鼠标键盘输入

EngineTick:

EngineTick是整个引擎流程Tick的主体,也是主要的耗时部分

其中的UWorld::Tick以每个level为单位进行分组方式

调用actor ticking,timer ticking ,tickable三种tick框架

SlateTick:

对Slate利用先前已经处理好的input进行Tick

这也是一个FengineLoop单位里面比较耗时的部分

FrameSync:

FrameSync一个loop里面进行的第二次的等待

意在完成render线程和game线程的同步,即使他们有一帧之差

在SlateTick完成之后会进行一个渲染命令的调用,之后会等待上一帧的render线程跑完

但因为game线程本来就比render线程要快一帧,所以基本上这里不怎么需要等待

如果这里还耗时了就说明render线程跑的不理想,存在瓶颈

DeferredTickTime

DeferredTickTime是一次loop的最后阶段,这个阶段以Fticker::tick为主体

同时也执行TickDeferredCommands,即为所有当前排队的延迟命令

Fticker::tick做了什么还不清楚,如何理解延迟命令还有哪些命令会排队也是暂未理解的


WaitFPS

在收起一些宏(我认为是做引擎的运行状态检查,平台检查,性能测试)之后

框架大概如下

void FengineLoop::Tick()
{

...
        FCoreDelegates::OnBeginFrame.Broadcast();
...

        // exit if frame limit is reached in benchmark mode, or if time limit is reached
        if ((FApp::IsBenchmarking() && MaxFrameCounter && (GFrameCounter > MaxFrameCounter)) ||
            (MaxTickTime && (TotalTickTime > MaxTickTime)))
        {
            FPlatformMisc::RequestExit(0);
        }

        // set FApp::CurrentTime, FApp::DeltaTime and potentially wait to enforce max tick rate
        {
            QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_UpdateTimeAndHandleMaxTickRate);
            GEngine->UpdateTimeAndHandleMaxTickRate();
            GEngine->SetSimulationLatencyMarkerStart(CurrentFrameCounter);
        }
        
        ....
        
        // beginning of RHI frame
        ENQUEUE_RENDER_COMMAND(BeginFrame)([CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
        {
            BeginFrameRenderThread(RHICmdList, CurrentFrameCounter);
        });
        
        ....
        
            FStats::AdvanceFrame( false, FStats::FOnAdvanceRenderingThreadStats::CreateStatic( &AdvanceRenderingThreadStatsGT ) );
            
        bool bIdleMode;
        {

            QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_Idle);

            // Idle mode prevents ticking and rendering completely
            bIdleMode = ShouldUseIdleMode();
            if (bIdleMode)
            {
                // Yield CPU time
                FPlatformProcess::Sleep(.1f);
            }
        }            
        
        

}

我认为的WaitFPS从 FCoreDelegates::OnBeginFrame.Broadcast()开始的

一开始是这点没有什么疑惑,随后去找Sleep操作发生在哪里

第一眼看见的是FPlatformProcess::Sleep(.1f),但是发生他在FStats::AdvanceFrame这一标志之后

也发生在提交BeginFrame的提交渲染命令之后,所以应该不是这句

随后把注意力锁到了中间的两段语句上

if ((FApp::IsBenchmarking() && MaxFrameCounter
&& (GFrameCounter > MaxFrameCounter)) 
||(MaxTickTime && (TotalTickTime > MaxTickTime)))
{
FPlatformMisc::RequestExit(0);
}

这说的应该是当实际的TotalTickTime达到了所能容忍的MaxTickTime

应该请求退出(稍微看了一下这个exit好像是直接退出引擎而非跳过此次循环)

接着是这一段:

        // set FApp::CurrentTime, FApp::DeltaTime and potentially wait to enforce max tick rate
        {
            QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_UpdateTimeAndHandleMaxTickRate);
            GEngine->UpdateTimeAndHandleMaxTickRate();
            GEngine->SetSimulationLatencyMarkerStart(CurrentFrameCounter);
        }

按注释所言这里可能会等待执行的Max tick rate,和我们的MaxFPS应该是同一个东西

Sleep的操作应该是在GEngine->UpdateTimeAndHandleMaxTickRate();中完成了

随后就是以RHICmdList, CurrentFrameCounter发送渲染命令RenderCmd_BeginFrame

还有FStats::AdvanceFrame将前进的这一帧(我理解这里的Advance是推进,前进)加入到统计数据里面来

这样WaitFPS的部分就完成了,进入SlateInput的部分。


Slate Input

整段收集Slate input的代码数量比较少

        // process accumulated Slate input
        if (FSlateApplication::IsInitialized() && !bIdleMode)
        {
            CSV_SCOPED_TIMING_STAT_EXCLUSIVE(Input);
            SCOPE_TIME_GUARD(TEXT("SlateInput"));
            QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_Tick_SlateInput);
            LLM_SCOPE(ELLMTag::UI);

            FSlateApplication& SlateApp = FSlateApplication::Get();
            {
                QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_Tick_PollGameDeviceState);
                SlateApp.PollGameDeviceState();
            }
            // Gives widgets a chance to process any accumulated input
            {
                QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_Tick_FinishedInputThisFrame);
                SlateApp.FinishedInputThisFrame();
            }
        }

首先是确认Slate是否初始化完成,且引擎不在Idle模式

暂时还不清楚这个idle模式是什么含义,但起码能清楚在这里我们不希望引擎是idle模式的

然后我们get到一个SlateApp,查询游戏设备的状态,

做完这步就结束这一帧的所有Slateinput储存起来

这一步也可能将之前累积的输入也一并进行处理


Engine Tick

GEngine->Tick(FApp::GetDeltaTime(), bIdleMode);

在一个Loop里面关于EngineTick的调用就这一行,也就是UGameEngine::Tick

但这一句调用的内部是十分复杂的,做了很多事情,所有gameplay相关的元素都在里面进行tick

所以计划之后再分析这个EngineTick的流程


Slate Tick

在insight中看,Slate Tick分为Slate::Tick (Platform and Input)和Slate::Tick (Time and Widgets)

FengineLoop::Tick()
{
        ...
   
        // Tick the platform and input portion of Slate application, we need to do this before we run things
        // concurrent with networking.
        if (FSlateApplication::IsInitialized() && !bIdleMode)
        {
            {
                QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_ProcessPlayerControllersSlateOperations);
                check(!IsRunningDedicatedServer());

                // Process slate operations accumulated in the world ticks.
                ProcessLocalPlayerSlateOperations();
            }

            FSlateApplication::Get().Tick(ESlateTickType::PlatformAndInput);
        }
        
        ...
        
        // Tick(Advance) Time for the application and then tick and paint slate application widgets.
        // We split separate this action from the one above to permit running network replication concurrent with slate widget ticking and painting.
        if (FSlateApplication::IsInitialized() && !bIdleMode)
        {
            FSlateApplication::Get().Tick(ESlateTickType::TimeAndWidgets);
        }
}

两部分Tick的中间的涉及到一些有关处理slate task并发的宏,还是以关注两部分的slate tick流程为主先将宏收起来

在聊聊tick这两部分的处理之前可以先看看Slate::Tick里面:

首先在FSlateApplication中有这样一句话:它(指slate::tick)在除了game thread之外的线程是无效的,除非我们只是更新时间

在insight里面确实如此,slate相关的task没有分到除了game线程之外的任何线程。

其次是不要在tick中不同的if-语句中添加代码,如果需要添加功能请添加在TickPlatform里面


Slate的Tick第一部分tick了平台和输入,跳转到了FSlateApplication::TickPlatform中

在这个函数中我理解的是将之前收集的input,转化为了消息然后pump出去

会计算一个bSynthesizedCursorMove(是否同步光标移动的值),这在下一部分的Tick将派上用场

然后会在所有user上GenerateGestures生成一个我们探测到的模拟姿态(还不理解做什么用)

这一部分主要是为下一步的draw slate的流程做准备,所以在流程图里面被称为PrePass(预先阶段)


Slate的Tick第二部分tick了时间和控件,TimeAndWidgets实际上在UE定义的枚举里面是包含了Time和Widgets的

所以将执行FSlateApplication::TickTime()和FSlateApplication::TickAndDrawWidgets(float DeltaTime)

TickTime里面的源码逻辑很简单

void FSlateApplication::TickTime()
{
    LastTickTime = CurrentTime;
    CurrentTime = FPlatformTime::Seconds();

    // Handle large quantums
    const double MaxQuantumBeforeClamp = 1.0 / 8.0;        // 8 FPS
    if (GetDeltaTime() > MaxQuantumBeforeClamp)
    {
        LastTickTime = CurrentTime - MaxQuantumBeforeClamp;
    }
}

GetDeltaTime()会计算一个真实的更新时间DeltaTime

如果超过了最大限制1/ 8秒会将LastTickTime减去1/ 8秒


从TickAndDrawWidgets的名字就能看出函数做的不仅仅是DrawWidget,也承担了一部分tick的职责

从后文来看,我们tick了一个一些时间用于后续计算,最主要的是tick了slate的通知(FSlateNotificationManager::Get().Tick())

函数内先是Renderer->ReleaseAccessedResources(/* Flush State */ false)

释放我们可能缓存和报告的任何临时材质或纹理资源,然后报告防止这些资源被GC,释放最后一帧使用的队列

然后FSlateInvalidationRoot::ClearAllWidgetUpdatesPending()清除所有上一帧挂起的更新

看得出来在Draw控件的时候是急需资源来进行渲染的。

接着一段比较复杂的计算平均更新时间的逻辑,具体实现看的不是很明白:

    // Update average time between ticks.  This is used to monitor how responsive the application "feels".
    // Note that we calculate this before we apply the max quantum clamping below, because we want to store
    // the actual frame rate, even if it is very low.
    {
        const float RunningAverageScale = 0.1f;
        AverageDeltaTime = AverageDeltaTime * ( 1.0f - RunningAverageScale ) + GetDeltaTime() * RunningAverageScale;
        if( FSlateThrottleManager::Get().IsAllowingExpensiveTasks() )
        {
            // Clamp to avoid including huge hitchy frames in our average
            const float ClampedDeltaTime = FMath::Clamp( GetDeltaTime(), 0.0f, 1.0f );
            AverageDeltaTimeForResponsiveness = AverageDeltaTimeForResponsiveness * ( 1.0f - RunningAverageScale ) + ClampedDeltaTime * RunningAverageScale;
        }
    }

但根据注释所言,最后计算出来的是一个真实的平均时间间隔,用于反应UI程序的感受和反应

这个值并不是为我们接下来渲染widget的流程服务的

它用于反应帧率是否在我们所理想的状态(FSlateApplication::IsRunningAtTargetFrameRate())


接着就是draw widget的部分,先是根据我们在外部上层计算得到的LastTickTime

计算了两个bool值bIsUserIdle和bAnyActiveTimersPending(是否闲置,是否有活跃的timer在等待)

来决定在下文是否要跳过我们的draw widget :

        const float SleepThreshold = SleepBufferPostInput.GetValueOnGameThread();
        const double TimeSinceInput = LastTickTime - LastUserInteractionTime;
        const double TimeSinceMouseMove = LastTickTime - LastMouseMoveTime;
    
        const bool bIsUserIdle = (TimeSinceInput > SleepThreshold) && (TimeSinceMouseMove > SleepThreshold);
        const bool bAnyActiveTimersPending = AnyActiveTimersArePending();

这就很符合直觉,毕竟假如用户没有做任何事情(Idle),那么这个Slate不刷新也是完全合理的

如果一直执行下去(没有sleep),则最后DrawWindows()更新所有的窗口


GetPendingCleanupObjects

首先我并没有直接看到同步的代码,而是看到了GetPendingCleanupObjects的操作

        // Find the objects which need to be cleaned up the next frame.
        FPendingCleanupObjects* PreviousPendingCleanupObjects = PendingCleanupObjects;
        PendingCleanupObjects = GetPendingCleanupObjects();

注释所言这段是用于获取下一帧用来清理的对象,也就是说这部分的GC是在每一帧都进行的

每帧获取当前的PendingCleanupObjects准备delete(其实感觉很少在ue里面看见原生的delete)

然后用GetPendingCleanupObjects将PendingCleanupObjects更新

点进函数里面看发现这部分是从render线程拿来的东西

准备被Delete的部分会在最后的tick ticker部分中被delete掉

Frame Sync

看着这个部分名字望文生义感觉像帧同步(其实根本不是网络那个帧同步)

但实际上是等待上一帧的render线程执行完成game线程才往下走(也就是渲染指令提交完毕之后)

因为在UE中允许Game线程比Render线程执行快一帧(也有可能是两帧)

这样就确保Render拿到的数据是完完全全计算好的了数据,Render线程可以尽情的自由发挥

(即使是这样做,很多项目的瓶颈也还是在Render上面)

这部分的代码构造也很简单:

        {
            SCOPE_CYCLE_COUNTER(STAT_FrameSyncTime);
            // this could be perhaps moved down to get greater parallelism
            // Sync game and render thread. Either total sync or allowing one frame lag.
            static FFrameEndSync FrameEndSync;
            static auto CVarAllowOneFrameThreadLag =             IConsoleManager::Get().FindTConsoleVariableDataInt(TEXT("r.OneFrameThreadLag")); 
            FrameEndSync.Sync( CVarAllowOneFrameThreadLag->GetValueOnGameThread() != 0 );
        }

所做的是同步Game和Render线程,要么完全同步要么有一帧之差

通过x函数拿到某个数作为Sync的参数,真正做同步操作的也是FrameEndSync.Sync这一步

一般来说波动小的情况下,Game线程不用在这里花太多时间等待render线程,除非Render线程的瓶颈太严重了


Deferred Tick Time

这个流程是以FengineLoop为单位的循环中的最后一步流程

在这个流程中我们tick core ticker,threads,还有一些DeferredCommands

首先的当务之急是先在前一帧之前排队等待延迟清理的对象:

delete PreviousPendingCleanupObjects;

紧接着是三行关键代码分别对应core ticker,threads,DeferredCommands:

FTicker::GetCoreTicker().Tick(FApp::GetDeltaTime());
FThreadManager::Get().Tick();
GEngine->TickDeferredCommands();

我最关心的是CoreTicker所指的是什么,而FTicker::Tick又做了什么事情

void FTicker::Tick(float DeltaTime)
{

    ....
    
    if (!Elements.Num())
    {
        return;
    }

    // make sure we scope the "InTick" state
    TGuardValue<bool> TickGuard(bInTick, true);

    CurrentTime += DeltaTime;

    while (Elements.Num())
    {
        
        if (Elements.Last().FireTime > CurrentTime)
        {
            TickedElements.Add(Elements.Pop(false));
        }
        else
        {
            CurrentElement = Elements.Pop(false);
            bCurrentElementRemoved = false;
            bool bRemoveElement = !CurrentElement.Fire(DeltaTime);
            if (!bRemoveElement && !bCurrentElementRemoved)
            {
                CurrentElement.FireTime = CurrentTime + CurrentElement.DelayTime;
                TickedElements.Push(CurrentElement);
            }
        }
    }
    Exchange(TickedElements, Elements);
    CurrentElement.Delegate.Unbind();
}

首先是检查属性Elements的数量,为空则直接跳过这次tick

我们发现Elements实际上是个以FElement和TInlineAllocator<1>为索引的二维数组

指的是未来将被fire(销毁,解雇,炒鱿鱼)的委托

接着往下看到while(Elements.Num())就知道整个FTicker::Tick都在做与委托相关的事情

我们会遍历整个Elements数组,挨个将数组内的元素弹出并跟踪他们确保他们的安全更新

在遍历完成之后会交换Elements和TickedElements,当tick完成时清除CurrentElement委托

这样一来我明白FTicker::Tick实际上就是用来fire(理解成解雇还挺不错的)所有延迟的委托的

虽然我此时也还并不是很清楚这么做的目的是什么


接着是FThreadManager::Tick(),根据他声明和定义中的注释

猜测这一步做的是tick所有的fake线程还有运行在他们之上的对象


实际上DeferredCommands是一个Fstring类型的数组,其中的元素一一对应着真正的Commands

通过ULocalPlayer::Exec( LocalPlayer->GetWorld(), *DeferredCommands[DeferredCommandsIndex], *GLog )

来将DeferredCommands一一执行,但哪些命令会在tick里面被延迟也还不清楚


完成所有流程后,我会广播FCoreDelegates::OnEndFrame.Broadcast()和调用EndFrame的RenderCmd

然后计算一下CPU的使用情况const FCPUTime CPUTime = FPlatformTime::GetCPUTime();

如果有加入性能分析的代码例如Trace框架,也会在这里打个桩告知Profiler以此为单位结束一个FengineLoop

这样一来一个单位的FengineLoop流程就走完了,虽有些细节还不了解是怎么做的为什么这么做

但大体的流程已经摸的差不多了,之后会深入的看下以Gengine和Uworld为单位的tick

也了解下整个tick的框架和actor的分组tick是怎么联系的