super1 发表于 2021-8-13 16:35

【Unreal从0到1】【第二章:引擎渲染管线分析】2.1,线程的并行异步与渲染模块分析

在正式开始分析渲染管线前,有必要对虚幻引擎的任务系统进行简单梳理,这是理解多线程间通信的前提,只有先搞明白它的MeshDrawPipline是怎么回事,才能有条不紊的分析渲染管线各个阶段具体发生了什么
注意:源码为UE4.26
一,虚幻的多线程Task系统

虚幻引擎是多线程渲染的,分析引擎线程处理机制要了解它的Task系统。UE4虽然是基于C++11标准的但是并没有使用原生的多线程与原子操作,而是自己重新封装了相关的类库,并魔改了大量的语义语法,甚至重写了部分数学库【是的,一言不合就重写编译器,徒手撸库】
可以在核心模块——Core模块中找到所有线程处理文件,包括下面三类实现机制:
FRunable:标准多线程AsyncTask:基于线程池的异步实现TaskGraph:任务图表系统
1,FRunable
其中FRunable是标准多线程实现机制:


但FRunable类只是线程执行体,它会被作为参数传入FRunnableThread(这才是真正的线程),然后通过虚函数机制覆写并执行Run方法。FRunnableThread根据不同平台的API不同派生出了FRunnableThreadWin,FRunnableThreadApple,FRunnableThreadAndroid与FRunnableThreadUnix,FRunnableThreadHoloLens等。FRunable是多线程实现最直接最底层的方法不过官方并不推荐,因为UE4还有进一步封装的AsyncTask与TaskGraph
2,AsyncTask
AsyncTask主要由FAsyncTask与FAutoDeleteAsyncTask两部分组成:【注意:继承FNonAbandonableTask的Task不可以在执行阶段终止,即使执行Abandon函数也会去触发DoWork函数】


它是对Runable进一步的封装,基于FQueuedThreadPool实现的异步任务系统,本质上是对IQueuedWork(任务队列)的实现


FQueuedThreadPool是虚幻的线程池,其中Create用于创建不同的FQueuedThread,AddQueuedWork用于添加IQueuedWork,这是线程池的两个主要组成部分【但是注意FQueuedThreadPool是抽象类,即只提供接口并没有实现,实现是在FQueuedThreadPoolBase中完成的,它公有继承于FQueuedThreadPool】。其中FQueuedThread公有继承与FRunable(所以本质上还是通过FRunable实现的),不过多了一个指针类成员FEvent* DoWorkEvent【用于判断该线程的状态,如果挂起状态,则DoworkEvent执行wait函数返回false,如果线程池中通过AddQueuedWork为该线程增加了任务,并且执行了DoWorkEvent的Trigger函数,则wait返回true】


网上找到一份比较详细的关系图



3,TaskGraph
这是新版引擎默认使用的多线程实现机制,也是官方最推荐的,封装也是最复杂的,当然也是基于Runable实现的,可以在TaskGraph.cpp中找到相关实现:
TaskGraph中工作线程是FWorkThread,它由两部分组成FRunnableThread* RunnableThread
FTaskThreadBase* TaskGraphWorker。这两个是继承与FRunable的线程执行体,是真正的执行线程。


同样的,FTaskThreadBase也是一个抽象类,具体活儿是由其派生出的两个子类——FTaskThreadAnyThread与FNamedTaskThread实现的,顾名思义就是非指定名称的Task线程与指定名称的Task线程,后面讨论线程间通信时不加说明都是指有名称的Task线程




AsyncTask时通过FQueuedThreadPool来Create Thread和AddQueuedWork的,而TaskGraph通过FTaskGraphImplementation来创建和分配任务,这个类公有继承于FTaskGraphInterface【与FThreadManager类似,这才是任务分配的管理者,不过它也是个抽象类只负责提供接口,其功能具体实现是在FTaskGraphImplementation中完成的】。引擎初始化FTaskGraphImplementation会创建24个FWorkerThread,其中包括5个FNamedTaskThread:
GameThread:游戏线程,也是虚幻引擎的主线程,向RenderThread发出CommondListActualRenderingThread:渲染线程,也是本节重点关照的,向RHI发送CommondListRHIThread:RHI线程,向不同平台GPU发送指令AudioThread:AudioThreadStatThread:StatThread
以及N个FTaskThreadAnyThread类型的Thread。其中StatThread与RenderingThread会在引擎初始化阶段执行FEngineLoop.PreInit时创建新的Runbale
好了,浅尝辄止~【了解到当前程度对于TA来说足够了,客户端程序请继续深挖~】如果想更详细的梳理UE4的多线程Task系统,可以去研读这位大佬的Blog:
https://www.cnblogs.com/timlly/p/14327537.html
或者自己啃源码,接下来让我们把主要精力放在渲染线程上~
二,UE4的多线程渲染

1,主要线程间通信
实现多线程渲染只要关注GameThread,RenderThread与RHIThread即可。游戏线程是主线程是发送命令的,渲染线程与RHI线程只是执行者。游戏线程通过某些接口向渲染线程的Queue入队回调接口,以便渲染线程稍后运行时从渲染线程的Queue获取回调,一个个地执行从而生成Command List。
渲染线程负责分发执行渲染Task,这些任务用于处理逻辑渲染管线的不同pass,但这里的管线与渲染指令是平台无关的,它是一个抽象层,渲染线程通过向RHI线程发送CommondList,再由RHI线程向不同的硬件平台发送渲染Task以便对应的GPU完成运算。
RHI是一个中间抽象层,把逻辑渲染管线与底层硬件分离了开来实现跨平台渲染,实际上RHI层还可以细分为两部分,由具体的RHI执行层与上方的一个薄层组成。RHI抽象层负责接收逻辑渲染层指令,转化并分化给不同的RHI执行层,再由对应的RHI执行层调用对应平台的图形API向GPU发送硬件指令完成具体的渲染。


游戏线程要保证优先与对应渲染线程的执行,所以它们之间肯定是并行异步的,渲染线程与RHI线程也被设计为异步的


游戏线程由系统启动进程时被同时创建的,在引擎启动时直接存储到全局变量中,稍后会被设置到TaskGraph系统中,即整个TaskGraph系统要在引擎Prelnit阶段完成初始化,具体实现可以在LaunchEngineLoop.cpp中找到
int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
    (......)
   
    // 获取当前线程id, 存储到全局变量中.
    GGameThreadId = FPlatformTLS::GetCurrentThreadId();
    GIsGameThreadIdInitialized = true;

    FPlatformProcess::SetThreadAffinityMask(FPlatformAffinity::GetMainGameMask());
    // 设置游戏线程数据(但很多平台都是空的实现体)
    FPlatformProcess::SetupGameThread();
   
    (......)
   
    if (bCreateTaskGraphAndThreadPools)
    {
      SCOPED_BOOT_TIMING("FTaskGraphInterface::Startup");
      FTaskGraphInterface::Startup(FPlatformMisc::NumberOfCores());
      // 将当前线程(主线程)附加到TaskGraph的GameThread命名插槽中. 这样主线程便和TaskGraph联动了起来.
      FTaskGraphInterface::Get().AttachToThread(ENamedThreads::GameThread);
    }
}UE4为了整个结构更清晰采取了模块化的设计思路,一些类被设计用来在不同模块或者线程中通信,它们在命名上具备一定的规律,比如游戏线程中的UPrimitiveComponent就对应渲染线程中的FPrimitiveSceneProxy,出现频率比较高的有以下:


类似的,渲染线程与RHI线程间的通信也是通过一组Class实现的
2,渲染线程分析
研究UE4的渲染层主要是围绕RenderCore,Engine,Renderer三个模块进行的,后面在分析不同渲染管线执行逻辑时也是集中在这三个模块范围内,它们之间的关系简单概括如下:


其中渲染线程是在RenderCore模块中定义的,它独立于游戏线程存在。Renderer中主要定了引擎所支持的不同渲染管线,会在后面几篇中逐一分析。
渲染线程可以在RenderingThread.h与RenderingThread.cpp中找到,并在RenderingThread.h中声明了全部对外的接口,RenderingThread.cpp完成相关接口与功能的定义,具体可以查看相关代码。这里只梳理下渲染线程的大概执行顺序:
(1)初始准备
多线程渲染同样也是基于TaskGraph的,所以首先FTaskGraphInterface初始化相应的渲染线程所需的FNameTaskThread,然后调用StartRenderingThread函数并创建渲染线程执行体FRenderingThread,并把全局变量GIsThreadedRendering标为true。
(2)执行主渲染线程
渲染执行体FRenderingThread承载了渲染线程的主要工作,这也是一个抽象类,公有继承自FRunnable,它提供了一系列接口"获取同步事件.处理线程捕获关系,设置渲染线程平台相关的数据“并最后进入渲染线程主循环。渲染的主循环由RenderingThreadMain完成,包括”当前线程附加到TaskGraph的RenderThread插槽中,渲染线程不同阶段的处理,恢复线程线程到游戏线程“等


这里注意RenderingThreadMain只是负责把当前线程附加到TaskGraph中,创建新的渲染线程是在引擎PreInit阶段实现的,FEngineLoop::PreInitPostStartupScreen会调用StartRenderingThread,所以这里才是真正创建渲染线程Runnable的地方,RenderingThread.h与RenderingThread.cpp中只是提供调用的接口。
(3)结束并恢复到游戏线程

3,RHI线程分析
RHI线程的接口实现单独封装在了RHI模块中,然后在渲染线程中完成调用,可以在RedneringThread.cpp文件中的FRHIThread中找到:


其中 FRunnableThread* Thread表示所在的RHI线程;Start函数用于在开始时创建RHI线程;Run函数是主要的实现函数,包括“初始化TLS,将FRHIThread所在的RHI线程附加到askGraph体系中并指定到ENamedThreads::RHIThread,启动RHI线程直到返回,然后清理TLS”一套功能接口;FRHIThread& Get()则是一个单例接口用于保证线程安全。
对于渲染线程可以发现渲染线程是在FEnginelLoop:PreIintPostStartupScreen中调用StartRenderingThread()执行实现Runnable创建的,FRenderingThread本身只是提供了主要的功能接口。但RHI线程则是直接在FRHIThread对象内创建实际线程的,同时FRHIThread也是在StartRenderingThread()中。
关于RHI一些比较重要的类:
FRHICommandBaseFRHICommandFRHICommandListBaseFRHIComputeCommandListFRHICommandList:FRHIAsyncComputeCommandListImmediateFRHICommandListImmediate
它们之间的关系如下:


看样子好像有两种向GPU API发送渲染命令的方式——通过插入FRHICommandListBase链表,或者直接调用相应渲染平台FDynamicRHI的实现,我看主要是采用第一种【这一块还没有完全读透,后面找时间补一下~~】。
FRHIComputeCommandList是一个非常核心的类,提供了很多与着色层通信的接口:一种是***uniformBuffer,一种是***Parameter,一种是***ComputeShader,还有堆栈处理。
shaderParameter与ShaderResourceParameter代表了两类C++与HLSL的Bind,前者用于绑定可调参数,后者用于绑定贴图资源,计划在后面分析物理着色的时候进一步梳理具体实现细节。Setshadertexture与setshadersampler看样子是纹理绑定与采样器相关的
SetGlobalUniformBuffer与SetShaderUniformBuffer分别是全局常量缓冲与材质常量缓冲,关于这两类缓冲区的详细内容可以去翻“龙书”
三,不同渲染管线调用

分析渲染管线的具体实现大部分时间聚焦于Renderer模块:


Fcene是一个功能庞大的基类,定义了各种场景剔除与加速的八叉树结构,灯光数据;                          FSceneRender主要用于创建场景渲染,这里是万物开始的地方,里面列举了一些重要的成员函数但并不是全部,其中有几个函数尤其需要注意:
CreateSceneRender是一个指针里面根据不同的shading Path来new不同的Renderer,虚幻就是通过这一机制来区分开走哪一条渲染管道的;


FMeshElementCollector用于向FSceneView收集所有通过可见性测试的PrimitiveSceneProxy对象来生成不用的MeshBatch,MeshBtach记录了一组拥有相同Material Render Proxy(材质实例)和VertexFactory的FMeshBatchElement【单个网格模型的数据,包含网格渲染中所需的部分数据,如顶点、索引、UniformBuffer及各种标识等】接下来SetupMeshPass会出场,根据不同的MeshBatch生成不同的MeshPassProcesser,进而生成MeshDrawCommand【发生在InitView阶段,2.2分析PC端渲染管线时会详细说明】,即把MeshBatch置入不同的pass并生成MeshDrawCommand提交RHI抽象层,RHI抽象层根据不同平台的差异交给对应的RHI应用层链接具体的GPU执行渲染,整个过程如下:


MeshBatch用来解耦FPrimitiveSceneProxy与不同pass之间的联系,MeshBatch与MeshPassProcesser的设计意味着场景中每一个物体可能执行的pass是不一样的,至于DrawIndexedPrimmitive则是DX的绘图API(OpenGL就是glDrawElements)。
FSceneRender会派生出FDeferredShadingSceneRender与FMobileSceneRender,可以在这两个类中找到延迟渲染管线与移动渲染管线都有哪些pass,每个pass分别做了什么。
所以,接下来会对不同渲染管道进行具体分析~
Reference:

阅读源码时这位大佬的Blog帮我少走了很多弯路,感谢那些“开疆扩土”的勇士!
https://www.cnblogs.com/timlly/p/14327537.html
页: [1]
查看完整版本: 【Unreal从0到1】【第二章:引擎渲染管线分析】2.1,线程的并行异步与渲染模块分析