Filament引擎(三) ——引擎渲染流程
通过Filament引擎(二) ——引擎的调用及接口层核心对象的介绍我们知道,要在项目中使用filament,首先我们需要构建出filament的Engine的对象,然后通过filament::Engine对象实例,来构建其他对象,组装渲染场景,执行渲染操作等。那么filament::Engine的构建过程具体发生了什么事情呢?我们组装的渲染场景,又是如何被渲染渲染出来的?在本篇博客中,我们进一步去了解下Filament引擎的内部是如何工作的。
一、Engine构建
我们通过Engine::Builder().build()
来创建filament::Engine对象,会调用到Engine* FEngine::create(Builder const& builder)
函数来创建FEngine对象。
在编译时,如果开启FILAMENT_SINGLE_THREADED宏,则filament不会开启单独的渲染线程,否则Engine.create的时候,会构建单独的DriverThread,渲染工作在此线程中执行。出于渲染效率的考虑,我们在使用filament时,一般都是会采用异步渲染的方式。
在异步渲染的模式下,filament渲染的核心对象Driver,会在渲染线程中根据调用者配置和当前运行环境进行构建。构建成功后,通知工作线程Driver构建成功,然后渲染线程会进入命令处理的循环中。
二、工作线程
我们将调用Filament命令的线程称为工作线程。在工作线程中,我们调用filament的API,创建IndexBuffer、VertexBufer、Texture、Material之类的渲染对象,构建渲染场景,然后调用渲染器进行渲染,实际上并不会真正的调用OpenGL、Metal、Vulkan、DirectX这样的底层渲染API。
在构建渲染对象时,实际上会调用我们封装的渲染驱动(OpenGLDriver、MetalDriver、VulkanDriver等,具体调用取决于平台及构建Engine时的配置),会先创建出filament抽象出的对应的RHI对象,如果需要调到底层渲染API,引擎会通过CommandStream在CircularBuffer实例中构建出对应的Command,由渲染线程进行执行。
在Filament引擎(一) ——渲染框架设计中异步渲染的实现这部分,对此部分实现,也进行了分析和说明。它用了一些C++开发中的小技巧,在此也不再赘述。
渲染驱动指令的构建
以IndexBuffer::setBuffer
的调用为例,其堆栈如下:
CommandStream::updateIndexBuffer
函数是通过DriverAPI.inc中的宏DECL_DRIVER_API_N
声明,DECL_DRIVER_API_N
会展开为DECL_DRIVER_API
,在CommandStream.h中引入DriverAPI.inc前对DECL_DRIVER_API
进行了定义:
#define DECL_DRIVER_API(methodName, paramsDecl, params) \inline void methodName(paramsDecl) { \DEBUG_COMMAND_BEGIN(methodName, false, params); \using Cmd = COMMAND_TYPE(methodName); \void* const p = allocateCommand(CommandBase::align(sizeof(Cmd))); \new(p) Cmd(mDispatcher.methodName##_, APPLY(std::move, params)); \DEBUG_COMMAND_END(methodName, false); \}
所以,实际上CommandStream::updateIndexBuffer的实现,宏展开后如下:
inline void updateIndexBuffer(filament::backend::Handle<filament::backend::HwIndexBuffer> ibh, filament::backend::BufferDescriptor && data, unsigned int byteOffset){ mDriver.debugCommandBegin(this, false, "updateIndexBuffer");using Cmd = CommandType<decltype(&Driver::updateIndexBuffer)>::Command<&Driver::updateIndexBuffer>;void* const p = allocateCommand(CommandBase::align(sizeof(Cmd)));new(p) Cmd(mDispatcher.updateIndexBuffer_, std::move(ibh), std::move(data), std::move(byteOffset));mDriver.debugCommandEnd(this, false, "updateIndexBuffer");
}
filament以DriverAPI中的函数作为模板参数,通过CommandType模板类及其内部模板类Command,将DriverAPI函数及调用传入的参数封装成CommandBase的子类对象,对象存储在CircularBuffer中。这样,渲染线程就能不关注渲染的具体指令,而是按照统一的调用方式进行执行。
三、渲染线程
渲染线程需要做的工作,只是不断的从CircularBuffer中取出需要执行命令,让Driver进行执行。
FEngine::execute
:通过调用mCommandBufferQueue.waitForCommands
,每次循环从引擎实例中的CommandBufferQueue实例内,取出当前的待执行的Commands(std::vector<CommandBufferQueue::Range>
), 然后遍历的将Range所指向的内存Buffer,传递给CommandStream.execute
进行处理。
-
CommandBufferQueue::Range
记录的只有begin和end两个void*
指针,指向的是一组Commands的起止地址。CircularBuffer、Command及Range的关系示意如下:
-
mCommandBufferQueue.waitForCommands
内部在命令队列为空或者在暂停渲染时,进入等待状态,阻塞渲染线程的工作。
CommandStream.execute
: CommandStream封装了代表渲染驱动的Driver,在execute方法中,会将FEngine.execute
传递进来的buffer(CommandBufferQueue::Range
) 转换成Command(CommandBase*
)进行执行。Command执行(CommandBase.execute
)会返回下一个CommandBase对象的指针。
四、帧渲染
通过上面的分析,我们大致可以知道,在工作线程中,我们调用的filament的API,会直接或间接的转换成渲染驱动命令,存储在CircularBuffer中,由渲染线程进行消费。
对于VertexBuffer、IndexBuffer、Texture等对象的创建,filament会构建出对应渲染驱动命令。对于Material对象,则会在其实例化的时候(createInstance
),构建对应的渲染驱动命令。渲染Entity的构建,会构建“创建渲染图元”的驱动命令,这些调用路径都相对比较简单且直观。
这种工作线程+渲染线程的方式,是现代渲染引擎比较通用的实现方案。其实对于一个渲染引擎来说,更关键的是,渲染场景的组织如何去设计,以及在工作线程中如何将组织好的渲染场景转换成渲染线程可以“无脑”执行的渲染指令。
在filament中,当我们组织好场景后,会调用Renderer.beginFrame
、Renderer.render
以及Renderer.endFrame
进行场景的“渲染”。这个过程,实际上就是将我们组织的渲染场景,变成一系列的渲染驱动命令,发送到渲染线程执行。
beginFrame
在beginFrame中,会利用FrameSkipper去动态控制渲染帧的跳过策略,在渲染负载过高时返回false。我们在使用时,当beginFrame返回false时,就不在调用Renderer.render
,避免因渲染延迟导致画面卡顿。
除此之外,beginFrame中主要做的工作包括:
SwapChain::makeCurrent
: 交换链和渲染环境的上下文绑定,让当前渲染线程知道要把图画到哪里,确保后续的渲染操作能正确显示在指定的面上。CommandStream::tick
: 发个指令让渲染线程执行需要在渲染线程执行的周期性的任务。这些任务一般是因为渲染后端实现时需要控制某些渲染指令的调用时机而发出的任务。CommandStream::beginFrame
: 发送beginFrame的渲染驱动命令。不同驱动(MetalDriver、OpenGLDriver、VulkanDriver等)处理不同。FrameInfoManager::beginFrame
: 帧信息收集,FrameInfoManager用于管理帧级别的渲染元数据和性能监控信息。FEngine.prepare
: 主要工作都是针对材质,包括将材质实例中的参数的修改同步到渲染管线,保证运行时修改的材质参数能被GPU使用。以及检查着色器程序与材质参数一致性。
render
filament中每帧的渲染由View进行组织,场景(Scene)和相机(Camera)是其进行内容呈现的必要元素。render的核心调用栈为:FRenderer::render
->FRenderer::renderInternal
->FRenderer::renderJob
。
在render中,主要是对场景、相机的存在进行判断,以及保证渲染前的flush操作。
在renderInternal中,会基于Engine中RenderPassArena
构建一个RootArenaScope
对象,在渲染构建帧图的过程中,RenderPass
会被存储到RenderPassArean
中。在renderInternal执行完成后,RootAreanScope
自动析构,析构时会把新加入到RenderPassArean
中的RenderPass
使用的内存进行回收。此外,renderInternal中,会为JobSystem
创建rootJob,并在函数执行完成前runAndWait(rootJob)
,以保证在此间所有的Job都在管控中切执行完成。
在renderJob中,最为核心的工作包括帧图(FrameGraph)的构建、编译和执行 以及 后处理(PostProcessManager)的设置,主要的流程大致为:
engine.getPostProcessManager().setFrameUniforms(driver, view.getFrameUniforms())
将后处理过程中需要用到的渲染统一变量进行同步。- 获取view中的各种配置项和功能状态,包括抗锯齿、防抖动、后处理等等各种配置和状态。
view.prepare
进行View的准备工作,其入参中的cameraInfo由view.computeCameraInfo
返回,并根据后处理、fass等设置,在必要时将渲染视口的尺寸调整为16的倍数,帮助优化内存分配和四边形渲染。scene->prepare
收集渲染此场景所需的所有信息。工作流程:- 遍历Entities,按照光源实体和渲染实体进行分类,存储到不同的容器中。
- 根据光源实体和渲染实体的容器中实体个数,调整
mLightData
和mRenderableData
的容器大小,他们分别是光照和渲染的SoA(Structure of Arrays)数据。 - 发起任务到JobSystem中执行,填充光照和渲染的SoA。定向光源(
directional ligth
)需要单独处理。
- 当场景中设置了POINT、FOCUSED_SPOT及SPOT类型的光源,会在任务系统(JobSystem)中运行
FView::prepareVisibleLights
,对设置进来的这些光源进行准备工作。- 进行必要的光照剔除,确定场景中那些光源是可见的。未被标记参与光照投影计算的(
lightCaster
)、与视椎不相交等情况下的光源,会比标记为不可见。可见光源会被排到SoA的前面来,便于渲染处理。 - 计算光源和相机之间的距离,按照离相机由近到远对光源进行排序。按照源码中注释的解释,这么做是未来方便后续构建光源树。如果光源数量超过GPU缓冲区所能容纳的数量,距离相机较远的光源会被舍弃掉。
- 进行必要的光照剔除,确定场景中那些光源是可见的。未被标记参与光照投影计算的(
- 开启了视椎裁切的时候,将不在视椎中的渲染对象进行剔除,标记为
VISIBLE_RENDERABLE_BIT
。 prepareVisibleLights
执行完成后,判断如果存在动态光源,就进行Froxel化。Froxel是filament中的光源在视椎空间下的体素化,结合Frustum和Voxel造的词。光照效果的渲染,filament采用的是分簇前向渲染的方式,来平衡渲染效果和渲染效率。关于filament具体的光照实现,在另外一篇博客中再进一步分析。- 进行阴影的准备工作,主要是根据光照信息进行ShadowMap的构建和更新。
- 按照渲染实体的可见性,将渲染的SoA进行分组,然后进行渲染对象的准备工作,更新渲染的SoA。分组包括:
- 主摄像机可见: 被主摄像机捕获且需要直接渲染到屏幕的对象,会参与主渲染通道。
- 主摄像机可见且进行定向光阴影投射:主摄像机可见且需要为定向光投射阴影,在生成平行光阴影贴图时渲染,同时也会参与主渲染通道。
- 进行定向光阴影投射:不可见于主摄像机,但需要进行定向光阴影投射,仅在生成平行光阴影贴图时渲染。
- 潜在的点光源阴影投射:可能被点光源或聚光灯照射,但未被主摄像机直接看到,在生成点光源阴影贴图时渲染。
- 明确不可见:完全不可见且无需参与任何渲染或阴影计算,会在渲染时被剔除。
- 进行光照信息的准备工作,更新光照的UBO,以及设置IBL(Indirect Light)等等。
view.prepareUpscaler
进行上采样的设置。- FrameGraph的构建和设置,这部分在renderJob中占据最大的篇幅。其主要流程如下:
- 以Renderer中的
mResourceAllocator
作为入参,构建FrameGraph实例。 - 获取FrameGraph中的Blackboard,它主要是作为FrameGraph中的全局资源管理器,用于存储和传递渲染过程中需要共享的虚拟资源(如纹理、渲染目标等)。在有阴影效果时,设置其阴影资源,阴影资源会用到
view.prepare
时构建的ShadowMap。 - 构建FrameGraph的RenderTarget资源,作为FrameGraph的渲染目标。
- 根据渲染对象的可见性进行图元更新。
- 在此过程中,会解析各种设置和状态,构建RenderPass,作为FrameGraph中的渲染单元。
- 以Renderer中的
- FrameGraph的“编译”和执行。编译阶段,会分析渲染通道依赖关系,进行无效节点的剔除,并标记资源生命周期,以保证在资源不再被使用时及时销毁或回收。执行阶段就是根据编译结果动态实例化GPU资源并将渲染指令提交到真正的渲染线程中。
关于renderJob中关键源码的具体分析,后续进行进一步的展开,此处仅做简单的流程性分析。
endFrame
一帧渲染完结,由endFrame中来进行必要的指令提交,资源回收等工作,主要包括:
SwapChain::commit
: 交换链提交,一般会刷新缓冲区,可以看做是将渲染管线的后缓冲区提交到渲染链前段,使渲染结果能从GPU到显示设备。FrameInfoManager::endFrame
: 帧信息收集完结,同beginFrame中的FrameInfoManager::beginFrame
对应。CommandStream::tick
: 发个指令让渲染线程执行需要在渲染线程执行的周期性的任务。mResourceAllocator.gc
帧资源回收
欢迎转载,转载请保留文章出处。求闲的博客[https://blog.csdn.net/junzia/article/details/149294146]