目录
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);
Compute queue families
在物理设备和队列系列一章中,我们已经了解了队列系列以及如何选择图形队列系列。Compute使用队列系列特性标志位VK_QUEUE_COMPUTE_BIT。所以,如果我们想做计算工作,我们需要从支持计算的队列系列中获取一个队列。
请注意,Vulkan要求支持图形操作的实现至少有一个同时支持图形和计算操作的队列系列,但实现也可能提供专用的计算队列。这个专用计算队列(没有图形位)提示异步计算队列。为了保持本教程对初学者的友好,我们将使用一个可以同时执行图形和计算操作的队列。这也将避免我们处理几种高级同步机制。
对于我们的计算示例,我们需要稍微更改设备创建代码:
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());
int i = 0;
for (const auto& queueFamily : queueFamilies) {
if ((queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) && (queueFamily.queueFlags & VK_QUEUE_COMPUTE_BIT)) {
indices.graphicsAndComputeFamily = i;
}
i++;
}
更改后的队列系列索引选择代码现在将尝试查找同时支持图形和计算的队列系列。
然后,我们可以在createLogicalDevice中从这个队列系列中获取一个计算队列:
vkGetDeviceQueue(device, indices.graphicsAndComputeFamily.value(), 0, &computeQueue);
The compute shader stage
在图形示例中,我们使用了不同的管道阶段来加载着色器和访问描述符。使用VK_SHADER_STAGE_COMPUTE_BIT管道以类似的方式访问计算着色器。因此,加载计算着色器与加载顶点着色器相同,但具有不同的着色器阶段。我们将在接下来的段落中详细讨论这一点。Compute还为描述符和管道引入了一种新的绑定点类型,名为VK_PIPELINE_BIND_POINT_COMPUTE,我们稍后将不得不使用它。
Loading compute shaders
在我们的应用程序中加载计算着色器与加载任何其他着色器相同。唯一真正的区别是我们需要使用上面提到的VK_SHADER_STAGE_COMPUTE_BIT。
auto computeShaderCode = readFile("shaders/compute.spv");
VkShaderModule computeShaderModule = createShaderModule(computeShaderCode);
VkPipelineShaderStageCreateInfo computeShaderStageInfo{};
computeShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
computeShaderStageInfo.stage = VK_SHADER_STAGE_COMPUTE_BIT;
computeShaderStageInfo.module = computeShaderModule;
computeShaderStageInfo.pName = "main";
...
Preparing the shader storage buffers
早些时候,我们了解到可以使用着色器存储缓冲区将任意数据传递给计算着色器。在这个例子中,我们将把一个粒子阵列上传到GPU,这样我们就可以直接在GPU的内存中操纵它。
在飞行中的帧一章中,我们谈到了在飞行中每帧复制资源,这样我们就可以让CPU和GPU保持忙碌。首先,我们为缓冲区对象和备份它的设备内存声明一个向量:
std::vector<VkBuffer> shaderStorageBuffers;
std::vector<VkDeviceMemory> shaderStorageBuffersMemory;
在createShaderStorageBuffers中,我们然后调整这些向量的大小,以匹配飞行中的最大帧数:
shaderStorageBuffers.resize(MAX_FRAMES_IN_FLIGHT);
shaderStorageBuffersMemory.resize(MAX_FRAMES_IN_FLIGHT);
通过此设置,我们可以开始将初始粒子信息移动到GPU。我们首先在主机端初始化粒子向量:
// Initialize particles
std::default_random_engine rndEngine((unsigned)time(nullptr));
std::uniform_real_distribution<float> rndDist(0.0f, 1.0f);
// Initial particle positions on a circle
std::vector<Particle> particles(PARTICLE_COUNT);
for (auto& particle : particles) {
float r = 0.25f * sqrt(rndDist(rndEngine));
float theta = rndDist(rndEngine) * 2 * 3.14159265358979323846;
float x = r * cos(theta) * HEIGHT / WIDTH;
float y = r * sin(theta);
particle.position = glm::vec2(x, y);
particle.velocity = glm::normalize(glm::vec2(x,y)) * 0.00025f;
particle.color = glm::vec4(rndDist(rndEngine), rndDist(rndEngine), rndDist(rndEngine), 1.0f);
}
然后,我们在主机内存中创建暂存缓冲区,以保存初始粒子特性:
VkDeviceSize bufferSize = sizeof(Particle) * PARTICLE_COUNT;
VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;
createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);
void* data;
vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
memcpy(data, particles.data(), (size_t)bufferSize);
vkUnmapMemory(device, stagingBufferMemory);
使用此分段缓冲区作为源,然后创建每帧着色器存储缓冲区,并将粒子特性从分段缓冲区复制到以下每个缓冲区:
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
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]);
// Copy data from the staging buffer (host) to the shader storage buffer (GPU)
copyBuffer(stagingBuffer, shaderStorageBuffers[i], bufferSize);
}
}
Descriptors
为计算设置描述符几乎与图形相同。唯一的区别是描述符需要设置VK_SHADER_STAGE_COMPUTE_BIT,以使计算阶段可以访问它们:
std::array<VkDescriptorSetLayoutBinding, 3> layoutBindings{};
layoutBindings[0].binding = 0;
layoutBindings[0].descriptorCount = 1;
layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
layoutBindings[0].pImmutableSamplers = nullptr;
layoutBindings[0].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
...
请注意,您可以在此处组合着色器阶段,因此如果您希望从顶点和计算阶段访问描述符,例如,对于具有共享参数的统一缓冲区,只需设置两个阶段的位:
layoutBindings[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_COMPUTE_BIT;
下面是示例的描述符设置。布局如下所示:
std::array<VkDescriptorSetLayoutBinding, 3> layoutBindings{};
layoutBindings[0].binding = 0;
layoutBindings[0].descriptorCount = 1;
layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
layoutBindings[0].pImmutableSamplers = nullptr;
layoutBindings[0].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
layoutBindings[1].binding = 1;
layoutBindings[1].descriptorCount = 1;
layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
layoutBindings[1].pImmutableSamplers = nullptr;
layoutBindings[1].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
layoutBindings[2].binding = 2;
layoutBindings[2].descriptorCount = 1;
layoutBindings[2].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
layoutBindings[2].pImmutableSamplers = nullptr;
layoutBindings[2].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 3;
layoutInfo.pBindings = layoutBindings.data();
if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &computeDescriptorSetLayout) != VK_SUCCESS) {
throw std::runtime_error("failed to create compute descriptor set layout!");
}
查看此设置,您可能会想知道为什么我们对着色器存储缓冲区对象有两个布局绑定,即使我们只渲染单个粒子系统。这是因为粒子位置基于增量时间逐帧更新。这意味着每一帧都需要知道最后一帧的粒子位置,因此它可以用新的增量时间更新它们,并将它们写入自己的SSBO:
为此,计算着色器需要访问上一帧和当前帧的SSBO。这是通过在描述符设置中将两者传递给计算着色器来完成的。请参阅storageBufferInfoLastFrame和storageBufferInfo CurrentFrame:
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
VkDescriptorBufferInfo uniformBufferInfo{};
uniformBufferInfo.buffer = uniformBuffers[i];
uniformBufferInfo.offset = 0;
uniformBufferInfo.range = sizeof(UniformBufferObject);
std::array<VkWriteDescriptorSet, 3> descriptorWrites{};
...
VkDescriptorBufferInfo storageBufferInfoLastFrame{};
storageBufferInfoLastFrame.buffer = shaderStorageBuffers[(i - 1) % MAX_FRAMES_IN_FLIGHT];
storageBufferInfoLastFrame.offset = 0;
storageBufferInfoLastFrame.range = sizeof(Particle) * PARTICLE_COUNT;
descriptorWrites[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[1].dstSet = computeDescriptorSets[i];
descriptorWrites[1].dstBinding = 1;
descriptorWrites[1].dstArrayElement = 0;
descriptorWrites[1].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
descriptorWrites[1].descriptorCount = 1;
descriptorWrites[1].pBufferInfo = &storageBufferInfoLastFrame;
VkDescriptorBufferInfo storageBufferInfoCurrentFrame{};
storageBufferInfoCurrentFrame.buffer = shaderStorageBuffers[i];
storageBufferInfoCurrentFrame.offset = 0;
storageBufferInfoCurrentFrame.range = sizeof(Particle) * PARTICLE_COUNT;
descriptorWrites[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[2].dstSet = computeDescriptorSets[i];
descriptorWrites[2].dstBinding = 2;
descriptorWrites[2].dstArrayElement = 0;
descriptorWrites[2].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
descriptorWrites[2].descriptorCount = 1;
descriptorWrites[2].pBufferInfo = &storageBufferInfoCurrentFrame;
vkUpdateDescriptorSets(device, 3, descriptorWrites.data(), 0, nullptr);
}
请记住,我们还必须从描述符池中请求SSBO的描述符类型:
std::array<VkDescriptorPoolSize, 2> poolSizes{};
...
poolSizes[1].type = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
poolSizes[1].descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT) * 2;
我们需要将从池请求的VK_DESCRIPTOR_TYPE_STORAGE_BUFFER类型的数量增加两倍,因为我们的集合引用了上一帧和当前帧的SSBO。
Compute pipelines
由于计算不是图形管道的一部分,因此我们不能使用vkCreateGraphicsPlanches。相反,我们需要使用vkCreateComputePipelines创建一个专用的计算管道来运行我们的计算命令。由于计算管道不接触任何光栅化状态,因此其状态比图形管道少得多:
VkComputePipelineCreateInfo pipelineInfo{};
pipelineInfo.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO;
pipelineInfo.layout = computePipelineLayout;
pipelineInfo.stage = computeShaderStageInfo;
if (vkCreateComputePipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &computePipeline) != VK_SUCCESS) {
throw std::runtime_error("failed to create compute pipeline!");
}
设置要简单得多,因为我们只需要一个着色器阶段和一个管道布局。管道布局的工作原理与图形管道相同:
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &computeDescriptorSetLayout;
if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &computePipelineLayout) != VK_SUCCESS) {
throw std::runtime_error("failed to create compute pipeline layout!");
}
Compute space
在讨论计算着色器如何工作以及如何向GPU提交计算工作负载之前,我们需要讨论两个重要的计算概念:工作组和调用。它们定义了一个抽象的执行模型,用于说明GPU的计算硬件如何在三维(x、y和z)中处理计算工作负载。
工作组定义GPU的计算硬件如何形成和处理计算工作负载。您可以将它们视为GPU必须处理的工作项。工作组维度由应用程序在命令缓冲区时使用分派命令设置。
然后,每个工作组都是执行相同计算着色器的调用集合。调用可能会并行运行,其维度在计算着色器中设置。单个工作组中的调用可以访问共享内存。
这张图在三个维度上显示了这两者之间的关系:
工作组(由vkCmdDispatch定义)和调用的维度数量取决于(由计算着色器中的本地大小定义)输入数据的结构。如果你在一维数组上工作,就像我们在本章中所做的那样,你只需要为这两个数组指定x维度。
例如:如果我们调度一个工作组计数[64,1,1],计算着色器本地大小为[32,32,1],那么我们的计算着色器将被调用64 x 32 x 32=65536次。
请注意,工作组和本地大小的最大计数因实现而异,因此应始终检查VkPhysicalDeviceLimits中与计算相关的maxComputeWorkGroupCount、maxComputeWorkGroupInvocations和maxComputeWork GroupSize限制。
Compute shaders
现在,我们已经了解了设置计算着色器管道所需的所有部分,现在是时候看看计算着色器了。我们了解到的关于使用GLSL着色器的所有内容(例如顶点和片段着色器)也适用于计算着色器。语法是相同的,许多概念(如在应用程序和着色器之间传递数据)也是相同的。但有一些重要的区别。
用于更新粒子线性阵列的非常基本的计算着色器可能如下所示:
#version 450
layout (binding = 0) uniform ParameterUBO {
float deltaTime;
} ubo;
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[ ];
};
layout (local_size_x = 256, local_size_y = 1, local_size_z = 1) in;
void main()
{
uint index = gl_GlobalInvocationID.x;
Particle particleIn = particlesIn[index];
particlesOut[index].position = particleIn.position + particleIn.velocity.xy * ubo.deltaTime;
particlesOut[index].velocity = particleIn.velocity;
...
}
着色器的顶部包含着色器输入的声明。首先是绑定0处的统一缓冲区对象,这是我们在本教程中已经了解的内容。下面我们声明与C++代码中的声明相匹配的Particle结构。然后,绑定1使用上一帧的粒子数据引用着色器存储缓冲区对象(请参见描述符设置),并将2个点绑定到当前帧的SSBO,这是我们将使用此着色器更新的一个点。
一个有趣的事情是这个与计算空间相关的仅计算声明:
layout (local_size_x = 256, local_size_y = 1, local_size_z = 1) in;
这定义了当前工作组中此计算着色器的调用次数。如前所述,这是计算空间的局部部分。因此,local_前缀。当我们处理线性一维粒子阵列时,我们只需要在local_size_x中为x维度指定一个数字。
然后,主函数读取最后一帧的SSBO,并将更新的粒子位置写入当前帧的SSBO。与其他着色器类型类似,计算着色器有自己的一组内置输入变量。内置始终以gl_作为前缀。其中一个内置的是gl_GlobalInvocationID,它是唯一标识当前调度中当前计算着色器调用的变量。我们使用它来索引粒子阵列。
Running compute commands
Dispatch
现在是时候告诉GPU进行一些计算了。这是通过在命令缓冲区内调用vkCmdDispatch来完成的。虽然不完全正确,但调度是用于计算的,就像vkCmdDraw用于图形一样。这最多在三个维度中分派给定数量的计算工作项。
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
if (vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
throw std::runtime_error("failed to begin recording command buffer!");
}
...
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipelineLayout, 0, 1, &computeDescriptorSets[i], 0, 0);
vkCmdDispatch(computeCommandBuffer, PARTICLE_COUNT / 256, 1, 1);
...
if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
throw std::runtime_error("failed to record command buffer!");
}
vkCmdDispatch将在x维度中调度PARTITE_COUNT/256个本地工作组。由于我们的粒子阵列是线性的,我们将其他两个维度保持在一个维度,从而产生一维分派。但为什么我们要将(阵列中的)粒子数除以256?这是因为在上一段中,我们定义了工作组中的每个计算着色器将执行256次调用。因此,如果我们有4096个粒子,我们将调度16个工作组,每个工作组运行256个计算着色器调用。根据您的工作负载和正在运行的硬件,通常需要对这两个数字进行一些修补和分析。如果您的粒子大小是动态的,并且不能总是除以256,那么您可以始终在计算着色器的开头使用gl_GlobalInvocationID,如果全局调用索引大于您的粒子数,则从中返回。
与计算管道的情况一样,计算命令缓冲区包含的状态比图形命令缓冲区少得多。无需启动渲染过程或设置视口。
Submitting work
由于我们的示例同时执行计算和图形操作,我们将对每帧的图形和计算队列进行两次提交(请参见drawFrame函数):
...
if (vkQueueSubmit(computeQueue, 1, &submitInfo, nullptr) != VK_SUCCESS) {
throw std::runtime_error("failed to submit compute command buffer!");
};
...
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
第一次提交到计算队列将使用计算着色器更新粒子位置,然后第二次提交将使用更新的数据绘制粒子系统。
Synchronizing graphics and compute
同步是Vulkan的一个重要组成部分,在结合图形进行计算时更是如此。错误或缺乏同步可能会导致顶点阶段开始绘制(=读取)粒子,而计算着色器尚未完成更新(=写入)粒子(写入后读取危险),或者计算着色器可能会开始更新管道顶点部分仍在使用的粒子(读取后写入危险)。
因此,我们必须通过适当地同步图形和计算负载来确保这些情况不会发生。根据您提交计算工作负载的方式,有不同的方法,但在我们的两次单独提交中,我们将使用信号量和围栏来确保顶点着色器在计算着色器完成更新之前不会开始获取顶点。
这是必要的,因为即使两个提交是一个接一个排序的,也不能保证它们在GPU上按此顺序执行。添加等待和信号信号量可确保执行顺序。
因此,我们首先为createSyncObjects中的计算工作添加一组新的同步原语。与图形围栏一样,计算围栏是在信号状态下创建的,因为否则,在等待围栏发出信号时,第一次绘制将超时,具体如下:
std::vector<VkFence> computeInFlightFences;
std::vector<VkSemaphore> computeFinishedSemaphores;
...
computeInFlightFences.resize(MAX_FRAMES_IN_FLIGHT);
computeFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
VkSemaphoreCreateInfo semaphoreInfo{};
semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
...
if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &computeFinishedSemaphores[i]) != VK_SUCCESS ||
vkCreateFence(device, &fenceInfo, nullptr, &computeInFlightFences[i]) != VK_SUCCESS) {
throw std::runtime_error("failed to create compute synchronization objects for a frame!");
}
}
然后,我们使用这些来同步计算缓冲区提交与图形提交:
// Compute submission
vkWaitForFences(device, 1, &computeInFlightFences[currentFrame], VK_TRUE, UINT64_MAX);
updateUniformBuffer(currentFrame);
vkResetFences(device, 1, &computeInFlightFences[currentFrame]);
vkResetCommandBuffer(computeCommandBuffers[currentFrame], /*VkCommandBufferResetFlagBits*/ 0);
recordComputeCommandBuffer(computeCommandBuffers[currentFrame]);
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &computeCommandBuffers[currentFrame];
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &computeFinishedSemaphores[currentFrame];
if (vkQueueSubmit(computeQueue, 1, &submitInfo, computeInFlightFences[currentFrame]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit compute command buffer!");
};
// Graphics submission
vkWaitForFences(device, 1, &inFlightFences[currentFrame], VK_TRUE, UINT64_MAX);
...
vkResetFences(device, 1, &inFlightFences[currentFrame]);
vkResetCommandBuffer(commandBuffers[currentFrame], /*VkCommandBufferResetFlagBits*/ 0);
recordCommandBuffer(commandBuffers[currentFrame], imageIndex);
VkSemaphore waitSemaphores[] = { computeFinishedSemaphores[currentFrame], imageAvailableSemaphores[currentFrame] };
VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_VERTEX_INPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount = 2;
submitInfo.pWaitSemaphores = waitSemaphores;
submitInfo.pWaitDstStageMask = waitStages;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[currentFrame];
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &renderFinishedSemaphores[currentFrame];
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
与信号量一章中的示例类似,此设置将立即运行计算着色器,因为我们没有指定任何等待信号量。这很好,因为我们正在等待当前帧的计算命令缓冲区在使用vkWaitForFences命令提交计算之前完成执行。
另一方面,图形提交需要等待计算工作完成,以便在计算缓冲区仍在更新顶点时不会开始提取顶点。因此,我们等待当前帧的computeFinishedEmahores,并在VK_PIPELINE_STAGE_VERTEX_INPUT_BIT阶段等待图形提交,其中顶点被消耗。
但它还需要等待呈现,以便片段着色器在呈现图像之前不会输出到颜色附件。因此,我们还在VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT阶段等待当前帧上的imageAvailableSemaphores。
Drawing the particle system
早些时候,我们了解到Vulkan中的缓冲区可以有多个用例,因此我们创建了包含粒子的着色器存储缓冲区,其中包含着色器存储缓冲位和顶点缓冲位。这意味着我们可以使用着色器存储缓冲区进行绘制,就像我们在前面章节中使用的“纯”顶点缓冲区一样。
我们首先设置顶点输入状态以匹配粒子结构:
struct Particle {
...
static std::array<VkVertexInputAttributeDescription, 2> getAttributeDescriptions() {
std::array<VkVertexInputAttributeDescription, 2> attributeDescriptions{};
attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0].offset = offsetof(Particle, position);
attributeDescriptions[1].binding = 0;
attributeDescriptions[1].location = 1;
attributeDescriptions[1].format = VK_FORMAT_R32G32B32A32_SFLOAT;
attributeDescriptions[1].offset = offsetof(Particle, color);
return attributeDescriptions;
}
};
请注意,我们不会向顶点输入属性添加速度,因为这仅由计算着色器使用。
然后,我们像使用任何顶点缓冲区一样进行绑定和绘制:
vkCmdBindVertexBuffers(commandBuffer, 0, 1, &shaderStorageBuffer[currentFrame], offsets);
vkCmdDraw(commandBuffer, PARTICLE_COUNT, 1, 0, 0);
Conclusion
在本章中,我们学习了如何使用计算着色器将工作从CPU卸载到GPU。如果没有计算着色器,现代游戏和应用程序中的许多效果要么不可能实现,要么运行速度会慢得多。但除了图形,计算还有很多用例,本章只给你一个可能的一瞥。因此,现在您知道如何使用计算着色器,您可能需要了解一些高级计算主题,如:
- Shared memory
- Asynchronous compute
- Atomic operations
- Subgroups
您可以在官方Khronos Vulkan样本库中找到一些高级计算样本。