SPIR-V着色器被嵌入在 模块 之中。每个模块可以包含一个或多个着色器。每个着色器具有一个入口点,该入口点具有一个名字和一个着色器类型,着色器类型用于定义当前着色器跑在哪个着色 阶段 。入口点则是当前着色器开始执行的位置。一个SPIR-V模块伴随着创建信息被传递给Vulkan,然后Vulkan返回表示该模块的一个对象。该模块对象随后可以用于构造一条 流水线。这就是一单个着色器完整编译的版本,伴随着在当前设备上要运行它所需要的信息。
SPIR-V对于Vulkan而言是仅有的官方支持的着色语言。它在API层被接受并且最终用于构造流水线,这些流水线是配置一个Vulkan设备的对象,为你的应用完成工作。
SPIR-V被设计为对一些工具和驱动而言非常容易处理的表示。这通过不同实现之间的多样性来提升可移植性。一个SPIR-V模块的内部表示是一条32位字的流,存放在存储器中。除非你是一位工具写手或计划自己生成SPIR-V,否则的话你不太需要直接处理SPIR-V的二进制编码。而是说,你要么可以看SPIR-V的可读的文本表示,或是使用诸如 glslangvalidator 这样的官方Khronos GLSL编译工具来生成SPIR-V。
下面我们可以写一个计算着色器源文件,命名为 simpleKernel.comp,然后拿给 glslangvalidator 去编译。其中 .comp 后缀名能告诉 glslangvalidator 该着色器将作为一个计算着色器进行编译。
#version 460 core
void main(void)
{
// Do Nothing...
}
然后我们使用以下命令行(笔者用的是Windows环境):
%VK_SDK_PATH%/Bin/glslangValidator -o simpleKernel.spv -V100 simpleKernel.comp
随后我们就能看到生成了一个名为 simpleKernel.spv 的SPIR-V二进制文件。我们可以使用SPIR-V反汇编器对该二进制文件再进行反汇编。我们使用 spirv-dis 这一官方反汇编工具。
%VK_SDK_PATH%/Bin/spirv-dis -o simpleKernel.spvasm simpleKernel.spv
以上命令将会输出人可读的汇编文件 simpleKernel.spvasm。其内容如下:
; SPIR-V
; Version: 1.0
; Generator: Khronos Glslang Reference Front End; 10
; Bound: 6
; Schema: 0
OpCapability Shader
%1 = OpExtInstImport "GLSL.std.450"
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpSource GLSL 460
OpName %main "main"
%void = OpTypeVoid
%3 = OpTypeFunction %void
%main = OpFunction %void None %3
%5 = OpLabel
OpReturn
OpFunctionEnd
我们可以看到SPIR-V的文本形式看上去像一种古怪的汇编语言的变种。我们可以逐行过一下上述反汇编内容,然后看看它是如何与原始的GLSL输入相关联的。上面所输出汇编的每一行表示一单条SPIR-V指令,一条指令可能会由多个符号(token)构成。此外,分号(;)开头的语句为一条注释。
流中的第一条指令是 OpCapability Shader,它请求开启着色器能力。SPIR-V功能被粗略地划分为 指令 和 特征。在你的着色器能使用任一这些特征之前,必须声明它将使用哪种特征作为其一部分。上述代码中的着色器是一个图形着色器并从而使用 Shader 这一能力。随着我们介绍更多SPIR-V以及Vulkan功能,我们将会介绍每种特征所依赖的各种不同的能力。
接着,我们看到 %1 = OpExtInstImport "GLSL.std.450"。这本质上是导入额外的一组相应于GLSL版本450所包含的功能,而这往往也是原始着色器所写入的。注意,这条指令最前面写有 %1 = 。这是对当前指令的结果赋上一个ID。OpExtInstImport 的结果在效果上是一个库。当我们想调用此库中的函数时,我们就使用 OpExtInst 这条指令来实现。它具有两个操作数,分别是一个库(OpExtInstImport 指令的结果)和一个指令索引。这允许SPIR-V指令集可被任意扩展。
接着,我们看到了某些额外的声明。OpMemoryModel 为此模块指定了工作存储器模型,在上述代码例子中是对应于GLSL版本450的逻辑存储器模型。这意味着所有存储器访问所有存储器访问通过资源执行而不是通过一个物理存储器模型。而物理存储器模型访问存储器时是直接通过指针。
接着是对当前模块的入口点的声明。OpEntryPoint GLCompute %main "main" 指令意味着有一个入口点对应于OpenGL计算着色器,所导出的函数名为 main,而这里所指定的ID为 %main,该符号将会被 spirv-as 汇编器根据上下文来分配一个具体的ID号。从上述代码中我们看到已经分配了 %1、%3 和 %5,而唯独没有 %2 和 %4。而这两个ID号很有可能最终在被编译的时候会被 %main 和 下面一个符号 %void 所分配。这里的函数名 "main" 用于引用入口点,当我们将所产生的着色器模块递回给Vulkan时需要此模块的入口点。
然后我们会在后一条指令中使用上面的 %main 这个符号—— OpExecutionMode %main LocalSize 1 1 1 定义了此着色器的执行组大小为 1×1×1 个工作项。如果局部大小 layout 修饰符缺省的话,那么GLSL会隐式指定local size为 1×1×1。
下面的两条指令只是简单地提供一些信息。OpSource GLSL 460 指明当前模块是用GLSL版本460进行编译的,而 OpName %main "main" 为具有ID为 %main 的符号提供了一个名字。
现在我们就可以来看看这个 main 函数真正有料的部分。首先,%void = OpTypeVoid 声明了我们想用 %void 这一符号作为类型 void。正如前面所述,它最终可能会被分配的ID为2。在SPIR-V中所有一切都具有一个ID,甚至是类型定义。较大的聚合类型可以通过顺序地引用更小的、更简单的类型进行构造。然而,我们需要从某个地方开始,而这里将一个类型分配给 void 正是我们开始的地方。
%3 = OpTypeFunction %void 意味着我们定义一个为3的ID作为一个函数类型,该函数类型取了 void 作为其返回类型(先前声明为 void)并且没有任何参数。我们在下面一行使用了该类型。%main = OpFunction %void None %3 这意味着我们声明了一个符号 %main(最终被分配的ID可能为4,而我们之前用它命名了 "main")作为函数3(上面那条语句)的一个实例,它返回类型为 void,并且没有其他特别的声明。这是通过指令中的 None 进行指示,而该位置上其他可用的参数包括是否内联、或是该变量是否为常量等等。
最后,我们看到对一个标签(标签没有被使用,而只是编译器操作所留下的副作用)、隐式的返回语句、以及最终函数结束的声明。这就是我们SPIR-V的末尾。