渲染器缓存

Qt 3D的运行是基于两种现有的数据结构:

Scene Graph-描述场景的内容;

Frame Graph-描述渲染Scene Graph的方式。

每渲染一帧画面,我们必须做大量的工作,才能把scene graphframe graph中的抽象描述转换为底层的draw调用并传送至GPU。简而言之,步骤如下:

遍历frame graph并识别各个渲染阶段。每个阶段包括渲染target(屏幕或FBO);要使用哪个摄影机;要使用哪个窗口;应该绘制scene graph的哪些部分;设置GPU的特定状态(例如,禁用深度测试或写入,或启用模板测试)。

步骤1中的每个渲染阶段都需要从scene graph中筛选出我们关心的实体。

为每个实体以及当前渲染阶段选择相应的着色器。实体可以在不同阶段使用不同的着色器,例如,使用一个简单的片段着色器执行early Z填充或生成阴影贴图,而使用完全光照着色器实现屏幕上最终效果。

合并uniform变量(用于自定义着色器中的变量)。

将所有这些信息绑定到RenderCommands中。

一旦所有阶段都完成了,我们将通过一个独立线程向OpenGL提交RenderCommands,由于OpenGL历史悠久,它对线程非常挑剔。

OpenGL提交线程迭代各个渲染阶段和其中包含的命令,将它们从我们的中间描述翻译成OpenGL格式,并分派给原始的OpenGL函数调用。

这一切都使Qt 3D变得非常灵活,但代价是运行时的性能。通常能够大幅提升性能的方法无非就是通过缓存避免无谓的绘制开销。理论上,我们可以通过缓存一些中间结果来取得提升。而实际上,要考虑很多内容比如怎样结合动态渲染模式等,渲染器缓存确实很难做到。

这其中有太多可以影响渲染场景外观的东西需要跟踪,还要弄清楚当不同画面之间某些属性更新之后,必须重新绘制的最小任务集是什么。我们已经在Qt 5版本中添加了一些跟踪功能,但要完全做到这一点需要更大的重构。

在详细描述我们在这方面所做的工作之前,我先讨论另一个问题:

现代图形API

到目前为止,Qt Quick(基本上)已经完全架构在OpenGL(或OpenGL ES)之上,Qt 3D大抵如此。虽然OpenGL长期以来为图形工程师提供了很好的服务,但它是一个非常古老的API,有一些根生蒂固的结构性问题,以至于在不引入新API的情况下无法解决。此外,OpenGL经过多年的扩展和改造,试图跟上现代GPU的实际工作方式,并处理艺术家们所要求的、不断增长的数据量。尽管这促使OpenGL做了大量令人印象深刻的改进,但它仍然受到限制,特别是其多线程模型和驱动实现中的启发式模式,即驱动试图预测应用程序开发者的行为模式。


如上一节所述,在驱动程序内部,OpenGL的操作方式与Qt 3D非常相似。当您发出一堆OpenGL函数调用时,这些调用会被转换成命令并存储在命令缓冲区中,然后在某个时间点(由驱动程序的最佳预估决定)被提交给硬件进行处理。

一旦命令缓冲区中的命令被硬件处理掉,下一帧我们必须再次发出OpenGL函数调用。同样的流程会一帧接一帧地发生,这可非常浪费。

在驱动中,创建命令是一项非常耗资源的操作,而且在OpenGL中,这一切都被限制在一个线程内。所以,清空命令缓冲区有点浪费。编写驱动的GPU厂商添加了各种启发算法,试图预测库和应用程序开发者实际的意图,藉此尽可能缓存数据并优化操作。这使得驱动变得更大、更复杂、更难维护,并在某些情况下导致GPU厂商之间的巨大性能差异。

OpenGL的线程模型本质上是单线程的。是的,可以通过共享context等一些方式支持多线程,但在驱动内部调用仍然会被序列化。考虑到OpenGL已有20多年的历史,这并不奇怪。

OpenGL标准陈旧是另一个问题。苹果公司已宣布弃用OpenGL,将只专注于将Metal作为其图形API。在未来的某个时候,我们可能会发现OpenGLMacOSiOS中消失。即使在那之前,这些平台上的OpenGL库也不会看到任何新的功能了(事实上,它们已经很多年没有更新了)。

对于这些问题我们能做什么?好吧,在过去几年中,现代图形API的出现就是用于解决这些和其他问题的。VulkanMetalDirectX 12都是非常流行的API,与OpenGL相比,它们提供了更直接控制GPU的接口。
您可能会说这太好了,但其实存在妥协之处。OpenGL驱动程序所做的大部分工作现在由库或应用程序开发者负责。乍听上去很吓人,然而在某种程度上的确如此。但是,毕竟我们可以利用自己对应用程序工作模式的宏观理解从GPU中榨取性能。另一方面我们可以选择在更短的时间内完成类似的工作,从而让CPU/GPU进入休眠或省电模式,最终提高续航表现。这对于移动设备和台式机而言都是巨大的提升。

OpenGL驱动程序将丢弃命令缓冲区,而且它在每一帧上的创建消耗都很高,但是当我们作为应用程序开发者使用Vulkan或类似工具时,我们可以知道何时保留这些命令缓冲区并在下一帧重新提交它们是安全的。

您可能想知道那有什么好处。提交相同的命令缓冲区只能让我们在屏幕上看到与前一帧完全相同的内容,难道不是吗?如果是的话,那这么做有什么意义呢?

这是好问题。其实即使我们一次又一次地向GPU提交相同的命令缓冲区,它们引用的资源却可以包含不同的数据。不仅是顶点缓冲区和纹理,还可以包括通常用于保存材质属性和相机变换矩阵的uniform缓冲区对象。如果我们能够跟踪场景中哪些东西发生变化,就能确定是否可以将相同的命令重新提交给GPU,从而节省大量工作,这就非常棒。

还有一个锦上添花的情况!Vulkan使用了主命令缓冲区和辅命令缓冲区的概念。主命令缓冲区是我们提交给GPU的内容,可能包含对辅命令缓冲区的调用。一种常见的使用方式是预先记录某些实体的绘制命令并保存到辅命令缓冲区。

当我们想要绘制整个场景时,我们的渲染器可以创建一个主命令缓冲区,调用那些可见实体的命令缓冲区。当可见性改变时(例如,如果相机移动或某些实体移动),我们可以重新记录主命令缓冲区。那也很好。

更多的锦上添花!使用Vulkan,我们还可以在不同线程上读写命令缓冲区!我们来负责向GPU队列提交命令缓冲区,并在不同的GPU队列(图形/计算/传输等)之间以及GPUCPU之间同步任务。

如您所见,我们可以在所涉及的操作和硬件上获得更多的控制,但我们必须做更多的工作。总的来说,这是一个巨大的性能提升机会。

继续聊Qt 6中的Qt 3D

Qt 6开发时间节点来看,我们正积极研究这两个大方向。从以上描述可以看出,这两项任务都涉及大量工作,关于如何跟踪用户在scene graphframe graph上状态的修改以及接下来Qt 3D必须完成的剩余工作。这包括我们如何最终缓存命令缓冲区以及帧之间的其他中间状态,以避免不必要的重复工作。

正如您可能已经了解,Qt QuickQt Quick 3D会在QRhi层基础上重新构建,QRhi层提供对VulkanMetalDirectX 11OpenGL的支持。我们仍在研究它是否可以合理扩展此功能以满足Qt 3D在功能和多线程方面的需求,或者是否需要使用其他方式集成图形API,这样Qt 3D仍然可以很好地与Qt QuickQt Widgets模块配合使用。

这方面还有很多工作要做,但初步结果看起来非常有希望。我们测试了包含大约1000个实体的场景,在一个中档桌面平台上当我们试图最大化利用GPU时,可以实现每秒600帧(画面无撕裂)的渲染速度,或者当我们限制到60fps时,可以实现1%CPU负载占用!现在这还只使用了单个内核!为了进一步改进多线程架构,超越Qt 5系列的极限,我们正在验证一些想法。

这项工作有一个副产品,我们还开发出了frame graph的下一个迭代版本,它的更新非常自然平滑,因此更加容易理解,也更易于Qt 3D用户的修改。

总结

如您所见,在Qt 5.x周期及以后的时间里,我们会在幕后做许多工作改进Qt 3D。我们还将寻找改进public API的方法,但我们预计在这方面不会有太大的变化,而是对一些不太理想的函数名和属性名进行一些清理。
所有这些改进也将有利于基于Qt 3DKuesa和其他任何使用Qt 3D开发的3D应用程序。这些变化将帮助打造一个坚实的基础,使我们可以在Qt 6时加入更多令人兴奋的扩展。