Qt Quick 场景图默认渲染器#

本文档解释了默认场景图渲染器如何在内部分工作,以便用户可以以最佳方式编写使用它的代码,无论是从性能还是功能角度来看。

无需理解渲染器的内部细节即可获得良好的性能。然而,在集成场景图或了解为什么无法从图形芯片中获得最高效率时,它可能有所帮助。

注意

即使每帧都是唯一的,并且所有内容都从头开始上传,默认渲染器也能表现出色。

QML场景中的Qt Quick项填充了一个QSGNode实例的树。一旦创建,这个树就描述了某个帧应该如何渲染的完整描述。它完全不包含对Qt Quick项的任何引用,并且将在大多数平台上由单独的线程进行处理和渲染。渲染器是场景图的一个独立部分,它遍历QSGNode树,并使用在QSGGeometryNode中定义的几何形状和在QSGMaterial中定义的着色器状态来更新图形状态并生成绘制调用。

如果需要,可以使用内部场景图后端API完全替换渲染器。这主要对希望利用非标准硬件特性的平台厂商具有重要意义。对于大多数用例,默认渲染器将足够。

默认渲染器主要关注两个优化渲染的策略:绘制调用的批处理和GPU上几何形状的保留。

批处理#

尽管传统的2D API,如QPainter、Cairo或Context2D,被设计来处理每帧数千个单独的绘制调用,但OpenGL和其他硬件加速API在最少的绘制调用和最小化的状态变更时表现最佳。

注意

以下部分将以OpenGL为例,但这些概念也适用于其他图形API。

考虑以下用例

../_images/visualcanvas_list.png

绘制此列表最简单的方法是按单元格逐个绘制。首先绘制背景。这是一个特定颜色的矩形。在OpenGL术语中,这意味着选择一个着色器程序来执行纯色填充,设置填充颜色,设置包含x和y偏移量的变换矩阵,然后使用例如glDrawArrays来绘制组成矩形的两个三角形。接下来绘制图标。在OpenGL术语中,这意味着选择一个着色器程序来绘制纹理,选择要使用的活动纹理,设置变换矩阵,启用alpha混合,然后使用例如glDrawArrays来绘制组成图标边界矩形的两个三角形。文本和单元格之间的分隔线遵循类似的模式。这个过程为列表中的每个单元格重复,因此对于较长的列表,OpenGL状态变化和绘图调用的开销完全抵消了使用硬件加速API可能带来的好处。

当每个基本图形很大时,这种开销可以忽略不计,但典型UI中有很多小项目,加在一起就构成了相当大的开销。

默认场景图渲染器在这些限制下工作,并会尝试将这些单独的基本图形合并到批次中,同时保持完全相同的视觉结果。结果是OpenGL状态变化更少,绘图调用更少,从而实现最佳性能。

不透明图形

渲染器将在不透明图形和需要alpha混合的图形之间进行区分。通过使用OpenGL的Z缓冲区和为每个图形分配一个唯一的Z位置,渲染器可以在不考虑它们在屏幕上的位置或与其他元素重叠的情况下自由地重新排序不透明图形。通过查看每个图形的材料状态,渲染器将创建不透明的批次。从Qt Quick核心项目集合中,这包括具有不透明颜色的矩形项和完全不透明的图像,如JPEG或BMP。

使用不透明图形的另一个好处是,不透明图形不需要启用GL_BLEND,这可能会非常昂贵,尤其是在移动和嵌入式GPU上。

不透明图形以从前到后的方式渲染,并启用glDepthMaskGL_DEPTH_TEST。在内置早期z检查的GPU上,这意味着不需要为被遮挡的像素或像素块运行片段着色器。请注意,渲染器仍然需要将这些节点考虑在内,并且顶点着色器仍然为这些图形中的每个顶点运行,因此如果应用程序知道某物被完全遮挡,最佳的做法是明确地使用Item::visibleItem::opacity隐藏它。

注意

Item::z用于控制项相对于其兄弟项的堆叠顺序。它与渲染器和OpenGL的Z缓冲区没有直接关系。

_alpha混合图形

不透明图形绘制完成后,渲染器将禁用glDepthMask,启用GL_BLEND,并以从后到前的顺序渲染所有alpha混合图形。

alpha混合图形的批处理需要在渲染器上做更多工作,因为重叠的元素需要以正确的顺序渲染,才能使alpha混合看起来正确。仅依赖Z缓冲区是不够的。渲染器对所有alpha混合图形进行遍历,并查看它们的边界矩形及其材料状态,以确定哪些元素可以批处理,哪些不能。

../_images/visualcanvas_overlap.png

在最左侧的情况中,蓝色背景可以在一个调用中绘制,两个文本元素可以在另一个调用中绘制,因为文本仅与它们放置在最前面的背景重叠。在最右侧的情况中,“项目4”的背景与“项目3”的文本重叠,因此在这种情况下,背景和文本的绘制都需要使用单独的调用。

在Z轴方向上,alpha原语与不透明节点交织在一起,并且当可用时可以触发早期z值,但同样,将Item::visible 设置为false总是更快。

与其他3D原语混合#

场景图可支持伪三维和正确的三维原语。例如,可以使用 ShaderEffect 实现“翻页”效果或使用 QSGGeometry 和自定义材质实现凹凸映射的环面。在此过程中,需要注意默认渲染器已经使用深度缓冲区。

渲染器修改从QSGMaterialShader::vertexShader()返回的顶点着色器,并在应用模型视图和投影矩阵后压缩顶点的z值,然后在z方向添加一个小平移,使其定位在正确的z位置。

压缩假定z值在0到1的范围内。

纹理图集#

活动纹理是唯一的OpenGL状态,这意味着不能将使用不同OpenGL纹理的多个原语批处理。因此,Qt Quick场景图允许将多个 QSGTexture 实例分配为较大纹理的较小子区域;这就是纹理图集。

纹理图集的最大好处是,现在多个 QSGTexture 实例引用的是相同的OpenGL纹理实例。这使得批量绘制带有纹理的调用成为可能,例如图像项,BorderImage 项,ShaderEffect 项以及C++类型,如使用纹理的 QSGSimpleTextureNode 和自定义QSGGeometryNodes。

注意

大纹理不会放入纹理图集。

通过将 TextureCanUseAtlas 传递给 createTextureFromImage() 来创建基于图集的纹理。

注意

基于图集的纹理没有从0到1的范围的纹理坐标。使用 normalizedTextureSubRect() 获取图集的纹理坐标。

场景图使用启发式算法来确定图集的大小以及进入图集的大小阈值。如果需要不同的值,可以使用环境变量覆盖它们,例如:QSG_ATLAS_WIDTH=[width]QSG_ATLAS_HEIGHT=[height]QSG_ATLAS_SIZE_LIMIT=[size]。更改这些值对平台供应商通常更有意义。

批量根

除了将兼容的基本原语合并到批次中之外,默认渲染器还试图最小化每个帧需要发送到GPU的数据量。默认渲染器会识别属于同一部分的子树,并尝试将它们放入单独的批次中。一旦确定了批次,它们就会被合并、上传并存储在GPU内存中,使用顶点缓冲对象。

变换节点

每个Qt Quick项目都会向场景图树插入一个QSGTransformNode来管理其x,y,缩放或旋转。子项目将被填充在这个变换节点下。默认渲染器会跟踪变换节点在帧间的状态,并查看子树以确定变换节点是否是成为一批根的好候选人。一个在帧间发生变化且具有相当复杂子树的变换节点可以成为一个批次根。

批次根子树中的QSGGeometryNodes相对根在CPU上预先变换。然后它们被上传并保留在GPU上。当变换发生变化时,渲染器只需要更新根的矩阵,而不是每个单独的项目,这使得列表和网格滚动非常快。在连续的帧中,只要节点没有被添加或删除,绘制列表就是免费的。当新内容进入子树时,获取该内容的批次将被重建,但这仍然是相对较快的。在通过网格或列表平移时,通常每个添加或删除节点的帧都有几个不变的帧。

将变换节点识别为批次根的另一个好处是,它允许渲染器保留已更改的树的部分。例如,假设一个UI由列表和按钮行组成。当列表正在滚动且委托正在添加和删除时,UI的其余部分(按钮行)并没有改变,可以使用已经存储在GPU上的几何图形来绘制该部分。

可以使用环境变量重写变换节点成为批次根的节点和顶点阈值:QSG_RENDERER_BATCH_NODE_THRESHOLD=[count]QSG_RENDERER_BATCH_VERTEX_THRESHOLD=[count]。重写这些标志对平台供应商通常更有用。

注意

在批次根之下,为每唯一组材质状态和几何类型创建一个批次。

剪裁

Item::clip 设置为true时,将在其几何图形中创建一个矩形QSGClipNode。默认渲染器将使用OpenGL中的裁剪来应用这个裁剪。如果项目被旋转了非90度的角度,将使用OpenGL的模板缓冲区。Qt Quick项目仅支持通过QML设置矩形作为裁剪,但场景图API和默认渲染器可以使用任何形状进行裁剪。

当对一个子树应用剪辑时,该子树需要使用独特的OpenGL状态进行渲染。这意味着当Item::clip为true时,该项目的批处理仅限于其子项。当子项很多,如ListViewGridView,或者有复杂子项(例如textarea)时,这是可以接受的。然而,对于较小项目(例如按钮标签、文本字段或列表代理和单元格),应谨慎使用剪辑以避免批处理。通过安排UI使得不透明项目覆盖Flickable周围的区域,否则依靠窗口边缘进行剪辑通常可以避免。

Item::clip设置为true也会设置ItemIsViewport标志;设置了ItemObservesViewport标志的子项可以使用视口进行预剪辑步骤:例如Text会省略完全位于视口之外的文本行。省略场景图节点或限制vertices是一种优化,可以通过在C++中设置flags而不是在QML中设置Item::clip来实现。

在自定义项目中实现updatePaintNode(),如果它可以在大几何区域内渲染大量细节,应考虑是否有效限制到视口内的图形;如果是,可以设置ItemObservesViewport标志,并从clipRect()读取当前可见区域。一个结果是,updatePaintNode()将被频繁调用(通常在视口内容移动时每帧至少调用一次)。

顶点缓冲区#

每个批次使用一个顶点缓冲区对象(VBO)来在其GPU上存储数据。此顶点缓冲区在帧之间保留,并在它所代表的场景图部分发生变化时更新。

默认情况下,渲染器将使用GL_STATIC_DRAW将数据上传到VBO。可以通过设置环境变量QSG_RENDERER_BUFFER_STRATEGY=[strategy]来选择不同的上传策略。有效的值是streamdynamic。更改此值对于平台供应商最有用。

抗锯齿#

场景图支持两种类型的抗锯齿。默认情况下,通过在原语(如矩形和图像)的边缘添加更多顶点来实现抗锯齿,使得边缘渐变为透明。我们称此方法为顶点抗锯齿。如果用户请求多采样OpenGL上下文,通过使用QQuickWindow::setFormat()设置具有大于0个样本的QSurfaceFormat,场景图将选择基于多采样的抗锯齿(MSAA)。这两种技术会影响内部渲染方式,并且有不同的限制。

还可以通过设置环境变量 QSG_ANTIALIASING_METHODvertexmsaa 来覆盖抗锯齿方法。

顶点抗锯齿可能导致相邻原语边缘产生缝隙,即使这两个边缘在数学上是相同的。而多重采样抗锯齿不会。

顶点抗锯齿#

可以使用 Item::antialiasing 属性逐项启用和禁用顶点抗锯齿。它将无需考虑底层硬件支持,并且可以产生高品质的抗锯齿,无论是正常渲染的原语,还是转换到帧缓冲对象(例如使用 ShaderEffectSource 类型)的原语。

使用顶点抗锯齿的缺点是,每个启用抗锯齿的原语都需要进行混合。在批处理方面,这意味着渲染器需要做更多的工作来判断原语是否可以批处理,并且由于场景中其他元素的覆盖,可能会导致批处理更少,从而影响性能。

在低端硬件上,混合也可能非常昂贵。因此,对于覆盖大部分屏幕的图像或圆角矩形,这些原语内部的混合量可能导致显著的性能损失,因为整个原语都必须混合。

多重采样抗锯齿#

多重采样抗锯齿是一种硬件功能,其中硬件为原语中的每个像素计算覆盖值。某些硬件可以以非常低的成本进行多重采样,而其他硬件可能需要更多的内存和更多的GPU周期来渲染一帧。

使用多重采样抗锯齿,许多原语(如圆角矩形和图像元素)可以进行抗锯齿处理,并且在场景图中仍然保持 不透明。这意味着渲染器在创建批处理时工作更简单,并且可以依靠早期z来避免过度绘制。

当使用多重采样抗锯齿时,帧缓冲对象的渲染内容需要额外的扩展来支持帧缓冲的多重采样。通常包括 GL_EXT_framebuffer_multisampleGL_EXT_framebuffer_blit。大多数桌面芯片都有这些扩展,但在嵌入式芯片中则较为少见。当硬件中不可用帧缓冲多重采样时,帧缓冲对象中渲染的内容将不会被抗锯齿处理,包括 ShaderEffectSource 中的内容。

性能#

如一开始所述,为了获得好的性能,不需要了解渲染器的详细信息。它是为了优化常见的用例而编写的,并且几乎在所有情况下都能进行良好的性能表现。

  • 良好的性能来自于有效的批处理,尽可能少地重复上传几何图形。通过设置环境变量 QSG_RENDERER_DEBUG=render,渲染器将输出批处理效果、使用的批次数、哪种批处理被保留以及哪种批处理是不透明的统计信息。当追求最佳性能时,上传应仅在真正需要时发生,批次数应少于10个,其中至少应有3-4个是不透明的。

  • 默认渲染器不做任何CPU侧视口裁剪或遮挡检测。如果某事物不应可见,则不应显示。对于不应绘制的项目,请使用Item::visible: false。不添加此类逻辑的主要原因是它会增加额外的开销,这可能还会损害注意性能的应用程序。

  • 请确保使用纹理图集。除非图像太大,否则Image和BorderImage项目都会使用它。对于在C++中创建的纹理,调用QQuickWindow::createTexture()时传递TextureCanUseAtlas。通过设置环境变量QSG_ATLAS_OVERLAY,所有图集纹理都会着色,这样在应用程序中很容易识别。

  • 尽可能使用不透明原语。在不透明原语中,渲染器的处理速度更快,并且在GPU上绘制速度也更快。例如,PNG文件通常包含一个alpha通道,尽管每个像素都是完全不透明的。JPG文件总是不透明的。当向QQuickImageProvider提供图像或使用createTextureFromImage()创建图像时,如果可能的话,让图像具有QImage::Format_RGB32格式。

  • 注意,像上图那样的重叠复合项不能批量处理。

  • 裁剪会破坏批量处理。不要逐项使用它,不要在使用表格单元格、项目代理或相似的项中。而不是裁剪文本,请使用省略。而不是裁剪图像,创建一个返回裁剪图像的QQuickImageProvider

  • 批量处理只能用于16位索引。所有内置项目都使用16位索引,但自定义几何形状也可以自由使用32位索引。

  • 一些材质标志阻止了批量处理,其中最限制性的是阻止所有批量处理的RequiresFullMatrix

  • 对于单色背景的应用程序,应使用setColor()将其设置,而不是使用顶级矩形项。在调用glClear()时,将使用setColor(),这可能更快。

  • 梯形图像项不会被放置在全局图集中,也不会进行批量处理。

  • 与帧缓冲对象(FBO)读取相关的OpenGL驱动程序中的错误可能会损坏渲染的符号。如果您设置环境变量QML_USE_GLYPHCACHE_WORKAROUND,Qt将在RAM中保留符号的附加副本。这意味着在绘制之前绘制过的符号时,性能会略有降低,因为Qt通过CPU访问额外的副本。这也意味着符号缓存将使用两倍多的内存。这不会影响质量。

如果应用程序表现不佳,请确保渲染确实是瓶颈。使用分析器!环境变量QSG_RENDER_TIMING=1将输出一些有用的定时参数,这些参数可以用于确定问题的位置。

可视化#

为了可视化场景图默认渲染器的各个方面,可以将QSG_VISUALIZE环境变量设为以下各节详细说明的值之一。我们使用以下QML代码示例来提供某些变量输出的示例。

import QtQuick 2.2

Rectangle {
    width: 200
    height: 140

    ListView {
        id: clippedList
        x: 20
        y: 20
        width: 70
        height: 100
        clip: true
        model: ["Item A", "Item B", "Item C", "Item D"]

        delegate: Rectangle {
            color: "lightblue"
            width: parent.width
            height: 25

            Text {
                text: modelData
                anchors.fill: parent
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
            }
        }
    }

    ListView {
        id: clippedDelegateList
        x: clippedList.x + clippedList.width + 20
        y: 20
        width: 70
        height: 100
        clip: true
        model: ["Item A", "Item B", "Item C", "Item D"]

        delegate: Rectangle {
            color: "lightblue"
            width: parent.width
            height: 25
            clip: true

            Text {
                text: modelData
                anchors.fill: parent
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
            }
        }
    }
}

对于左侧的ListView,我们将它的clip属性设置为true。右侧的ListView也将每个代理的clip属性设置为true,以说明剪切对批处理的影响。

../_images/visualize-original.png

原始

注意

可视化的元素不遵守剪切,渲染顺序是随机的。

可视化批处理#

QSG_VISUALIZE设置为batches可在渲染器中可视化批处理。合并的批处理用实色绘制,未合并的批处理则用对角线线纹图案绘制。唯一的颜色少意味着批处理效果不错。如果批处理包含许多单独的节点,则未合并的批处理是不良的。

../_images/visualize-batches.png

QSG_VISUALIZE=batches

可视化剪切#

QSG_VISUALIZE设置为clip会在场景上方绘制红色区域来指示剪切。由于Qt Quick项目默认不进行剪切,通常不会可视化剪切。

../_images/visualize-clip.png

QSG_VISUALIZE=clip

可视化更改#

QSG_VISUALIZE设置为changes可在渲染器中可视化更改。场景图中更改使用随机颜色的闪烁叠加来表示。在原语上的更改使用实色表示,而在祖先上的更改(如矩阵或不透明度更改)使用图案表示。

可视化重复绘制#

QSG_VISUALIZE设置为overdraw可在渲染器中可视化重复绘制。通过将所有3D项目可视化来突出显示重复绘制。在这种模式下,也可以在一定程度上检测视口外的几何体。不透明项以绿色色调渲染,而半透明项以红色色调渲染。视口的边界框以蓝色渲染。不透明内容对场景图的处理更为容易,通常渲染也更快。

注意,上述代码中的根矩形是多余的,因为窗口本身也是白色的,所以在这种情况下绘制矩形是资源浪费。将其更改为Item可以略微提高性能。

../_images/visualize-overdraw-1.png ../_images/visualize-overdraw-2.png

QSG_VISUALIZE=overdraw

通过Qt渲染硬件接口进行渲染#

从Qt 6.0起,默认的适应通常通过由Qt GUI模块提供的图形抽象层,即Qt渲染硬件接口(RHI)进行渲染。这意味着与Qt 5不同,场景图不直接进行OpenGL调用。相反,它通过使用RHI API记录资源和绘制命令,然后将这些命令流转换为OpenGL、Vulkan、Metal或Direct 3D调用。着色器处理通过编写一次着色器代码,编译为SPIR-V,然后转换为适用于各种图形API的适当语言来实现统一。

要控制行为,可以使用以下环境变量:

环境变量

可能值

描述

QSG_RHI_BACKEND

vulkanmetalopengld3d11d3d12

请求特定的RHI后端。默认情况下,根据平台选择目标图形API,除非由该变量或等效的C++ API覆盖。当前默认值为Windows上的Direct3D 11,macOS上的Metal,其他地方为OpenGL。

QSG_INFO

1

类似于基于OpenGL的渲染路径,设置此项可以初始化Qt Quick场景图时打印系统信息,这在故障排除中非常有用。

QSG_RHI_DEBUG_LAYER

1

在适用的情况下(Vulkan,Direct3D),如果图形设备或实例对象上有可用的调试或验证层,则启用图形API实现的调试或验证层。对于macOS上的Metal,请设置环境变量METAL_DEVICE_WRAPPER_TYPE=1

QSG_RHI_PREFER_SOFTWARE_RENDERER

1

请求选择一个使用基于软件光栅化的适配器或物理设备。只有在基本API支持枚举适配器时才适用(例如Direct3D或Vulkan),否则忽略。

希望始终使用单个图形API运行的应用程序,也可以通过C++请求此功能。例如,在构造任何QQuickWindow之前,在main()的早期调用中,强制使用Vulkan(否则将会失败)

QQuickWindow::setGraphicsApi(QSGRendererInterface::Vulkan);

请参阅GraphicsApi。枚举值OpenGLVulkanMetalDirect3D11Direct3D12与设置QSG_RHI_BACKEND为等效字符串键时的效果相同。

所有QRhi后端都将选择系统默认的GPU适配器或物理设备,除非由QSG_RHI_PREFER_SOFTWARE_RENDERER或后端特定的变量,例如QT_D3D_ADAPTER_INDEXQT_VK_PHYSICAL_DEVICE_INDEX覆盖。目前不提供进一步适配器的可配置性。

从Qt 6.5开始,一些以前只能作为环境变量公开的设置现在作为C++ API在QQuickGraphicsConfiguration中可用。例如,设置QSG_RHI_DEBUG_LAYER并调用setDebugLayer(true)是等效的。