描述符池和描述符集

      +

      介绍

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

      描述符池

      描述符集不能直接创建,必须像命令缓冲区一样从池中分配。描述符集的等价物毫不意外地称为 描述符池。我们将编写一个新函数 createDescriptorPool 来设置它。

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

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

      vk::DescriptorPoolSize poolSize(vk::DescriptorType::eUniformBuffer, MAX_FRAMES_IN_FLIGHT);

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

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

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

      该结构有一个类似于命令池的可选标志,用于确定是否可以释放单个描述符集:VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT。我们不会在创建描述符集后触碰它,因此不需要这个标志。你可以将 flags 保留为默认值 0

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

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

      描述符集

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

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

      描述符集分配由 VkDescriptorSetAllocateInfo 结构描述。你需要指定从中分配的描述符池、要分配的描述符集数量以及基于它们的描述符集布局:

      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() };

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

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

      vk::raii::DescriptorPool descriptorPool = nullptr;
      std::vector<vk::raii::DescriptorSet> descriptorSets;
      
      ...
      
      descriptorSets.clear();
      descriptorSets = device.allocateDescriptorSets(allocInfo);

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

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

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

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

      如果你要覆盖整个缓冲区(就像我们在这个例子中做的那样),也可以使用 VK_WHOLE_SIZE 值作为范围。描述符的配置使用 vkUpdateDescriptorSets 函数更新,该函数接受一个 VkWriteDescriptorSet 结构数组作为参数。

      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, {});

      更新使用 vkUpdateDescriptorSets 应用。它接受两种数组作为参数:VkWriteDescriptorSet 数组和 VkCopyDescriptorSet 数组。后者如其名称所示,可用于将描述符相互复制。

      使用描述符集

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

      commandBuffers[currentFrame].bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, 0, *descriptorSets[currentFrame], nullptr);
      commandBuffers[currentFrame].drawIndexed(indices.size(), 1, 0, 0, 0);

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

      如果你现在运行程序,会发现不幸的是没有任何东西可见。问题在于,由于我们在投影矩阵中进行了 Y 翻转,顶点现在以逆时针顺序而不是顺时针顺序绘制。这导致背面剔除生效并阻止任何几何图形被绘制。转到 createGraphicsPipeline 函数并修改 VkPipelineRasterizationStateCreateInfo 中的 frontFace 以纠正这一点:

       vk::PipelineRasterizationStateCreateInfo rasterizer({}, vk::False, vk::False, vk::PolygonMode::eFill,
              vk::CullModeFlagBits::eBack, vk::FrontFace::eCounterClockwise, vk::False, 0.0f, 0.0f, 1.0f, 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;

      重新编译着色器和程序并运行它,你会发现你辛苦工作的彩色方块消失了!这是因为我们没有考虑 对齐要求

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

      • 标量必须按 N(给定 32 位浮点数为 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;

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

      下一章 中,我们将基于刚刚学到的内容,向场景中添加纹理。