Vulkan 教程 Khronos Vulkan® Tutorial · 中文翻译 GitHub

引言

在真实应用中渲染的 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_t 或 uint32_t。 目前不重复的顶点不到 65535 个,所以先用 uint16_t 就好。

和顶点数据一样,索引也需要上传到 vk::raii::Buffer 中,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();

		auto [stagingBuffer, stagingBufferMemory] =
		    createBuffer(bufferSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent);

		void *data = stagingBufferMemory.mapMemory(0, bufferSize);
		memcpy(data, indices.data(), (size_t) bufferSize);
		stagingBufferMemory.unmapMemory();

		std::tie(indexBuffer, indexBufferMemory) =
		    createBuffer(bufferSize, vk::BufferUsageFlagBits::eIndexBuffer | vk::BufferUsageFlagBits::eTransferDst, vk::MemoryPropertyFlagBits::eDeviceLocal);

		copyBuffer(stagingBuffer, indexBuffer, bufferSize);
}

只有两处明显不同。bufferSize 现在等于索引数量乘以索引类型(uint16_t 或 uint32_t)的大小。indexBuffer 的用途应改为 vk::BufferUsageFlagBits::eIndexBuffer,而不是 vk::BufferUsageFlagBits::eVertexBuffer,这很合理。除此之外,流程完全 一样。我们创建一个暂存缓冲区,把 indices 的内容复制进去,再把它复制到 最终的设备本地索引缓冲区。

使用索引缓冲区

用索引缓冲区绘制需要改动 recordCommandBuffer 的两处。首先像顶点缓冲区 那样绑定索引缓冲区。区别在于你只能有一个索引缓冲区。很遗憾,无法为每个 顶点属性分别使用不同的索引,所以即使只有一个属性不同,也仍然必须完整 复制顶点数据。

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

索引缓冲区用 vk::raii::CommandBuffer::bindIndexBuffer 绑定,参数包括 索引缓冲区、它内部的字节偏移,以及索引数据的类型。前面提过,可用的类型有 vk::IndexType::eUint16 和 vk::IndexType::eUint32。

只绑定索引缓冲区还不会改变什么,还需要修改绘制命令,告诉 Vulkan 使用 索引缓冲区。删掉 vk::raii::CommandBuffer::draw 那一行,换成 vk::raii::CommandBuffer::drawIndexed:

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

调用这个函数与 vk::raii::CommandBuffer::draw 非常相似。前两个参数指定 索引数量和实例数量。我们没有使用实例化,所以实例数写 1。索引数量表示 将传给顶点着色器的顶点数。下一个参数指定索引缓冲区内的偏移,值为 1 会让显卡从第二个索引开始读取。倒数第二个参数指定在按索引访问顶点缓冲区 之前加到顶点索引上的偏移。最后一个参数指定实例化用的偏移,我们没有用到。

现在运行程序,应该会看到:

indexed rectangle

现在你已经知道如何用索引缓冲区复用顶点来节省内存。在以后要加载复杂 3D 模型的章节里,这一点会格外重要。

上一章已经提到,像缓冲区这样的多个资源应当从单次内存分配中划出,但事实上 还可以更进一步。 驱动开发者建议, 把顶点缓冲区和索引缓冲区等多个缓冲区也存进同一个 vk::raii::Buffer,并在 vk::raii::CommandBuffer::bindVertexBuffers 之类的命令中使用偏移。这样做 的好处是数据排布更紧凑,对缓存更友好。如果多个资源不会在同一次渲染操作 中使用,甚至可以复用同一块内存,当然前提是刷新它们的数据。这种做法称为 别名(aliasing),有些 Vulkan 函数提供了显式标志来表明你要这样做。

在 下一章里, 我们会学习如何把频繁变化的参数传给 GPU。