狸村 Mystic Web · 狸村
目录

引言

上一章中的描述符集布局描述了可以绑定的描述符的类型。 在本章中,我们将为每个 vk::raii::Buffer 资源创建一个描述符集,以便将其绑定到统一缓冲描述符。

描述符池

描述符集不能直接创建,它们必须像命令缓冲一样从池中分配。 描述符集的对应物显然被称为 描述符池(descriptor pool)。 我们将编写一个新函数 createDescriptorPool 来设置它。

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

...

void createDescriptorPool()
{
}

我们首先需要使用 vk::DescriptorPoolSize 结构体来描述我们的描述符集将包含哪些描述符类型以及它们的数量。

vk::DescriptorPoolSize poolSize{ .type = vk::DescriptorType::eUniformBuffer, .descriptorCount = MAX_FRAMES_IN_FLIGHT };

我们将为每一帧分配其中一个描述符。 这个池大小结构体被主 vk::DescriptorPoolCreateInfo 引用:

vk::DescriptorPoolCreateInfo poolInfo{ .flags = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet, .maxSets = MAX_FRAMES_IN_FLIGHT, .poolSizeCount = 1, .pPoolSizes = &poolSize };

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

该结构体有一个可选的标志,类似于命令池,用于确定是否可以释放单个描述符集。 由于 vk::raii::DescriptorSets 在销毁时会销毁底层的 VkDescriptorSet,因此我们需要将其设置为 vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet 以允许这样做。

vk::raii::DescriptorPool descriptorPool = nullptr;

...

descriptorPool = vk::raii::DescriptorPool(device, poolInfo);

添加一个新的类成员来存储描述符池的句柄,并调用 vk::raii::DescriptorPool 构造函数来创建它。

描述符集

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

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

...

void createDescriptorSets()
{
}

描述符集分配使用 vk::DescriptorSetAllocateInfo 结构体来描述。 你需要指定要从中分配的描述符池、要分配的描述符集的数量,以及它们所基于的描述符集布局:

std::vector<vk::DescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT, *descriptorSetLayout);
vk::DescriptorSetAllocateInfo        allocInfo{.descriptorPool     = descriptorPool,
                                               .descriptorSetCount = static_cast<uint32_t>(layouts.size()),
                                               .pSetLayouts        = layouts.data()};

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

添加一个类成员来保存描述符集句柄,并使用 vk::raii::Device::allocateDescriptorSets 分配它们:

vk::raii::DescriptorPool             descriptorPool = nullptr;
std::vector<vk::raii::DescriptorSet> descriptorSets;

...

descriptorSets = device.allocateDescriptorSets(allocInfo);

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

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

引用缓冲的描述符(如我们的统一缓冲描述符)使用 vk::DescriptorBufferInfo 结构体进行配置。 此结构体指定缓冲及其包含描述符数据的区域。

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

如果你像我们这样覆盖整个缓冲,那么也可以使用 vk::WholeSize 值作为范围。 描述符的配置使用 vk::raii::Device::updateDescriptorSets 函数进行更新,该函数接受一个 vk::WriteDescriptorSet 结构体数组作为参数。

vk::DescriptorBufferInfo bufferInfo{.buffer = uniformBuffers[i], .offset = 0, .range = sizeof(UniformBufferObject)};
vk::WriteDescriptorSet   descriptorWrite{.dstSet          = descriptorSets[i],
                                         .dstBinding      = 0,
                                         .dstArrayElement = 0,
                                         .descriptorCount = 1,
                                         .descriptorType  = vk::DescriptorType::eUniformBuffer,
                                         .pBufferInfo     = &bufferInfo};

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

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

最后一个字段引用了一个包含 descriptorCount 个结构体的数组,这些结构体实际配置描述符。 具体需要用到三个字段中的哪一个,取决于描述符的类型。 pBufferInfo 字段用于引用缓冲数据的描述符。此结构体中还有其他元素,本示例未使用:pImageInfo 用于引用图像数据的描述符,pTexelBufferView 用于引用缓冲视图的描述符。 我们的描述符基于缓冲,因此我们只使用 pBufferInfo

device.updateDescriptorSets(descriptorWrite, {});

更新使用 vk::raii::Device::updateDescriptorSets 应用。 它接受两个数组作为参数:一个 vk::WriteDescriptorSet 数组和一个 vk::CopyDescriptorSet 数组。 后者可用于将描述符复制到另一个描述符,正如其名称所示。

使用描述符集

我们现在需要更新 recordCommandBuffer 函数,以便使用 vk::raii::CommandBuffer::bindDescriptorSets 将正确的描述符集绑定到着色器中的描述符。 这需要在 vk::raii::CommandBuffer::drawIndexed 调用之前完成:

commandBuffers[frameIndex].bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, 0, *descriptorSets[frameIndex], nullptr);
commandBuffer.drawIndexed(static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

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

如果你现在运行程序,你会注意到不幸的是什么都没有显示。 问题在于,由于我们在投影矩阵中做了 Y 轴翻转,顶点现在按逆时针顺序而不是顺时针顺序绘制。 这导致背面剔除生效,并阻止任何几何体被绘制。 转到 createGraphicsPipeline 函数,并修改 vk::PipelineRasterizationStateCreateInfo 中的 frontFace 来纠正这个问题:

vk::PipelineRasterizationStateCreateInfo rasterizer{.depthClampEnable        = vk::False,
                                                    .rasterizerDiscardEnable = vk::False,
                                                    .polygonMode             = vk::PolygonMode::eFill,
                                                    .cullMode                = vk::CullModeFlagBits::eBack,
                                                    .frontFace               = vk::FrontFace::eCounterClockwise,
                                                    .depthBiasEnable         = vk::False,
                                                    .lineWidth               = 1.0f};

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

spinning quad

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

对齐要求

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

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

struct UniformBuffer
{
    float4x4 model;
    float4x4 view;
    float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;

然而,事情并没有那么简单。 例如,尝试将结构体和着色器修改为如下所示:

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

struct UniformBuffer {
    float2 foo;
    float4x4 model;
    float4x4 view;
    float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;

重新编译你的着色器和程序并运行它,你会发现你费尽心思绘制的彩色方块消失了! 那是因为我们没有考虑 对齐要求(alignment requirements)

Vulkan 期望你结构体中的数据在内存中以特定方式对齐,例如:

  • 标量必须按 N 对齐(对于 32 位浮点数,N = 4 字节)。

  • float2 必须按 2N(= 8 字节)对齐。

  • float3float4 必须按 4N(= 16 字节)对齐。

  • 嵌套结构体必须按其成员的基础对齐方式对齐,并向上舍入到 16 的倍数。

  • float4x4 矩阵必须与 float4 具有相同的对齐方式。

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

我们原始的只有三个 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_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;
};

struct UniformBuffer
{
    Foo f1;
    Foo f2;
};
ConstantBuffer<UniformBuffer> 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 字段后记得重新编译着色器。

多个描述符集

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

struct UniformBuffer
{
};
ConstantBuffer<UniformBuffer> ubo;

你可以使用此功能将随对象变化的描述符和共享的描述符放入不同的描述符集中。 在这种情况下,你可以避免在绘制调用之间重新绑定大部分描述符,这可能更高效。

在接下来的章节中,我们将基于我们刚刚学到的知识,为场景添加纹理。

Vulkan 是 Khronos Group Inc. 的注册商标

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