索引缓冲区

      +

      介绍

      在现实世界的应用程序中,你将渲染的 3D 网格通常会在多个三角形之间共享顶点。即使是像绘制矩形这样简单的东西,这种情况也会发生:

      vertex vs index

      绘制一个矩形需要两个三角形,这意味着我们需要一个包含六个顶点的顶点缓冲区。问题是两个顶点的数据需要重复,导致 50% 的冗余。对于更复杂的网格来说,情况会更糟,其中顶点平均在三个三角形中重复使用。解决这个问题的方法是使用 索引缓冲区

      索引缓冲区本质上是一个指向顶点缓冲区的指针数组。它允许你重新排序顶点数据,并为多个顶点重用现有数据。上面的图示展示了如果我们有一个包含四个唯一顶点的顶点缓冲区,索引缓冲区对于矩形会是什么样子。前三个索引定义了右上角的三角形,最后三个索引定义了左下角三角形的顶点。

      索引缓冲区的创建

      在本章中,我们将修改顶点数据并添加索引数据,以绘制图示中的矩形。修改顶点数据以表示四个角:

      const std::vector<Vertex> vertices = {
          {{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}},
          {{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}},
          {{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}},
          {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}
      };

      左上角为红色,右上角为绿色,右下角为蓝色,左下角为白色。我们将添加一个新数组 indices 来表示索引缓冲区的内容。它应该与图示中的索引匹配,以绘制右上角的三角形和左下角的三角形。

      const std::vector<uint16_t> indices = {
          0, 1, 2, 2, 3, 0
      };

      根据 vertices 中的条目数量,你可以使用 uint16_tuint32_t 作为索引缓冲区。由于我们现在使用的唯一顶点少于 65535 个,可以继续使用 uint16_t

      与顶点数据一样,索引需要上传到 VkBuffer 中,以便 GPU 能够访问它们。定义两个新的类成员来保存索引缓冲区的资源:

      vk::raii::Buffer vertexBuffer = nullptr;
      vk::raii::DeviceMemory vertexBufferMemory = nullptr;
      vk::raii::Buffer indexBuffer = nullptr;
      vk::raii::DeviceMemory indexBufferMemory = nullptr;

      我们现在要添加的 createIndexBuffer 函数与 createVertexBuffer 几乎相同:

      void initVulkan() {
          ...
          createVertexBuffer();
          createIndexBuffer();
          ...
      }
      
      void createIndexBuffer() {
          vk::DeviceSize bufferSize = sizeof(indices[0]) * indices.size();
      
          vk::raii::Buffer stagingBuffer({});
          vk::raii::DeviceMemory stagingBufferMemory({});
          createBuffer(bufferSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, stagingBuffer, stagingBufferMemory);
      
          void* data = stagingBufferMemory.mapMemory(0, bufferSize);
          memcpy(data, indices.data(), (size_t) bufferSize);
          stagingBufferMemory.unmapMemory();
      
          createBuffer(bufferSize, vk::BufferUsageFlagBits::eTransferDst | vk::BufferUsageFlagBits::eIndexBuffer, vk::MemoryPropertyFlagBits::eDeviceLocal, indexBuffer, indexBufferMemory);
      
          copyBuffer(stagingBuffer, indexBuffer, bufferSize);
      }

      只有两个显著的区别。bufferSize 现在等于索引数量乘以索引类型的大小(uint16_tuint32_t)。indexBuffer 的用途应该是 VK_BUFFER_USAGE_INDEX_BUFFER_BIT 而不是 VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,这是合理的。除此之外,过程完全相同。我们创建一个暂存缓冲区来复制 indices 的内容,然后将其复制到最终的设备本地索引缓冲区。

      使用索引缓冲区

      使用索引缓冲区进行绘制需要对 recordCommandBuffer 进行两处更改。首先,我们需要绑定索引缓冲区,就像对顶点缓冲区所做的那样。区别在于你只能有一个索引缓冲区。不幸的是,不能为每个顶点属性使用不同的索引,因此即使只有一个属性不同,我们仍然必须完全复制顶点数据。

      commandBuffers[currentFrame].bindVertexBuffers(0, *vertexBuffer, {0});
      commandBuffers[currentFrame].bindIndexBuffer( *indexBuffer, 0, vk::IndexType::eUint16 );

      索引缓冲区通过 vkCmdBindIndexBuffer 绑定,该函数接受索引缓冲区、字节偏移量和索引数据类型作为参数。如前所述,可能的类型是 VK_INDEX_TYPE_UINT16VK_INDEX_TYPE_UINT32

      仅仅绑定索引缓冲区还不会改变任何东西,我们还需要更改绘制命令以告诉 Vulkan 使用索引缓冲区。删除 vkCmdDraw 行并将其替换为 vkCmdDrawIndexed

      commandBuffers[currentFrame].drawIndexed(indices.size(), 1, 0, 0, 0);

      对此函数的调用与 vkCmdDraw 非常相似。前两个参数指定索引数量和实例数量。我们没有使用实例化,因此只需指定 1 个实例。索引数量表示将传递给顶点着色器的顶点数量。下一个参数指定索引缓冲区中的偏移量,使用值 1 将使显卡从第二个索引开始读取。倒数第二个参数指定在索引到顶点缓冲区之前添加到顶点索引的偏移量。最后一个参数指定实例化的偏移量,我们没有使用它。

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

      indexed rectangle

      你现在知道如何通过使用索引缓冲区重用顶点来节省内存。这在未来的章节中加载复杂的 3D 模型时将变得尤为重要。

      前一章已经提到,你应该从单个内存分配中分配多个资源(如缓冲区),但实际上你应该更进一步。https://developer.nvidia.com/vulkan-memory-management[驱动程序开发者建议] 你还应该将多个缓冲区(如顶点和索引缓冲区)存储到单个 VkBuffer 中,并在 vkCmdBindVertexBuffers 等命令中使用偏移量。这样做的优点是数据更接近缓存,因此更友好。如果多个资源在同一渲染操作期间不使用,甚至可以重用同一块内存,前提是它们的数据当然会被刷新。这被称为 别名,一些 Vulkan 函数有明确的标志来指定你想要这样做。

      下一章 中,我们将学习如何将频繁更改的参数传递给 GPU。