Vulkan光线追踪中的compute shader

Compute Shader In Ray Tracing With Vulkan

着色器就像是GPU可以运行的功能。它们提供了一种方法来为你的GPU编程,以完成从模拟蛋白质折叠,到渲染虚拟世界,再到模拟天气的事情。最终,我们将编写一个着色器,对图像中的每个像素进行多次迭代的路径追踪。

在本章中,我们将编写一个类似于《Hello World》的计算着色器。然后我们将使用Vulkan在128个调用(线程)上并行运行它。

本教程中的着色器使用OpenGL着色语言(GLSL)。这是一种为GPU设计的具有类似C++语法的语言。

作为GLSL的一个例子,这里有我们将在本章中编写的完整的计算着色器。我们会在本章中解释它的作用。

#version 460
#extension GL_EXT_debug_printf : require

layout(local_size_x = 16, local_size_y = 8, local_size_z = 1) in;

void main()
{
    
    
  debugPrintfEXT("Hello from invocation (%d, %d)!\n", gl_GlobalInvocationID.x, gl_GlobalInvocationID.y);
}

Using Compute Shaders for Ray Tracing

在本教程中,我们将使用带有光线查询的计算着色器来完成通常可能使用光线追踪着色器来完成的工作。这可能有些令人惊讶,但它可以节省大约75行代码!不过,这也涉及到一些权衡因素。

然而,这也是有代价的,后面一章将描述如何使用光线追踪管道来执行光线追踪。本节的其余部分是可选的,读者可以跳到编写 " Writing a Hello, World Compute Shader "计算着色器部分。

有许多不同类型的着色器。

  • 计算着色器是为通用计算而设计的(它们有点像C++中的并行for循环的内部,有一些例外)。GPU使用计算流水线来运行它们。
  • 射线生成、任意命中、可调用、最接近命中、相交和错过的着色器与着色器绑定表(shader binding tables)一起用于光线追踪。GPU使用光线追踪管线来运行它们。
  • 顶点、细分控制、细分评估、几何、片段、任务和网格着色器是图形管道的一部分。应用程序将它们用于光栅化等方面(尽管也有许多其他用途)。

这些都可以被认为是对不同类型的数据进行操作的不同方式。因此,人们往往可以用一种着色器类型来完成另一种着色器的任务。

在一个传统的使用光线追踪管道的教程中,我们会使用一个光线生成着色器(ray generation shader),它需要一个像素坐标并追踪多条光线;一个最接近的命中着色器,它需要一个光线-网格交点并返回交点处的网格属性;还有一个未命中着色器(miss shader),它需要一个方向并返回该方向的天空颜色。然后,我们将创建一个着色器绑定表,为每个着色器提供记录,并使用光线追踪管道来启动光线。

然而,光线查询允许我们不仅从光线追踪管道执行光线追踪,而且还可从graphics and compute pipelines执行。我们所有不同的材质(描述光线与天空和网格的互动方式)都包含在一个着色器中,这就减少了代码的编写量。

Writing a Hello, World Compute Shader

Work Groups

非正式地讲,GPU为一个3D网格中的每一个点调用计算着色器的入口点。应用程序定义了如何将这个网格划分为同等大小的3D工作小组;每个工作小组包含一个3D网格的调用。

例如,假设我们想把一个15×13的float数组中的每个值都乘以2,这样做的一个方法是用工作组对数组进行平铺,每个工作组有16×8×1的调用。这就需要一个1×2×1的工作小组的网格–每个调用使用一个点。

在访问内存之前,每个元素都会检查以确保它在数组的范围内。 还可以将数组的 15 × 13 = 195 个元素从 0 编号到 194,然后运行计算着色器,在 1D 和 2D 索引系统之间转换,使用 细胞( 195 / 128 ) = 2个128 × 1 × 1 调用的工作组。 #### An Empty Compute Shader 我们的计算着色器将使用 1 个 16 × 8 × 1 调用的工作组,每次调用都将使用新`GL_EXT_debug_printf`扩展来打印“Hello, world!”。

首先,shaders在您的项目main.cpp文件旁边创建一个名为 的新目录。然后在shaders名为raytrace.comp.glsl. 这将包含我们的计算着色器代码。(本章不会进行光线追踪,但会在后面的章节中进行。)

现在,使用 CMake 重新配置并重新生成项目。这将设置编译此着色器的步骤。如果您使用的是 Visual Studio,您现在应该会看到raytrace.comp.glsl出现在Shader Files过滤器中:

打开raytrace.comp.glsl,然后输入以下代码。这个

  • 使用#version预处理器命令表示该文件使用 GLSL 4.6 版
  • 指定此计算着色器的工作组的大小 (16 × 8 × 1)
  • 定义一个函数,main,什么都不做:
#version 460

layout(local_size_x = 16, local_size_y = 8, local_size_z = 1) in;

void main()
{
    
    
}

您现在应该可以编译该项目,并把它生成SPIR-V命名的文件raytrace.comp.glsl.spv旁边raytrace.comp.glsl。构建系统已将此 GLSL 代码转换为 Vulkan 将使用的 SPIR-V 中间表示。我们将在着色器编译的工作原理部分讨论这种表示形式。

A Hello, World! Compute Shader

现在,我们将展示如何使用该debugPrintfEXT函数打印文本。为此,我们将添加一个预处理器指令来要求GL_EXT_debug_printf扩展,然后调用debugPrintfEXTinside main()

#version 460
#extension GL_EXT_debug_printf : require

layout(local_size_x = 16, local_size_y = 8, local_size_z = 1) in;

void main()
{
    
    
  debugPrintfEXT("Hello world!");
}

当 GPU 运行计算着色器时,所有 128 次调用都将打印“Hello world!”。验证层然后将这些消息中继到 CPU,最后到控制台窗口。

我们将在debugPrintfEXT下面提供有关此消息如何中继的更多详细信息,您也可以访问此页面 以获取更多信息。

How Shader Compilation Works

在 CPU 方面,您可能熟悉 CPU 如何具有各种不同的体系结构,例如 x86、x64 和 ARM。每个架构都有自己的指令集(也可能随时间变化)。因此,您通常必须为每种架构编译您的程序(例如,维护 32 位、64 位和 ARM 版本的应用程序)。

您可能还熟悉使用中间表示的工具,例如 LLVM。例如,可以使用 Clang 编译一个 C++ 程序,它将 C++ 代码转换为 LLVM 的中间表示;LLVM 然后优化此代码,然后可以输出目标架构的机器代码。

另一方面,GPU 具有更多种类的架构和指令集。然而 GLSL 着色器编写者通常可以忽略这一点,并且只需编译一次着色器。

为此,Vulkan 使用了与 LLVM 类似的方法:诸如glslangValidator 将 GLSL 代码转换为标准可移植中间表示-V 格式 (SPIR-V) 之类的程序。创建计算着色器管道时,您的 GPU 驱动程序采用此 SPIR-V 表示并对其进行优化和编译,以生成特定于 GPU 的汇编代码。

主要区别在于最终编译和优化在 Vulkan 中以即时方式(在创建管道时)发生,而不是在编译时发生。一旦着色器被转换为 GPU 组件,它就会被缓存在系统上。

这意味着开发人员不必提前为每个 GPU 架构编译着色器,并且编译器可以应用它只有在了解有关管道的某些事情时才能执行的优化,但也意味着程序具有,例如,数十成千上万的着色器可能面临运行时编译的挑战。
在这个示例中,构建系统使用了一个命令,例如

glslangValidator.exe --target-env vulkan1.2 -o shaders/raytrace.comp.glsl.spv shaders/raytrace.comp.glsl

生成 SPIR-V。CMake 构建系统将自动为项目中的任何 GLSL 文件执行此操作。(但是,确实需要重新运行 CMake 才能检测到新的着色器文件。)

Including Additional Headers

本章其余部分的大部分内容将重点介绍从 Vulkan 运行计算着色器。为此,我们需要创建一些对象。这是我们将创建的对象的地图;从对象 A 到对象 B 的箭头意味着创建对象 A 需要先创建对象 B。

首先,包含两个额外的头文件:nvh/fileoperations.hpp, 用于加载文件,以及nvvk/shaders_vk.hpp,用于从 SPIR-V 创建着色器模块。

您的整个包含部分现在应如下所示:

#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>

#include <nvh/fileoperations.hpp>  // For nvh::loadFile
#include <nvvk/context_vk.hpp>
#include <nvvk/error_vk.hpp>              // For NVVK_CHECK
#include <nvvk/resourceallocator_vk.hpp>  // For NVVK memory allocators
#include <nvvk/shaders_vk.hpp>            // For nvvk::createShaderModule
#include <nvvk/structs_vk.hpp>            // For nvvk::make

Defining the Work Group Width and Height

render_width和之后添加以下常量render_height

static const uint32_t workgroup_width  = 16;
static const uint32_t workgroup_height = 8;

Enabling Debug Printf

要启用 Debug Printf,我们需要添加VK_KHR_shader_non_semantic_info 扩展,然后告诉验证层启用 Debug Printf,并将 Debug Printf 的文本打印到标准输出。

如果这看起来很复杂,请不要担心 - 我们在本章中只使用 Debug Printf,我们将在下一章中删除此代码。有关更多信息,请参阅https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/master/docs/debug_printf.md。

添加VK_KHR_RAY_QUERY_EXTENSION_NAME( VK_KHR_ray_query) 扩展名后,添加以下代码:

  // Add the required device extensions for Debug Printf. If this is confusing,
  // don't worry; we'll remove this in the next chapter.
  deviceInfo.addDeviceExtension(VK_KHR_SHADER_NON_SEMANTIC_INFO_EXTENSION_NAME);
  VkValidationFeaturesEXT      validationInfo            = nvvk::make<VkValidationFeaturesEXT>();
  VkValidationFeatureEnableEXT validationFeatureToEnable = VK_VALIDATION_FEATURE_ENABLE_DEBUG_PRINTF_EXT;
  validationInfo.enabledValidationFeatureCount           = 1;
  validationInfo.pEnabledValidationFeatures              = &validationFeatureToEnable;
  deviceInfo.instanceCreateInfoExt                       = &validationInfo;
#ifdef _WIN32
  _putenv_s("DEBUG_PRINTF_TO_STDOUT", "1");
#else   // If not _WIN32
  putenv("DEBUG_PRINTF_TO_STDOUT=1");
#endif  // _WIN32

Creating a Shader Module

要创建着色器模块,我们需要:

  • 找到 SPIR-V 文件raytrace.comp.glsl.spv(因为应用程序可能从几个不同的目录启动)
  • 加载到内存中
  • 从中创建一个着色器模块

nvh::loadFile可以帮助完成前两个步骤。首先,在创建后定义一个std::vector要搜索的目录:raytrace.comp.glsl.spv``nvvk::Buffer buffer

  const std::string exePath(argv[0], std::string(argv[0]).find_last_of("/\\") + 1);
  std::vector<std::string> searchPaths = {
    
    exePath + PROJECT_RELDIRECTORY, exePath + PROJECT_RELDIRECTORY "..",
                                          exePath + PROJECT_RELDIRECTORY "../..", exePath + PROJECT_NAME};

然后,在创建命令池后,查找、加载和创建着色器模块,如下所示。nvh::loadFile查找文件并将其加载到std::string; nvvk::createShaderModule是 的辅助函数vkCreateShaderModule

// Shader loading and pipeline creation
  VkShaderModule rayTraceModule =
	nvvk::createShaderModule(context, nvh::loadFile("shaders/raytrace.comp.glsl.spv", true, searchPaths));

Specifying the Shader Stage and Entry Point

从技术上讲,一个 SPIR-V 文件可以有多个入口点:一个计算管道可以使用main(),而另一个可以使用main2(). 这就是 Vulkan 将其称为着色器模块的原因。

要告诉计算管道使用哪个入口点(并且这是一个计算着色器),请VkPipelineShaderStageCreateInfo按如下方式创建一个对象:

// Describes the entrypoint and the stage to use for this shader module in the pipeline
  VkPipelineShaderStageCreateInfo shaderStageCreateInfo = nvvk::make<VkPipelineShaderStageCreateInfo>();
  shaderStageCreateInfo.stage                           = VK_SHADER_STAGE_COMPUTE_BIT;
  shaderStageCreateInfo.module                          = rayTraceModule;
  shaderStageCreateInfo.pName                           = "main";

An Empty Pipeline Layout

在 C++ 中,您可以将参数传递给函数。与函数类比保持一致,描述符是将数据传递给着色器的一种方式。然而,我们不需要向本章的计算着色器传递任何东西。我们仍然需要创建一个空的管道布局,这有点像定义一个 C 函数。

void func(void);

以下是创建空管道布局的方法。您现在不必担心这段代码的含义,因为我们将在下一章替换它:

// For the moment, create an empty pipeline layout. You can ignore this code
  // for now; we'll replace it in the next chapter.
  VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo = nvvk::make<VkPipelineLayoutCreateInfo>();
  pipelineLayoutCreateInfo.setLayoutCount             = 0;
  pipelineLayoutCreateInfo.pushConstantRangeCount     = 0;
  VkPipelineLayout pipelineLayout;
  NVVK_CHECK(vkCreatePipelineLayout(context, &pipelineLayoutCreateInfo, VK_NULL_HANDLE, &pipelineLayout));

Creating the Compute Pipeline

最后,我们将使用vkCreateComputePipelinesVkComputePipelineCreateInfo对象创建计算管道。 VkComputePipelineCreateInfo看起来像这样:

typedef struct VkComputePipelineCreateInfo {
    
    
    VkStructureType                    sType;
    const void*                        pNext;
    VkPipelineCreateFlags              flags; // Lets one modify how the pipeline works
    VkPipelineShaderStageCreateInfo    stage; // Created above
    VkPipelineLayout                   layout; // Created above
    VkPipeline                         basePipelineHandle; // Used for deriving compute pipelines -
    int32_t                            basePipelineIndex;  // we won't use this in this tutorial.
} VkComputePipelineCreateInfo;

添加代码以填写此结构,如下所示:

  // Create the compute pipeline
  VkComputePipelineCreateInfo pipelineCreateInfo = nvvk::make<VkComputePipelineCreateInfo>();
  pipelineCreateInfo.stage                       = shaderStageCreateInfo;
  pipelineCreateInfo.layout                      = pipelineLayout;
  // Don't modify flags, basePipelineHandle, or basePipelineIndex

最后,创建计算管道如下。可以一次创建多个计算管道,但这里我们只需要创建一个:

VkPipeline computePipeline;
  NVVK_CHECK(vkCreateComputePipelines(context,                 // Device
                                      VK_NULL_HANDLE,          // Pipeline cache (uses default)
                                      1, &pipelineCreateInfo,  // Compute pipeline create info
                                      VK_NULL_HANDLE,          // Allocator (uses default)
                                      &computePipeline));      // Output

首先,删除以下行那套fillValue和fillValueU32,然后调用vkCmdFillBuffer-我们将运行计算着色器来代替它

// Fill the buffer
  const float     fillValue    = 0.5f;
  const uint32_t& fillValueU32 = reinterpret_cast<const uint32_t&>(fillValue);
  vkCmdFillBuffer(cmdBuffer, buffer.buffer, 0, bufferSizeBytes, fillValueU32);

要调度计算着色器,我们首先需要绑定计算着色器的管道(这是一个状态更改操作):

// Bind the compute shader pipeline
  vkCmdBindPipeline(cmdBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);

最后,添加一条指令来调度当前绑定的计算管道。在这里,我们告诉它我们只想运行一个 1 × 1 × 1 的工作组网格:

// Run the compute shader with one workgroup for now
  vkCmdDispatch(cmdBuffer, 1, 1, 1);

Modifying the Pipeline Barrier

由于我们从传输操作(vkCmdFillBuffer)改为着色器,我们需要将管道屏障从传输到主机的管道屏障,改为着色器到主机的管道屏障。将流水线屏障替换为以下内容。

// Add a command that says "Make it so that memory writes by the compute shader
  // are available to read from the CPU." (In other words, "Flush the GPU caches
  // so the CPU can read the data.") To do this, we use a memory barrier.
  VkMemoryBarrier memoryBarrier = nvvk::make<VkMemoryBarrier>();
  memoryBarrier.srcAccessMask   = VK_ACCESS_SHADER_WRITE_BIT;  // Make shader writes
  memoryBarrier.dstAccessMask   = VK_ACCESS_HOST_READ_BIT;     // Readable by the CPU
  vkCmdPipelineBarrier(cmdBuffer,                              // The command buffer
                       VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,   // From the compute shader
                       VK_PIPELINE_STAGE_HOST_BIT,             // To the CPU
                       0,                                      // No special flags
                       1, &memoryBarrier,                      // An array of memory barriers
                       0, nullptr, 0, nullptr);                // No other barriers

在本页的其余部分,此管道屏障将保持不变。

Cleaning Up

最后,确保在程序结束时前销毁我们在本章中创建的对象。在调用之前vkFreeCommandBuffers,添加以下几行:

  vkDestroyPipeline(context, computePipeline, nullptr);
  vkDestroyShaderModule(context, rayTraceModule, nullptr);
  vkDestroyPipelineLayout(context, pipelineLayout, nullptr);  // Will be removed in the next chapter

现在,您应该可以运行该程序了!如果一切顺利,您将看到如下输出:

INFO: UNASSIGNED-DEBUG-PRINTF
 --> Validation Information: [ UNASSIGNED-DEBUG-PRINTF ] Object 0: handle = 0x1cb865172d8, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x92394c89 | Hello world!
INFO: UNASSIGNED-DEBUG-PRINTF
 --> Validation Information: [ UNASSIGNED-DEBUG-PRINTF ] Object 0: handle = 0x1cb865172d8, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x92394c89 | Hello world!
...
WARNING: UNASSIGNED-DEBUG-PRINTF
 --> Validation Warning: [ UNASSIGNED-DEBUG-PRINTF ] Object 0: handle = 0x1cb865172d8, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x92394c89 | WARNING - Debug Printf message was truncated, likely due to a buffer size that was too small for the message

Invocation Indices

每个调用都可以通过查看 OpenGL 的gl_GlobalInvocationID变量来查找它在计算着色器调度的网格中的位置。为了结束本章,我们将修改我们的 GLSL 代码以打印出这些值。为此,请更改raytrace.comp.glsl为以下内容:

#version 460
#extension GL_EXT_debug_printf : require

layout(local_size_x = 16, local_size_y = 8, local_size_z = 1) in;

void main()
{
    
    
  debugPrintfEXT("Hello from invocation (%d, %d)!\n", gl_GlobalInvocationID.x, gl_GlobalInvocationID.y);
}

现在再次运行示例。您应该会看到像这样开始的输出:

INFO: UNASSIGNED-DEBUG-PRINTF
 --> Validation Information: [ UNASSIGNED-DEBUG-PRINTF ] Object 0: handle = 0x283f9fc4c48, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x92394c89 | Hello from invocation (0, 0)!

INFO: UNASSIGNED-DEBUG-PRINTF
 --> Validation Information: [ UNASSIGNED-DEBUG-PRINTF ] Object 0: handle = 0x283f9fc4c48, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x92394c89 | Hello from invocation (1, 0)!

INFO: UNASSIGNED-DEBUG-PRINTF
 --> Validation Information: [ UNASSIGNED-DEBUG-PRINTF ] Object 0: handle = 0x283f9fc4c48, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0x92394c89 | Hello from invocation (2, 0)!

所有这些调用都是并行运行的,因此输出可能是有序的,也可能不是。

Summary

在本章中,我们:

  • 使用 GLSL 编写和编译计算着色器;
  • 将着色器模块加载到 GPU 上;
  • 创建了一个计算管道;
  • 使用 vkCmdDispatch 运行计算着色器;
  • 使用 Debug Printf 从 GPU 打印调试信息。

在下一章中,我们将生成图像而不是文本数据——通过添加一个描述符来从计算着色器写入缓冲区!

翻译自:Computer Shader

猜你喜欢

转载自blog.csdn.net/lr_shadow/article/details/120903637