2.8 SPIR-V
SPIR-V是Khronos标准的一种中间语言,这是一种着色器程序分发的替代方案。OpenGL支持GLSL形式的着色器程序,同样也支持SPIR-V形式的着色器程序。通常来说,我们需要某些离线的处理工具,从GLSL这样的高级着色语言来生成SPIR-V形式的代码,进而在用户程序当中发布已生成的SPIR-V程序,而不是直接发布GLSL的源代码。
SPIR-V的创建、发布和使用都是采用二进制单元的模块(module)形式。一个SPIR-V模块在内存中是一段32位词的内容,或者直接存储为32位词的文件。不过,OpenGL和GLSL都不会直接操作文件,所以SPIR-V模块必须是作为内存中的32位词数据指针传递到OpenGL中使用的。
每个SPIR-V模块都可以包含一个或者多个入口点,用来启动一段着色器程序,并且每个入口点都隶属于已知的OpenGL流水线阶段(pipeline stage)。每个这样的入口点都会构成一段独立而完整的OpenGL流水线阶段。换句话说,桌面GLSL会保存多个编译过的着色器单元,然后将它们组合成一个阶段,但是SPIR-V着色器不同。它的编译过程是在离线状态下,通过某个前端工具将高级语言翻译成SPIR-V完成的,因此得到的是一个完整的阶段。即使对于同一个阶段来说,一个独立的SPIR-V模块也可能包含多个入口点。
SPIR-V模块是可以专有化的,也就是说,我们可以在最后编译之前实时修改模块中某些特定的标识常量。这样做是为了降低一个着色器的多个(轻微修改后的)版本对应的SPIR-V模块的数量。
2.8.1 选择SPIR-V的理由
如果用户期望发布SPIR-V形式的着色器,而不是GLSL形式的,那么可能有以下几种原因。有些原因可能符合你目前的状况,有些可能不符合:
更好的可移植性。有一类可移植性问题是因为不同平台的驱动程序对于GLSL的高级语法会有稍微不同的解释。而高级语言之所以被称作高级,部分原因就是它们节约了开发者的宝贵时间。但是,这种便利的前提条件有的时候是很难完全确定的,因而导致了驱动层面的不同结果。SPIR-V更为严格,对于语法的表达也更为规范,因此解释过程中并没有很大的歧义。所以SPIR-V在不同平台的解释过程中变数更小,因而提升了可移植性。当然,我们并没有使用SPIR-V进行编码,而是继续使用诸如GLSL这样的高级语言。但是为了生成SPIR-V程序,需要选择一个针对全平台的前端工具。也就是说,选择了一个独立的GLSL前端之后,我们就消除了因为不同平台的GLSL语法解释过程而产生的可移植性问题。有些人可能会选择其他的前端来编写着色器代码,这样也没有问题。我们真正要关注的重点是:应用程序中的GLSL着色器是否都采用了平台一致的GLSL解释方式,进而生成一致的SPIR-V代码。
多种源语言支持。SPIR-V可以支持GLSL之外的其他高级语言。只要最后发布的SPIR-V是正确格式的,我们就不需要关心它是如何生成的。
减少发布尺寸。SPIR-V有多种特性来显著降低着色器发布后的尺寸。对于独立的着色器来说,SPIR-V的形式通常比GLSL的形式更大一些,但这两者生成的最终结果其实都很小。但是如果将相关的着色器集合起来,尺寸就会大得多。而SPIR-V提供了两种特性来处理这种集合的形式:每个模块的专有化和多重入口点。专有化可以让我们延迟修改某些常量数值,而同一个SPIR-V模块中的多重入口点可以在多个程序段中共享同一个函数体的实例。发布GLSL的时候,需要针对每一个着色器都发布一份函数体的拷贝,而SPIR-V的发布只需要一个拷贝即可。
保护源代码。有时候也叫作代码的混淆,因为很多时候我们并不希望用过于清晰的方式来发布自己的着色器源代码。着色器的源码可能是某种新奇想法或者知识产权,而你不一定愿意把这些成果完全透明地发布给其他人,让他们随意改动。采用离线编译源代码到SPIR-V,然后发布SPIR-V代码的方法,就可以避免直接发布自己的源代码。这样其他人就很难理解这样的着色器代码是如何工作的。没错,这样的代码依然可以反编译成GLSL或其他高级着色语言的形式,或者重新再转换成SPIR-V的语言。不过,这样的逆向工程需要得到对应的法律许可,因而也就为发布者提供了真正的知识产权保护机制。
我们选择中间语言而非高级语言的另一个理由是为了保证实时编译器的性能,但是这里也要注意。高性能的着色器执行过程通常需要对应的调度和寄存器分配算法,而实时运行这件事本身是需要消耗时间的。这些后续步骤无法通过可移植的中间语言来消除。而实时编译器的性能是可以通过多种途径来提升的。例如,解析高级语言的过程需要花费时间。虽然解析只是整个编译过程的一小部分,但是如果着色器代码中含有大量无用的代码段,或者我们需要将多段着色器代码编译为相同的中间结果的话,这里的性能损耗还是非常显著的。在这种情况下,使用SPIR-V可以明显降低解析所需的时间。同样,有些高级的优化特性也可以离线完成,但是需要避免使用那些平台相关的优化方法,否则在某些平台上可能会损害性能。举例来说,是否将所有的函数设置为内联形式,这就是一个平台相关的特性。
2.8.2 SPIR-V与OpenGL
在OpenGL中使用SPIR-V着色器的方法,与使用GLSL着色器非常类似。正如之前所介绍的,创建了着色器对象之后,我们还需要两个步骤来关联SPIR-V的入口点与每个着色器对象。第一步是调用glShaderBinary()来关联SPIR-V模块与着色器对象:
void glShaderBinary(GLsizei count, const GLuint shaders, enum binaryformat, const void binary, GLsizei length);
如果binaryformat设置为GL_SHADER_BINARY_FORMAT_SPIR_V_ARB,那么binary中需要设置SPIR-V模块所关联的一组着色器对象。shaders包含一组着色器对象的句柄,大小为count。每个着色器对象句柄对应一个唯一的着色器类型,可以是GL_VERTEX_SHADER、GL_FRAGMENT_SHADER、GL_TESS_CONTROL_SHADER、GL_TESS_EVALUATION_SHADER、GL_GEOMETRY_SHADER或者GL_COMPUTE_SHADER中的一种。binary指向一个合法SPIR-V模块的第一个字节,而length包含了SPIR-V模块的字节长度。如果我们成功地使用了SPIR-V模块,那么shaders中的每个入口都可以从这个SPIR-V模块中获取入口点。这些着色器编译的状态会被设置为GL_FALSE。
因为SPIR-V通常是由32位的数据流所组成的,因此我们需要将自己的SPIR-V代码大小转换成字节数再传递给glShaderBinary()。glShaderBinary()函数也可以用于其他非源码形式的着色器,因此它是一个通用的函数,而不是专用于SPIR-V的,除非指定SHADER_BINARY_FORMAT_SPIR_V_ARB。
第二步是使用glSpecializeShader()来关联SPIR-V入口点与着色器对象,如果成功的话,那么编译状态会从glShaderBinary()所设置的GL_FALSE变成GL_TRUE:
void glSpecializeShader(GLuint shader, const char pEntryPoint, GLuint numSpecializationConstants, const uint pConstantIndex, const uint* pConstantValue);
设置SPIR-V模块中入口点的名字,并设置SPIR-V模块中专有化常量的值。shader表示与SPIR-V模块关联(使用glShaderBinary())的着色器对象的名字。而pEntryPoint是一个UTF-8字符串指针,使用NULL截断,它表示SPIR-V模块中name着色器对应的入口点名称。如果pEntryPoint为空,那么默认字符串为“main”。
numSpecializationConstants表示本次调用过程中专有化常量的数量。pConstantIndex表示一个数组的指针,它包含了numSpecializationConstants个无符号整型数据。pConstantValue中对应的数据即被用来设置专有化常量的值,其索引位置由pConstantIndex中的数据决定。虽然这个数组是无符号整型数据组成的,但是每个数值都是根据模块中设置的类型来进行按位转换的。因此,我们也可以在pConstantValue数组中使用浮点数常量,并采用IEEE-754标准的表示方法。pConstantIndex中没有引用的专有化常量在SPIR-V模块中依然保留原有的数值。当着色器的专有化完成之后,着色器的编译状态将会设置为GL_TRUE。如果失败的话,着色器的编译状态会设置为GL_FALSE,同时我们可以在着色器编译日志中找到相关的失败信息。
我们将会在本节后面的部分讨论GLSL专有化的方法。
完成这两步之后,我们就可以使用glAttachShader()和glLinkProgram()了,这和我们以前使用glShaderSource()来编写GLSL代码的过程是一样的,其他的工作流程也完全一致。
2.8.3 使用GLSL在OpenGL中生成SPIR-V
OpenGL对于生成SPIR-V的方法并没有要求,只需要SPIR-V本身完整即可。这对于很多高级语言的支持,以及创建SPIR-V的本地工具来说是很好的特性,并且也可以方便我们编写和交换标准高级语言格式的着色器。为了辅助这一点,Khronos对于GLSL创建SPIR-V的过程进行了标准化。
GLSL有两种创建SPIR-V形式的着色器的方法:一种是创建Vulkan对应的SPIR-V(通过KHR_glsl_vulkan扩展);另一种是创建OpenGL对应的SPIR-V(通过ARB_gl_spirv扩展)。当然,这里会着重讨论OpenGL对应的SPIR-V在GLSL中的生成过程。这里所说的GLSL也就是标准GLSL,但是会有少量的增加和少量的删减,以及一部分更改。总体上来说,它的所有输入和输出都需要设置一个location,而I/O与SSO模型的用法是类似的。其他方面则与本章所介绍的GLSL完全相同。
验证SPIR-V
OpenGL驱动并不会完全支持SPIR-V的实时验证,因为SPIR-V在离线状态下生成对于系统性能来说更有利。OpenGL只需要正确执行经过完整验证的SPIR-V数据即可。也就是说,如果SPIR-V无效,那么得到的结果也是无法预知的。Khronos已经开发了一个SPIR-V的验证工具,以及其他一些工具,可以在下面的地址下载:
https://github.com/KhronosGroup/SPIRV-Tools
它是离线的,可以确保你要发布的SPIR-V是可用的。这个工具需要集成到你自己的离线工具链当中,以便最大限度地符合着色器的可移植性需求。
GLSL中针对SPIR-V生成的增补项
OpenGL GLSL中针对SPIR-V的核心增补项就是专有化。专有化常量可以很大程度上降低着色器中可变量的数量。因此着色器的常量可以延迟发生改变,而不需要重新生成着色器。
总体上来说,如果在编译阶段就知道哪些数值是常量,那么我们就可以优化并生成更快的可执行代码(否则系统可能会访问一直保持不变的数值)。循环语句执行的次数是可知的,因此计算量也可以简化。因为常量具有这些益处,GLSL着色器通常会通过预编译宏或者某些自动生成的代码来进行此类参数化的工作。然后就会因为参数数值的差别而生产成多组不同的着色器代码。如果使用专有化常量,这样的参数就会被特别标注出来,并给定一个默认值,并且被当作一个常量对待(虽然它的数值在最终运行时编译的时候还是可以发生变化)。因此,我们可以只创建一个着色器,然后使用专有化常量发布,之后在运行时设置正确的常量数值。在GLSL中可以这样书写:
这里我们声明param是一个专有化常量(通过constant_id),默认值为8。数值17表示param在运行时的标识,如果用户程序想通过OpenGL API(也就是之前的glSpecializeShader())来改变默认值,就需要引用这个数值。
编译SPIR-V的时候,SPIR-V着色器会把param作为一个专有化常量进行追踪。如果要为这个着色器创建一个渲染流水线,那么SPIR-V着色器中会给出正确的常量值并且针对它进行优化。因此,我们就不需要为了常量的多个变化值而修改同一个着色器对象了。
SPIR-V中移除的GLSL特性
有些GLSL的传统特性并不受到SPIR-V的支持。我们将这些特性列在这里,并且给出建议的替代方案。
子程序(subroutine):OpenGL GLSL的子程序特性在SPIR-V中无法使用。我们可以使用GLSL的其他方法来替代这个功能,比如switch语句以及函数调用。举例来说:
过时特性:过时的特性本来就应当避免,其中有一些会被SPIR-V完全忽略掉。其中包括一些过时的纹理函数,例如texture2D(),它无法使用的原因是texture2D现在已经被保留为类型关键字,用来生成不用独立采样和2D纹理的sampler2D。它的替代者是texture,这个新版本的内置函数被用来执行纹理查找的操作。
兼容模式(compatibility prof?ile):总体上来说,凡是只属于兼容模式的特性都不会被SPIR-V所支持,并且兼容模式的GLSL也不允许用来生成SPIR-V。你需要设置着色器使用核心模式(core prof?ile)的特性,包括我们之前提到的,专用于GLSL中的SPIR-V的特性。
gl_DepthRangeParameters():SPIR-V没有为深度范围参数设置内置的变量。如果用户希望在着色器中使用此类信息,可以直接声明自己的uniform变量,并且通过API显式设置它们的数值。
SPIR-V中变更的GLSL特性
gl_FragColor广播:直接使用GLSL而不通过SPIR-V的时候,写入到gl_FragColor相当于对所有的颜色输出附件(color-output attachment)统一写入。但是SPIR-V不支持这个特性。理想情况下,我们需要声明想要写入的输出变量,并且显式地进行写入。如果依然使用gl_FragColor的话,那么写入它的数值相当于只写入到位置0的那一个颜色输出附件。
2.8.4 Glslang
Khronos Group提供了一个GLSL的参考前端工具,可以用来从GLSL生成SPIR-V,并且支持OpenGL和Vulkan。要注意的是,你必须指定你生成的SPIR-V是对应哪个API的,它们对应的特性不同,GLSL语义也不同。虽然这是Khronos提供的验证GLSL正确性的前端工具,但它只是一个SPIR-V编译器的示例程序而已,并不是唯一能够做这件事的工具。
Glslang是GitHub上维护的一个开源项目,地址为:
https://github.com/KhronosGroup/glslang
注意,glslang是一个Khronos提供的参考工具,可以验证OpenGL GLSL或者OpenGL ES的ESSL的语义正确性。不过目前它还没有被Khronos认可为SPIR-V生成所用的检测工具,只是一个示例性质的实现而已。
2.8.5 SPIR-V中包含了什么
SPIR-V采用简单的纯二进制格式,可以表达为一种高级的中间语言。它采用简单的32位词的简单线性队列进行存储。如果你要从一个离线的编译器获取结果,或者将结果设置给API,那么它会被表达为一个32位词的数据流(但是你需要把尺寸乘以4,以便得到glShaderBinary()所期望的字节数)。它采用自包含的形式,字符串词并没有进行进一步的封装,而是直接从文件中读写原始词序列,或者设置给API的入口点。在序列当中,前几个字段的数据提供了对后面数据的可用性检查功能,包括数据伊始的SPIR-V魔法数字(magic number),它应当是0x07230203。如果你得到的结果在字节上是反向的,那么你取得的可能不是一个完整的32位词,也可能你的大小端(endianness)设置与文件本身相反。
一个用高级语言编写的着色器转换到SPIR-V之后,几乎不会丢失信息。它可以保留紧凑的控制特性和其他高级的结构、GLSL自有的类型,以及内置变量数据,因此进行更高性能的优化时不会导致目标平台上的结果丢失信息。
对于SPIR-V更多内部细节的讲解超出了本书的范畴,我们只是希望告诉用户如何使用GLSL来生成SPIR-V,并且在应用程序中发布它,而不涉及自己编写SPIR-V的方法。