Qt 3D 渲染框架图¶
帧图是控制场景渲染方式的数据结构。
Qt 3D 渲染方面允许渲染算法完全由数据驱动。控制数据结构被称为帧图。类似于 Qt 3D ECS(实体组件系统)允许您通过从实体和组件的树构建场景来定义所谓的场景图,帧图也是一种树结构,但用于不同的目的。即控制如何渲染场景。
在渲染单个帧的过程中,3D渲染器可能会多次改变状态。这些状态变化的数量和性质不仅取决于场景中的材质(着色器、网格几何、纹理和统一变量),还取决于你使用的高级渲染方案。
例如,使用传统的简单前向渲染方案与使用延迟渲染方法非常不同。其他功能如反射、阴影、多视口和早期z填充通道都会改变渲染器在一帧过程中需要设置的状态以及这些状态变化需要发生的时间。
作为比较,负责绘制Qt Quick 2场景的Qt Quick 2场景图渲染器是用C++硬编码的,用于执行诸如图元批处理和先渲染不透明项目后渲染透明项目等操作。在Qt Quick 2的情况下,这完全没问题,因为它涵盖了所有需求。正如你从上面列出的一些示例中可以看到的那样,考虑到可用的多种渲染方法,这种硬编码的渲染器对于通用的3D场景来说可能不够灵活。或者,如果渲染器可以做得足够灵活以涵盖所有此类情况,其性能可能会因为过于通用而受到影响。更糟糕的是,更多的渲染方法一直在被研究。因此,我们需要一种既灵活又可扩展的方法,同时易于使用和维护。这就是framegraph的用武之地!
帧图中的每个节点定义了渲染器将用于渲染场景的配置的一部分。节点在帧图树中的位置决定了以该节点为根的子树何时何地会成为渲染管道中的活动配置。正如我们稍后将看到的,渲染器会遍历这棵树,以便在帧的每个点上构建渲染算法所需的状态。
显然,如果你只想在屏幕上渲染一个简单的立方体,你可能会觉得这是小题大做。然而,一旦你开始想要处理稍微复杂一些的场景,这就派上用场了。对于常见的情况,Qt 3D 提供了一些现成的示例框架图,可以直接使用。
我们将通过展示一些示例和生成的帧图来演示帧图概念的灵活性。
请注意,与由实体和组件组成的场景图不同,帧图仅由嵌套节点组成,这些节点都是QFrameGraphNode的子类。这是因为帧图节点不是我们虚拟世界中的模拟对象,而是支持信息。
我们将很快看到如何构建我们的第一个简单的框架图,但在此之前,我们将介绍可用的框架图节点。与场景图树一样,QML和C++ API是一一对应的,因此您可以选择您最喜欢的一个。为了可读性和简洁性,本文选择了QML API。
帧图的美妙之处在于,通过组合这些简单的节点类型,可以配置渲染器以满足您的特定需求,而无需接触任何复杂、低级的C/C++渲染代码。
FrameGraph 规则¶
为了构建一个功能正确的帧图树,你应该了解一些关于如何遍历它以及如何将其提供给Qt 3D渲染器的规则。
设置帧图¶
FrameGraph树应分配给QRenderSettings组件的activeFrameGraph属性,该组件本身是Qt 3D场景中根实体的组件。这就是使其成为渲染器的活动帧图的原因。当然,由于这是一个QML属性绑定,活动帧图(或其部分)可以在运行时动态更改。例如,如果您想为室内和室外场景使用不同的渲染方法,或者启用或禁用某些特殊效果。
Entity { id: sceneRoot components: RenderSettings { activeFrameGraph: ... // FrameGraph tree } }
注意
activeFrameGraph 是 QML 中 FrameGraph 组件的默认属性。
Entity {
id: sceneRoot
components: RenderSettings {
... // FrameGraph tree
}
}
如何使用帧图¶
Qt 3D 渲染器对帧图树执行深度优先遍历。请注意,由于遍历是深度优先的,定义节点的顺序很重要。
当渲染器到达帧图的叶节点时,它会收集从叶节点到根节点路径所指定的所有状态。这定义了用于渲染帧的一部分的状态。如果你对Qt 3D的内部机制感兴趣,这种状态的集合被称为RenderView。
给定RenderView中包含的配置,渲染器会收集场景图中所有要渲染的实体,并从它们构建一组RenderCommands,并将它们与RenderView关联起来。
RenderView 和一组 RenderCommands 的组合被传递以提交给 OpenGL。
当对帧图中的每个叶节点重复此操作时,帧就完成了,渲染器调用 QOpenGLContext::swapBuffers() 来显示帧。
本质上,framegraph 是一种用于配置 Qt 3D 渲染器的数据驱动方法。由于其数据驱动的特性,我们可以在运行时更改配置,允许非 C++ 开发人员或设计师更改帧的结构,并尝试新的渲染方法,而无需编写数千行样板代码。
框架图示例¶
既然你已经了解了编写帧图树时需要遵守的规则,我们将通过几个例子来详细解析它们。
一个简单的正向渲染器¶
正向渲染是指以传统方式使用OpenGL,直接渲染到后缓冲区,一次渲染一个对象,并在渲染过程中对每个对象进行着色。这与延迟渲染相反,在延迟渲染中,我们渲染到一个中间的G-buffer。以下是一个可用于正向渲染的简单FrameGraph:
Viewport { normalizedRect: Qt.rect(0.0, 0.0, 1.0, 1.0) property alias camera: cameraSelector.camera ClearBuffers { buffers: ClearBuffers.ColorDepthBuffer CameraSelector { id: cameraSelector } } }
如你所见,这棵树有一个叶子,总共由3个节点组成,如下图所示。
使用上述定义的规则,这个框架图树生成了一个具有以下配置的单一RenderView:
叶子节点 -> 渲染视图
填充整个屏幕的视口(使用归一化坐标以便轻松支持嵌套视口)
颜色和深度缓冲区设置为清除
在暴露的相机属性中指定的相机
几种不同的FrameGraph树可以产生相同的渲染结果。只要从叶节点到根节点收集的状态相同,结果也会相同。最好将保持最长时间不变的状态放在framegraph的根部附近,因为这将导致更少的叶节点,从而总体上减少RenderViews的数量。
Viewport { normalizedRect: Qt.rect(0.0, 0.0, 1.0, 1.0) property alias camera: cameraSelector.camera CameraSelector { id: cameraSelector ClearBuffers { buffers: ClearBuffers.ColorDepthBuffer } } }CameraSelector { Viewport { normalizedRect: Qt.rect(0.0, 0.0, 1.0, 1.0) ClearBuffers { buffers: ClearBuffers.ColorDepthBuffer } } }
多视口框架图¶
让我们继续看一个稍微复杂一点的例子,它从4个虚拟摄像机的视角将场景图渲染到窗口的4个象限中。这是3D CAD或建模工具中常见的配置,也可以调整用于帮助渲染赛车游戏中的后视镜或闭路电视摄像头的显示。
Viewport { id: mainViewport normalizedRect: Qt.rect(0, 0, 1, 1) property alias Camera: cameraSelectorTopLeftViewport.camera property alias Camera: cameraSelectorTopRightViewport.camera property alias Camera: cameraSelectorBottomLeftViewport.camera property alias Camera: cameraSelectorBottomRightViewport.camera ClearBuffers { buffers: ClearBuffers.ColorDepthBuffer } Viewport { id: topLeftViewport normalizedRect: Qt.rect(0, 0, 0.5, 0.5) CameraSelector { id: cameraSelectorTopLeftViewport } } Viewport { id: topRightViewport normalizedRect: Qt.rect(0.5, 0, 0.5, 0.5) CameraSelector { id: cameraSelectorTopRightViewport } } Viewport { id: bottomLeftViewport normalizedRect: Qt.rect(0, 0.5, 0.5, 0.5) CameraSelector { id: cameraSelectorBottomLeftViewport } } Viewport { id: bottomRightViewport normalizedRect: Qt.rect(0.5, 0.5, 0.5, 0.5) CameraSelector { id: cameraSelectorBottomRightViewport } } }
这棵树稍微复杂一些,有5个叶子。按照之前的相同规则,我们从FrameGraph中构建了5个RenderView对象。以下图表展示了前两个RenderView的构建过程。其余的RenderView与第二个图表非常相似,只是使用了其他子树。
完整地说,创建的RenderViews是:
渲染视图 (1)
定义了全屏视口
颜色和深度缓冲区设置为清除
渲染视图 (2)
定义了全屏视口
定义了子视口(渲染视口将相对于其父视口进行缩放)
渲染视图 (3)
定义了全屏视口
定义了子视口(渲染视口将相对于其父视口进行缩放)
CameraSelector 已指定
渲染视图 (4)
定义了全屏视口
定义了子视口(渲染视口将相对于其父视口进行缩放)
CameraSelector 已指定
渲染视图 (5)
定义了全屏视口
定义了子视口(渲染视口将相对于其父视口进行缩放)
CameraSelector 已指定
然而,在这种情况下,顺序很重要。如果ClearBuffers节点是最后一个而不是第一个,这将导致黑屏,原因很简单,因为所有内容在精心渲染后立即被清除。出于类似的原因,它不能用作FrameGraph的根节点,因为这将导致每次调用时都会清除整个屏幕。
尽管FrameGraph的声明顺序很重要,但Qt 3D能够并行处理每个RenderView,因为每个RenderView在生成一组要提交的RenderCommands时是独立的,而RenderView的状态是有效的。
Qt 3D 使用基于任务的方法来实现并行化,这种方法自然随着可用核心数量的增加而扩展。这在以下图表中展示了前面的示例。
RenderViews的RenderCommands可以在多个核心上并行生成,只要我们注意在专用的OpenGL提交线程上按正确顺序提交RenderViews,生成的场景将正确渲染。
延迟渲染器¶
在渲染方面,延迟渲染在渲染器配置上与正向渲染相比是一个完全不同的概念。延迟渲染不是绘制每个网格并应用着色器效果来着色,而是采用了一种两次渲染传递的方法。
首先,场景中的所有网格都使用相同的着色器绘制,该着色器通常为每个片段输出至少四个值:
世界法线向量
颜色(或其他一些材料属性)
深度
世界位置向量
这些值中的每一个都将存储在一个纹理中。法线、颜色、深度和位置纹理组成了所谓的G-Buffer。在第一遍渲染过程中,屏幕上不会绘制任何内容,而是绘制到G-Buffer中,以备后续使用。
一旦所有的网格都被绘制,G-Buffer 就会被当前相机可见的所有网格填充。然后,第二个渲染通道通过从 G-Buffer 纹理中读取法线、颜色和位置值,并将颜色输出到全屏四边形上,用于将场景渲染到后缓冲区,并应用最终的颜色着色。
该技术的优势在于,复杂效果所需的大量计算能力仅在第二遍渲染时使用,且仅用于实际被摄像机看到的元素。第一遍渲染不会消耗太多处理能力,因为每个网格都是使用简单的着色器绘制的。因此,延迟渲染将着色和光照与场景中的对象数量解耦,而是将其与屏幕分辨率(和G-Buffer)耦合。这是一种在许多游戏中使用的技术,因为它能够使用大量的动态光源,但代价是额外的GPU内存使用。
(上述代码改编自qt3d/tests/manual/deferred-renderer-qml。)
图形上,生成的帧图看起来像:
生成的 RenderViews 是:
RenderView (1)
指定应使用哪个相机
定义一个填充整个屏幕的视口
选择所有具有场景层组件的实体
将
gBuffer设置为活动渲染目标清除当前绑定的渲染目标(
gBuffer)上的颜色和深度仅选择场景中具有与RenderPassFilter中注释匹配的材质和技术的实体
RenderView (2)
定义一个填满整个屏幕的视口
为图层组件screenQuadLayer选择所有实体
清除当前绑定的帧缓冲区(屏幕)上的颜色和深度缓冲区
仅选择场景中具有与RenderPassFilter中注释匹配的材质和技术的实体
framegraph的其他好处¶
由于FrameGraph树完全是数据驱动的,并且可以在运行时动态修改,您可以:
为不同的平台和硬件准备不同的帧图树,并在运行时选择最合适的
轻松在场景中添加并启用视觉调试
根据场景中特定区域需要渲染的内容的性质,使用不同的FrameGraph树
实现新的渲染技术,而无需修改Qt 3D的内部结构
结论¶
我们已经介绍了FrameGraph及其组成的节点类型。然后我们继续讨论了一些示例,以说明framegraph的构建规则以及Qt 3D引擎如何在幕后使用framegraph。到现在为止,您应该对FrameGraph及其使用方法有了很好的概述(也许可以为前向渲染器添加一个early z-fill pass)。此外,您应该始终记住,FrameGraph是供您使用的工具,这样您就不会局限于Qt 3D提供的现成渲染器和材质。