介绍

前一章中的描述符布局描述了可以绑定的描述符类型。在本章中,我们将为每个 VkBuffer 资源创建一个描述符集合,以将其绑定到 uniform 缓冲描述符。

描述符池

描述符集合不能直接创建,它们必须像命令缓冲一样从池中分配。描述符集合的等效物不出所料地称为描述符池。我们将编写一个新的函数 createDescriptorPool 来进行设置。

void initVulkan() {
    ...
    createUniformBuffers();
    createDescriptorPool();
    ...
}

...

void createDescriptorPool() {

}

我们首先需要使用 VkDescriptorPoolSize 结构描述我们的描述符集合将包含哪些描述符类型以及每种类型的数量。

VkDescriptorPoolSize poolSize{};
poolSize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
poolSize.descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);

我们将为每一帧分配这些描述符之一。此池大小结构由主要的 VkDescriptorPoolCreateInfo 引用

VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.poolSizeCount = 1;
poolInfo.pPoolSizes = &poolSize;

除了可用的单个描述符的最大数量外,我们还需要指定可能分配的描述符集合的最大数量

poolInfo.maxSets = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);

该结构具有一个可选标志,类似于命令池,用于确定是否可以释放单个描述符集合:VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT。我们创建描述符集合后不会再对其进行操作,因此我们不需要此标志。您可以将 flags 保留为其默认值 0

VkDescriptorPool descriptorPool;

...

if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool) != VK_SUCCESS) {
    throw std::runtime_error("failed to create descriptor pool!");
}

添加一个新的类成员来存储描述符池的句柄,并调用 vkCreateDescriptorPool 来创建它。

描述符集合

我们现在可以分配描述符集合本身了。为此目的添加一个 createDescriptorSets 函数

void initVulkan() {
    ...
    createDescriptorPool();
    createDescriptorSets();
    ...
}

...

void createDescriptorSets() {

}

描述符集合分配通过 VkDescriptorSetAllocateInfo 结构描述。您需要指定要从中分配的描述符池、要分配的描述符集合的数量以及作为其基础的描述符布局

std::vector<VkDescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT, descriptorSetLayout);
VkDescriptorSetAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
allocInfo.descriptorPool = descriptorPool;
allocInfo.descriptorSetCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);
allocInfo.pSetLayouts = layouts.data();

在我们的例子中,我们将为飞行中的每一帧创建一个描述符集合,所有集合都具有相同的布局。不幸的是,我们确实需要布局的所有副本,因为下一个函数期望一个与集合数量匹配的数组。

添加一个类成员来保存描述符集合句柄,并使用 vkAllocateDescriptorSets 分配它们

VkDescriptorPool descriptorPool;
std::vector<VkDescriptorSet> descriptorSets;

...

descriptorSets.resize(MAX_FRAMES_IN_FLIGHT);
if (vkAllocateDescriptorSets(device, &allocInfo, descriptorSets.data()) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate descriptor sets!");
}

您不需要显式清理描述符集合,因为它们会在描述符池被销毁时自动释放。调用 vkAllocateDescriptorSets 将分配描述符集合,每个集合都有一个 uniform 缓冲描述符。

void cleanup() {
    ...
    vkDestroyDescriptorPool(device, descriptorPool, nullptr);

    vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
    ...
}

描述符集合现在已经分配,但其中的描述符仍然需要配置。我们现在添加一个循环来填充每个描述符

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {

}

引用缓冲区的描述符(例如我们的 uniform 缓冲描述符)使用 VkDescriptorBufferInfo 结构配置。此结构指定缓冲区以及其中包含描述符数据的区域。

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
    VkDescriptorBufferInfo bufferInfo{};
    bufferInfo.buffer = uniformBuffers[i];
    bufferInfo.offset = 0;
    bufferInfo.range = sizeof(UniformBufferObject);
}

如果您要像我们在此示例中一样覆盖整个缓冲区,那么也可以对范围使用 VK_WHOLE_SIZE 值。描述符的配置使用 vkUpdateDescriptorSets 函数更新,该函数将 VkWriteDescriptorSet 结构的数组作为参数。

VkWriteDescriptorSet descriptorWrite{};
descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrite.dstSet = descriptorSets[i];
descriptorWrite.dstBinding = 0;
descriptorWrite.dstArrayElement = 0;

前两个字段指定要更新的描述符集合和绑定。我们给我们的 uniform 缓冲绑定索引 0。请记住,描述符可以是数组,因此我们还需要指定要更新的数组中的第一个索引。我们没有使用数组,因此索引只是 0

descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
descriptorWrite.descriptorCount = 1;

我们需要再次指定描述符的类型。可以一次更新数组中的多个描述符,从索引 dstArrayElement 开始。descriptorCount 字段指定要更新的数组元素的数量。

descriptorWrite.pBufferInfo = &bufferInfo;
descriptorWrite.pImageInfo = nullptr; // Optional
descriptorWrite.pTexelBufferView = nullptr; // Optional

最后一个字段引用一个包含 descriptorCount 结构的数组,这些结构实际配置描述符。实际需要使用三个中的哪一个取决于描述符的类型。pBufferInfo 字段用于引用缓冲区数据的描述符,pImageInfo 用于引用图像数据的描述符,pTexelBufferView 用于引用缓冲区视图的描述符。我们的描述符基于缓冲区,因此我们使用 pBufferInfo

vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0, nullptr);

更新使用 vkUpdateDescriptorSets 应用。它接受两种类型的数组作为参数:VkWriteDescriptorSet 数组和 VkCopyDescriptorSet 数组。后者可用于将描述符相互复制,顾名思义。

使用描述符集合

我们现在需要更新 recordCommandBuffer 函数,以实际将每帧的正确描述符集合绑定到着色器中的描述符,使用 vkCmdBindDescriptorSets。这需要在 vkCmdDrawIndexed 调用之前完成

vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &descriptorSets[currentFrame], 0, nullptr);
vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

与顶点和索引缓冲区不同,描述符集合不是图形管线独有的。因此,我们需要指定是要将描述符集合绑定到图形管线还是计算管线。下一个参数是描述符所基于的布局。接下来的三个参数指定第一个描述符集合的索引、要绑定的集合的数量以及要绑定的集合数组。我们稍后会回到这一点。最后两个参数指定用于动态描述符的偏移量数组。我们将在以后的章节中介绍这些。

如果您现在运行程序,您会注意到不幸的是什么都不可见。问题是由于我们在投影矩阵中所做的 Y 翻转,顶点现在以逆时针顺序而不是顺时针顺序绘制。这会导致背面剔除生效,并阻止绘制任何几何图形。转到 createGraphicsPipeline 函数并修改 VkPipelineRasterizationStateCreateInfo 中的 frontFace 以纠正此问题

rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;

再次运行程序,您现在应该看到以下内容

矩形已变为正方形,因为投影矩阵现在校正了宽高比。updateUniformBuffer 负责屏幕大小调整,因此我们不需要在 recreateSwapChain 中重新创建描述符集合。

对齐要求

到目前为止,我们忽略了一件事,即 C++ 结构中的数据应如何与着色器中的 uniform 定义匹配。简单地在两者中使用相同的类型似乎很明显

struct UniformBufferObject {
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

layout(binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

但是,这并不是全部。例如,尝试修改结构和着色器,使其看起来像这样

struct UniformBufferObject {
    glm::vec2 foo;
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

layout(binding = 0) uniform UniformBufferObject {
    vec2 foo;
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

重新编译您的着色器和程序并运行它,您会发现您迄今为止使用的彩色正方形消失了!那是因为我们没有考虑到对齐要求

Vulkan 希望您结构中的数据以特定方式在内存中对齐,例如

  • 标量必须按 N 对齐(= 4 字节,给定 32 位浮点数)。
  • vec2 必须按 2N 对齐(= 8 字节)
  • vec3vec4 必须按 4N 对齐(= 16 字节)
  • 嵌套结构必须按其成员的基本对齐方式对齐,向上舍入到 16 的倍数。
  • mat4 矩阵必须与 vec4 具有相同的对齐方式。

您可以在 规范中找到完整的对齐要求列表。

我们原始的着色器只有三个 mat4 字段,已经满足了对齐要求。由于每个 mat4 的大小为 4 x 4 x 4 = 64 字节,因此 model 的偏移量为 0view 的偏移量为 64,proj 的偏移量为 128。所有这些都是 16 的倍数,这就是它工作正常的原因。

新结构以 vec2 开头,它只有 8 字节大小,因此会抵消所有偏移量。现在 model 的偏移量为 8view 的偏移量为 72proj 的偏移量为 136,它们都不是 16 的倍数。为了解决这个问题,我们可以使用 C++11 中引入的 alignas 说明符

struct UniformBufferObject {
    glm::vec2 foo;
    alignas(16) glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

如果您现在编译并再次运行程序,您应该会看到着色器再次正确接收其矩阵值。

幸运的是,有一种方法可以在大多数情况下不必考虑这些对齐要求。我们可以在包含 GLM 之前定义 GLM_FORCE_DEFAULT_ALIGNED_GENTYPES

#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES
#include <glm/glm.hpp>

这将强制 GLM 使用已经为我们指定了对齐要求的 vec2mat4 版本。如果您添加此定义,则可以删除 alignas 说明符,并且您的程序仍然可以正常工作。

不幸的是,如果您开始使用嵌套结构,此方法可能会崩溃。考虑 C++ 代码中的以下定义

struct Foo {
    glm::vec2 v;
};

struct UniformBufferObject {
    Foo f1;
    Foo f2;
};

以及以下着色器定义

struct Foo {
    vec2 v;
};

layout(binding = 0) uniform UniformBufferObject {
    Foo f1;
    Foo f2;
} ubo;

在这种情况下,f2 的偏移量将为 8,而它应该是 16 的偏移量,因为它是一个嵌套结构。在这种情况下,您必须自己指定对齐方式

struct UniformBufferObject {
    Foo f1;
    alignas(16) Foo f2;
};

这些陷阱是始终明确对齐方式的一个很好的理由。这样,您就不会被对齐错误造成的奇怪症状所迷惑。

struct UniformBufferObject {
    alignas(16) glm::mat4 model;
    alignas(16) glm::mat4 view;
    alignas(16) glm::mat4 proj;
};

不要忘记在删除 foo 字段后重新编译着色器。

多个描述符集合

正如一些结构和函数调用所暗示的那样,实际上可以同时绑定多个描述符集合。创建管线布局时,您需要为每个描述符集合指定一个描述符布局。然后,着色器可以像这样引用特定的描述符集合

layout(set = 0, binding = 0) uniform UniformBufferObject { ... }

您可以使用此功能将每个对象变化的描述符和共享的描述符放入单独的描述符集合中。在这种情况下,您可以避免在绘制调用中重新绑定大多数描述符,这可能更有效。

C++ 代码 / 顶点着色器 / 片段着色器