LOADING...

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

loading

UE的render线程和RHI

2022/5/14

在入职一星期多一点的时候,成功的通过集思广益一些前辈的文章还有对源码的了解

梳理出了主线程和render以及RHI线程是如何分工的

这样一来,虽然不清楚各个模块的细节,但UE的整体架构和执行流程弄清楚了

也根据自己已学的内容和前辈的经验尝试总结出了一些优化的方案

我很庆幸我拥有一个特别好的导师,在学习的旅途中总是及时出现为我梳理思路解答难题

而我也很幸运的一路向前冲没有掉进哪个模块的坑里,最后能把全局都大体拿下

能在一星期掌握整套的流程,真的感觉十分的开销

UE的渲染线程和RHI以及相关优化

在UE中,game线程负责逻辑tick和其他线程的调度

render线程负责剔除、合批,场景遍历和draw api的生成

RHI线程负责drawapi的执行(我理解是把渲染命令serialize,通过图形API到GPU)

UE以前是没有RHI的,只有game线程和render线程

为了加快渲染线程的计算能力,把场景遍历放到了task system中的其他线程上,

可见性剔除则继续留在渲染线程中,提交渲染命令就轮给RHI来做

本篇会梳理一次EngineLoop中,提交渲染命令的几个关键点,

适当的深入源码,配合insight还有一些前辈的整合的资料来分析UE的渲染和RHI是如何执行的

Let’s Go!


渲染线程一览

拿张图来稍微梳理一下三大线程模型的概念是什么样的

img

image-20220511111049070

不难看出,我们在render线程从BeginFrame开始作为一个循环,在EndFrame结束渲染工作进入CPU Stall Wait

render线程的具体工作分为两部分,即Draw Scene和Draw Slate,在他们的流程中调度RHI线程来协助工作

render线程依次从渲染队列拿任务来做的,当没有任务时就会等待,同时在渲染中也可能等待RHI

当任务特别多时,game线程会在许多阶段触发flush操作强行等待渲染线程执行到某个位置,这样则会导致game线程等待

我们还是会希望尽量把线程跑满,尽可能减少等待的情况发生


BeginFrame and EndFrame

把开始和结束放在一起,是因为他们性质相似,都在FengineLoop中简单的往渲染队列发送命令

render线程会告知相关的rhi线程开始和停止工作

FengineLoop::Tick()
{
....
        // beginning of RHI frame
        ENQUEUE_RENDER_COMMAND(BeginFrame)([CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
        {
            BeginFrameRenderThread(RHICmdList, CurrentFrameCounter);
        });
        
....
        
        // end of RHI frame
        ENQUEUE_RENDER_COMMAND(EndFrame)(
            [CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
            {
                EndFrameRenderThread(RHICmdList, CurrentFrameCounter);
            });
....
}

两个操作分别在FengineLoop::Tick()的开头与末尾,他们的具体执行的函数分为两部分,之后往后其他入队操作也是如此

一部分后边传入进宏的lambda表达式,用于计算和提供信息,而ENQUEUE_RENDER_COMMAND对应的宏的则跑了:

template<typename TSTR, typename LAMBDA>
FORCEINLINE_DEBUGGABLE void EnqueueUniqueRenderCommand(LAMBDA&& Lambda)

这个函数一般跑在渲染线程上,同时根据一些状态判断来决定是否调用RHI或者其他线程来协助渲染任务的进行。

一般情况下往往会走到调用传入的lambda然后调度RHI的if分支。


DrawScene

我们一般在计算完场景之后,往渲染队列发送渲染场景的命令,具体位置如下

FengineLoop Tick()
{
   UGameEngine::Tick()
   {
     UGameEngine::RedrawViewports()
      {
       FViewport::Draw()
       {
          EnqueueBeginRenderFrame()  //实际上是更新RT到BackBuffer上
          ...
          ViewportClient->Draw()
          {
             UGameViewportClient::Draw()
             {
             
                //calculate scene in game thread 
                FRendererModule::BeginRenderingViewFamily()
                {
                  ...
                  ENQUEUE_RENDER_COMMAND(FDrawSceneCommand)
                  ...
                }
                
             }
          }
          ...
          UGameViewportClient::OnViewportRendered().Broadcast(this);
        }
      }
   }
}

由Insight和渲染关系的流程图可知,计算场景的部分还是由game线程做的

计算场景完之后才往渲染队列发送FDrawSceneCommand命令,两个过程是串行的同步的,有依赖关系

在ENQUEUE_RENDER_COMMAND(FDrawSceneCommand)中将SceneRenderer以lambda传给渲染线程

FDrawSceneCommand分为三个部分,Init,PostInit,ExecCmd

实际上每个阶段所做的都是Flush一些API calls

Init:

主要是做visibility(裁剪)和shadow的计算

PostInit:

这个时候需要等待RHI线程执行完(所以说RHI线程如果执行有瓶颈也会反卡到Render线程)

ExecCmd:

这个地方就真正的生成各种Pass流程的api往RHI上送

可以说这个部分是一次绘制中真正的大头

(实际观察发现这里的时间并不是最多的,有些时候在Init阶段做裁剪甚至比这个阶段时间更多)


结束场景绘制之后,还会进行一些DebugHUD(stat)的绘制,

这个步骤也处于ReDrawViewport中,紧接着场景的计算之后由Game线程执行

可以理解绘制场景和HUD都依赖场景的计算,而绘制场景在Render线程中进行,绘制HUD则在Game线程


DrawState

真正绘制UI的地方绘制场景之后,由Game线程上的Slate::Tick(Time and Widget)

在Game线程的FSlateApplication::TickAndDrawWidgets中发生,相关的计算都进行在Game线程

在调用相关的宏之后往render线程塞入RenderCmd_DrawSlate

render线程在完成绘制场景的任务后才会执行绘制State的部分

因为game线程往往跑得快,而绘制State也不需要什么预计算直接拿着game线程的数据跑

因此这一步在渲染线程上也不怎么耗时

image-20220513190949587

此时render线程上做的和当时在绘制场景时做的也差不多,也是flush生成一堆api往RHI送


ENQUEUE_RENDER_COMMAND

ENQUEUE_RENDER_COMMAND执行在Rendering线程,调用则是一般在Game线程

我们可以看看这个ENQUEUE_RENDER_COMMAND的宏是做什么的

#define ENQUEUE_RENDER_COMMAND(Type) \
    struct Type##Name \
    {  \
        static const char* CStr() { return #Type; } \
        static const TCHAR* TStr() { return TEXT(#Type); } \
    }; \
    EnqueueUniqueRenderCommand<Type##Name>

以FengineLoop中每帧结束的地方为例,将原处替换就变成了

// end of RHI frame
        struct EndFrameName 
        {  
        static const char* CStr() { return "EndFrame"; } 
        static const TCHAR* TStr() { return TEXT("EndFrame"); } 
        }; 
        EnqueueUniqueRenderCommand<EndFrameName>(
            [CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
        {
            EndFrameRenderThread(RHICmdList, CurrentFrameCounter);
        });

实际上所做的是将lambda表达式子的值传入到EnqueueUniqueRenderCommand里面

我们可以看看几个比较典型此处的lambda中的函数(因为不习惯lambda一开始还以为只跑一个函数)

EndFrameRenderThread:

static inline void EndFrameRenderThread(FRHICommandListImmediate& RHICmdList, uint64 CurrentFrameCounter)
{
    RHICmdList.EnqueueLambda([CurrentFrameCounter](FRHICommandListImmediate& InRHICmdList)
    {
        GEngine->SetRenderSubmitLatencyMarkerEnd(CurrentFrameCounter);
    });

    FCoreDelegates::OnEndFrameRT.Broadcast();
    RHICmdList.EndFrame();
}

实际上是接受了RHI的命令列表还有帧数的计数器

在其中做一个广播,通知RT已经清空,然后在RHI的列表上告知这一帧已结束

同理我们也可以分析一下BeginFrame时做了什么

BeginFrameRenderThread:

static inline void BeginFrameRenderThread(FRHICommandListImmediate& RHICmdList, uint64 CurrentFrameCounter)
{

    GRHICommandList.LatchBypass();
    GFrameNumberRenderThread++;

    RHICmdList.BeginFrame();
    FCoreDelegates::OnBeginFrameRT.Broadcast();

    RHICmdList.EnqueueLambda([CurrentFrameCounter](FRHICommandListImmediate& InRHICmdList)
    {
        GEngine->SetRenderSubmitLatencyMarkerStart(CurrentFrameCounter);
    });
}

同样接受了RHI的命令列表还有帧数的计数器

从命名上推断,给渲染线程的帧数计数++,然后在RHI列表里面启动这一帧,做一个广播通知RT已经准备完成

看了一下GFrameNumberRenderThread等重要数据会放在CoreGlobalscpp里面

其中还存了GInputTime,GFrameNumber,推测这里的G可能是Global或者是Game的意思(感觉前者)

其实和渲染相关的函数内部也很符合直觉,有较高的可读性,毕竟这些线程什么都是跑在CPU上的


我们的函数EnqueueUniqueRenderCommand,在RenderingThread.cpp上

这个是在ENQUEUE_RENDER_COMMAND宏之内的,用于发送渲染命令的较为通用的一个函数

template<typename TSTR, typename LAMBDA>
FORCEINLINE_DEBUGGABLE void EnqueueUniqueRenderCommand(LAMBDA&& Lambda)
{
    typedef TEnqueueUniqueRenderCommandType<TSTR, LAMBDA> EURCType;

    if (IsInRenderingThread())
    {
    
        FRHICommandListImmediate& RHICmdList = GetImmediateCommandList_ForRenderCommand();
        Lambda(RHICmdList);
        //执行lambda表达式
    }
    else
    {
        if (ShouldExecuteOnRenderThread())
        {
            //检查渲染线程,创建一个渲染任务,压入渲染队列立即由渲染线程执行
            CheckNotBlockedOnRenderThread();
            TGraphTask<EURCType>::CreateTask().ConstructAndDispatchWhenReady(Forward<LAMBDA>(Lambda));
        }
        else
        {
            //直接由主线程执行
            EURCType TempCommand(Forward<LAMBDA>(Lambda));
            FScopeCycleCounter EURCMacro_Scope(TempCommand.GetStatId());
            TempCommand.DoTask(ENamedThreads::GameThread, FGraphEventRef());
        }
    }
}

调用ENQUEUE_RENDER_COMMAND一般都在主线程,也就是Game线程

而ENQUEUE_RENDER_COMMAND执行往往是在Rendering线程,所以往往走if的第一个分支

我们传入的Lambda表达式由此处被执行,因此假如在BeginFrame时调用的ENQUEUE_RENDER_COMMAND,

则展开就是EnqueueUniqueRenderCommand(LAMBDA&& Lambda)

实际上就是将底下的这个式子传入给RHI线程跑,我们最终也是通过这个来调度RHI的

[CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
        {
            BeginFrameRenderThread(RHICmdList, CurrentFrameCounter);
        });

Insight截取分析

我们跑了UE官方的的ActionRPG项目(手游,PC端调试运行,延迟渲染管线)

添加了-statnamedevents命令行参数,获得了我们所需的几乎所有想知晓的事件

image-20220510183221588


这个案例走的是延迟渲染的管线,截取帧耗时16.4ms,其中13.4ms用于Wait FPS,游戏线程的真实tick实际上只做了2.9ms

看得出来我们的目标帧率为60fps,13.4ms的时间在等待说明我们很轻易的就在游戏线程上跑Tick

假设我们耗时16.4ms的gameloop对应的是图中右下角的render loop

同样的看我们从WaitFPS时塞入渲染队列的BeginFrame,渲染用时4.9ms

一样是很轻松,大部分时间都用于CPU Stall,出色的完成了任务

这证明在此机器上,我们在较低负载的情况下完成了游戏循环,循环进行的较为理想和可控

因此综上所述,目前没有明显的性能瓶颈。


移动端的性能优化目标

笔者目前在研究移动平台的性能优化,对比PC端来说,移动端的硬件性能较为羸弱

需要考虑尽可能的减少Wait时间来提高CPU和GPU的效率,并结合安卓/IOS的平台做线程的优化

同时也需要考虑高负载带来的电量消耗和性能问题,所以满载运行也不一定是最好的选择

因此我所理解的性能优化,是根据不同设备进行不同的性能分级

尽可能的在目标机型可以承受的情况下拉高占用率提高硬件效率跑到目标帧率

同时通过限定目标帧率来控制功耗和温度,达到尽可能长时间在移动平台上有稳定表现

(例如能偶尔跑上60或者刚开始可以跑3分钟60,不如全程锁45)


目前能想到的优化手段

按照前面的截取示例来说,我们的游戏循环已经较为理想了

但假设我们会希望再好一些,把等待的时间再缩短,负载率再提高,应该怎么做呢

优化Uworld的分组Tick

在我们截取的项目中,Uworld的Tick中的PrePhysics阶段是耗时较高的一个阶段

PrePhysics用的是上一次Tick完的最新的物理数据,也是我们的默认tick,不加以优化的话可能很多事情都会在这里做

此时会启动物理线程来进行物理模拟,和DuringPhysics并行执行

而由于分组Tick是串行的,所以后续步骤需要等待DuringPhysics和他并行的物理线程走完才能接着往下

也就是说即使我们在DuringPhysics什么也不做,他也会被物理线程拖慢时间

img

因此我们可以将一些不依赖物理结果的步骤放到During或者其他地方

这样看来我们在等待物理线程执行时Game线程在During时期就不用处于等待状态,也能进行一些Tick的工作

这样一来虽然总的Tick任务还是一样的,但是省去了Game线程上等待物理线程的时间


提前写入UI的深度跳过一些绘制

在Render线程中,每次渲染Slate的部分都是在渲染场景之后才进行的

这样不免有一些像素可能会被UI挡住,这些被挡住的场景像素实则是浪费掉的

可以考虑在每次渲染流程开始之前,先在RT上UI的对应位置先写入深度值

这样在处理场景绘制的时候在进行到深度测试时就会自动跳过这些对应区域的绘制


在合适的情况下增加RHI线程

经过一些前辈的优化文章和对RHI线程的介绍,我对RHI的也多了一些了解和想法

在4.22时RHI甚至还是实验性功能,也就是说一开始UE的渲染是不包含RHI的

RHI是Render Hardware Interface的缩写,把各个平台的图形API包装成统一接口,供上层渲染来使用

RHI线程负责将Render API serialize推送到GPU上执行(iOS不开RHI单独的线程,安卓会开单独的RHI线程)

而图形API中的一些heavy指令例如glDrawArray这种,会要求GPU分配缓冲区执行各种操作

GPU此时会禁止CPU再提交命令过来,这个时候CPU就会进行等待,而单RHI线程只拥有单个cmdbuffer的serialize能力

因此在渲染场景中的大物件或者其他需要大量Draw Call的情况下,RHI线程会成为瓶颈

有些先进的图形API例如METAL/Vulkan则具有多个cmdbuffer,可以多开RHI然后并行化的serialize API command


所以不难看出,其实并不是越多draw call就越有可能引起RHI卡顿,实际上还是heavy api的锅

RHI通常是卡GPU在对api的处理上,而不是对api的serielize上

所以在一些前辈的优化方案中可以看到他们采取的是将一些heavy api分离到其他的RHI中去做

但本身RHI一条线上具有依赖时序关系的工作线被分到几条线上去做

如果不能解决相对应的依赖关系,最后导致几个RHI线程互等也是没法解决问题的

例如,draw api有对资源的依赖(buffer,shader link to program,texture)

因此如何最终的真正做到将RHI线程上的工作做到并行化才是真正的难题