着色器模块

      +

      与早期API不同,Vulkan中的着色器代码需要以字节码格式指定,而不是像https://en.wikipedia.org/wiki/OpenGL_Shading_Language[GLSL]、https://shader-slang.org/slang/user-guide/[SLANG]和https://en.wikipedia.org/wiki/High-Level_Shading_Language[HLSL]这样的人类可读语法。这种字节码格式称为https://www.khronos.org/spir[SPIR-V],专为与Vulkan(Khronos API)一起使用而设计。它是一种可用于编写图形和计算着色器的格式,但在本教程中,我们将重点介绍Vulkan图形管线中使用的着色器。

      使用字节码格式的优势在于,GPU供应商编写的将着色器代码转换为本机代码的编译器显著简化。过去经验表明,对于像GLSL这样的人类可读语法,一些GPU供应商对标准的解释相当灵活。如果您恰好使用这些供应商之一的GPU编写非平凡着色器,那么您可能会遇到其他供应商的驱动程序因语法错误而拒绝您的代码,或者更糟的是,由于编译器错误导致着色器运行不同。通过使用像SPIR-V这样直接的字节码格式,这些问题有望避免。

      然而,这并不意味着我们需要手动编写这种字节码。Khronos发布了他们自己的独立于供应商的编译器,将Slang编译为SPIR-V。该编译器旨在验证您的着色器代码完全符合标准,并生成一个可以随程序发布的SPIR-V二进制文件。您还可以将此编译器作为库包含在内,以在运行时生成SPIR-V,但在本教程中我们不会这样做,直到我们在未来章节中讨论反射。尽管我们可以通过`slangc`直接使用此编译器,但我们将在cmake构建过程中使用`slangc`。

      Slang是一种具有C风格语法的着色语言。用其编写的程序有一个主入口点,每个对象都会调用它。与HLSL类似,Slang使用参数和返回值进行输入和输出,并通过注释帮助描述这些变量的关联。该语言包含许多辅助图形编程的功能,如内置的向量和矩阵基元。包括用于叉积、矩阵-向量乘积、AI的自动微分和围绕向量的反射等操作的函数。向量类型称为`float`,后跟一个表示元素数量的数字。例如,3D位置可以存储在`float3`中。

      可以通过`.x`这样的成员(称为swizzle操作符)访问单个组件,但也可以同时从多个组件创建新向量。例如,表达式`float3(1.0,2.0, 3.0).xy`将得到`float2`。向量的构造函数也可以接受向量对象和标量值的组合。例如,可以用`float3(float2(1.0, 2.0), 3.0)构造`float3

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

      顶点着色器

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

      裁剪坐标*是来自顶点着色器的四维向量,随后通过将其整个向量除以其最后一个分量转换为*标准化设备坐标。这些标准化设备坐标是https://en.wikipedia.org/wiki/Homogeneous_coordinates[齐次坐标],将帧缓冲区映射到看起来像[-1, 1]乘[-1, 1]的坐标系,如下所示:

      normalized device coordinates

      如果您之前涉足过计算机图形学,应该已经熟悉这些内容。如果您之前使用过OpenGL,您会注意到Y坐标的符号现在翻转了。Z坐标现在使用与Direct3D相同的范围,从0到1。

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

      triangle coordinates

      我们可以通过将最后一个分量设置为`1`,从顶点着色器输出裁剪坐标中的标准化设备坐标。这样,将裁剪坐标转换为标准化设备坐标的除法不会改变任何内容。

      通常这些坐标会存储在顶点缓冲区中,但在Vulkan中创建顶点缓冲区并用数据填充它并不简单。因此,我决定在满足看到屏幕上弹出三角形的喜悦之后再进行此操作。与此同时,我们将做一些不太正统的事情:直接在顶点着色器中包含坐标。代码如下:

      static float2 positions[3] = float2[](
          float2(0.0, -0.5),
          float2(0.5, 0.5),
          float2(-0.5, 0.5)
      );
      
      struct VertexOutput {
          float4 sv_position : SV_Position;
      };
      
      [shader("vertex")]
      VertexOutput vertMain(uint vid : SV_VertexID) {
          VertexOutput output;
          output.sv_position = float4(positions[vid], 0.0, 1.0);
          return output;
      }

      `vertMain`函数为每个顶点调用。参数中带有`SV_VertexID`注释的内置变量包含当前顶点的索引。这通常是顶点缓冲区的索引,但在我们的情况下,它将是硬编码顶点数据数组的索引。每个顶点的位置从着色器中的常量数组访问,并与虚拟的`z`和`w`分量组合以产生裁剪坐标中的位置。内置注释`SV_Position`作为VertexOutput结构中的输出。

      如果您熟悉其他着色语言(如GLSL或HLSL),值得一提的是没有绑定指令。这是Slang的一个特性。Slang旨在通过声明顺序自动推断绑定。位置的结构是静态的,以通知编译器我们在着色器中不需要任何绑定。

      细心的观察者会注意到我们将主函数称为vertMain而不是main,这是因为Slang和SPIR-V都支持在一个文件中具有多个入口点。这在处理具有多个着色器组合的管线时非常重要。例如,即使是简单的光线追踪演示也会有四个或更多的小着色器,每个都需要自己的文件,这可能会变得繁琐。Slang的另一个主要特性是能够创建着色器库或模块;留给读者的练习是探索更多关于这种功能丰富的着色语言的内容。

      在本教程中,我们将通过将着色器保持为单个文件来展示最佳实践。如果您了解GLSL,附件文件夹中有GLSL版本的着色器,它们是直接翻译的。

      片段着色器

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

      [shader("fragment")]
      float4 fragMain() : SV_Target
      {
          return float4(1.0, 0.0, 0.0, 1.0);
      }

      `fragMain`入口点函数为每个片段调用,就像顶点着色器`vertMain`函数为每个顶点调用一样。Slang中的颜色是4分量向量,R、G、B和alpha通道在[0, 1]范围内。与顶点着色器中的`SV_Position`不同,没有内置变量来输出当前片段的颜色。您必须为每个帧缓冲区指定自己的输出变量,其中`SV_TARGET`注释指定帧缓冲区的索引。红色写入此`outColor`变量,该变量链接到索引`0`的第一个(也是唯一的)帧缓冲区。

      每顶点颜色

      使整个三角形变红并不是很有趣,像下面这样的东西看起来不是更好吗?

      triangle coordinates colors

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

      static float3 colors[3] = float3[](
          float3(1.0, 0.0, 0.0),
          float3(0.0, 1.0, 0.0),
          float3(0.0, 0.0, 1.0)
      );

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

      struct VertexOutput {
          float3 color;
          float4 sv_position : SV_Position;
      };
      
      [shader("vertex")]
      VertexOutput vertMain(uint vid : SV_VertexID) {
          VertexOutput output;
          output.sv_position = float4(positions[vid], 0.0, 1.0);
          output.color = colors[vid];
          return output;
      }

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

      [shader("fragment")]
      float4 fragMain(VertexOutput inVert) : SV_Target
      {
          float3 color = inVert.color;
          return float4(color, 1.0);
      }

      输入变量不一定需要使用相同的名称,但如果它们在同一个文件中,不重复自己确实很方便。但无论如何,它们将使用`location`指令指定的索引链接在一起。`fragMain`函数已修改为输出颜色和alpha值。如上图所示,`fragColor`的值将在三个顶点之间的片段中自动插值,从而产生平滑的渐变。

      编译着色器

      在项目的根目录中创建一个名为`shaders`的目录,并将着色器存储在名为`shader.slang`的文件中。

      `shader.slang`的内容应为:

      static float2 positions[3] = float2[](
          float2(0.0, -0.5),
          float2(0.5, 0.5),
          float2(-0.5, 0.5)
      );
      
      static float3 colors[3] = float3[](
          float3(1.0, 0.0, 0.0),
          float3(0.0, 1.0, 0.0),
          float3(0.0, 0.0, 1.0)
      );
      
      struct VertexOutput {
          float3 color;
          float4 sv_position : SV_Position;
      };
      
      [shader("vertex")]
      VertexOutput vertMain(uint vid : SV_VertexID) {
          VertexOutput output;
          output.sv_position = float4(positions[vid], 0.0, 1.0);
          output.color = colors[vid];
          return output;
      }
      
      [shader("fragment")]
      float4 fragMain(VertexOutput inVert) : SV_Target
      {
          float3 color = inVert.color;
          return float4(color, 1.0);
      }

      我们现在将使用`slangc`程序将其编译为SPIR-V字节码。

      Windows

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

      C:/VulkanSDK/x.x.x.x/bin/slangc.exe shader.slang -target spirv -profile spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name -entry vertMain -entry fragMain -o slang.spv

      将`slangc.exe`的路径替换为您安装Vulkan SDK的路径。双击文件运行它。

      Linux

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

      /home/user/VulkanSDK/x.x.x.x/x86_64/bin/slangc shader.slang -target spirv -profile spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name -entry vertMain -entry fragMain -o slang.spv

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

      平台特定指令结束

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

      注意:在撰写本文时,SlangC将原生支持SPIR-V 1.3及以上版本,而无需通过发出GLSL来获取SPIR-V。虽然本教程中的所有内容都可以在SPIR-V 1.0中工作,但这需要我们打破Slang着色器为多个文件,这引出了一个问题,有什么意义?此外,从1.4开始的SPIR-V意味着您将熟悉标准提供的最新内容,而不是从旧版本开始。

      如果您的着色器包含语法错误,编译器将告诉您行号和问题,如您所料。例如,尝试省略分号,然后再次运行编译器脚本。还可以尝试不带任何参数运行编译器,看看它支持哪些标志。例如,它还可以将字节码输出为人类可读的格式,以便您确切地看到着色器在做什么以及在此阶段应用的任何优化。

      在命令行上编译着色器是最直接的选项之一,但本教程中使用的最佳路径是创建一个CMake函数:

      function (add_slang_shader_target TARGET)
        cmake_parse_arguments ("SHADER" "" "SOURCES" ${ARGN})
        set (SHADERS_DIR ${CMAKE_CURRENT_LIST_DIR}/shaders)
        set (ENTRY_POINTS -entry vertMain -entry fragMain)
        add_custom_command (
                OUTPUT ${SHADERS_DIR}
                COMMAND ${CMAKE_COMMAND} -E make_directory ${SHADERS_DIR}
        )
        add_custom_command (
                OUTPUT  ${SHADERS_DIR}/slang.spv
                COMMAND ${SLANGC_EXECUTABLE} ${SHADER_SOURCES} -target spirv -profile spirv_1_4 -emit-spirv-directly -fvk-use-entrypoint-name ${ENTRY_POINTS} -o slang.spv
                WORKING_DIRECTORY ${SHADERS_DIR}
                DEPENDS ${SHADERS_DIR} ${SHADER_SOURCES}
                COMMENT "Compiling Slang Shaders"
                VERBATIM
        )
        add_custom_target (${TARGET} DEPENDS ${SHADERS_DIR}/slang.spv)
      endfunction()

      然后,您可以将Slang构建步骤添加到您的目标中,如下所示:

      add_slang_shader_target( foo SOURCES ${SHADER_SLANG_SOURCES})
      target_add_dependencies(bar PUBLIC foo)

      加载着色器

      现在我们有了生成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:将文件作为二进制文件读取(避免文本转换)

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

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

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

      file.seekg(0, std::ios::beg);
      file.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));

      最后,关闭文件并返回字节:

      file.close();
      
      return buffer;

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

      void createGraphicsPipeline() {
          auto shaderCode = readFile("shaders/slang.spv");
      }

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

      创建着色器模块

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

      [[nodiscard]] vk::raii::ShaderModule createShaderModule(const std::vector<char>& code) const {
      
      }

      该函数将接受一个带有字节码的缓冲区作为参数,并从中创建一个`VkShaderModule`。

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

      vk::ShaderModuleCreateInfo createInfo{ .codeSize = code.size() * sizeof(char), .pCode = reinterpret_cast<const uint32_t*>(code.data()) };

      然后可以通过调用`vkCreateShaderModule`创建`VkShaderModule`:

      vk::raii::ShaderModule shaderModule{ device, createInfo };

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

      return shaderModule;

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

      void createGraphicsPipeline() {
          vk::raii::ShaderModule shaderModule = createShaderModule(readFile("shaders/slang.spv"));

      着色器阶段创建

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

      我们将从为顶点着色器填充结构开始,再次在`createGraphicsPipeline`函数中。

      vk::PipelineShaderStageCreateInfo vertShaderStageInfo{ .stage = vk::ShaderStageFlagBits::eVertex, .module = shaderModule,  .pName = "vertMain" };

      前两个参数是标志和我们正在操作的阶段。接下来的两个参数指定包含代码的着色器模块和要调用的函数,称为_入口点_。这意味着可以将多个片段着色器组合到单个着色器模块中,并使用不同的入口点来区分它们的行为。

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

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

      vk::PipelineShaderStageCreateInfo fragShaderStageInfo{ .stage = vk::ShaderStageFlagBits::eFragment, .module = shaderModule, .pName = "fragMain" };

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

      vk::PipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

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