[Vulkan教程]绘制一个三角形/图形管线基础/着色器模块(Shader modules)

和其它API不同,Vulkan中的着色器代码需要用字节码格式,而不是像GLSLHLSL这样的可读代码。这种字节码格式叫SPIR-V,旨在Vulkan和OpenCL(同为Khronos API)一起使用。它可用于图形着色器和计算着色器,在本教程中,我们我们重点介绍Vulkan图形管线中使用的着色器。

使用字节码格式的优势在于,GPU供应商编写的将着色器代码转换为本地代码的编译器明显不那么复杂。过去已经表明,供应商对可读的语法(如GLSL)的标准的解释相当灵活。如果你碰巧写了不一般的着色器代码,又使用了这些GPU中的一个,你的代码就有一定风险在其他供应商的驱动程序中因语法错误而被拒绝,或者更糟的是,你的代码会由于编译器错误而以不同方式运行。使用像SPIR-V这样的简单字节码,有望避免这种情况。

这并不意味着我们需要手工编写这个字节码。Khronos发布了他们自己独立于供应商的编译器,可以将GLSL编译位SPIR-V。改编译器旨在验证我们的着色器代码是否完全符合标准,并生成一个可以随程序一起提供的SPIR-V二进制文件。我们还可以将此编译器作为库,在运行时生成SPIR-V,但我们不会在本教程中这样做。虽然我们可以使用glslangValidator作为编译器,但我们会使用谷歌的glslc
来代替。glslc的优点是,它使用和gccclang等知名的编译器相同的参数格式,并且包含一些额外的功能。它们都包含在Vulkan SDK中,我们无需额外下载任何内容。

GLSL是一种具有C风格语法的着色语言。用它编写的程序有一个为每个对象调用的main函数。GLSL不使用参数作为输入,也不使用返回值作为输出,而是使用全局变量来处理输入和输出。该语言有许多有助于图形编程的功能,例如内置的向量和矩阵基元。包括叉乘、矩阵向量乘法、反射等操作的函数。向量类型叫做vec,后跟一个数字代表元素的数量。例如可代表3D位置vec3。我们可以通过像.x这样的成员访问单个元素,也可以一次性访问多个成员而创建一个新的向量。例如,vec3(1.0,2.0,3.0).zy可以创建一个vec2。向量的构造也可以通过向量对象和标量值的组合来完成。例如vec3可以通过vec3(vec2(1.0,2.0),3.0)来构造。

正如前一章提到的,我们需要编写一个顶点着色器和一个片元着色器来在屏幕上得到一个三角形。接下来的两节会分别介绍两个部分,然后我会向你展示如何生成两个SPIR-V文件并将它们加载到程序中。

顶点着色器

顶点着色器会处理每个传入的顶点。它会接受顶点的属性作为输入,包括位置、颜色、法线、和纹理坐标等。输出是裁剪空间的最终位置以及需要传递给片元着色器的属性,包括颜色和纹理坐标等。这些值将由光栅化器在片元上进行插值以产生平滑的梯度。

裁剪坐标是一个4维向量,之后会通过将整个向量除以最后一个分量来转换为归一化设备坐标(NDC)。归一化设备坐标是其次坐标,将帧缓冲区映射到 [-1,1]x[-1,1] ,如下所示:
Vulkan第十节 - 图1
如果你以前搞过计算机图形,你应该很熟悉这些(如果真没搞过,可以先看看这个 Getting started/08 Coordinate Systems/))。如果你以前使用过OpenGL,你应该注意到Y的符号翻转了,并且Z使用与Direct3D中相同的范围,0到1。

对于我们的第一个三角形,我们不会应用任何变换,我们直接指定三个顶点的位置作为归一化设备坐标以创建三角形:
Vulkan第十节 - 图2
我们可以从顶点着色器通过输出最后一个元素设为1的裁剪坐标来直接输出归一化设备坐标。这样,在裁剪坐标转换为归一化设备坐标时不会改变任何内容。

这些坐标一般都存储在顶点缓冲区,但在Vulkan创建顶点缓冲区并使用它们比较麻烦。所以,我们把这个过程推迟到我们从屏幕上看到三角形之后。我们现在先取个巧,将坐标直接包含在顶点着色器中。代码如下:

  1. #version 450
  2. vec2 positions[3] = vec2[](
  3. vec2(0.0, -0.5),
  4. vec2(0.5, 0.5),
  5. vec2(-0.5, 0.5)
  6. );
  7. void main() {
  8. gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
  9. }
  10. 1234567891011

main函数会为每个顶点执行一次。gl_VertexIndex(OpenGL中应该是gl_VertexID)变量表示当前顶点的索引。这通常是顶点在顶点缓冲区中的索引,在我们的例子中,我们硬编码它们作为我们顶点数组的索引。从着色器的常量数组中访问每个顶点的位置,结合默认的zw分量,生成裁剪空间中的位置。内置变量gl_Position作为裁剪坐标输出。

片元着色器

由顶点着色器输出的位置形成的三角形使用片元填充了屏幕上的一个区域。在这些片元上调用片元着色器生成(多)帧缓冲区中的颜色和深度。一个输出由红色完全填充的片元着色器如下所示:

  1. #version 450
  2. layout(location = 0) out vec4 outColor;
  3. void main() {
  4. outColor = vec4(1.0, 0.0, 0.0, 1.0);
  5. }
  6. 1234567

同顶点着色器为每个顶点调用一次一样,片元着色器器为每个片元调用一次main函数。GLSL中的颜色有四个分量RGBalpha,值域为[0,1]。与顶点着色器中的gl_Position不同,片元着色器没有内置变量作为当前片元的输出颜色。我们需要为每个帧缓冲指定输出变量,layout(location = 0)修饰符表示指定帧缓冲的索引。红色被写入到outColor变量,该变量连接到索引为0的帧缓冲,也是唯一的帧缓冲。

每个顶点的颜色

整个全红的三角形不是很有趣(我觉得挺有趣的),像下面这样是不是更好看些?
Vulkan第十节 - 图3
我们稍微改下两个着色器。我们先为每个顶点指定不同的颜色。在顶点着色器天天加一个颜色数组,和位置数组差不多:

  1. vec3 colors[3] = vec3[](
  2. vec3(1.0, 0.0, 0.0),
  3. vec3(0.0, 1.0, 0.0),
  4. vec3(0.0, 0.0, 1.0)
  5. );
  6. 12345

然后把它们传递给片元着色器,这样就可以插值输出到帧缓冲。在顶点着色器添加一个输出,然后在main函数中赋值:

  1. layout(location = 0) out vec3 fragColor;
  2. void main() {
  3. gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
  4. fragColor = colors[gl_VertexIndex];
  5. }
  6. 123456

同时在片元着色器添加一个对应的输入:

  1. layout(location = 0) in vec3 fragColor;
  2. void main() {
  3. outColor = vec4(fragColor, 1.0);
  4. }
  5. 12345

输入变量不需要使用相同的名称,它们通过location指定的索引链接在一起。main函数输出带有固定alpha的颜色。如那个彩色图片所示,fragColor会被光栅化器自动插值产生平滑的渐变。

编译着色器

之后我们用cmake来自动完成这个都过程。 TODO

加载着色器

现在我们已经可以生成SPIR-V着色器了,接下来把它们加载的我们的程序中,并在之后弄到图形管线中。写一个简单的函数把它们加载到程序中:

  1. #include <fstream>
  2. ...
  3. static std::vector<char> readFile(const std::string& filename) {
  4. std::ifstream file(filename, std::ios::ate | std::ios::binary);
  5. if (!file.is_open()) {
  6. throw std::runtime_error("failed to open file!");
  7. }
  8. }
  9. 1234567891011

readFile以二进制形式将指定文件读到std::vector<char>中。读的时候,用ate表示从文件尾开始读,binary表示二进制读。从文件尾开始可以让我们直接获取文件的大小,然后分配对应大小的缓存:

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

然后,移到文件开始,一次性读取所有内容:

  1. file.seekg(0);
  2. file.read(buffer.data(), fileSize);
  3. 12

最后,关闭文件并返回数据:

  1. file.close();
  2. return buffer;
  3. 123

createGraphicsPipeline函数中调用它读取两个着色器:

  1. void createGraphicsPipeline() {
  2. auto vertShaderCode = readFile("shaders/vert.spv");
  3. auto fragShaderCode = readFile("shaders/frag.spv");
  4. }
  5. 1234

我们可以打印一下文件大小和缓冲区大小,确保它们相同来保证文件正确读取。字节码不需要以0结尾,我们在使用它们时会显式指定它们的大小。

创建着色器模块

在我们将字节码传递到管线前,我们需要将它们封装到VkShaderModule对象中。我们创建一个函数createShaderModule

  1. VkShaderModule createShaderModule(const std::vector<char>& code) {
  2. }
  3. 123

这个函数以字节码为参数,并返回一个VkShaderModule对象。

创建着色器模块非常简单,我们只需要通过VkShaderModuleCreateInfo结构体指定字节码缓冲区和其大小即可(注意,该结构体以uing32_t*指向缓冲区,但以字节数指定缓冲区大小)。我们通过reinterpret_cast将指针转换一下。

  1. VkShaderModuleCreateInfo createInfo{};
  2. createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
  3. createInfo.codeSize = code.size();
  4. createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());
  5. 1234

然后调用vkCreateShaderModule函数创建VkShaderModule

  1. VkShaderModule shaderModule;
  2. if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
  3. throw std::runtime_error("failed to create shader module!");
  4. }
  5. 1234

函数参数同之前创建对象的函数相同:逻辑设备、创建信息、可选的分配器、和句柄保存指针。在创建着色器模块后,字节码缓存即可释放。最后返回着色器模块:

  1. return shaderModule;
  2. 1

着色器模块只是简单的封装了字节码。在图形管线创建后,SPIR-V才真正编译和链接为机器码供GPU执行。这意味着我们可以在图形管线创建之后就销毁着色器模块,这也是我们将着色器模块作为局部变量的原因:

  1. void createGraphicsPipeline() {
  2. auto vertShaderCode = readFile("shaders/vert.spv");
  3. auto fragShaderCode = readFile("shaders/frag.spv");
  4. VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
  5. VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);
  6. 123456

销毁工作通过在函数末尾添加两个vkDestroyShaderModule调用来完成。本章之后的代码都应插入到这两个调用之前(代码很多哦,包括固定功能的设置等)。

  1. ...
  2. vkDestroyShaderModule(device, fragShaderModule, nullptr);
  3. vkDestroyShaderModule(device, vertShaderModule, nullptr);
  4. }
  5. 1234

着色器阶段创建

为了真正使用着色器,我们需要通过VkPiplineShaderStageCreateInfo将它们指定到特定的管线阶段,作为实际管线创建过程的一部分。

我们在createGraphicsPipeline函数中填充这个结构体:

  1. VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
  2. vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
  3. vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
  4. 123

首先通过sType指定结构体类型,然后通过stage指定着色器要使用管线的哪个阶段。可用的枚举值我们已经在前面的章节说明了。

  1. vertShaderStageInfo.module = vertShaderModule;
  2. vertShaderStageInfo.pName = "main";
  3. 12

module指定了包含代码的着色器模块,pName指定了要调用的函数,即入口点。这意味着我们可以将不同的着色器片段组合到单个着色器模块中,然后通过入口点来区分它们的行为。我们暂时还用标准的main

还有一个可选的成员pSpecializationInfo,我们虽然不用它,但值得讨论。它允许你指定着色器常量。你可以用一个单一着色器模块,通过指定不同的常量,来配置其行为。这比你在渲染时通过变量配置着色器更高效,因为着色器可以通过常量来优化if等语句。如果你没有任何常量,你可将它设置为nullptr(默认值)。

对于片元着色器也一样:

  1. VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
  2. fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
  3. fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
  4. fragShaderStageInfo.module = fragShaderModule;
  5. fragShaderStageInfo.pName = "main";
  6. 12345

最后,定义一个数组包含这两个结构体,之后我们会在创建管线时引用它们。

  1. VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};
  2. 1

配置可编程阶段就这些内容。下一章,我们介绍固定功能阶段。