着色器模块

与早期的 API 不同,Vulkan 中的着色器代码必须以字节码格式指定,而不是像 GLSLHLSL 这样的人类可读语法。这种字节码格式称为 SPIR-V,旨在与 Vulkan 和 OpenCL(均为 Khronos API)一起使用。它是一种可用于编写图形和计算着色器的格式,但在本教程中,我们将重点关注 Vulkan 图形管线中使用的着色器。

使用字节码格式的优势在于,GPU 供应商编写的将着色器代码转换为原生代码的编译器复杂性大大降低。过去的经验表明,对于像 GLSL 这样的人类可读语法,一些 GPU 供应商对其标准的解释相当灵活。如果你使用其中一家供应商的 GPU 编写非平凡的着色器,则可能会因语法错误而导致其他供应商的驱动程序拒绝你的代码,或者更糟糕的是,由于编译器错误而导致着色器运行方式不同。使用像 SPIR-V 这样简单的字节码格式,有望避免这种情况。

但是,这并不意味着我们需要手动编写此字节码。Khronos 发布了自己的独立于供应商的编译器,该编译器将 GLSL 编译为 SPIR-V。该编译器旨在验证你的着色器代码是否完全符合标准,并生成一个可以随程序一起发布的 SPIR-V 二进制文件。你也可以将此编译器作为库包含在其中,以便在运行时生成 SPIR-V,但在本教程中我们不会这样做。虽然我们可以通过 glslangValidator.exe 直接使用此编译器,但我们将改用 Google 的 glslc.exeglslc 的优点在于,它使用与 GCC 和 Clang 等知名编译器相同的参数格式,并且包含一些额外的功能,例如包含。它们都已包含在 Vulkan SDK 中,因此你无需下载任何额外的内容。

GLSL 是一种具有 C 风格语法的着色语言。用它编写的程序都有一个 main 函数,该函数会为每个对象调用。GLSL 不使用参数作为输入和返回值作为输出,而是使用全局变量来处理输入和输出。该语言包含许多辅助图形编程的功能,例如内置的向量和矩阵原语。其中包括用于叉积、矩阵-向量积和围绕向量反射等操作的函数。向量类型称为 vec,数字表示元素的数量。例如,一个 3D 位置将存储在 vec3 中。可以通过像 .x 这样的成员访问单个组件,但也可以同时从多个组件创建新向量。例如,表达式 vec3(1.0, 2.0, 3.0).xy 将产生 vec2。向量的构造函数还可以采用向量对象和标量值的组合。例如,可以使用 vec3(vec2(1.0, 2.0), 3.0) 构造 vec3

正如上一章提到的,我们需要编写一个顶点着色器和一个片段着色器才能在屏幕上获得一个三角形。接下来的两节将介绍每个着色器的 GLSL 代码,之后我将向你展示如何生成两个 SPIR-V 二进制文件并将它们加载到程序中。

顶点着色器

顶点着色器处理每个传入的顶点。它以模型空间位置、颜色、法线和纹理坐标等属性作为输入。输出是裁剪坐标中的最终位置以及需要传递给片段着色器的属性,例如颜色和纹理坐标。然后,这些值将由光栅化器在片段上进行插值,以产生平滑的渐变。

裁剪坐标是来自顶点着色器的四维向量,随后通过将整个向量除以其最后一个分量来将其转换为归一化设备坐标。这些归一化设备坐标是 齐次坐标,它们将帧缓冲映射到如下所示的 [-1, 1] x [-1, 1] 坐标系

normalized device coordinates

如果你之前涉足过计算机图形学,那么你对此应该已经很熟悉了。如果你之前使用过 OpenGL,那么你会注意到 Y 坐标的符号现在被翻转了。Z 坐标现在使用与 Direct3D 中相同的范围,从 0 到 1。

对于我们的第一个三角形,我们不会应用任何变换,我们将直接将三个顶点的位置指定为归一化设备坐标,以创建以下形状

triangle coordinates

我们可以直接输出归一化设备坐标,方法是从顶点着色器中将它们作为裁剪坐标输出,并将最后一个分量设置为 1。这样,将裁剪坐标转换为归一化设备坐标的除法运算就不会改变任何值。

通常这些坐标会存储在顶点缓冲区中,但是在 Vulkan 中创建顶点缓冲区并用数据填充它并非易事。因此,我决定将其推迟到我们看到屏幕上弹出一个三角形之后。我们暂时要做一些不太寻常的事情:直接在顶点着色器中包含坐标。代码如下所示

#version 450

vec2 positions[3] = vec2[](
    vec2(0.0, -0.5),
    vec2(0.5, 0.5),
    vec2(-0.5, 0.5)
);

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
}

main 函数为每个顶点调用。内置的 gl_VertexIndex 变量包含当前顶点的索引。这通常是顶点缓冲区中的索引,但在我们的例子中,它将是硬编码顶点数据数组中的索引。每个顶点的位置从着色器中的常量数组访问,并与虚拟的 zw 分量组合以生成裁剪坐标中的位置。内置变量 gl_Position 作为输出。

片段着色器

由顶点着色器中的位置形成的三角形在屏幕上填充了一个区域,这个区域由片段组成。片段着色器在这些片段上调用,为帧缓冲区(或多个帧缓冲区)生成颜色和深度。一个简单的片段着色器,为整个三角形输出红色,如下所示

#version 450

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(1.0, 0.0, 0.0, 1.0);
}

main 函数为每个片段调用,就像顶点着色器的 main 函数为每个顶点调用一样。GLSL 中的颜色是 4 分量向量,R、G、B 和 alpha 通道的值在 [0, 1] 范围内。与顶点着色器中的 gl_Position 不同,没有内置变量可以为当前片段输出颜色。你必须为每个帧缓冲区指定自己的输出变量,其中 layout(location = 0) 修饰符指定帧缓冲区的索引。红色被写入到这个 outColor 变量,该变量链接到索引 0 处的第一个(也是唯一的)帧缓冲区。

逐顶点颜色

让整个三角形都是红色并没有什么意思,像下面这样的效果难道不会更好吗?

triangle coordinates colors

我们需要对两个着色器都进行一些更改才能实现这一点。首先,我们需要为三个顶点中的每一个指定不同的颜色。顶点着色器现在应该包含一个颜色数组,就像它对位置所做的那样

vec3 colors[3] = vec3[](
    vec3(1.0, 0.0, 0.0),
    vec3(0.0, 1.0, 0.0),
    vec3(0.0, 0.0, 1.0)
);

现在,我们只需要将这些逐顶点颜色传递给片段着色器,以便它可以将它们的插值输出到帧缓冲区。向顶点着色器添加颜色输出,并在 main 函数中写入

layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
    fragColor = colors[gl_VertexIndex];
}

接下来,我们需要在片段着色器中添加一个匹配的输入

layout(location = 0) in vec3 fragColor;

void main() {
    outColor = vec4(fragColor, 1.0);
}

输入变量不必使用相同的名称,它们将使用 location 指令指定的索引链接在一起。main 函数已被修改为输出颜色以及 alpha 值。如上图所示,fragColor 的值将自动在三个顶点之间的片段之间插值,从而产生平滑的渐变。

编译着色器

在你的项目根目录中创建一个名为 shaders 的目录,并将顶点着色器存储在名为 shader.vert 的文件中,将片段着色器存储在名为 shader.frag 的文件中。GLSL 着色器没有官方扩展名,但这两种扩展名通常用于区分它们。

shader.vert 的内容应该是

#version 450

layout(location = 0) out vec3 fragColor;

vec2 positions[3] = vec2[](
    vec2(0.0, -0.5),
    vec2(0.5, 0.5),
    vec2(-0.5, 0.5)
);

vec3 colors[3] = vec3[](
    vec3(1.0, 0.0, 0.0),
    vec3(0.0, 1.0, 0.0),
    vec3(0.0, 0.0, 1.0)
);

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
    fragColor = colors[gl_VertexIndex];
}

shader.frag 的内容应该是

#version 450

layout(location = 0) in vec3 fragColor;

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(fragColor, 1.0);
}

现在我们将使用 glslc 程序将它们编译为 SPIR-V 字节码。

Windows

创建一个包含以下内容的 compile.bat 文件

C:/VulkanSDK/x.x.x.x/Bin/glslc.exe shader.vert -o vert.spv
C:/VulkanSDK/x.x.x.x/Bin/glslc.exe shader.frag -o frag.spv
pause

glslc.exe 的路径替换为你安装 Vulkan SDK 的路径。双击该文件以运行它。

Linux

创建一个包含以下内容的 compile.sh 文件

/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.vert -o vert.spv
/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.frag -o frag.spv

glslc 的路径替换为你安装 Vulkan SDK 的路径。使用 chmod +x compile.sh 使脚本可执行并运行它。

平台特定说明结束

这两个命令告诉编译器读取 GLSL 源文件,并使用 -o(输出)标志输出 SPIR-V 字节码文件。

如果你的着色器包含语法错误,那么编译器将告诉你行号和问题,就像你期望的那样。例如,尝试省略一个分号并再次运行编译脚本。还可以尝试在不带任何参数的情况下运行编译器,以查看它支持哪些标志。例如,它还可以将字节码输出为人类可读的格式,以便你可以准确地看到你的着色器在做什么,以及在此阶段应用的任何优化。

在命令行上编译着色器是最直接的选择之一,它也是我们将在本教程中使用的选项,但也可以直接从你自己的代码中编译着色器。Vulkan SDK 包括 libshaderc,这是一个从你的程序中将 GLSL 代码编译为 SPIR-V 的库。

加载着色器

现在我们有了一种生成 SPIR-V 着色器的方法,是时候将它们加载到我们的程序中,以便在某个时候将它们插入到图形管道中了。我们首先编写一个简单的辅助函数来从文件中加载二进制数据。

#include <fstream>

...

static std::vector<char> readFile(const std::string& filename) {
    std::ifstream file(filename, std::ios::ate | std::ios::binary);

    if (!file.is_open()) {
        throw std::runtime_error("failed to open file!");
    }
}

readFile 函数将读取指定文件的所有字节,并将它们返回到由 std::vector 管理的字节数组中。我们首先使用两个标志打开文件

  • ate: 从文件末尾开始读取

  • binary: 将文件读取为二进制文件(避免文本转换)

从文件末尾开始读取的优点是,我们可以使用读取位置来确定文件的大小并分配缓冲区

size_t fileSize = (size_t) file.tellg();
std::vector<char> buffer(fileSize);

之后,我们可以寻址回到文件开头并一次读取所有字节

file.seekg(0);
file.read(buffer.data(), fileSize);

最后关闭文件并返回字节

file.close();

return buffer;

现在我们将从 createGraphicsPipeline 调用此函数以加载两个着色器的字节码

void createGraphicsPipeline() {
    auto vertShaderCode = readFile("shaders/vert.spv");
    auto fragShaderCode = readFile("shaders/frag.spv");
}

通过打印缓冲区的大小并检查它们是否与实际文件大小(以字节为单位)匹配,来确保正确加载着色器。请注意,该代码不需要以 null 结尾,因为它是一个二进制代码,我们稍后将明确指出它的大小。

创建着色器模块

在我们可以将代码传递给管道之前,我们必须将其包装在 VkShaderModule 对象中。让我们创建一个辅助函数 createShaderModule 来完成此操作。

VkShaderModule createShaderModule(const std::vector<char>& code) {

}

该函数将以字节码的缓冲区作为参数,并从中创建 VkShaderModule

创建着色器模块很简单,我们只需要指定指向字节码缓冲区的指针和它的长度。这些信息在 VkShaderModuleCreateInfo 结构中指定。唯一的问题是,字节码的大小是以字节为单位指定的,但是字节码指针是 uint32_t 指针,而不是 char 指针。因此,我们需要使用 reinterpret_cast 转换指针,如下所示。当你执行这样的转换时,你还需要确保数据满足 uint32_t 的对齐要求。幸运的是,数据存储在 std::vector 中,其中默认分配器已经确保数据满足最坏情况下的对齐要求。

VkShaderModuleCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = code.size();
createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());

然后可以使用对 vkCreateShaderModule 的调用创建 VkShaderModule

VkShaderModule shaderModule;
if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
    throw std::runtime_error("failed to create shader module!");
}

这些参数与之前的对象创建函数中的参数相同:逻辑设备、指向创建信息结构的指针、指向可选的自定义分配器的指针以及句柄输出变量。代码缓冲区在创建着色器模块后可以立即释放。不要忘记返回创建的着色器模块。

return shaderModule;

着色器模块只是我们在之前从文件中加载的着色器字节码及其定义函数的一个薄包装。SPIR-V 字节码的编译和链接到 GPU 执行的机器码,直到创建图形管线才会发生。这意味着我们可以在管线创建完成后再次销毁着色器模块,这就是为什么我们将它们设为 createGraphicsPipeline 函数中的局部变量而不是类成员的原因。

void createGraphicsPipeline() {
    auto vertShaderCode = readFile("shaders/vert.spv");
    auto fragShaderCode = readFile("shaders/frag.spv");

    VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
    VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);

清理工作应该在函数末尾通过添加两次 vkDestroyShaderModule 的调用来完成。本章其余的代码都将在这些行之前插入。

    ...
    vkDestroyShaderModule(device, fragShaderModule, nullptr);
    vkDestroyShaderModule(device, vertShaderModule, nullptr);
}

着色器阶段创建

为了实际使用着色器,我们需要在实际的管线创建过程中,通过 VkPipelineShaderStageCreateInfo 结构将它们分配给特定的管线阶段。

我们将从填写顶点着色器的结构开始,同样在 createGraphicsPipeline 函数中。

VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;

除了必须的 sType 成员之外,第一步是告诉 Vulkan 着色器将在哪个管线阶段使用。在前一章中描述的每个可编程阶段都有一个枚举值。

vertShaderStageInfo.module = vertShaderModule;
vertShaderStageInfo.pName = "main";

接下来的两个成员指定了包含代码的着色器模块,以及要调用的函数,称为入口点。这意味着可以将多个片段着色器组合到一个着色器模块中,并使用不同的入口点来区分它们的行为。 在这种情况下,我们将坚持标准的 main

还有一个(可选)成员 pSpecializationInfo,我们在这里不会使用它,但值得讨论。它允许你为着色器常量指定值。你可以使用单个着色器模块,通过为其使用的常量指定不同的值,在管线创建时配置其行为。这比在渲染时使用变量配置着色器更有效,因为编译器可以进行优化,例如消除依赖于这些值的 if 语句。 如果你没有任何这样的常量,则可以将成员设置为 nullptr,我们的结构初始化会自动执行此操作。

修改结构以适应片段着色器很容易

VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragShaderStageInfo.module = fragShaderModule;
fragShaderStageInfo.pName = "main";

最后定义一个包含这两个结构的数组,稍后我们将在实际的管线创建步骤中使用它来引用它们。

VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

这就是描述管线的可编程阶段的全部内容。在下一章中,我们将研究固定功能阶段。