简介

Vulkan 中的缓冲是用于存储任意数据的内存区域,这些数据可以被显卡读取。它们可以用于存储顶点数据,我们将在本章中进行此操作,但它们也可以用于我们将在未来章节中探索的许多其他目的。与我们到目前为止处理的 Vulkan 对象不同,缓冲不会自动为自己分配内存。前几章的工作表明,Vulkan API 让程序员几乎可以控制一切,而内存管理就是其中之一。

缓冲创建

创建一个新的函数 createVertexBuffer 并在 initVulkan 中在 createCommandBuffers 之前调用它。

void initVulkan() {
    createInstance();
    setupDebugMessenger();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapChain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createFramebuffers();
    createCommandPool();
    createVertexBuffer();
    createCommandBuffers();
    createSyncObjects();
}

...

void createVertexBuffer() {

}

创建缓冲需要我们填写一个 VkBufferCreateInfo 结构体。

VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(vertices[0]) * vertices.size();

结构体的第一个字段是 size,它指定缓冲的大小,以字节为单位。使用 sizeof 计算顶点数据的字节大小非常简单。

bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;

第二个字段是 usage,它指示缓冲中的数据将用于哪些目的。可以使用按位或来指定多个目的。我们的用例将是顶点缓冲,我们将在以后的章节中查看其他类型的用法。

bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

就像交换链中的图像一样,缓冲也可以由特定的队列族拥有,或者在多个队列族之间同时共享。缓冲将仅从图形队列中使用,因此我们可以坚持独占访问。

flags 参数用于配置稀疏缓冲内存,这与现在无关。我们将把它保留为默认值 0

我们现在可以使用 vkCreateBuffer 创建缓冲。定义一个类成员来保存缓冲句柄并将其命名为 vertexBuffer

VkBuffer vertexBuffer;

...

void createVertexBuffer() {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = sizeof(vertices[0]) * vertices.size();
    bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer) != VK_SUCCESS) {
        throw std::runtime_error("failed to create vertex buffer!");
    }
}

缓冲应该在程序结束前可用于渲染命令,并且它不依赖于交换链,因此我们将在原始的 cleanup 函数中清理它。

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);

    ...
}

内存需求

缓冲已创建,但实际上还没有分配任何内存给它。为缓冲分配内存的第一步是使用恰如其名的 vkGetBufferMemoryRequirements 函数查询其内存需求。

VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memRequirements);

VkMemoryRequirements 结构体有三个字段:

  • size:所需内存量的大小,以字节为单位,可能与 bufferInfo.size 不同。
  • alignment:缓冲在已分配的内存区域中开始的字节偏移量,取决于 bufferInfo.usagebufferInfo.flags
  • memoryTypeBits:适用于缓冲的内存类型的位字段。

显卡可以提供不同类型的内存进行分配。每种类型的内存在允许的操作和性能特征方面有所不同。我们需要结合缓冲的需求和我们自己的应用程序需求,以找到要使用的正确内存类型。让我们为此目的创建一个新的函数 findMemoryType

uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) {

}

首先,我们需要使用 vkGetPhysicalDeviceMemoryProperties 查询有关可用内存类型的信息。

VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);

VkPhysicalDeviceMemoryProperties 结构体有两个数组 memoryTypesmemoryHeaps。内存堆是不同的内存资源,例如专用 VRAM 和 RAM 中的交换空间,以在 VRAM 耗尽时使用。不同的内存类型存在于这些堆中。现在我们只关心内存的类型,而不是它来自哪个堆,但你可以想象这会影响性能。

让我们首先找到一种适合缓冲本身的内存类型。

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    if (typeFilter & (1 << i)) {
        return i;
    }
}

throw std::runtime_error("failed to find suitable memory type!");

typeFilter 参数将用于指定适合的内存类型的位字段。这意味着我们可以通过简单地迭代它们并检查相应的位是否设置为 1 来找到合适的内存类型的索引。

但是,我们不仅仅对适合顶点缓冲的内存类型感兴趣。我们还需要能够将我们的顶点数据写入该内存。memoryTypes 数组由 VkMemoryType 结构体组成,这些结构体指定每种内存类型的堆和属性。这些属性定义了内存的特殊功能,例如能够映射它,以便我们可以从 CPU 写入它。此属性由 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 指示,但我们还需要使用 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 属性。当我们映射内存时,我们将看到原因。

我们现在可以修改循环以检查对此属性的支持。

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
        return i;
    }
}

我们可能有多个期望的属性,因此我们应该检查按位与的结果不仅是非零值,而且等于期望的属性位字段。如果存在适合缓冲并且还具有我们需要的所有属性的内存类型,那么我们返回其索引,否则我们抛出一个异常。

内存分配

我们现在有一种确定正确内存类型的方法,因此我们可以通过填写 VkMemoryAllocateInfo 结构体来实际分配内存。

VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);

内存分配现在就像指定大小和类型一样简单,这两者都来自顶点缓冲的内存需求和期望的属性。创建一个类成员来存储内存的句柄,并使用 vkAllocateMemory 分配它。

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;

...

if (vkAllocateMemory(device, &allocInfo, nullptr, &vertexBufferMemory) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate vertex buffer memory!");
}

如果内存分配成功,那么我们现在可以使用 vkBindBufferMemory 将此内存与缓冲关联起来。

vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0);

前三个参数是不言自明的,第四个参数是内存区域内的偏移量。由于此内存是专门为此顶点缓冲分配的,因此偏移量仅为 0。如果偏移量为非零值,则它必须可被 memRequirements.alignment 整除。

当然,就像 C++ 中的动态内存分配一样,内存应该在某个时候被释放。绑定到缓冲对象的内存可以在缓冲不再使用后释放,因此让我们在缓冲被销毁后释放它。

void cleanup() {
    cleanupSwapChain();

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

填充顶点缓冲

现在是时候将顶点数据复制到缓冲了。这是通过使用 vkMapMemory缓冲内存映射 到 CPU 可访问的内存中来完成的。

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);

此函数允许我们访问由偏移量和大小定义的指定内存资源的区域。这里的偏移量和大小分别为 0bufferInfo.size。也可以指定特殊值 VK_WHOLE_SIZE 来映射所有内存。倒数第二个参数可用于指定标志,但当前 API 中尚无任何可用标志。它必须设置为值 0。最后一个参数指定映射内存的指针的输出。

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
    memcpy(data, vertices.data(), (size_t) bufferInfo.size);
vkUnmapMemory(device, vertexBufferMemory);

你现在可以简单地 memcpy 将顶点数据复制到映射的内存,并使用 vkUnmapMemory 再次取消映射它。不幸的是,驱动程序可能不会立即将数据复制到缓冲内存中,例如由于缓存。也可能对缓冲区的写入在映射的内存中尚不可见。有两种方法可以解决该问题:

我们选择了第一种方法,该方法确保映射的内存始终与已分配内存的内容匹配。请记住,这可能导致比显式刷新稍差的性能,但我们将在下一章中看到为什么这无关紧要。

刷新内存范围或使用一致的内存堆意味着驱动程序将意识到我们对缓冲区的写入,但这并不意味着它们实际上在 GPU 上可见。数据传输到 GPU 是在后台发生的操作,规范只是 告诉我们,它保证在下次调用 vkQueueSubmit 时完成。

绑定顶点缓冲

现在剩下的就是在渲染操作期间绑定顶点缓冲。我们将扩展 recordCommandBuffer 函数来执行此操作。

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

VkBuffer vertexBuffers[] = {vertexBuffer};
VkDeviceSize offsets[] = {0};
vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);

vkCmdDraw(commandBuffer, static_cast<uint32_t>(vertices.size()), 1, 0, 0);

vkCmdBindVertexBuffers 函数用于将顶点缓冲绑定到绑定点,就像我们在上一章中设置的那样。除了命令缓冲之外,前两个参数指定我们将要为其指定顶点缓冲的绑定点的偏移量和数量。最后两个参数指定要绑定的顶点缓冲数组以及开始从中读取顶点数据的字节偏移量。你还应该更改对 vkCmdDraw 的调用,以传递缓冲中的顶点数,而不是硬编码的数字 3

现在运行程序,你应该再次看到熟悉的三角形。

尝试通过修改 vertices 数组将顶部顶点的颜色更改为白色。

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

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

在下一章中,我们将研究另一种将顶点数据复制到顶点缓冲的方法,该方法可以带来更好的性能,但需要更多的工作。

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