Drawing a triangle/Graphics pipeline basics/Shader modules

目录

Vertex shader

Fragment shader

Per-vertex colors

Compiling the shaders

Loading a shader

Creating shader modules

Shader stage creation


与早期的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直接使用这个编译器,但我们将使用谷歌的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可以用vec3(vec2(1.0, 2.0), 3.0)构造。

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

Vertex shader

顶点着色器处理每个传入顶点。它将其属性(如世界位置、颜色、法线和纹理坐标)作为输入。输出是剪辑坐标中的最终位置,以及需要传递给片段着色器的属性,如颜色和纹理坐标。然后,光栅化器将在片段上对这些值进行插值,以生成平滑的渐变。

剪辑坐标是来自顶点着色器的四维向量,随后通过将整个向量除以其最后一个分量将其转换为标准化的设备坐标。这些标准化设备坐标是将帧缓冲区映射到[-1,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);
}

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

Fragment shader

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

#version 450

layout(location = 0) out vec4 outColor;

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

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

Per-vertex 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)
);

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

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);
}

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

Compiling the shaders

在项目的根目录中创建一个名为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使脚本可执行并运行它。

End of platform-specific instructions

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

如果着色器包含语法错误,那么编译器将按照您的预期告诉您行号和问题。尝试省略分号作为示例,然后再次运行编译脚本。还可以尝试在没有任何参数的情况下运行编译器,以查看它支持的标志类型。例如,它还可以将字节码输出为人类可读的格式,以便您可以准确地看到着色器正在执行的操作以及在此阶段应用的任何优化。

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

Loading a shader

现在我们已经有了一种生成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");
}

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

Creating shader modules

在将代码传递到管道之前,我们必须将其包装在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);
}

Shader stage creation

要实际使用着色器,我们需要通过VkPipelineShaderStageCreateInfo结构将它们指定给特定的管道阶段,作为实际管道创建过程的一部分。

我们将首先填充顶点着色器的结构,同样是在createGraphicsPlance函数中。

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};

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值