顶点缓冲区创建

简介

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

缓冲区创建

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

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

...

void createVertexBuffer() {

}
c++

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

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

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

bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
c++

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

bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
c++

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

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!");
    }
}
c++

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

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);

    ...
}
c++

内存需求

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

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

VkMemoryRequirements 结构具有三个字段

  • size:所需内存量的字节大小,可能与 bufferInfo.size 不同。

  • alignment:缓冲区在内存分配区域中开始的字节偏移量,取决于 bufferInfo.usagebufferInfo.flags

  • memoryTypeBits:适用于该缓冲区的内存类型的位域。

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

uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) {

}
c++

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

VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
c++

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!");
c++

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;
    }
}
c++

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

内存分配

现在我们有了一种确定正确内存类型的方法,因此我们可以通过填充 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);
c++

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

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;

...

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

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

vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0);
c++

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

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

void cleanup() {
    cleanupSwapChain();

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

填充顶点缓冲区

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

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

此函数允许我们访问由偏移量和大小定义的指定内存资源的区域。这里的偏移量和大小分别为 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);
c++

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

  • 使用主机一致的内存堆,用 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 指示。

  • 在写入映射内存后调用 vkFlushMappedMemoryRanges,在从映射内存读取之前调用 vkInvalidateMappedMemoryRanges

我们选择了第一种方法,它确保映射的内存始终与分配的内存的内容匹配。请记住,这可能会导致比显式刷新略差的性能,但我们将在下一章中了解为什么这并不重要。

刷新内存范围或使用一致的内存堆意味着驱动程序将知道我们对缓冲区的写入,但这并不意味着它们实际上在 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);
c++

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

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

triangle

尝试通过修改 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++

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

triangle white

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