顶点缓冲区创建
介绍
Vulkan 中的缓冲区是用于存储图形卡可以读取的任意数据的内存区域。它们可以用来存储顶点数据,我们将在本章中这样做,但它们也可以用于我们将在未来章节中探讨的许多其他用途。与我们迄今为止处理的 Vulkan 对象不同,缓冲区不会自动为自己分配内存。前几章的工作表明,Vulkan API 让程序员几乎控制一切,内存管理就是其中之一。
缓冲区创建
创建一个新函数 createVertexBuffer,并在 initVulkan 中的 createCommandBuffers 之前调用它。
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
createVertexBuffer();
createCommandBuffers();
createSyncObjects();
}
...
void createVertexBuffer() {
}
创建缓冲区需要填写一个 VkBufferCreateInfo 结构体。
vk::BufferCreateInfo bufferInfo({}, sizeof(vertices[0]) * vertices.size(), vk::BufferUsageFlagBits::eVertexBuffer, vk::SharingMode::eExclusive);
构造函数的第一个字段是 flags,用于配置稀疏缓冲区内存,目前不相关。我们将其保留为默认值 0。接下来是 size,它指定缓冲区的字节大小。使用 sizeof 计算顶点数据的字节大小非常简单。
第三个字段是 usage,它指示缓冲区中的数据将用于哪些目的。可以使用位或操作指定多个目的。我们的用例将是一个顶点缓冲区,我们将在未来的章节中探讨其他类型的用途。
就像交换链中的图像一样,缓冲区也可以由特定的队列族拥有,或者同时由多个队列族共享。缓冲区将仅用于图形队列,因此我们可以坚持独占访问。
flags 参数用于配置稀疏缓冲区内存,目前不相关。我们将其保留为默认值 0。
我们现在可以使用 vkCreateBuffer 创建缓冲区。定义一个类成员来保存缓冲区句柄,并将其称为 vertexBuffer。
vk::raii::Buffer vertexBuffer = nullptr;
...
void createVertexBuffer() {
vk::BufferCreateInfo bufferInfo{ .size = sizeof(vertices[0]) * vertices.size(), .usage = vk::BufferUsageFlagBits::eVertexBuffer, .sharingMode = vk::SharingMode::eExclusive };
vertexBuffer = vk::raii::Buffer(device, bufferInfo);
}
缓冲区应在渲染命令中可用,直到程序结束,并且它不依赖于交换链。
内存需求
缓冲区已经创建,但它还没有分配任何内存。为缓冲区分配内存的第一步是使用 vkGetBufferMemoryRequirements 函数查询其内存需求。
vk::MemoryRequirements memRequirements = vertexBuffer.getMemoryRequirements();
VkMemoryRequirements 结构体有三个字段:
-
size:所需内存的字节大小可能与bufferInfo.size不同。 -
alignment:缓冲区在分配的内存区域中开始的字节偏移量,取决于bufferInfo.usage和bufferInfo.flags。 -
memoryTypeBits:适合缓冲区的内存类型的位字段。
显卡可以提供不同类型的内存来分配。每种类型的内存在允许的操作和性能特征方面有所不同。我们需要结合缓冲区的需求和我们自己的应用程序需求来找到合适的内存类型。为此,我们创建一个新函数 findMemoryType。
uint32_t findMemoryType(uint32_t typeFilter, vk::MemoryPropertyFlags properties) {
}
首先,我们需要使用 vkGetPhysicalDeviceMemoryProperties 查询可用内存类型的信息。
vk::PhysicalDeviceMemoryProperties memProperties = physicalDevice->getMemoryProperties();
VkPhysicalDeviceMemoryProperties 结构体有两个数组 memoryTypes 和 memoryHeaps。内存堆是不同的内存资源,如专用 VRAM 和 VRAM 用完时 RAM 中的交换空间。不同类型的内存存在于这些堆中。现在我们只关心内存类型,而不关心它来自哪个堆,但你可以想象这可能会影响性能。
首先,我们找到适合缓冲区本身的内存类型:
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 结构体来实际分配内存。
vk::MemoryAllocateInfo memoryAllocateInfo( memRequirements.size, findMemoryType(memRequirements.memoryTypeBits, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent) );
内存分配现在就像指定大小和类型一样简单,这两者都源自顶点缓冲区的内存需求和所需的属性。创建一个类成员来存储内存的句柄,并使用 vkAllocateMemory 分配它。
vk::raii::Buffer vertexBuffer = nullptr;
vk::raii::DeviceMemory vertexBufferMemory = nullptr;
...
vertexBufferMemory = vk::raii::DeviceMemory( device, memoryAllocateInfo );
如果内存分配成功,那么我们现在可以使用 vkBindBufferMemory 将此内存与缓冲区关联:
vertexBuffer.bindMemory( *vertexBufferMemory, 0 );
前三个参数是不言自明的,第四个参数是内存区域内的偏移量。由于此内存是专门为此顶点缓冲区分配的,因此偏移量仅为 0。如果偏移量非零,则它必须能被 memRequirements.alignment 整除。
填充顶点缓冲区
现在是时候将顶点数据复制到缓冲区了。这是通过使用 vkMapMemory 将缓冲区内存映射到 CPU 可访问的内存来完成的。
void* data = vertexBufferMemory.mapMemory(0, bufferInfo.size);
此函数允许我们访问由偏移量和大小定义的指定内存资源的区域。这里的偏移量和大小分别为 0 和 bufferInfo.size。
void* data = vertexBufferMemory.mapMemory(0, bufferInfo.size);
memcpy(data, vertices.data(), bufferInfo.size);
vertexBufferMemory.unmapMemory();
现在你可以简单地将顶点数据 memcpy 到映射的内存中,然后使用 vkUnmapMemory 再次取消映射。不幸的是,驱动程序可能不会立即将数据复制到缓冲区内存中,例如由于缓存。也可能对缓冲区的写入尚未在映射的内存中可见。有两种方法可以解决这个问题:
-
使用主机一致的内存堆,由
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT指示 -
在写入映射的内存后调用
vkFlushMappedMemoryRanges,并在从映射的内存读取之前调用vkInvalidateMappedMemoryRanges
我们选择了第一种方法,它确保映射的内存始终与分配的内存的内容匹配。请记住,这可能会导致比显式刷新稍差的性能,但我们将在下一章中看到为什么这并不重要。
刷新内存范围或使用一致的内存堆意味着驱动程序将知道我们对缓冲区的写入,但这并不意味着它们实际上已经在 GPU 上可见。数据传输到 GPU 是一个在后台发生的操作,规范只是 告诉我们 它保证在下一次调用 vkQueueSubmit 时完成。
绑定顶点缓冲区
现在剩下的就是在渲染操作期间绑定顶点缓冲区。我们将扩展 recordCommandBuffer 函数来实现这一点。
commandBuffers[currentFrame].bindPipeline(vk::PipelineBindPoint::eGraphics, *graphicsPipeline);
commandBuffers[currentFrame].bindVertexBuffers(0, *vertexBuffer, {0});
commandBuffers[currentFrame].draw(3, 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++ 代码 / slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器