目录
Shader storage buffer objects (SSBO)
Preparing the shader storage buffers
Synchronizing graphics and compute
Introduction
在本附加章节中,我们将了解计算着色器。到目前为止,之前的所有章节都涉及Vulkan管道的传统图形部分。但与OpenGL等旧API不同,Vulkan中的计算着色器支持是强制性的。这意味着,无论是高端桌面GPU还是低功耗嵌入式设备,您都可以在每个可用的Vulkan实现上使用计算着色器。
这打开了图形处理器单元(GPGPU)上通用计算的世界,无论您的应用程序在何处运行。GPGPU意味着您可以在GPU上进行一般计算,这在传统上是CPU的一个领域。但是随着GPU变得越来越强大和灵活,许多需要CPU通用功能的工作负载现在可以在GPU上实时完成。
可以使用GPU计算能力的几个例子是图像处理、可见性测试、后期处理、高级照明计算、动画、物理(例如,用于粒子系统)等等。甚至可以将计算用于不需要任何图形输出的非视觉计算工作,例如数字处理或AI相关的事情。这被称为“无头计算”。
Advantages
在GPU上进行计算成本高昂的计算有几个优点。最明显的是从CPU上卸载工作。另一种是不需要在CPU的主内存和GPU的内存之间移动数据。所有数据都可以留在GPU上,而无需等待主内存的缓慢传输。
除此之外,GPU是高度并行化的,其中一些GPU具有数万个小型计算单元。这通常使它们比具有几个大型计算单元的CPU更适合高度并行的工作流。
The Vulkan pipeline
重要的是要知道计算与管道的图形部分是完全分离的。这在以下官方规范中的Vulkan管道框图中可见:
在这个图中,我们可以看到左边的管道的传统图形部分,右边的几个阶段不是这个图形管道的一部分,包括计算着色器(阶段)。随着计算着色器阶段与图形管道分离,我们将能够在我们认为合适的任何地方使用它。这与例如始终应用于顶点着色器的变换输出的片段着色器非常不同。
图的中心还显示,例如,描述符集也被计算使用,因此我们所了解的关于描述符布局、描述符集和描述符的所有内容也适用于这里。
An example
我们将在本章中实现的一个易于理解的示例是基于GPU的粒子系统。这种系统在许多游戏中使用,通常由数千个需要以交互帧速率更新的粒子组成。渲染这样的系统需要两个主要组件:作为顶点缓冲区传递的顶点,以及基于某些公式更新它们的方法。
基于“经典”CPU的粒子系统将粒子数据存储在系统的主存储器中,然后使用CPU来更新它们。更新后,顶点需要再次传输到GPU的内存中,以便在下一帧中显示更新的粒子。最直接的方法是用每个帧的新数据重新创建顶点缓冲区。这显然是非常昂贵的。根据您的实现,还有其他选项,如映射GPU内存,以便它可以由CPU写入(在桌面系统上称为“可调整大小的BAR”,或在集成GPU上称为统一内存)或仅使用主机本地缓冲区(由于PCI-E带宽,这将是最慢的方法)。但无论选择哪种缓冲区更新方法,都需要CPU“往返”来更新粒子。
对于基于GPU的粒子系统,不再需要这种往返。顶点仅在开始时上载到GPU,所有更新都使用计算着色器在GPU的内存中完成。这一速度更快的主要原因之一是GPU与其本地内存之间的带宽更高。在基于CPU的场景中,您将受到主内存和PCI express带宽的限制,这通常只是GPU内存带宽的一小部分。
在具有专用计算队列的GPU上执行此操作时,可以与图形管道的渲染部分并行更新粒子。这叫做“异步计算”,是本教程中未涉及的高级主题。
这是本章代码的截图。此处显示的粒子由GPU上的计算着色器直接更新,无需任何CPU交互:
Data manipulation
在本教程中,我们已经了解了不同的缓冲区类型,如用于传递基本体的顶点和索引缓冲区,以及用于将数据传递到着色器的统一缓冲区。我们还使用图像进行纹理映射。但到目前为止,我们总是使用CPU写入数据,只在GPU上进行读取。
计算着色器引入的一个重要概念是能够任意读取和写入缓冲区。为此,Vulkan提供了两种专用存储类型。
Shader storage buffer objects (SSBO)
着色器存储缓冲区(SSBO)允许着色器读取缓冲区和写入缓冲区。使用这些类似于使用统一缓冲区对象。最大的区别在于,您可以将其他缓冲区类型别名为SSBO,并且它们可以任意大。
回到基于GPU的粒子系统,您现在可能想知道如何处理由计算着色器更新(写入)和由顶点着色器读取(绘制)的顶点,因为这两种用法似乎都需要不同的缓冲区类型。
但事实并非如此。在Vulkan中,您可以指定缓冲区和图像的多种用途。因此,对于要用作顶点缓冲区(在图形过程中)和存储缓冲区(计算过程中)的粒子顶点缓冲区,只需使用以下两个使用标志创建缓冲区:
VkBufferCreateInfo bufferInfo{};
...
bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
...
if (vkCreateBuffer(device, &bufferInfo, nullptr, &shaderStorageBuffers[i]) != VK_SUCCESS) {
throw std::runtime_error("failed to create vertex buffer!");
}
使用bufferInfo.usage设置的两个标志VK_BUFFER_USAGE_VERTEX_BUFFER_BIT和VK_BUFFER_USAGE_STORAGE_BUFFER_BIT告诉实现,我们希望将此缓冲区用于两种不同的场景:作为顶点着色器中的顶点缓冲区和存储缓冲区。注意,我们在这里还添加了VK_BUFFER_USAGE_TRANSFER_DST_BIT标志,以便我们可以将数据从主机传输到GPU。这是至关重要的,因为我们希望着色器存储缓冲区仅保留在GPU内存中(VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT),我们需要将数据从主机传输到此缓冲区。
下面是使用createBuffer函数的相同代码:
createBuffer(bufferSize, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, shaderStorageBuffers[i], shaderStorageBuffersMemory[i]);
用于访问此类缓冲区的GLSL着色器声明如下所示:
struct Particle {
vec2 position;
vec2 velocity;
vec4 color;
};
layout(std140, binding = 1) readonly buffer ParticleSSBOIn {
Particle particlesIn[ ];
};
layout(std140, binding = 2) buffer ParticleSSBOOut {
Particle particlesOut[ ];
};
在这个例子中,我们有一个类型化的SSBO,每个粒子都有一个位置和速度值(参见粒子结构)。然后SSBO包含未绑定数量的粒子,如[]所示。不必指定SSBO中元素的数量是优于例如统一缓冲区的优点之一。std140是一个内存布局限定符,用于确定着色器存储缓冲区的成员元素如何在内存中对齐。这为我们提供了在主机和GPU之间映射缓冲区所需的某些保证。
在计算着色器中写入这样的存储缓冲区对象是直接的,类似于在C++端写入缓冲区的方式:
particlesOut[index].position = particlesIn[index].position + particlesIn[index].velocity.xy * ubo.deltaTime;
Storage images
请注意,本章中我们将不进行图像处理。本段旨在让读者了解计算着色器也可用于图像处理。
存储映像允许您读取和写入映像。典型的用例是将图像效果应用于纹理、进行后期处理(反过来又非常类似)或生成mip贴图。
这与图像类似:
VkImageCreateInfo imageInfo {};
...
imageInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT;
...
if (vkCreateImage(device, &imageInfo, nullptr, &textureImage) != VK_SUCCESS) {
throw std::runtime_error("failed to create image!");
}
使用imageInfo.USAGE设置的两个标志VK_IMAGE_USAGE_SAMPLED_BIT和VK_IMACE_USAGE_STORAGE_BIT告诉实现,我们希望将此图像用于两种不同的场景:作为片段着色器中采样的图像和计算机着色器中的存储图像;
存储图像的GLSL着色器声明看起来类似于片段着色器中使用的采样图像:
layout (binding = 0, rgba8) uniform readonly image2D inputImage;
layout (binding = 1, rgba8) uniform writeonly image2D outputImage;
这里的一些不同之处是图像格式的附加属性,如rgba8,只读和只读限定符,告诉实现我们将只从输入图像读取并写入输出图像。最后但并非最不重要的是,我们需要使用image2D类型来声明存储映像。
然后使用imageLoad和imageStore完成计算着色器中存储图像的读取和写入:
vec3 pixel = imageLoad(inputImage, ivec2(gl_GlobalInvocationID.xy)).rgb;
imageStore(outputImage, ivec2(gl_GlobalInvocationID.xy), pixel);