简介

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

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

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

索引缓冲创建

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

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 才能访问它们。定义两个新的类成员来保存索引缓冲区的资源

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

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

void initVulkan() {
    ...
    createVertexBuffer();
    createIndexBuffer();
    ...
}

void createIndexBuffer() {
    VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, indices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, indexBuffer, indexBufferMemory);

    copyBuffer(stagingBuffer, indexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

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

索引缓冲区应该在程序结束时清理,就像顶点缓冲区一样

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, indexBuffer, nullptr);
    vkFreeMemory(device, indexBufferMemory, nullptr);

    vkDestroyBuffer(device, vertexBuffer, nullptr);
    vkFreeMemory(device, vertexBufferMemory, nullptr);

    ...
}

使用索引缓冲

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

vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);

vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT16);

索引缓冲区使用 vkCmdBindIndexBuffer 绑定,它具有索引缓冲区、其中的字节偏移量和索引数据类型作为参数。如前所述,可能的类型是 VK_INDEX_TYPE_UINT16VK_INDEX_TYPE_UINT32

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

vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

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

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

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

前一章已经提到你应该从单个内存分配中分配多个资源,如缓冲区,但实际上你应该更进一步。驱动程序开发者建议 你也应该将多个缓冲区(如顶点缓冲区和索引缓冲区)存储到单个 VkBuffer 中,并在诸如 vkCmdBindVertexBuffers 之类的命令中使用偏移量。这样做的好处是你的数据在那种情况下更具有缓存友好性,因为它更靠近在一起。如果多个资源在同一渲染操作期间未使用,则甚至可以重用相同的内存块,当然前提是它们的数据已刷新。这被称为别名,一些 Vulkan 函数具有显式标志来指定你要这样做。

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