狸村 Mystic Web · 狸村
目录

引言

在本附加章节中,我们将了解计算着色器。 到目前为止,前面的所有章节都涉及 Vulkan 管线的传统图形部分。 但与 OpenGL 等较旧的 API 不同,Vulkan 中的计算着色器支持是强制性的。 这意味着你可以在任何可用的 Vulkan 实现上使用计算着色器,无论它是高端桌面 GPU 还是低功耗嵌入式设备。

这为图形处理器上的通用计算(GPGPU)打开了大门,无论你的应用程序在哪里运行。 GPGPU 意味着你可以在 GPU 上进行通用计算,这传统上是 CPU 的领域。 但随着 GPU 变得越来越强大和灵活,许多原本需要 CPU 通用计算能力的工作负载现在可以在 GPU 上实时完成。

GPU 计算能力的一些使用示例包括图像处理、可见性测试、后期处理、高级光照计算、动画、物理模拟(例如粒子系统)等等。 甚至可以将计算用于不需要任何图形输出的纯计算工作,例如数值计算或 AI 相关任务。 这被称为“无头计算(headless compute)”。

优势

在 GPU 上执行计算密集型计算有几个优点。 最明显的是将工作从 CPU 卸载。 另一个是不需要在 CPU 主内存和 GPU 内存之间移动数据。 所有数据都可以保留在 GPU 上,而无需等待来自主内存的慢速传输。

除此之外,GPU 具有高度并行性,其中一些拥有数万个小型计算单元。 与拥有几个大型计算单元的 CPU 相比,这通常使它们更适合高度并行的工作流程。

Vulkan 管线

重要的是要知道计算与管线的图形部分是完全分离的。 这在官方规范中的 Vulkan 管线框图中可见:

vulkan pipeline block diagram

在该图中,我们可以看到左侧传统的图形管线部分,以及右侧不属于此图形管线的几个阶段,包括计算着色器(阶段)。 由于计算着色器阶段与图形管线分离,我们可以在任何我们认为合适的地方使用它。 这与例如片段着色器(它始终应用于顶点着色器的变换输出)非常不同。

图的中心还显示,例如描述符集也被计算使用,因此我们学到的关于描述符布局、描述符集和描述符的所有内容也适用于此处。

示例

我们将在本章中实现的一个易于理解的示例是基于 GPU 的粒子系统。 这种系统在许多游戏中使用,通常包含数千个需要以交互帧率更新的粒子。 渲染这样的系统需要两个主要部分:作为顶点缓冲传递的顶点,以及基于某种方程更新它们的方法。

“经典”的基于 CPU 的粒子系统会将粒子数据存储在系统主内存中,然后使用 CPU 来更新它们。 更新后,需要将顶点再次传输到 GPU 内存,以便在下一帧显示更新后的粒子。 最直接的方法是每帧使用新数据重新创建顶点缓冲。 这非常昂贵。 根据你的实现,还有其他选项,例如映射 GPU 内存以便 CPU 可以写入(在桌面系统上称为“可调整大小的 BAR”,或在集成 GPU 上称为统一内存),或者仅使用主机本地缓冲(由于 PCI-E 带宽,这将是最慢的方法)。 但无论你选择哪种缓冲更新方法,你总是需要“往返”到 CPU 来更新粒子。

使用基于 GPU 的粒子系统,不再需要这种往返。 顶点仅在开始时上传到 GPU,所有更新都使用计算着色器在 GPU 内存中完成。 这更快的主要原因之一是 GPU 与其本地内存之间的带宽高得多。 在基于 CPU 的情况下,你会受到主内存和 PCI-express 带宽的限制,这通常只是 GPU 内存带宽的一小部分。

在具有专用计算队列的 GPU 上执行此操作时,你可以与图形管线的渲染部分并行更新粒子。 这称为“异步计算(async compute)”,是本教程未涵盖的高级主题。

以下是本章代码的屏幕截图。 此处显示的粒子由计算着色器直接在 GPU 上更新,无需任何 CPU 交互:

compute shader particles

数据操作

在本教程中,我们已经了解了不同的缓冲类型,例如用于传递图元的顶点和索引缓冲,以及用于将数据传递到着色器的统一缓冲。 我们还使用了图像进行纹理映射。 但到目前为止,我们总是使用 CPU 写入数据,并且只在 GPU 上读取。

使用计算着色器引入的一个重要概念是能够任意地从缓冲 读取写入。 为此,Vulkan 提供了两种专用的存储类型。

着色器存储缓冲对象(SSBO)

着色器存储缓冲(SSBO)允许着色器从缓冲读取和写入。 使用它类似于使用统一缓冲对象。 最大的区别在于你可以将其他缓冲类型别名化为 SSBO,并且它们可以任意大。

回到基于 GPU 的粒子系统,你现在可能想知道如何处理由计算着色器更新(写入)并由顶点着色器读取(绘制)的顶点,因为这两种用途似乎需要不同的缓冲类型。

但事实并非如此。 在 Vulkan 中,你可以为缓冲和图像指定多种用途。 因此,要将粒子顶点缓冲用作顶点缓冲(在图形通道中)和存储缓冲(在计算通道中),你只需使用这两个用途标志创建缓冲:

vk::BufferCreateInfo bufferInfo{};
...
bufferInfo.usage = vk::BufferUsageFlagBits::eVertexBuffer | vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eTransferDst;
...

shaderStorageBuffers[i] = vk::raii::Buffer(*device, bufferInfo);

使用 bufferInfo.usage 设置的两个标志 vk::BufferUsageFlagBits::eVertexBuffervk::BufferUsageFlagBits::eStorageBuffer 告诉实现我们想要将此缓冲用于两种不同的场景:作为顶点着色器中的顶点缓冲和作为存储缓冲。 请注意,我们还在此处添加了 vk::BufferUsageFlagBits::eTransferDst 标志,以便我们可以将数据从主机传输到 GPU。 这至关重要,因为我们希望着色器存储缓冲仅保留在 GPU 内存中(vk::MemoryPropertyFlagBits::eDeviceLocal),我们需要将数据从主机传输到此缓冲。

以下是使用 createBuffer 辅助函数的相同代码:

vk::raii::Buffer shaderStorageBufferTemp({});
vk::raii::DeviceMemory shaderStorageBufferTempMemory({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eVertexBuffer | vk::BufferUsageFlagBits::eTransferDst, vk::MemoryPropertyFlagBits::eDeviceLocal, shaderStorageBufferTemp, shaderStorageBufferTempMemory);
copyBuffer(stagingBuffer, shaderStorageBufferTemp, bufferSize);
shaderStorageBuffers.emplace_back(std::move(shaderStorageBufferTemp));
shaderStorageBuffersMemory.emplace_back(std::move(shaderStorageBufferTempMemory));

访问此类缓冲的 Slang 着色器声明如下所示:

struct Particle {
	float2 position;
	float2 velocity;
    float4 color;
};

struct ParticleSSBO {
    Particle particles;
};
StructuredBuffer<ParticleSSBO> particlesIn;
RWStructuredBuffer<ParticleSSBO> particlesOut;

在这个例子中,我们有一个类型化的 SSBO,其中每个粒子都有一个位置和速度值(参见 Particle 结构体)。 然后 SSBO 包含无限数量的粒子,因为它被放入一个没有上限的 StructuredBuffer 中,并且对于 particlesIn 是一个只读缓冲。对于 particlesOut,我们将其放入 RWStructedBuffer 中。 不必在 SSBO 中指定元素数量是相对于统一缓冲等的一个优势。

在计算着色器中写入这样的存储缓冲对象很简单,类似于你在 C++ 端写入缓冲的方式:

particlesOut[index].particles.position = particlesIn[index].particles.position + particlesIn[index].particles.velocity.xy * ubo.deltaTime;

存储图像

注意:我们不会在本章中进行图像处理。 此段是为了让读者了解计算着色器也可用于图像处理。

存储图像允许你从图像读取和写入。 典型用例是将图像效果应用于纹理、进行后期处理(这非常相似)或生成 mipmap。

这对于图像也是类似的:

vk::ImageCreateInfo imageInfo {};
...
imageInfo.usage = vk::ImageUsageFlagBits::eSampled | vk::ImageUsageFlagBits::eStorage;
...

textureImage = std::make_unique<vk::raii::SwapchainKHR>( *device, swapChainCreateInfo );

使用 imageInfo.usage 设置的两个标志 vk::ImageUsageFlagBits::eSampledvk::ImageUsageFlagBits::eStorage 告诉实现我们想要将此图像用于两种不同的场景:作为片段着色器中的采样图像和作为计算机着色器中的存储图像。

存储图像的 Slang 着色器声明类似于例如在片段着色器中使用的采样图像:

[vk::image_format("r32f")] Texture2D<float> inputImage;
[vk::image_format("r32f")] RWTexture2D<float> outputImage;

这里的一些区别是额外的属性,如用于图像格式的 r32f,使用只读的 Texture2D 和读写 RWTexture2D 指定。 最后但同样重要的是,我们需要使用 RWTexture2D 类型来声明存储图像。

然后在计算着色器中从存储图像读取和写入是通过数组查找语法完成的:

float3 pixel = inputImage[int2(gl_GlobalInvocationID.xy)].rgb;
outputImage[int2(gl_GlobalInvocationID.xy)] = pixel;

计算队列族

在物理设备和队列族章节中,我们已经了解了队列族以及如何选择图形队列族。 计算使用队列族属性标志位 vk::QueueFlagBits::eCompute。 因此,如果我们想要进行计算工作,我们需要从支持计算的队列族中获取一个队列。

请注意,Vulkan 要求支持图形操作的实现至少有一个同时支持图形和计算操作的队列族,但实现也可能提供专用的计算队列。 这个专用的计算队列(没有图形位)暗示了异步计算队列。 然而,为了使本教程对初学者友好,我们将使用一个可以同时执行图形和计算操作的队列。 这也将使我们免于处理几种高级同步机制。

对于我们的计算示例,我们需要稍微更改设备创建代码:

std::vector<vk::QueueFamilyProperties> queueFamilyProperties = physicalDevice->getQueueFamilyProperties();

// 获取 queueFamilyProperties 中同时支持图形和计算的第一个索引
auto graphicsAndComputeQueueFamilyProperty =
  std::find_if( queueFamilyProperties.begin(),
                queueFamilyProperties.end(),
                []( vk::QueueFamilyProperties const & qfp ) { return (qfp.queueFlags & vk::QueueFlagBits::eGraphics && qfp.queueFlags & vk::QueueFlagBits::eCompute); } );
graphicsAndComputeIndex = static_cast<uint32_t>( std::distance( queueFamilyProperties.begin(), graphicsAndComputeQueueFamilyProperty ) );

更改后的队列族索引选择代码现在将尝试找到一个同时支持图形和计算的队列族。

然后,我们可以在 createLogicalDevice 中从此队列族获取一个计算队列:

computeQueue = std::make_unique<vk::raii::Queue>( *device, graphicsAndComputeIndex, 0 );

计算着色器阶段

在图形示例中,我们使用了不同的管线阶段来加载着色器和访问描述符。 计算着色器通过使用 vk::ShaderStageFlagBits::eCompute 管线以类似方式访问。 因此,加载计算着色器与加载顶点着色器相同,但使用不同的着色器阶段。 我们将在接下来的段落中详细讨论这一点。 计算还为描述符和管线引入了一个新的绑定点类型,名为 vk::PipelineBindPoint::eCompute,我们稍后将不得不使用它。

加载计算着色器

在我们的应用程序中加载计算着色器与加载任何其他着色器相同。 唯一的真正区别是我们需要使用上面提到的 vk::ShaderStageFlagBits::eCompute

auto computeShaderCode = readFile("shaders/slang.spv");

vk::PipelineShaderStageCreateInfo computeShaderStageInfo({}, vk::ShaderStageFlagBits::eCompute, shaderModule, "compMain");
...

准备着色器存储缓冲

之前,我们了解到可以使用着色器存储缓冲将任意数据传递给计算着色器。 对于此示例,我们将粒子数组上传到 GPU,以便我们可以直接在 GPU 内存中操作它。

在多帧并行章节中,我们讨论了为每帧飞行中的帧复制资源,以便我们可以让 CPU 和 GPU 保持忙碌。 首先,我们为缓冲对象及其支持它的设备内存声明一个向量:

std::vector<vk::raii::Buffer> shaderStorageBuffers;
std::vector<vk::raii::DeviceMemory> shaderStorageBuffersMemory;

createShaderStorageBuffers 中,我们然后清除这些向量,以清理已创建的任何对象,这是我们的 RAII 实践。

shaderStorageBuffers.clear();
shaderStorageBuffersMemory.clear();

有了这个设置,我们可以开始将初始粒子信息移动到 GPU。 我们首先在主机端初始化一个粒子向量:

// 初始化粒子
std::default_random_engine rndEngine((unsigned)time(nullptr));
std::uniform_real_distribution<float> rndDist(0.0f, 1.0f);

// 圆形上的初始粒子位置
std::vector<Particle> particles(PARTICLE_COUNT);
for (auto& particle : particles) {
    float r = 0.25f * sqrtf(rndDist(rndEngine));
    float theta = rndDist(rndEngine) * 2.0f * 3.14159265358979323846f;
    float x = r * cosf(theta) * HEIGHT / WIDTH;
    float y = r * sinf(theta);
    particle.position = glm::vec2(x, y);
    particle.velocity = normalize(glm::vec2(x,y)) * 0.00025f;
    particle.color = glm::vec4(rndDist(rndEngine), rndDist(rndEngine), rndDist(rndEngine), 1.0f);
}

然后我们在主机内存中创建一个暂存缓冲来保存初始粒子属性:

    vk::DeviceSize bufferSize = sizeof(Particle) * PARTICLE_COUNT;

    // 创建用于上传数据到 GPU 的暂存缓冲
    vk::raii::Buffer stagingBuffer({});
    vk::raii::DeviceMemory stagingBufferMemory({});
    createBuffer(bufferSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, stagingBuffer, stagingBufferMemory);

    void* dataStaging = stagingBufferMemory.mapMemory(0, bufferSize);
    memcpy(dataStaging, particles.data(), (size_t)bufferSize);
    stagingBufferMemory.unmapMemory();

使用此暂存缓冲作为源,我们然后创建每帧的着色器存储缓冲,并将粒子属性从暂存缓冲复制到每个缓冲中:

    // 将初始粒子数据复制到所有存储缓冲
    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        vk::raii::Buffer shaderStorageBufferTemp({});
        vk::raii::DeviceMemory shaderStorageBufferTempMemory({});
        createBuffer(bufferSize, vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eVertexBuffer | vk::BufferUsageFlagBits::eTransferDst, vk::MemoryPropertyFlagBits::eDeviceLocal, shaderStorageBufferTemp, shaderStorageBufferTempMemory);
        copyBuffer(stagingBuffer, shaderStorageBufferTemp, bufferSize);
        shaderStorageBuffers.emplace_back(std::move(shaderStorageBufferTemp));
        shaderStorageBuffersMemory.emplace_back(std::move(shaderStorageBufferTempMemory));
    }
}

描述符

为计算设置描述符与图形几乎相同。 唯一的区别是描述符需要设置 vk::ShaderStageFlagBits::eCompute 以使其可被计算阶段访问:

 std::array layoutBindings{
            vk::DescriptorSetLayoutBinding(0, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eCompute, nullptr),
 };
...

请注意,你可以在此处组合着色器阶段,因此如果你希望描述符可从顶点和计算阶段访问,例如对于跨它们共享参数的一致缓冲,你可以为两个阶段设置位:

layoutBindings[0].stageFlags = vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eCompute;

以下是我们的示例的描述符设置。 布局如下所示:

std::array layoutBindings{
    vk::DescriptorSetLayoutBinding(0, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eCompute, nullptr),
    vk::DescriptorSetLayoutBinding(1, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eCompute, nullptr),
    vk::DescriptorSetLayoutBinding(2, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eCompute, nullptr)
};

vk::DescriptorSetLayoutCreateInfo layoutInfo({}, layoutBindings.size(), layoutBindings.data());
computeDescriptorSetLayout = std::make_unique<vk::raii::DescriptorSetLayout>( *device, layoutInfo );

查看此设置,你可能会想知道为什么我们有两个用于着色器存储缓冲对象的布局绑定,即使我们只渲染一个粒子系统。 这是因为粒子位置基于增量时间逐帧更新。 这意味着每帧需要知道上一帧的粒子位置,以便用新的增量时间更新它们并将其写入自己的 SSBO:

compute ssbo read write

为此,计算着色器需要访问上一帧和当前帧的 SSBO。 这是通过在我们的描述符设置中将两者传递给计算着色器来完成的。 请参阅 storageBufferInfoLastFramestorageBufferInfoCurrentFrame

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
    vk::DescriptorBufferInfo bufferInfo(uniformBuffers[i], 0, sizeof(UniformBufferObject));

    vk::DescriptorBufferInfo storageBufferInfoLastFrame(shaderStorageBuffers[(i - 1) % MAX_FRAMES_IN_FLIGHT], 0, sizeof(Particle) * PARTICLE_COUNT);
    vk::DescriptorBufferInfo storageBufferInfoCurrentFrame(shaderStorageBuffers[i], 0, sizeof(Particle) * PARTICLE_COUNT);
    std::array descriptorWrites{
        vk::WriteDescriptorSet( computeDescriptorSets[i], 0, 0, 1, vk::DescriptorType::eUniformBuffer, nullptr, &bufferInfo ),
        vk::WriteDescriptorSet( computeDescriptorSets[i], 1, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &storageBufferInfoLastFrame),
        vk::WriteDescriptorSet( computeDescriptorSets[i], 2, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &storageBufferInfoCurrentFrame),
    };
    device->updateDescriptorSets(descriptorWrites, {});
}

请记住,我们还需要从描述符池中请求 SSBO 的描述符类型:

std::array poolSize {
    vk::DescriptorPoolSize( vk::DescriptorType::eUniformBuffer, MAX_FRAMES_IN_FLIGHT),
    vk::DescriptorPoolSize(  vk::DescriptorType::eStorageBuffer, MAX_FRAMES_IN_FLIGHT * 2)
};

我们需要将从池中请求的 vk::DescriptorType::eStorageBuffer 类型的数量加倍,因为我们的集合引用了上一帧和当前帧的 SSBO。

计算管线

由于计算不是图形管线的一部分,我们不能使用 device→createGraphicsPipeline。 相反,我们需要使用 device→createComputePipeline 创建一个专用的计算管线来运行我们的计算命令。 由于计算管线不涉及任何光栅化状态,它比图形管线少得多:

vk::PipelineLayoutCreateInfo pipelineLayoutInfo({}, 1, &**computeDescriptorSetLayout);

computePipelineLayout = std::make_unique<vk::raii::PipelineLayout>( *device, pipelineLayoutInfo );

设置要简单得多,因为我们只需要一个着色器阶段和一个管线布局。 管线布局的工作方式与图形管线相同:

vk::ComputePipelineCreateInfo pipelineInfo({}, computeShaderStageInfo, *computePipelineLayout);
computePipeline = std::make_unique<vk::raii::Pipeline>(device->createComputePipeline( nullptr, pipelineInfo));

计算空间

在我们进入计算着色器如何工作以及我们如何将计算工作负载提交给 GPU 之前,我们需要讨论两个重要的计算概念:工作组(work groups)调用(invocations)。 它们定义了一个抽象执行模型,用于描述计算工作负载如何由 GPU 的计算硬件在三个维度(x、y 和 z)中处理。

工作组 定义计算工作负载如何由 GPU 的计算硬件形成和处理。 你可以将它们视为 GPU 必须完成的工作项。 工作组维度由应用程序在命令缓冲时使用调度(dispatch)命令设置。

然后每个工作组是一组 调用,它们执行相同的计算着色器。 调用可能并行运行,它们的维度在计算着色器中设置。 单个工作组内的调用可以访问共享内存。

此图显示了这两个在三个维度中的关系:

compute space

工作组的维度数(由 computeCommandBuffers[frameIndex]→dispatch 定义)和调用数(由计算着色器中的局部大小定义)取决于输入数据的结构方式。 如果你例如处理一维数组,就像我们在本章中所做的那样,你只需为两者指定 x 维度。

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

请注意,工作组和局部大小的最大计数因实现而异,因此你应该始终检查 VkPhysicalDeviceLimits 中与计算相关的 maxComputeWorkGroupCountmaxComputeWorkGroupInvocationsmaxComputeWorkGroupSize 限制。

计算着色器

现在我们已经了解了设置计算着色器管线所需的所有部分,是时候看看计算着色器了。 我们学到的关于使用 GLSL 着色器的所有内容,例如顶点和片段着色器,也适用于计算着色器。 语法相同,许多概念(如在应用程序和着色器之间传递数据)也相同。 但有一些重要的区别。

用于更新线性粒子数组的一个非常基本的计算着色器可能如下所示:

struct Particle {
	float2 position;
	float2 velocity;
    float4 color;
};

struct UniformBuffer {
    float deltaTime;
};
ConstantBuffer<UniformBuffer> ubo;

struct ParticleSSBO {
    Particle particles;
};
StructuredBuffer<ParticleSSBO> particlesIn;
RWStructuredBuffer<ParticleSSBO> particlesOut;



[shader("compute")]
[numthreads(256,1,1)]
void compMain(uint3 threadId : SV_DispatchThreadID)
{
    uint index = threadId.x;

    particlesOut[index].particles.position = particlesIn[index].particles.position + particlesIn[index].particles.velocity.xy * ubo.deltaTime;
    particlesOut[index].particles.velocity = particlesIn[index].particles.velocity;

    // 在窗口边界翻转运动
    if ((particlesOut[index].particles.position.x <= -1.0) || (particlesOut[index].particles.position.x >= 1.0)) {
        particlesOut[index].particles.velocity.x = -particlesOut[index].particles.velocity.x;
    }
    if ((particlesOut[index].particles.position.y <= -1.0) || (particlesOut[index].particles.position.y >= 1.0)) {
        particlesOut[index].particles.velocity.y = -particlesOut[index].particles.velocity.y;
    }

}

着色器的顶部包含着色器输入的声明。 首先是在绑定 0 处的一个统一缓冲对象,这是我们在本教程中已经学过的。 下面我们声明与 C++ 代码中的声明匹配的 Particle 结构体。 然后绑定 1 指向上一个帧的粒子数据的着色器存储缓冲对象(参见描述符设置)。绑定 2 指向当前帧的 SSBO,这是我们将使用此着色器更新的那个。

有趣的是这个与计算空间相关的仅计算声明:

[numthreads(256,1,1)]

这定义了当前工作组中此计算着色器的调用次数。 如前所述,这是计算空间的局部部分。 由于我们处理的是线性 1D 粒子数组,我们只需要在 [numthreads(x,y,z)] 中为 x 维度指定一个数字。

然后 compMain 函数从上一帧的 SSBO 读取,并将更新后的粒子位置写入当前帧的 SSBO。 与其他着色器类型类似,计算着色器有自己的内置输入变量集。 我们传入的 ThreadId 是一个变量,它唯一标识当前调度中的当前计算着色器调用。它通过 SV_DispatchThreadID 注解获得该能力。 我们使用它来索引到粒子数组中。

运行计算命令

调度

现在是时候真正告诉 GPU 进行计算了。 这是通过在命令缓冲中调用 computeCommandBuffers[frameIndex]→dispatch 来完成的。 虽然不完全正确,但调度(dispatch)对于计算就像绘制调用(如 commandBuffers[frameIndex]→draw)对于图形一样。 这会调度给定数量的计算工作项,最多三个维度。

computeCommandBuffers[frameIndex]->begin({});
...

computeCommandBuffers[frameIndex]->bindPipeline(vk::PipelineBindPoint::eCompute, *computePipeline);
computeCommandBuffers[frameIndex]->bindDescriptorSets(vk::PipelineBindPoint::eCompute, *computePipelineLayout, 0, {computeDescriptorSets[frameIndex]}, {});

computeCommandBuffers[frameIndex]->dispatch( PARTICLE_COUNT / 256, 1, 1 );

...

computeCommandBuffers[frameIndex]->end();

computeCommandBuffers[frameIndex]→dispatch 将在 x 维度调度 PARTICLE_COUNT / 256 个本地工作组。 由于我们的粒子数组是线性的,我们将其他两个维度保持为 1,从而形成一维调度。 但为什么我们要将粒子数(在我们的数组中)除以 256? 那是因为在前面的段落中,我们定义了一个工作组中的每个计算着色器将执行 256 次调用。 因此,如果我们有 4096 个粒子,我们将调度 16 个工作组,每个工作组运行 256 个计算着色器调用。 使这两个数字正确通常需要一些调整和分析,具体取决于你的工作负载和运行的硬件。 如果你的粒子大小是动态的,不能总是被例如 256 整除,你可以在计算着色器开始时使用 gl_GlobalInvocationID,如果全局调用索引大于粒子数,则返回。

正如计算管线的情况一样,计算命令缓冲的状态比图形命令缓冲少得多。 不需要开始渲染通道或设置视口。

提交工作

由于我们的示例同时执行计算和图形操作,我们将每帧向图形队列和计算队列进行两次提交(参见 drawFrame 函数):

...
computeQueue->submit(submitInfo, **computeInFlightFences[frameIndex]);
...
graphicsQueue->submit(submitInfo, **inFlightFences[frameIndex]);

第一次提交到计算队列使用计算着色器更新粒子位置,第二次提交将使用更新后的数据绘制粒子系统。

同步图形和计算

同步是 Vulkan 的重要组成部分,在将计算与图形结合时更是如此。 错误或缺乏同步可能会导致顶点阶段开始绘制(=读取)粒子,而计算着色器尚未完成更新(=写入)它们(读后写风险),或者计算着色器可能开始更新仍由管线的顶点部分使用的粒子(写后读风险)。

因此,我们必须通过正确同步图形和计算负载来确保这些情况不会发生。 根据你提交计算工作负载的方式,有不同的方法可以做到这一点,但在我们的情况下,有两个单独的提交,我们将使用信号量和围栏来确保顶点着色器不会在计算着色器完成更新顶点之前开始获取顶点。

这是必要的,因为即使两个提交是按顺序一个接一个地提交的,也不能保证它们在 GPU 上按此顺序执行。 添加等待和信号信号量可确保此执行顺序。

因此,我们首先在 createSyncObjects 中为计算工作添加一组新的同步原语。 计算围栏,就像图形围栏一样,以 signaled 状态创建,否则第一次绘制会在等待围栏 signaled 时超时,详见此处:

std::vector<std::unique_ptr<vk::raii::Fence>> computeInFlightFences;
std::vector<std::unique_ptr<vk::raii::Semaphore>> computeFinishedSemaphores;
...
computeInFlightFences.resize(MAX_FRAMES_IN_FLIGHT);
computeFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
    ...
    computeFinishedSemaphores[i] = std::make_unique<vk::raii::Semaphore>(*device, vk::SemaphoreCreateInfo());
    computeInFlightFences[i] = std::make_unique<vk::raii::Fence>(*device, vk::FenceCreateInfo(vk::FenceCreateFlagBits::eSignaled));
}

然后我们使用这些来同步计算缓冲提交与图形提交:

{
    // 计算提交
    while ( vk::Result::eTimeout == device->waitForFences(**computeInFlightFences[frameIndex], vk::True, UINT64_MAX) )
        ;

    updateUniformBuffer(frameIndex);
    device->resetFences( **computeInFlightFences[frameIndex] );
    computeCommandBuffers[frameIndex]->reset();
    recordComputeCommandBuffer();

    const vk::SubmitInfo submitInfo({}, {}, {**computeCommandBuffers[frameIndex]}, { **computeFinishedSemaphores[frameIndex]});
    computeQueue->submit(submitInfo, **computeInFlightFences[frameIndex]);
}
{
    // 图形提交
    while ( vk::Result::eTimeout == device->waitForFences(**inFlightFences[frameIndex], vk::True, UINT64_MAX))
...

    device->resetFences(  **inFlightFences[frameIndex] );
    commandBuffers[frameIndex]->reset();
    recordCommandBuffer(imageIndex);

    vk::Semaphore waitSemaphores[] = {**presentCompleteSemaphore[frameIndex], **computeFinishedSemaphores[frameIndex]};
    vk::PipelineStageFlags waitDestinationStageMask[] = { vk::PipelineStageFlagBits::eVertexInput, vk::PipelineStageFlagBits::eColorAttachmentOutput };
    const vk::SubmitInfo submitInfo( waitSemaphores, waitDestinationStageMask, {**commandBuffers[frameIndex]}, {**renderFinishedSemaphore[frameIndex]} );
    graphicsQueue->submit(submitInfo, **inFlightFences[frameIndex]);

与信号量章节中的示例类似,此设置将立即运行计算着色器,因为我们没有指定任何等待信号量。 请注意,我们上面使用了作用域括号,以确保我们使用的 RAII 临时变量有机会在计算和图形阶段之间自行清理。 这没问题,因为我们在使用 device→waitForFences 命令进行计算提交之前等待当前帧的计算命令缓冲执行完成。

另一方面,图形提交需要等待计算工作完成,以便不会在计算缓冲仍在更新顶点时开始获取顶点。 因此,我们等待当前帧的 computeFinishedSemaphores,并使图形提交在 vk::PipelineStageFlagBits::eVertexInput 阶段等待,该阶段消耗顶点。

但它也需要等待呈现,因此片段着色器不会在图像被呈现之前输出到颜色附件。 因此,我们还在 vk::PipelineStageFlagBits::eColorAttachmentOutput 阶段等待当前帧的 imageAvailableSemaphores

时间线信号量:一种改进的同步机制

上面描述的同步方法使用二值信号量,它们具有简单的 signaled/unsignaled 状态。虽然这在许多场景中工作良好,但 Vulkan 还提供了一种更强大的同步原语:时间线信号量。

时间线信号量最初作为扩展引入,后来在 Vulkan 1.2 中提升为核心。与二值信号量不同,时间线信号量具有一个 64 位无符号整数值,可以等待和信号到特定值。这提供了几个优于二值信号量的优势:

  1. 可重用性:单个时间线信号量可用于多个同步点,减少了所需信号量的数量。

  2. 主机同步:时间线信号量可以从主机(CPU)信号和等待,而无需向队列提交命令。

  3. 乱序信号:你可以将时间线信号量信号到一个比当前正在等待的值更高的值,从而允许更灵活的同步模式。

  4. 多个挂起信号:与只能挂起信号一次的二值信号量不同,时间线信号量可以有多个挂起信号。

让我们看看如何修改我们的粒子系统示例以使用时间线信号量代替二值信号量:

首先,我们需要在创建逻辑设备时启用时间线信号量功能:

vk::PhysicalDeviceTimelineSemaphoreFeaturesKHR timelineSemaphoreFeatures;
timelineSemaphoreFeatures.timelineSemaphore = vk::True;
// 将此链接到你的设备创建信息

我们不是创建多个二值信号量,而是创建一个单一的时间线信号量:

vk::SemaphoreTypeCreateInfo semaphoreType{ .semaphoreType = vk::SemaphoreType::eTimeline, .initialValue = 0 };
semaphore = vk::raii::Semaphore(device, {.pNext = &semaphoreType});
timelineValue = 0;

在我们的绘制帧函数中,我们使用递增的时间线值来协调计算和图形之间的工作:

// 更新此帧的时间线值
uint64_t computeWaitValue = timelineValue;
uint64_t computeSignalValue = ++timelineValue;
uint64_t graphicsWaitValue = computeSignalValue;
uint64_t graphicsSignalValue = ++timelineValue;

对于计算提交,我们使用时间线信号量提交信息结构体:

vk::TimelineSemaphoreSubmitInfo computeTimelineInfo{
    .waitSemaphoreValueCount = 1,
    .pWaitSemaphoreValues = &computeWaitValue,
    .signalSemaphoreValueCount = 1,
    .pSignalSemaphoreValues = &computeSignalValue
};

vk::PipelineStageFlags waitStages[] = {vk::PipelineStageFlagBits::eComputeShader};

vk::SubmitInfo computeSubmitInfo{
    .pNext = &computeTimelineInfo,
    .waitSemaphoreCount = 1,
    .pWaitSemaphores = &*semaphore,
    .pWaitDstStageMask = waitStages,
    .commandBufferCount = 1,
    .pCommandBuffers = &*computeCommandBuffers[frameIndex],
    .signalSemaphoreCount = 1,
    .pSignalSemaphores = &*semaphore
};

computeQueue.submit(computeSubmitInfo, nullptr);

类似地,对于图形提交:

vk::PipelineStageFlags waitStage = vk::PipelineStageFlagBits::eVertexInput;
vk::TimelineSemaphoreSubmitInfo graphicsTimelineInfo{
    .waitSemaphoreValueCount = 1,
    .pWaitSemaphoreValues = &graphicsWaitValue,
    .signalSemaphoreValueCount = 1,
    .pSignalSemaphoreValues = &graphicsSignalValue
};

vk::SubmitInfo graphicsSubmitInfo{
    .pNext = &graphicsTimelineInfo,
    .waitSemaphoreCount = 1,
    .pWaitSemaphores = &*semaphore,
    .pWaitDstStageMask = &waitStage,
    .commandBufferCount = 1,
    .pCommandBuffers = &*commandBuffers[frameIndex],
    .signalSemaphoreCount = 1,
    .pSignalSemaphores = &*semaphore
};

graphicsQueue.submit(graphicsSubmitInfo, nullptr);

在呈现之前,我们等待图形工作完成:

vk::SemaphoreWaitInfo waitInfo{
    .semaphoreCount = 1,
    .pSemaphores = &*semaphore,
    .pValues = &graphicsSignalValue
};

// 在呈现之前等待图形完成
auto result = device.waitSemaphores(waitInfo, UINT64_MAX);
if (result != vk::Result::eSuccess)
{
    throw std::runtime_error("failed to wait for semaphore!");
}

vk::PresentInfoKHR presentInfo{
    .waitSemaphoreCount = 0, // 不需要二值信号量
    .pWaitSemaphores = nullptr,
    .swapchainCount = 1,
    .pSwapchains = &*swapChain,
    .pImageIndices = &imageIndex
};

这种时间线信号量方法比二值信号量实现提供了几个好处:

  1. 简化资源管理:我们只需要一个信号量,而不是每飞行帧多个信号量。

  2. 更明确的同步:时间线值清楚地表明哪些操作相互依赖。

  3. 减少开销:更少的同步对象意味着管理它们的开销更少。

  4. 更灵活的同步模式:时间线信号量支持更复杂的同步场景,而这些场景在使用二值信号量时很困难。

时间线信号量在具有多个依赖操作的场景中特别有用,例如我们的先计算后图形工作流程,或者当你需要在主机和设备之间同步时。它们提供了一种更强大、更灵活的同步机制,可以简化代码,同时支持更复杂的同步模式。

绘制粒子系统

之前,我们了解到 Vulkan 中的缓冲可以有多种用例,因此我们创建了包含粒子的着色器存储缓冲,同时具有着色器存储缓冲位和顶点缓冲位。 这意味着我们可以像在之前的章节中使用“纯”顶点缓冲一样,使用着色器存储缓冲进行绘制。

我们首先设置顶点输入状态以匹配我们的粒子结构体:

struct Particle {
    ...

    static std::array<vk::VertexInputAttributeDescription, 2> getAttributeDescriptions() {
        return {
            vk::VertexInputAttributeDescription( 0, 0, vk::Format::eR32G32Sfloat, offsetof(Particle, position) ),
            vk::VertexInputAttributeDescription( 1, 0, vk::Format::eR32G32B32A32Sfloat, offsetof(Particle, color) ),
        };
    }
};

请注意,我们没有将 velocity 添加到顶点输入属性中,因为它仅由计算着色器使用。

然后我们像绑定任何顶点缓冲一样绑定并绘制它:

commandBuffers[frameIndex]->bindVertexBuffers(0, { *shaderStorageBuffers[frameIndex] }, {0});

commandBuffers[frameIndex]->draw( PARTICLE_COUNT, 1, 0, 0 );

结论

在本章中,我们学习了如何使用计算着色器将工作从 CPU 卸载到 GPU。 如果没有计算着色器,现代游戏和应用程序中的许多效果要么不可能实现,要么运行速度慢得多。 但计算甚至比图形有更多的用例,本章只是让你了解了可能的冰山一角。 既然你已经知道如何使用计算着色器,你可能想了解一些高级计算主题,例如:

  • 共享内存

  • 异步计算

  • 原子操作

  • 子组

你可以在官方 Khronos Vulkan 示例仓库中找到一些高级计算示例。

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。