在Qt图形系列博客的第三部分(第一部分、第二部分),我们会了解在Qt 5.14中,将Qt Quick的Scene Graph切换到通过QRhi (Qt渲染硬件接口)渲染时,着色器是如何工作的。我们先研究着色器的处理方式,然后再深入研究RHI,因为在Qt Quick中当需要使用ShaderEffect Item或自定义材质时,必须自己编写片段和/或顶点着色器代码,因此必需要了解新的着色器处理方法(到Qt 6时才能升级)。
说到Qt 6:虽然我们在这里描述的内容只会应用到Qt 5.14,后面的版本还会有许多修改,但是我们现在阐述的内容极有可能是Qt 6中处理图形和通用计算着色器的基础,当然到时细节内容会打磨得更加精致。
查看qtdeclarative源代码树(即包含QtQml 、QtQuick和相关模块的git代码仓库),然后进入着色器目录,其中包含了Qt Quick Scene Graph内建材质的顶点着色器和片段着色器代码,你会发现Qt Quick已为每个GLSL顶点和片段着色器准备了两个版本:
为什么这样处理呢?这是为了兼容支持使用了核心配置(core profile)的OpenGL(对应OpenGL 3.2及以上)。由于OpenGL标准并没有要求新版本的OpenGL实现必须支持GLSL 100/110/120的编译(即老的GLSL版本),因此Qt不得不准备了两个版本的GLSL:一个适配OpenGL ES 2.0、OpenGL 2.1和兼容性配置(compatibility profile),另一个(GLSL版本号为150)专门用于适配核心配置。如本系列博客第一部分所述,在需要把自定义OpenGL渲染和基础的Qt Quick UI结合的使用场景中,提供两个版本的着色器代码才能使开发者可以自由选择使用哪个版本的OpenGL。因为不论选择兼容性配置还是核心配置,Qt Quick都可以正常渲染。
当着色器版本的数量是2时,这种实现方式还是可以正常实施的。但如果现在我们还需要添加Vulkan风格的GLSL、HLSL和MSL呢?遗憾的是,这种方式无法规模化扩展。
与OpenGL不同,一些较新的图形API不再支持内置着色器编译。(再见了,glCompileShader)。而即使最终还是支持着色器编译,这部分功能可能变成了一个分离的库,但它们可能不提供运行时反射机制,这意味着没有办法动态定位输入顶点以及其他顶点、片段或通用计算着色器所需要的材质,以及这些材质的布局。(例如,一个uniform变量的名称和偏移量)
一个内部细节:Qt Quick Scene Graph的批次处理系统需要对顶点着色器进行一些调整,在一个称为合并批次中调整材质(就是当多个几何节点最终合并到一个draw调用后得到的结果)。把着色器传送到glCompileShader之前动态修改,这种方式适用于只有一种着色语言在使用的情况,不能简单扩展到必须为多种不同语言实现相同逻辑的情况。
看看Khronos的SPIR页面,里面有一张很好的关于SPIR-V开源生态系统的信息图片。为什么不尝试在此基础上进行开发呢?
我们感兴趣的关键组件如下:
glslang, 从GLSL(OpenGL 或 Vulkan 风格)到SPIR-V的编译器, 结果是一个中间语言。
SPIRV-Cross,把SPIR-V向高级语言进行反射和反汇编的库,比如 GLSL、HLSL和MSL。
因此,如果我们“标准化”一种语言,比如Vulkan风格的GLSL,把它编译成SPIR-V,我们就可以适配Vulkan了。然后,如果我们通过SPIRV-Cross运行SPIR-V的二进制文件,就可以获得所需的反射信息,并可以为各种版本的GLSL、HLSL和Metal着色器语言生成源代码。
(是的,GLSL仍然至关重要,因为虽然有让OpenGL可以直接使用SPIR-V的扩展,但是指望这套方式在实际中应用并不现实,因为这样的扩展在90%的Qt目标平台和设备上不存在——例如,OpenGL ES 2.0在2019年仍然常见。)
最后,将所有这些(包括元数据反射特性)打包到一个可以方便(反)序列化的包中,这样就得到了我们的解决方案。
因此,设置QSG_RHI=1然后运行Qt Quick应用程序,其后端渲染管道是这样的:
Vulkan-flavor GLSL
[ -> generate batching-friendly variant for vertex shaders]
-> glslang : SPIR-V bytecode
-> SPIRV-Cross : reflection metadata + GLSL/HLSL/MSL source
-> pack it all together and serialize to a .qsb file
.qsb扩展名来自于执行上述步骤的命令行工具的名称——qsb,Qt Shader Baker的缩写。(不要与qbs混淆)
在运行时,.qsb文件被反序列化成QShader实例。它是一个相当简单的容器,遵循标准的Qt模式,如隐式共享,并为一个着色器托管多个版本的源码和字节码以及包含反射数据的QShaderDescription。与RHI的其他部分一样,这些类目前都是私有的API。
图形层直接使用QShader实例。图形流水线的状态对象为每个激活的着色步骤分配一个QShader。然后QRhi后端从QShader容器中选择适当的着色器版本。
在Qt 5.14中,具体选择规则如下:
当目标是Vulkan时,选则SPIR-V 1.0
当目标是 D3D11时,选择HLSL源码或DXBC Shader Model 5.0
当目标为Metal时,选择兼容MSL的Metal 1.2或者预编译的metallib
当目标为OpenGL ES 上下文时, 分别选择版本为320 es、310 es、300 es和100 es的GLSL源代码(从上下文支持的最高版本开始降序排列)
当目标为OpenGL核心配置的上下文时,选择版本为460、450、…、330、150的GLSL源代码(从上下文支持的最高版本开始降序排列)
当目标是非核心配置的OpenGL上下文时,选择版本为120或者110的GLSL源代码(按同样的优先级排序)。
上表中的HLSL和MSL条目初看可能会有些奇怪。这是因为我们即可以在运行时源码编译HLSL和MSL(我们的默认方法),同时也做了一些实验,允许在.qsb包中包含预编译的中间格式。在实践中,这意味着调用fxc(目前还不支持dxc——它也在计划中,但只有在我们开始研究D3D12时才真正相关)或Metal命令行工具,然后再在管道中执行上面所示的“打包”步骤。这里的挑战当然是这些工具与它们的平台(分别是Windows和macOS)绑定在一起,因此qsb只有当在该平台上运行时才能被启用。例如,在Linux上手动生成.qsb文件不可行。从长远来看,这可能不是什么大问题,因为在Qt 6的规划中,我们会研究更好地与构建系统集成,所以像qsb这样的手动运行工具就不那么常见了。
来自Qt Shader Tools模块。它提供了一个称为QShaderBaker的API以及一个称为qsb的命令行工具来执行上面描述的编译、转换和打包步骤。
这里有一点需要注意:这是一个Qt-labs模块,所以它不会随Qt 5.14一起发布。
为什么呢?主要是因为第三方依赖,例如glslang和SPIRV-Cross。涉及到需要在我们所有的目标平台上编译和运行的情况时,就会有许多事情需要调查和确认,有些与许可证相关。如果所有这些听起来都很熟悉,那是因为在本博客系列的第一部分讨论API转换解决方案时提到了其中的一些问题。因此,现在生成.qsb包就牵涉到了该模块的检查和构建,然后才能手动运行.qsb工具。
尽管我们还是需要一个新的集成打包在Qt中的解决方案,目前依赖一个离线着色处理并不是件坏事。不管发生什么,它都是Qt 6的目标之一。我们的愿景是拥有一些与Qt构建系统集成的东西,这样上述的着色器处理步骤就可以在应用程序(或库)构建时完成。但这推迟到成为一个未来的目标,主要是因为即将到来的qmake -> cmake切换。一旦情况稳定下来,我们就可以开始在新系统上构建解决方案了。
看看qt/ src/quick/scenegraph/shaders_ng,答案很明显:通过手动运行qsb(注意名称很贴切的compile.bat),并通过Qt资源系统在Qt quick库中引入生成的.qsb文件。正如上面所概述的,稍后应该会变得更加精巧一些,但是现在已经完成了任务。
.vert和.frag文件包含了与Vulkan兼容的GLSL代码,并且没有包含在Qt Quick 构建中。scenegraph.qrc中只有.qsb文件。
这张幻灯片出自我在NDC TechTown 2019上的演讲,很好地总结了这个过程:
每个材质只有一对顶点和片段着色器,总是以与Vulkan兼容的GLSL的形式编写,遵循一些简单的约定(比如只使用一个uniform缓冲区,位于binding 0处)。
所有这些文件都通过着色器机制运行,产生一个QShader包。在这个例子中,结果是相同着色器的六个版本,加上反射数据(qsb可以打印为JSON文本;然而.qsb文件本身是压缩的不可读的二进制文件)。这样就解决了上面的问题一和二。
注意着色器列表中的[Standard]标签。如果这是一个顶点着色器,并且指定了-b参数,输出着色器的数量将是12个,而不是6个。另外的6个将被标记为[Batchable],这表明它们是非常友好的批处理,对Qt Quick scenegraph的渲染器做了轻微的修改。这解决了问题3,代价是存储会有所提高。(由于减少了运行时工作,所以最终是值得的)
本文涵盖了新着色器管道背后的核心概念。我们将会在另一篇文章中讨论ShaderEffect和QSGMaterial。基本思想(Qt 5.14中的)是传递.qsb文件的名称,而不是着色器源码字符串,但对于材质需要特别注意几个问题(主要是由于使用uniform缓冲区代替了单一的缓冲区,而且由于没有了线程上下文的概念,因此任何人都可以随意改变状态)。下次再详细讲。