Qt 3D 渲染框架图

Qt 3D 渲染功能允许渲染算法完全由数据驱动。控制数据结构称为框架图。与Qt 3D ECS(实体组件系统)允许您通过从实体和组件树中构建场景来定义所谓的场景图类似,框架图也是一种树形结构,但用于不同的目的。即控制场景的渲染方式

在渲染单帧的过程中,三维渲染器可能会多次改变状态。这些状态变化的次数和性质不仅取决于场景中存在哪些材质(着色器、网格几何体、纹理和统一变量),还取决于使用哪种高级渲染方案。

例如,使用传统的简单前向渲染方案与使用延迟渲染方法截然不同。反射、阴影、多视口和早期 Z 填充传递等其他功能都会改变渲染器在一帧画面中需要设置的状态,以及这些状态变化需要发生的时间。

作为比较,负责绘制Qt Quick 2 场景的Qt Quick 2 场景图渲染器是用 C++ 硬连线的,可以完成基元批处理和先渲染不透明项后渲染透明项等工作。就Qt Quick 2 而言,这完全没有问题,因为它涵盖了所有要求。从上面列出的一些示例中可以看出,鉴于有多种渲染方法可供选择,这种硬连线渲染器对于通用 3D 场景来说可能不够灵活。或者说,即使渲染器的灵活性足以涵盖所有这些情况,它的性能也可能会因为过于通用而受到影响。更糟糕的是,更多的渲染方法正在不断被研究出来。因此,我们需要一种既灵活、可扩展,又易于使用和维护的方法。这就是框架图!

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

显然,如果你只想在屏幕上渲染一个简单的立方体,你可能会觉得这样做有点多余。但是,一旦你想开始制作稍微复杂一些的场景,这就派上用场了。对于常见情况,Qt 3D 提供了一些开箱即用的框架图示例。

我们将通过展示几个示例和生成的框架图来演示框架图概念的灵活性。

请注意,与由实体(Entities)和组件(Components)组成的场景图(Scenegraph)不同,框架图只由嵌套节点(nodes)组成,这些节点都是Qt3DRender::QFrameGraphNode 的子类。这是因为框架图节点不是虚拟世界中的模拟对象,而是支持信息。

我们很快就会看到如何构建第一个简单的框架图,但在此之前,我们将介绍可供使用的框架图节点。就像场景图树一样,QML 和 C++ 应用程序接口是一一对应的,因此您可以选择您最喜欢的一种。出于可读性和简洁性的考虑,本文选择了 QML API。

框架图的美妙之处在于,结合这些简单的节点类型,就可以配置渲染器以满足您的特定需求,而无需接触任何繁琐的底层 C/C++ 渲染代码。

框架图规则

为了构建一个正常运行的框架图树,你应该了解一些关于如何遍历框架图树以及如何将其提供给Qt 3D 渲染器的规则。

设置框架图

帧图树应分配给 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 关联。
  • 渲染视图和一组渲染命令的组合将提交给 OpenGL。
  • 当框架图中的每个叶节点都重复上述步骤时,框架就完成了,渲染器就会调用QOpenGLContext::swapBuffers() 来显示框架。

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

框架图示例

现在你已经知道了编写框架结构树时需要遵守的规则,下面我们将通过几个示例对其进行分解。

简单的前向渲染器

前向渲染是指以传统方式使用 OpenGL,直接向后缓冲区渲染,每次渲染一个对象,并在渲染过程中为每个对象着色。这与延迟渲染不同,延迟渲染是指渲染到中间G 缓冲区。下面是一个可用于前向渲染的简单 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 树可以产生相同的渲染结果。只要从叶到根收集的状态相同,结果也会相同。最好将保持不变的状态放置在靠近帧图根部的位置,因为这样可以减少叶节点的数量,从而减少总体渲染视图的数量。

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 或建模工具的常见配置,也可进行调整,以帮助渲染赛车游戏中的后视镜或 CCTV 摄像机显示。

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 如下:

  • 渲染视图 (1)
    • 已定义全屏视口
    • 颜色和深度缓冲区设置为清除
  • 渲染视图 (2)
    • 已定义全屏视口
    • 已定义子视口(渲染视口将相对于其父视口缩放)
    • CameraSelector 指定
  • 渲染视图 (3)
    • 已定义全屏视口
    • 已定义子视口(渲染视口将相对于其父视口缩放)
    • CameraSelector 指定
  • 渲染视图 (4)
    • 已定义全屏视口
    • 已定义子视口(渲染视口将相对于其父视口缩放)
    • CameraSelector 指定
  • 渲染视图 (5)
    • 已定义全屏视口
    • 已定义子视口(渲染视口将相对于其父视口缩放)
    • CameraSelector 指定

不过,在这种情况下,顺序很重要。如果ClearBuffers 节点是最后一个而不是第一个,就会导致黑屏,原因很简单,所有内容在经过精心渲染后都会被立即清除。出于同样的原因,我们也不能将 节点作为 FrameGraph 的根节点,因为这样会导致我们调用清除每个视口的整个屏幕。

虽然 FrameGraph 的声明顺序很重要,但Qt 3D 可以并行处理每个渲染视图,因为每个渲染视图都独立于其他渲染视图,以便在渲染视图的状态有效时生成一组需要提交的渲染命令。

Qt 3D 渲染引擎使用基于任务的并行方法,这自然会随着可用内核数量的增加而增加。如下图所示为上一个示例。

渲染视图的渲染命令(RenderCommands)可以在多个内核上并行生成,只要我们注意在专用的 OpenGL 提交线程上以正确的顺序提交渲染视图,就能正确渲染生成的场景。

延迟渲染器

说到渲染,与前向渲染相比,延迟渲染在渲染器配置方面是一种不同的方式。延迟渲染采用两次渲染传递的方法,而不是绘制每个网格并应用着色器效果对其进行着色。

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

  • 世界法向量
  • 颜色(或其他材质属性)
  • 深度
  • 世界位置矢量

每个值都将存储在纹理中。法线、颜色、深度和位置纹理构成了所谓的 G 缓冲区。第一次绘制时不会在屏幕上绘制任何内容,而是绘制到 G-Buffer 中,以供以后使用。

当所有网格都绘制完成后,G-Buffer 中就会充满摄像机当前能看到的所有网格。然后,第二次渲染会通过从 G 缓冲区纹理中读取法线、颜色和位置值,并将颜色输出到全屏四边形上,从而将场景渲染到带有最终着色的后缓冲区。

这种技术的优势在于,复杂特效所需的高运算能力只在第二道处理过程中用于摄像机实际看到的元素。由于每个网格都是通过简单的着色器绘制的,因此第一遍渲染不会耗费太多的处理能力。因此,延迟渲染将着色和光照与场景中物体的数量分离开来,而将其与屏幕(和 G 缓冲区)的分辨率联系起来。这种技术已在许多游戏中使用,因为它可以使用大量的动态光照,而不增加 GPU 内存使用量。

Viewport {
    id: root
    normalizedRect: Qt.rect(0.0, 0.0, 1.0, 1.0)

    property GBuffer gBuffer
    property alias camera: sceneCameraSelector.camera
    property alias sceneLayer: sceneLayerFilter.layers
    property alias screenQuadLayer: screenQuadLayerFilter.layers

    RenderSurfaceSelector {

        CameraSelector {
            id: sceneCameraSelector

            // Fill G-Buffer
            LayerFilter {
                id: sceneLayerFilter
                RenderTargetSelector {
                    id: gBufferTargetSelector
                    target: gBuffer

                    ClearBuffers {
                        buffers: ClearBuffers.ColorDepthBuffer

                        RenderPassFilter {
                            id: geometryPass
                            matchAny: FilterKey {
                                name: "pass"
                                value: "geometry"
                            }
                        }
                    }
                }
            }

            TechniqueFilter {
                parameters: [
                    Parameter { name: "color"; value: gBuffer.color },
                    Parameter { name: "position"; value: gBuffer.position },
                    Parameter { name: "normal"; value: gBuffer.normal },
                    Parameter { name: "depth"; value: gBuffer.depth }
                ]

                RenderStateSet {
                    // Render FullScreen Quad
                    renderStates: [
                        BlendEquation { blendFunction: BlendEquation.Add },
                        BlendEquationArguments {
                            sourceRgb: BlendEquationArguments.SourceAlpha
                            destinationRgb: BlendEquationArguments.DestinationColor
                        }
                    ]

                    LayerFilter {
                        id: screenQuadLayerFilter
                        ClearBuffers {
                            buffers: ClearBuffers.ColorDepthBuffer
                            RenderPassFilter {
                                matchAny: FilterKey {
                                    name: "pass"
                                    value: "final"
                                }
                                parameters: Parameter {
                                    name: "winSize"
                                    value: Qt.size(1024, 768)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

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

从图形上看,生成的帧图如下

生成的渲染视图如下

  • 渲染视图 (1)
    • 指定使用哪个摄像头
    • 定义填充整个屏幕的视口
    • 为图层组件 sceneLayer 选择所有实体
    • gBuffer 设置为活动渲染目标
    • 清除当前绑定的渲染目标(gBuffer )上的颜色和深度
    • 只选择场景中的实体,这些实体的材质和技术必须与图层组件 sceneLayer 中的注释相匹配。RenderPassFilter
  • 渲染视图 (2)
    • 定义一个填充整个屏幕的视口
    • 为图层组件 screenQuadLayer 选择所有实体
    • 清除当前绑定帧缓冲区(屏幕)上的颜色和深度缓冲区
    • 只选择场景中的实体,这些实体的材质和技术必须与图层组件 screenQuadLayer 中的注释相匹配。RenderPassFilter

帧图的其他优点

由于帧图树完全由数据驱动,可在运行时动态修改,因此您可以

  • 为不同的平台和硬件提供不同的框架图树,并在运行时选择最合适的框架图树
  • 在场景中轻松添加和启用可视化调试
  • 根据场景中特定区域需要渲染的内容,使用不同的帧图树
  • 无需修改Qt 3D 的内部结构即可实施新的渲染技术

总结

我们已经介绍了 FrameGraph 以及组成 FrameGraph 的节点类型。然后,我们继续讨论了几个示例,以说明框架图的构建规则以及Qt 3D 引擎如何在幕后使用框架图。至此,您应该对 FrameGraph 及其使用方法(也许可以在前向渲染器中添加早期 Z 填充)有了相当全面的了解。此外,您还应始终牢记,FrameGraph 是供您使用的工具,因此您不必受限于Qt 3D 开箱即用的渲染器和材质。

© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.