狸村 Mystic Web · 狸村
目录

引言

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

缓冲创建

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

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

...

void createVertexBuffer()
{
}

创建缓冲需要我们填充一个 vk::BufferCreateInfo 结构体。

vk::BufferCreateInfo bufferInfo{.size        = sizeof(vertices[0]) * vertices.size(),
                                .usage       = vk::BufferUsageFlagBits::eVertexBuffer,
                                .sharingMode = vk::SharingMode::eExclusive};

size 字段以字节为单位指定缓冲的大小。使用 sizeof 计算顶点数据的字节大小很简单。

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

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

还有一个 flags 字段,用于配置稀疏缓冲内存。它现在不相关,因此我们将其保留为默认值 0

我们现在可以使用 vk::raii::Buffer 构造函数创建缓冲。 定义一个类成员来保存缓冲句柄,并将其称为 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);
}

缓冲应在整个程序结束之前可用于渲染命令,并且它不依赖于交换链。

内存需求

缓冲已创建,但它尚未分配任何内存。 为缓冲分配内存的第一步是使用函数 vk::raii::Buffer::getMemoryRequirements 查询其内存需求。

vk::MemoryRequirements memRequirements = vertexBuffer.getMemoryRequirements();

vk::MemoryRequirements 结构体有三个字段:

  • size:所需内存的大小(以字节为单位),可能与 bufferInfo.size 不同。

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

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

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

uint32_t findMemoryType(uint32_t typeFilter, vk::MemoryPropertyFlags properties) {

}

首先,我们需要使用 vk::raii::PhysicalDevice::getMemoryProperties 查询有关可用内存类型的信息。

vk::PhysicalDeviceMemoryProperties memProperties = physicalDevice.getMemoryProperties();

vk::PhysicalDeviceMemoryProperties 结构体有两个数组 memoryTypesmemoryHeaps。 内存堆是不同的内存资源,如专用 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 数组由 vk::MemoryType 结构体组成,这些结构体指定了每个内存类型的堆和属性。 这些属性定义了内存的特殊功能,例如能够映射它以便我们可以从 CPU 写入它。 此属性用 vk::MemoryPropertyFlagBits::eHostVisible 指示,但我们还需要使用 vk::MemoryPropertyFlagBits::eHostCoherent 属性。 我们将在映射内存时看到原因。

我们现在可以修改循环,以同时检查此属性的支持:

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

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

内存分配

我们现在有了确定正确内存类型的方法,因此我们可以通过填充 vk::MemoryAllocateInfo 结构体来实际分配内存。

vk::MemoryAllocateInfo memoryAllocateInfo{
    .allocationSize  = memRequirements.size,
    .memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent)};

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

vk::raii::Buffer       vertexBuffer       = nullptr;
vk::raii::DeviceMemory vertexBufferMemory = nullptr;

...

vertexBufferMemory = vk::raii::DeviceMemory(device, memoryAllocateInfo);

如果内存分配成功,那么我们现在可以使用 vk::raii::Buffer::bindBufferMemory 将此内存与缓冲关联:

vertexBuffer.bindMemory( *vertexBufferMemory, 0 );

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

填充顶点缓冲

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

void* data = vertexBufferMemory.mapMemory(0, bufferInfo.size);

此函数允许我们访问由偏移量和大小定义的指定内存资源区域。 这里的偏移量和大小分别为 0bufferInfo.size

void* data = vertexBufferMemory.mapMemory(0, bufferInfo.size);
memcpy(data, vertices.data(), bufferInfo.size);
vertexBufferMemory.unmapMemory();

现在你可以简单地将顶点数据 memcpy 到映射的内存中,然后再次使用 vk::raii::DeviceMemory::unmapMemory 取消映射。 不幸的是,驱动程序可能不会立即将数据复制到缓冲内存中,例如,由于缓存的原因。 也可能对缓冲的写入在映射的内存中还不可见。 有两种方法可以处理这个问题:

  • 使用主机一致的内存堆,由 vk::MemoryPropertyFlagBits::eHostCoherent 指示

  • 在写入映射的内存后调用 vk::raii::Device::flushMappedMemoryRanges,并在从映射的内存读取之前调用 vk::raii::Device::invalidateMappedMemoryRanges

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

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

绑定顶点缓冲

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

commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, *graphicsPipeline);

commandBuffer.bindVertexBuffers(0, *vertexBuffer, {0});

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

vk::raii::CommandBuffer::bindVertexBuffers 函数用于将顶点缓冲绑定到绑定,就像我们在上一章中设置的那样。 第一个参数是绑定的偏移量。 第二个参数是要绑定的缓冲数组。 最后一个参数是相同大小的字节偏移量数组,用于开始读取顶点数据。 你还应该更改对 vk::CommandBuffer::draw 的调用,以传递缓冲中的顶点数,而不是硬编码的数字 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}}
};

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

triangle white

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

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。