Vulkan教程 – 08 着色器及编译SPIR-V

  • Post author:
  • Post category:其他

着色器模块

不像是之前的API,Vulkan着色器代码一定要用字节码格式,而不是人类可读的语法如GLSL和HLSL。这个字节码就是SPIR-V,设计用于Vulkan和OpenCL。这是一个可以用于编写图形和计算着色器的格式,但是我们主要关注的是Vulkan的图形管线。使用字节码格式的优点之一是GPU厂商写的编译器将着色器代码转化为原生代码会非常简单。过去的经验表明,人类易读的语法如GLSL,某些GPU厂商是能很便捷地解读这些标准的。但是如果你碰巧写了不一般的着色器,那可能会导致厂家的着色器因为你的语法错误而拒绝执行,甚至更糟,就是能执行,却因为编译器bug得到的是错误的结果。直接用字节码格式就能避免这些问题。

但是,这不表示我们要自己动手写字节码,Khronos已经发行了他们自己的厂商无关的编译器,能够将GLSL编译到SPIR-V格式。这个编译器就是用来验证你的着色器都是和标准兼容的,它会产生一个SPIR-V的二进制输出,可以和你的程序一同发行。该编译器包含在LunarG SDK中了,也就是glslangValidator.exe,所以不用额外下载任何内容。

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二进制文件并加载到程序中。

第一部分,顶点着色器。顶点着色器处理每个到来的顶点,用其属性如世界坐标,颜色,法线和材质坐标等作为输入。输出是最终在裁剪坐标的位置和需要传递给片段着色器的属性,比如颜色和材质坐标。这些值会被片段着色器根据光栅器插值,产生平滑的梯度。

裁剪坐标是来自顶点着色器的四维向量,随后被通过最后一个元素除以整个向量转变成一个归一化设备坐标。这些归一化设备坐标是齐次坐标,将帧缓冲映射到纵横都是[-1, 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
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) out vec4 outColor;

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

main方法被每个片段调用,就和顶点着色器的main方法被每个顶点调用一样。GLSL的颜色是由4部分组成的向量,就是RGB和alpha通道,范围都是[0, 1]。不像是顶点着色器的gl_Position,没有内置变量为当前片段输出一个颜色。你必须为每个帧缓冲明确自己的输出变量,布局(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方法已经被修改了,以输出颜色和透明度。fragColor的值会被自动插值,得到平滑梯度效果。

接着就是编译着色器了,在项目根目录创建一个shaders目录,存储我们的着色器代码。两份着色器分别是shader.vert和shader.frag,GLSL没有官方扩展名,但是这两个通常用于区分他们。

shader.vert如下所示:

#version 450
#extension GL_ARB_separate_shader_objects : enable

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
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) in vec3 fragColor;

layout(location = 0) out vec4 outColor;

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

现在我们准备用glslangValidator将其编译成SPIR-V字节码:

glslc shader.vert -o vert.spv
glslc shader.frag -o frag.spv

这两条命令用-V标志调用了编译器,表明要求编译器将GLSL源文件编译成SPIR-V字节码。当你运行编译脚本的时候,就会发现两个SPIR-V二进制文件产生了,即vert.spv和frag.spv。这些名字直接来自shader类型,但是你可以进行重命名。Vulkan SDK包含了libshaderc,也就是将你的GLSL代码编译成SPIR-V的东西。

接着是加载着色器部分。

当前我们可以产生SPIR-V着色器了,是时候将其加载到我们的程序中了,然后在某个时刻将其插入到图形管线中。我们先要写一个简单的助手方法来从文件加载二进制数据:

#include <fstream>
#include <vector>

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管理的byte数组。我们用两个标记打开该文件:

ate:开始读的时候在文件末尾,就是说打开文件的时候定位到文件尾;

binary:以二进制文件读取文件(避免text转换)。

开始读的时候定位到文件尾的好处是我们能利用读取位置来确定文件大小,然后分配一个缓冲:

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

下面准备创建着色器模块,在开始将代码传递到管线之前,我们需要将其包装到VkShaderModule对象中,创建一个createShaderModule方法:

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

}

该方法会接收一个字节码缓冲作为参数,创建一个VkShaderModule出来。创建着色器模块是很容易的,只需要指定一个指向到缓冲的指针,以及它的长度。这些信息都在VkShaderModuleCreateInfo结构体中,有一点要注意的是字节码的大小是用字节指定的,但是字节码指针是uint32_t类型的指针而不是char类型的指针。因此我们将需要用reinterpret_cast转换,转换的时候要保证数据满足uint32_t的对齐要求。幸好该数据就是存储在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());

VkShaderModule就能通过调用vkCreateShaderModule调用了:

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中将其设置为局部变量,而不是作为类成员:

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

清理就在方法结束的时候添加两行:

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提供了该着色器会在管线哪个阶段使用的信息。每个可编程阶段都有一个枚举值。接下来的两个成员表明了着色器模块以及用触发的方法,也就是入口点。这意味着能组合多个片段着色器为单个着色器模块,用不同的入口点来差异化其表现。但是这里我们还是用标准的main方法:

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

还有一个可选成员pSpecializationInfo,这里不用,但是值得讨论一下。它能让你指定着色器常量的值,你可以用单个着色器模块,它的行为会在管线创建阶段通过不同常数值来进行配置。这比渲染的时候用变量配置着色器要高效,因为编译器能够做优化,比如依赖这些值的if语句。你不需要任何那样的常量就置为空指针,也就是结构体初始化自动执行的操作。

修改该结构体以适配片段着色器:

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

以上就是所有渲染管线可编程阶段的介绍,下一章看固定管线阶段。


版权声明:本文为u012911347原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。