Qt 3D渲染帧图#

帧图是一种控制场景渲染的数据结构。

Qt 3D渲染特性允许渲染算法完全由数据驱动。控制数据结构被称为帧图。类似于Qt 3D ECS(实体组件系统)允许您通过从实体和组件的树中构建场景来定义所谓的场景图,帧图也是一种树状结构,但用于不同的目的。具体来说,控制场景是如何被渲染的。

在渲染单个帧的过程中,3D渲染器很可能会多次改变状态。这些状态变化的数量和性质不仅取决于场景中包含的材料(着色器、网格几何形状、纹理和统一变量),还取决于您正在使用的高级渲染方案。

例如,使用传统的简单前向渲染方案与使用延迟渲染方法非常不同。其他功能,如反射、阴影、多个视口以及早期Z填充遍历等,都会改变渲染器必须在本帧中设置哪些状态以及在何时发生这些状态变化。

相比之下,负责绘制Qt Quick 2场景的Qt Quick 2场景图渲染器在C++中被硬编码用于的事情,如批处理原语和透明物体渲染在非透明物体后。在Qt Quick 2的情况下,这完全合适,因为这样就涵盖了所有要求。正如你们从列出的示例中可以看到的那样,这样的硬编码渲染器对于通用的3D场景来说可能不够灵活,因为可用的渲染方法很多。或者如果可以让渲染器足够灵活以涵盖所有此类情况,其性能可能会因过于通用而受损。更糟糕的是,每天都在研究更多的渲染方法。因此,我们需要一种在简单易用和维护的同时,既灵活又可扩展的方法。这就是帧图的出现!

帧图中的每个节点都定义了渲染器将用于渲染场景的一部分配置。节点在帧图树中的位置决定了子树(以该节点为根)何时何地将成为渲染管道中的活动配置。正如我们稍后将要看到的,渲染器将遍历此树,以便在帧中每个点建立渲染算法所需的状态。

显然,如果只是想在屏幕上渲染一个简单的立方体,你可能认为这是多余的。但是,当你想让场景变得稍复杂一点时,这就会派上用场。对于常见的场合,Qt 3D提供了一些可以即时使用的示例帧图。

我们将通过几个示例及其生成的帧图来展示帧图概念的灵活性。

请注意,与由实体和组件组成的场景图不同,帧图仅由嵌套节点组成,这些节点都是QFrameGraphNode的子类。这是因为帧图节点不是我们虚拟世界中的模拟物体,而是支持信息。

我们将很快看到如何构建第一个简单的帧图,但在那之前,我们将介绍可供您使用的帧图节点。同样,与场景图树类似,QML和C++ API是一对一匹配的,因此您可以选择您最喜欢的。为了提高可读性和简洁性,本文选择了QML API。

帧图的美丽之处在于,通过组合这些简单的节点类型,您可以配置渲染器以适应您的特定需求,而无需触碰任何复杂的、底层的C/C++渲染代码。

帧图规则#

为了构建一个正确运行的帧图树,您应该了解一些关于其遍历方式和如何将其提供给Qt 3D渲染器的规则。

设置帧图#

帧图树应该分配给QRenderSettings组件的activeFrameGraph属性,该组件是Qt 3D场景根实体的一个组件。这使得它成为渲染器的活动帧图。当然,由于这是一个QML属性绑定,活动帧图(或其部分)可以在运行时动态更改。例如,如果您想为室内和室外场景使用不同的渲染方法,或者要启用或禁用某些特效。

Entity {
    id: sceneRoot
    components: RenderSettings {
         activeFrameGraph: ... // FrameGraph tree
    }
}

注意

activeFrameGraph是QML中帧图组件的默认属性。

Entity {
    id: sceneRoot
    components: RenderSettings {
         ... // FrameGraph tree
    }
}

帧图的用法#

  • Qt 3D渲染器将对帧图树进行深度优先遍历。请注意,由于遍历是深度优先的,因此:您定义节点的顺序很重要

  • 当渲染器到达帧图的叶子节点时,它将从叶子节点到根节点的路径上收集所有指定状态。这定义了渲染帧部分所使用的状态。如果您对Qt 3D的内部结构感兴趣,这个状态集合被称为RenderView

  • 给定包含在RenderView中的配置,渲染器将收集将要在场景图中渲染的所有实体,并从中构建一个RenderCommands集合,并将其与RenderView关联。

  • 将RenderView和RenderCommands集合提交给OpenGL。

  • 为帧图中的每个叶子节点重复此操作后,整个帧就完成了,并且渲染器调用QOpenGLContext::swapBuffers()来显示该帧。

帧图的核心是一种数据驱动的配置Qt 3D渲染器的方法。由于其数据驱动性,我们可以在运行时更改配置,允许非C++开发人员或设计人员更改帧的结构,并尝试新的渲染方法,而无需编写数千行的样板代码。

帧图示例#

现在您知道了编写帧图树的规则,我们将会通过几个示例并进行分解。

简单的前向渲染器#

前向渲染指的是以传统方式使用OpenGL,逐个对象直接渲染到后缓冲区,并在过程中进行着色。这与延迟渲染相反,在这种方式中,我们渲染到一个中间的缓冲器。以下是一个用于前向渲染的简单帧图。

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个节点,如图所示。

../_images/simple-framegraph.png

根据上面定义的规则,这个帧图树产生一个配置如下的单个RenderView:

  • 叶子节点 -> RenderView

    • 填充整个屏幕的可视区域(使用归一化坐标以简化嵌套可视区的支持)

    • 颜色和深度缓冲区将被设置为清除

    • 在暴露的相机属性中指定的相机

多个不同的FrameGraph树可以产生相同的渲染结果。只要从叶到根收集的状态相同,结果也将相同。最好将保持最长时间的状态放到framegraph的根部,这将导致更少的叶节点,从而减少总的RenderView数量。

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
           }
      }
}

多可视区域帧图#

让我们来看一个稍微复杂一点的示例,它从四个虚拟相机的角度将场景图渲染到窗口的四个象限中。这是3D CAD或建模工具的常见配置,或者可以调整以帮助渲染赛车游戏中的后视镜或闭路电视监控器的显示。

../_images/multiviewport.png
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与第二个图类似,只是其他子树。

../_images/multiviewport-1.png ../_images/multiviewport-2.png

以下是创建的RenderView完整列表:

  • RenderView (1)

    • 定义了全屏视图窗口

    • 颜色和深度缓冲区将被设置为清除

  • RenderView (2)

    • 定义了全屏视图窗口

    • 定义了子视图窗口(渲染视图将相对于其父视图进行缩放)

    • 指定了CameraSelector

  • RenderView (3)

    • 定义了全屏视图窗口

    • 定义了子视图窗口(渲染视图将相对于其父视图进行缩放)

    • 指定了CameraSelector

  • RenderView (4)

    • 定义了全屏视图窗口

    • 定义了子视图窗口(渲染视图将相对于其父视图进行缩放)

    • 指定了CameraSelector

  • RenderView (5)

    • 定义了全屏视图窗口

    • 定义了子视图窗口(渲染视图将相对于其父视图进行缩放)

    • 指定了CameraSelector

然而,在这种情况下,顺序很重要。如果ClearBuffers节点在最后而不是最早,这将导致整个屏幕变黑,简单的原因是所有内容在经过精心渲染后被清除。出于类似的原因,它也不能用作FrameGraph的根,因为这会导致在每次视图窗口中调用清除整个屏幕。

尽管FrameGraph的声明顺序很重要,但Qt 3D能够并行处理每个RenderView,因为每个RenderView在与其他RenderView独立的情况下生成一组要提交的RenderCommands。

Qt 3D使用基于任务的并行方法,这种方法会随着可用核心数量自然扩展。下图显示了前一个示例。

../_images/framegraph-parallel-build.png

RenderView的RenderCommands可以在多个核心上并行生成,只要我们小心地将RenderView提交到专门的OpenGL提交线程的正确顺序,生成的场景就可以正确渲染。

延迟渲染器#

在渲染方面,与前向渲染相比,延迟渲染在渲染器配置方面有所不同。延迟渲染不是为每个网格绘制模型并应用着色器效果进行着色,而是采用了一种两渲染通道方法。

首先,场景中的所有网格都使用相同的着色器进行绘制,该着色器通常会对每个片段输出至少四个值

  • 世界法线向量

  • 颜色(或其他材料属性)

  • 深度

  • 世界位置向量

这些值中的每一个都将存储在一个纹理中。法线、颜色、深度和位置纹理组成了所谓的G-Buffer。第一次遍历期间不会在屏幕上绘制任何内容,而是在G-Buffer中绘制,以便稍后使用。

当所有网格绘制完成时,G-缓冲区将填满当前摄像机能看到的所有网格。第二个渲染过程随后用于将场景渲染到后缓冲区,通过从G-缓冲区纹理中读取法线、颜色和位置值,并将颜色输出到全屏四边形上,以获得最终的颜色着色。

这种技术的优势在于,对于复杂效果所需要的强大计算能力只在第二阶段使用,并且仅用于摄像机实际看到的元素。第一阶段不需要太多的处理能力,因为每个网格都是使用一个简单的着色器被绘制的。因此,延迟渲染将着色和光照与场景中的对象数量解耦,并将其与屏幕的分辨率(和G-缓冲区)关联。这是许多游戏使用的技术,因为它可以在额外GPU内存使用的情况下,通过牺牲一些性能来实现大量动态光源。

(上述代码改编自 qt3d/tests/manual/deferred-renderer-qml。)

从图形上看,结果帧图看起来像

../_images/deferred-framegraph.png

并且结果渲染视图是

  • RenderView (1)

    • 指定要使用的摄像机

    • 定义一个填充整个屏幕的视口

    • 选择具有层组件sceneLayer的所有实体

    • 将gBuffer设置为活动的渲染目标

    • 清除当前绑定的渲染目标(gBuffer)的颜色和深度

    • 选择场景中具有与渲染通道过滤器中注释匹配的材质和技术的实体

  • RenderView (2)

    • 定义一个填充整个屏幕的视口

    • 选择具有层组件screenQuadLayer的所有实体

    • 清除当前绑定的帧缓冲区(屏幕)的颜色和深度缓冲区

    • 选择场景中具有与渲染通道过滤器中注释匹配的材质和技术的实体

帧图的其它好处#

由于帧图树完全是数据驱动的,并且在运行时可以动态修改,所以你可以

  • 为不同的平台和硬件提供不同的帧图树,并在运行时选择最合适的

  • 轻松在场景中添加和启用可视化调试

  • 根据需要渲染的场景区域的性质,使用不同的帧图树

  • 无需修改Qt 3D的内部结构即可实现新的渲染技术

结论#

我们介绍了帧图及其组成的节点类型。然后我们继续讨论一些示例来阐述帧图构建规则以及Qt 3D引擎如何幕后使用帧图。到目前为止,你应该对帧图及其使用方法有一个很好的概述(也许是将早期Z填充通道添加到前向渲染器中)。此外,你应该始终记住,帧图是你使用的工具,这样你就不会局限于Qt 3D提供的内置渲染器和材质。