着色器模块
与早期的 API 不同,Vulkan 中的着色器代码必须以字节码格式指定,而不是像 GLSL 和 HLSL 这样的可读语法。这种字节码格式称为 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.exe
。glslc
的优点是它使用与 GCC 和 Clang 等知名编译器相同的参数格式,并包含一些额外的功能,例如includes。它们都已包含在 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] 坐标系,如下所示
如果您之前涉足过计算机图形学,那么您应该已经熟悉这些概念。如果您以前使用过 OpenGL,那么您会注意到 Y 坐标的符号现在已翻转。Z 坐标现在使用的范围与 Direct3D 中的范围相同,从 0 到 1。
对于我们的第一个三角形,我们不会应用任何变换,我们将直接将三个顶点的位置指定为归一化设备坐标,以创建以下形状
我们可以通过从顶点着色器输出裁剪坐标,并将最后一个分量设置为 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
变量包含当前顶点的索引。这通常是顶点缓冲的索引,但在我们的例子中,它将是硬编码顶点数据数组的索引。每个顶点的位置从着色器中的常量数组访问,并与虚拟的 z
和 w
分量组合以生成裁剪坐标中的位置。内置变量 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
处的第一个(也是唯一的)帧缓冲。
逐顶点颜色
将整个三角形变成红色不是很吸引人,像下面这样的效果看起来不是更漂亮吗?
我们必须对两个着色器进行一些更改才能实现此目的。首先,我们需要为三个顶点中的每一个指定不同的颜色。顶点着色器现在应该包含一个颜色数组,就像它对位置所做的那样
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/Bin32/glslc.exe shader.vert -o vert.spv
C:/VulkanSDK/x.x.x.x/Bin32/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);
之后,我们可以seek回到文件开头并一次读取所有字节
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};
这就是描述管线的可编程阶段的全部内容。在下一章中,我们将研究固定功能阶段。