Vulkan 学习(19)---- Vulkan Compute

目录

Vulkan Compute 概述

与 OpenGL 等较旧的 API 不同,Vulkan 中强制要求支持计算着色器,这意味着您可以在任何可用的 Vulkan 实现上使用计算着色器,无论它是高端桌面 GPU 还是低功耗嵌入式设备
GPU 的计算功能可以用于图像处理、可见性测试、后期处理、高级照明计算、动画、物理(例如粒子系统)等等,甚至可以用于非可见性的工作比如 AI 和 数字运算等

使用 GPU 进行计算的一个明显的优势是

  • 降低 CPU 负载
  • 避免 CPU 和 GPU 之间交换数据
    所有数据都可以直接保持在 GPU 上,无需从缓慢的主存储器中获取数据

一个例子

我们将实现的一个易于理解的示例是基于 GPU 的粒子系统,这种系统在很多游戏中会与使用,通常由数千个每帧都更新的粒子组成,渲染粒子需要两个主要的过程: 使用顶点缓冲区传递顶点,粒子的计算和更新

传统的粒子系统是将粒子数据存储在主内存中,然后使用 CPU 更新,更新后,顶点需要再次传输到 GPU 的内存,以便它可以在下一帧中显示更新的粒子

最直接的方法是为每帧使用新数据重新创建顶点缓冲区。这显然非常耗费资源,当然也可以映射 GPU 的内存方便 CPU 写入,或者使用主机本地缓冲区(由于 PCI-E 带宽,这将是最慢的方法),但是无论使用那种方法,始终需要 CPU 和 GPU 之间交换数据来更新粒子

使用基于 GPU 的粒子系统,就不需要此过程,顶点上传到 GPU,并且所有更新都在 GPU 的内存中使用计算着色器完成,之所以速度更快的主要原因之一是 GPU 与直接管理的内存(Local Memory)之间更高的带宽

同时在具有专门计算队列的 GPU 上执行时,可以与图形管线并行执行来更新粒子

使用流程

前面已经了解了用于传递图元的顶点和索引的缓冲区(vertebuffer和indexbuffer)以及用于将数据传递到着色器的统一缓冲区(uniform buffer)
Vulkan Compute 引入的一个重要概念是能够任意地从缓冲区读取和写入到缓冲区, 为此,Vulkan 提供了两种专用的存储类型 SSBO(Shader Storage Buffer Object) 和 vkImage

SSBO

SSBO (Shader Storage Buffer Object) 是允许计算着色器读取和写入的缓冲区,使用它们类似于使用统一缓冲区对象

区别于之前我们使用的 VertexBuffer IndexBuffer 和 Texture Mapping Image 都是 Shader 只读类型的 Buffer

Uniform Buffer 也是 Shader 可读可写的,但是大小必须和 Graphic Shader 中定义的一致,

但是 SSBO 的特点就是大小不限定,也就是大小可以根据要处理的数据量决定,要处理数据越多,创建的 SSBO buffers size 越大

在 Vulkan 中,您可以为缓冲区和图像指定多种用途,因此粒子顶点缓冲区既可以用作顶点缓冲区(在图形通道中),又可以用作存储缓冲区(在计算通道中)

c 复制代码
// 作为 SSBO Usage VK_BUFFER_USAGE_STORAGE_BUFFER_BIT
createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, shaderStorageBuffers[i], shaderStorageBuffersMemory[i]);

void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties, VkBuffer& buffer, VkDeviceMemory& bufferMemory) {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &buffer) != VK_SUCCESS) {
        throw std::runtime_error("failed to create buffer!");
    }

    VkMemoryRequirements memRequirements;
    vkGetBufferMemoryRequirements(device, buffer, &memRequirements);

    VkMemoryAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);

    if (vkAllocateMemory(device, &allocInfo, nullptr, &bufferMemory) != VK_SUCCESS) {
        throw std::runtime_error("failed to allocate buffer memory!");
    }

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

VK_BUFFER_USAGE_VERTEX_BUFFER_BIT 和 VK_BUFFER_USAGE_STORAGE_BUFFER_BIT 这两个标志作为 bufferInfo.usage 一起设置

告诉实现我们希望将此缓冲区用于两种不同的场景:作为顶点着色器中的顶点缓冲区和作为存储缓冲区

对应的 compute Shader 可以使用下面的写法:

c 复制代码
layout(std140, binding = 1) readonly buffer ParticleSSBOIn {
   Particle particlesIn[ ];
};

layout(std140, binding = 2) buffer ParticleSSBOOut {
   Particle particlesOut[ ];
};

SSBO 包含由 标记的,并不指定大小,不必在 SSBO 中指定元素数量是优于例如统一缓冲区的优势之一
std140 是一个内存布局限定符,它确定着色器存储缓冲区的成员元素在内存中如何对齐

Storage Image

存储图像允许您读取和写入图像, 典型的用例包括将图像效果应用于纹理、进行后期处理(反过来非常相似)或生成 mip-map

c 复制代码
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_IMAGE_USAGE_STORAGE_BIT 这两个标志是希望将此图像用于两种不同的场景:

作为片段着色器中采样的图像和作为计算着色器中的存储图像

c 复制代码
layout (binding = 0, rgba8) uniform readonly image2D inputImage;
layout (binding = 1, rgba8) uniform writeonly image2D outputImage;

这里的一些差异是额外的属性,例如图像格式的 rgba8,readonly 和 writeonly 限定符,它们告诉实现我们只会读取输入图像并写入输出图像,我们需要使用 image2D 类型来声明存储图像

在计算着色器中使用 imageLoad 和 imageStore 来读取和写入存储图像

c 复制代码
vec3 pixel = imageLoad(inputImage, ivec2(gl_GlobalInvocationID.xy)).rgb;
imageStore(outputImage, ivec2(gl_GlobalInvocationID.xy), pixel);
计算流程
  1. 使用计算的队列族
    Vulkan 要求支持图形操作的实现至少有一个同时支持图形和计算操作的 queueFamilyIndex,

    但是也可能实现专用的计算队列,所以 queueFamilyIndex 要选择计算类型的队列

  2. 加载计算着色器

    加载计算着色器与加载任何其他着色器相同,唯一的区别就是 VK_SHADER_STAGE_COMPUTE_BIT

c 复制代码
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";
  1. 计算管线
    我们需要使用 vkCreateComputePipelines 创建专用的计算管道来运行我们的计算命令,由于计算管道不涉及任何光栅化状态,因此它比图形管道的状态少得多
c 复制代码
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!");
}

计算空间

计算着色器两个重要的计算概念:工作组(workgroup)和调用(invocation),它们定义了一个抽象的执行模型,用于说明 GPU 的计算硬件如何在三个维度(x 、y 和 z)中处理计算工作负载

  • 工作组 (workgroup)定义了计算工作负载的形成方式以及 GPU 的计算硬件如何处理它们,可以视为 GPU 要完成的工作项目,工作组的维度由应用程序在命令缓冲区时使用调度命令设置

  • 每个(workgroup)都使用调用(invocation)的集合,它们执行相同的计算着色器,可以潜在地并行运行,它们的维度在计算着色器中设置,单个工作组内的调用可以访问共享内存

    这里从 GPU 的结构分析每个(workgroup)都分配一块单独的私有内存,同时所有的工作组也会有全局的内存可以访问

工作组(workgroup)(由 vkCmdDispatch 定义)和调用(invocation)(由计算着色器中的局部大小定义)的维度数量取决于输入数据的结构

例如,处理一维数组,则只需为两者指定 x 维度即可

如果我们调度一个工作组计数 64, 1, 1,并且计算着色器的局部大小为 32, 32, 1,则我们的计算着色器将被调用 64 x 32 x 32 = 65,536 次

工作组和局部大小的最大计数因实现而异,受到 VkPhysicalDeviceLimits 中与计算相关的 maxComputeWorkGroupCount、maxComputeWorkGroupInvocations 和 maxComputeWorkGroupSize 限制

计算着色器

c 复制代码
#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 处的是 uniform 缓冲区对象

绑定在 1 处的是 着色器缓冲区对象,来自上一帧的输出 SSBO

绑定在 2 处是指向当前帧的 SSBO

其中关键的是计算着色器的声明:
layout (local_size_x = 256, local_size_y = 1, local_size_z = 1) in;

定义了当前计算着色器此计算着色器的调用次数,都是以 local 作为前缀

计算着色器也有自己的一组内置输入变量,内置变量是以 gl 作为前缀,其中一个内置变量是 gl_GlobalInvocationID, 它是一个变量,用于标志当前 GPU 调度,用于索引粒子数组

运行计算命令

GPU 真正完成计算实在命令缓冲区内调用 vkCmdDispatch 完成,这类似于 vkCmdDraw 对应图形的绘制调用

c 复制代码
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

vkBeginCommandBuffer(commandBuffer, &beginInfo)
...

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);
...
vkEndCommandBuffer(commandBuffer)

PARTICLE_COUNT 除以 256 的原因是我们在工作组中定义每个计算着色器执行 256 次调用