LOADING...

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

loading

UE4中Game线程的主流程Tick

2022/5/10

这篇文章中间磨洋工磨了比较久,整体写的也不是很好

主要是Uworld的更新涉及到太多的宏和函数跳转嵌套,给我整的麻麻的

但好在最后还是弄清楚了最主要的tick流程是在做什么

为接下来弄清楚RHI线程和Render线程做好铺垫

UGameEngine::tick

是FengineLoop中游戏线程的tick主体,实际上是UGameEngine::tick

其实这里困惑了我好久,因为我对着Gengine->Tick并不能直接找到其定义,到相关的cpp文件找也没找到

再加上ue源码的头文件和Cpp文件也不是一一对应的,一度怀疑自己眼花了还是cpp学的有问题

然后发现了是PURE_VIRTUAL这个宏搞的鬼,最后定位到了真正的tick函数的位置

(cpp文件中可以不用实现此函数,同时自己其他函数中又可以直接调用此函数,且子类需要强制实现此函数)

在FengineLoop的注释上对这个函数的解释是main game engine tick (world, game objects, etc.)

也印证game线程的主要tick都是在这个函数里面完成的

主要的学习方式还是按照一些前辈整合的一些流程先看看有个印象,然后再去源码里面挨个验证思想


总体流程

engine的tick分为以下几个阶段:

TickAsyncLoading:

也被称为StaticTick阶段,主要是做一些异步资源的更新

WorldTick:

遍历WorldList,逐World的做tick,也是我们这阶段tick的主体

里面其实还包含了对每个world都做一遍TickWorldTravel处理关卡加载逻辑

Tickable GameObjects Without World

引擎会让一些对象继承FTickableGameObject来获得tick的功能

我们这里tick的是不在世界内的tick,世界内Tickable的tick在Utick里面做

RedrawViewPort:

注释写的是Render everything,意思就是说这个draw操作就渲染了所有所需的元素

但写成Redraw让我怀疑这一次Loop里面在这之前也有draw的操作

后边想想这个Redraw可能是对应上一帧的,所以说Redraw也没错

渲染的流程走完以后还会进行一些后处理,例如PostRenderAllViewports做一些渲染完场景才能做的任务

然后还会给渲染队列塞个TickRenderingTimer来更新RT池


TickAsyncLoading

这里应该是涉及到UE4运行时期间加载资源的方法,引擎在这里调用了一个StaticTick

这样看得出来UE4的有些资源是runtime进行异步加载的,而且是逐tick

(可能猜想有些资源是逐world或者逐level更新,但还没有注意到相关内容)


WorldTick:

worldtick是GameEngine::Tick的主体之一,我们的逐level和分组tick都在里面完成

我们除了对每个world都做了tick之前还对他们做TickWorldTravel(Context, DeltaSeconds)来处理关卡加载的逻辑


Level的分组tick之前

首先是FDrawEvent* TickDrawEvent = BeginTickDrawEvent();

BeginTickDrawEvent()的构造里有ENQUEUE_RENDER_COMMAND(BeginDrawEventCommand)

向渲染队列发送一个BeginDrawEventCommand的命令,之后在DrawViewport阶段还会再塞一个BeginDrawingCommand

这种很相似的命名让我目前还没能分出这俩的功能差异

这个TickDrawEvent 会在最后再被调用一次,给渲染队列发送一个EndDrawEventCommand


首先会发送一个广播告知世界开始tick

FWorldDelegates::OnWorldTickStart.Broadcast(this, TickType, DeltaSeconds);

然后会更新我们的网络

        BroadcastTickDispatch(DeltaSeconds);
        BroadcastPostTickDispatch();

        if( NetDriver && NetDriver->ServerConnection )
        {
            TickNetClient( DeltaSeconds );
        }

接着验证是否是启用了高优先级的加载和无缝的切换地图,如果是就给异步加载更多时间

    if (Info->bHighPriorityLoading || Info->bHighPriorityLoadingLocal || IsInSeamlessTravel())
    {
        CSV_SCOPED_SET_WAIT_STAT(AsyncLoading);
        // Force it to use the entire time slice, even if blocked on I/O
        ProcessAsyncLoading(true, true, GPriorityAsyncLoadingExtraTime / 1000.0f);
    }

然后Tick我们的Nav导航系统:

    if (NavigationSystem != nullptr)
    {
        NavigationSystem->Tick(DeltaSeconds);
    }

Nav更新完以后会进行一个广播作为分组Tick开始的标志,在分组tick结束时也会进行一个广播

FWorldDelegates::OnWorldPreActorTick.Broadcast(this, TickType, DeltaSeconds);

....

FWorldDelegates::OnWorldPostActorTick.Broadcast(this, TickType, DeltaSeconds);

然后会遍历LevelCollection收集需要Tick的level假如到LevelsToTick,进行分组Tick

这取决于关卡是静态的还是动态的(一般我们所想可能是只会tick已加载的level)


Level的分组tick

分组tick如下:

    for (int32 i = 0; i < LevelCollections.Num(); ++i) {
       
        RunTickGroup(TG_PrePhysics);
        RunTickGroup(TG_StartPhysics);
        RunTickGroup(TG_DuringPhysics, false);
        RunTickGroup(TG_EndPhysics);
        RunTickGroup(TG_PostPhysics);
        
        GetTimerManager().Tick(DeltaSeconds);

        FTickableGameObject::TickObjects(this, TickType, bIsPaused, DeltaSeconds);
        
        PlayerController->UpdateCameraManager(DeltaSeconds);
        
        RunTickGroup(TG_PostUpdateWork);
        RunTickGroup(TG_LastDemotable);
        

    }

其实一般写Gameplay我们能够选择的分组主要就是四个:

TG_PrePhysics,TG_DuringPhysics,TG_PostPhysics,TG_PostUpdateWork

首先在PrePhysics开始之前就会StartFrame,进行模拟之前的工作,构建碰撞树

PrePhysics组

是一帧的开始。UE4很多component和actor的tick都在这里执行

此 tick 中的物理模拟数据属于上一帧,因为这一帧的物理模拟还没有开始

TG_StartPhysics:

在此之前会EnsureCollisionTreeIsBuilt()检查是否构建完物理树

随后通知PhysX进行物理模拟

TG_DuringPhysics

这个步骤和物理线程TG_StartPhysics组是并行的,不依赖物理的actor和component一般放这里tick

常见用途为更新物品栏画面或小地图显示。此处物理数据完全无关,或显示要求不精确,一帧延迟不会造成问题。

TG_EndPhysics:

通知PhysX停止物理模拟

TG_PostPhysics

物理模拟已经完成,假如我们的actor或者component需要依赖物理(骨骼,布料),则一般放在这里tick

渲染此帧时所有物理对象是位于它们的最终位置。

GetTimerManager().Tick:

Timer是Unreal 的定时器调度机制, 服务对象为 Delegate

FTickableGameObject::TickObjects(this, TickType, bIsPaused, DeltaSeconds):

Tickable Ticking 服务对象为 C++ 类, 我们会让对象继承自 FTickableGameObject 基类

重载TickGetStatId函数来获得Tick的功能

实现是每次 Tick 后遍历 FTickableStatics 集合中的所有 Tickable 对象并执行

因此 Tickable 对象会在每一帧执行, 不能设置 Tick 的时间间隔

UpdateCamera:

PlayerController->UpdateCameraManager(DeltaSeconds)会在这里更新相机

TG_PostUpdateWork:

在摄像机更新后发生。如特效必须知晓摄像机朝向的准确位置,可将控制这些特效的 actor 放置于此。

这也可用于在帧中绝对最靠后运行的游戏逻辑,如解决格斗游戏中两个角色在同一帧中尝试抓住对方的情况。

其中每个分组任务之间是串行的,必须在执行上一阶段分组Tick完成之后(否则阻塞)

才能执行下一阶段的分组Tick任务

Actor的Tick蓝图版本是实现了ReceiveTick(DeltaSeconds)

而cpp是通过LatentActionManager.ProcessLatentActions(this, MyWorld->GetDeltaSeconds());

这个函数在FengineLoop一些地方也能看到

Pawn是包含了AController的,它的tick通过AController 的 AddPawnTickDependency来实现

其他想获得Tick功能继承Uobject的则是通过继承FTickableGameObject获得Tick功能

在完成世界内的分组tick后,会进行一次广播宣告世界内的Tick完成

FWorldDelegates::OnWorldPostActorTick.Broadcast(this, TickType, DeltaSeconds);

Level的分组tick之后

在进行完Level的分组之后会简单的进行两次广播来刷新网络

BroadcastTickFlush(RealDeltaSeconds); 
BroadcastPostTickFlush(RealDeltaSeconds);

引擎会尝试GC(看来GC也是每帧都有),更新FX特效系统

最后执行EndTickDrawEvent往渲染队列塞一个EndDrawEventCommand

这样便于渲染线程知道卡在这里边的指令都是Uworld::Tick的


Tickable GameObjects Without World

前边tick Tickable的时候是Tick处于Uworld之内的,剩下的就会在这一步进行Tick

其实和Uworld内调用是同一个函数,只是参数Uworld设成了空指针


RedrawViewPort

RedrawViewPort(只谈EngineTick里面的部分)在游戏线程也是大头,

(我们的大头其实就是UWorld::Tick,DrawViewPort,DrawSlate)

分为渲染场景和渲染UI的部分,渲染UI的部分只会渲染一些Debug的UI信息例如stat

游戏UI采用的Slate的Tick层级实际上是和UworldTick在同一层级的)

不过我想实际上Game线程中这部分的Tick耗时主要还是做的线程调度和计算

渲染线程做culling、batching和渲染API的生成,RHI线程做渲染API的执行


首先进行GameViewport->Tick(DeltaSeconds)来tick,实际上是发送一个TickDelegate的广播

接着进入FViewport::Draw函数,进行一些截图数据的准备

准备完之后调用EnqueueBeginRenderFrame(bShouldPresent)

其中是ENQUEUE_RENDER_COMMAND(BeginDrawingCommand)发给渲染队列

接着调用ViewportClient->Draw(this, &Canvas)进行场景相关的计算

Draw函数有许多的继承,游戏调用的实际上是UgameViewportClient::Draw

image-20220510104406541

有一句重要的语句决定了我们走的是什么样的渲染管线,以及之后的渲染细节

GetRendererModule().BeginRenderingViewFamily(SceneCanvas,&ViewFamily);    
{
...
FSceneRenderer* SceneRenderer = FSceneRenderer::CreateSceneRenderer(ViewFamily, Canvas->GetHitProxyConsumer());
...
ENQUEUE_RENDER_COMMAND(FInitFXSystemCommand)
...
ENQUEUE_RENDER_COMMAND(FDrawSceneCommand)
...
}

这一句根据我们的ShadingPath决定走的是延迟渲染还是Mobile渲染(Mobile也可以做延迟渲染)

接着会将场景计算完成,打包成一个FDrawSceneCommand然后也发送给渲染线程

渲染场景的步骤完成后广播一次EndScene

跳出这一步后会把RT给clear掉然后继续渲染HUD,不过渲染与否居然是Slate决定的

// Clear areas of the rendertarget (backbuffer) that aren't drawn over by the views.
...

// Render the UI
if (FSlateApplication::Get().GetPlatformApplication()->IsAllowedToRender())
{
...
}

做完这些以后做一个广播,然后根据是否开启垂直同步进行一个渲染队列的入队操作

Canvas.Flush_GameThread();
UGameViewportClient::OnViewportRendered().Broadcast(this);
...
SetRequiresVsync(bLockToVsync);
EnqueueEndRenderFrame(bLockToVsync, bShouldPresent);

做完RedrawViewports的操作之后会通知渲染线程TickRenderingTimer,实际上所做的更新RT池的操作

这些以上就是ReDrawViewports的大部分主要操作,同时也是一套UGameEngine::tick的基本流程


全程的广播,函数嵌套,虚函数继承,多次往渲染队列添加命令让人感觉眼花缭乱

即使是大概看了一遍所有的流程依然还是觉得很晕,框架并没有像他的外层EngineLoop那么的简单明了

不过之后结合Render和RHI线程来理解再次加深分析应该还能再加深一点印象