暂存缓冲

简介

我们现在使用的顶点缓冲工作正常,但允许我们从 CPU 访问它的内存类型可能不是图形卡本身读取的最佳内存类型。最佳内存具有 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 标志,并且通常在专用图形卡上无法通过 CPU 访问。在本章中,我们将创建两个顶点缓冲区。一个在 CPU 可访问内存中的暂存缓冲,用于将数据从顶点数组上传到该缓冲,以及最终位于设备本地内存中的顶点缓冲。然后,我们将使用缓冲区复制命令将数据从暂存缓冲区移动到实际的顶点缓冲区。

传输队列

缓冲区复制命令需要支持传输操作的队列族,这通过 VK_QUEUE_TRANSFER_BIT 指示。好消息是,任何具有 VK_QUEUE_GRAPHICS_BITVK_QUEUE_COMPUTE_BIT 功能的队列族都已隐式支持 VK_QUEUE_TRANSFER_BIT 操作。在这些情况下,实现不需要在 queueFlags 中显式列出它。

如果您喜欢挑战,那么您仍然可以尝试专门使用不同的队列族进行传输操作。它将要求您对程序进行以下修改:

  • 修改 QueueFamilyIndicesfindQueueFamilies,以显式查找具有 VK_QUEUE_TRANSFER_BIT 位但没有 VK_QUEUE_GRAPHICS_BIT 的队列族。

  • 修改 createLogicalDevice 以请求传输队列的句柄

  • 为在传输队列族上提交的命令缓冲创建第二个命令池

  • 将资源的 sharingMode 更改为 VK_SHARING_MODE_CONCURRENT,并同时指定图形和传输队列族

  • 将任何传输命令(如 vkCmdCopyBuffer,我们将在本章中使用)提交到传输队列,而不是图形队列

这有点工作量,但它将教会您很多关于资源如何在队列族之间共享的知识。

抽象缓冲区创建

因为我们将在本章中创建多个缓冲区,因此最好将缓冲区创建移动到辅助函数。创建一个新函数 createBuffer,并将 createVertexBuffer 中的代码(映射除外)移动到其中。

void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties, VkBuffer& buffer, VkDeviceMemory& bufferMemory) {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

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

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

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

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

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

确保为缓冲区大小、内存属性和使用情况添加参数,以便我们可以使用此函数创建多种不同类型的缓冲区。最后两个参数是输出变量,用于写入句柄。

您现在可以从 createVertexBuffer 中删除缓冲区创建和内存分配代码,而只需调用 createBuffer

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, vertexBuffer, vertexBufferMemory);

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

运行您的程序以确保顶点缓冲仍然正常工作。

使用暂存缓冲区

我们现在将更改 createVertexBuffer 以仅使用主机可见缓冲区作为临时缓冲区,并使用设备本地缓冲区作为实际顶点缓冲区。

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);
}

我们现在正在使用一个新的 stagingBufferstagingBufferMemory 进行映射和复制顶点数据。在本章中,我们将使用两个新的缓冲区使用标志:

  • VK_BUFFER_USAGE_TRANSFER_SRC_BIT:缓冲区可以用作内存传输操作的源。

  • VK_BUFFER_USAGE_TRANSFER_DST_BIT:缓冲区可以用作内存传输操作的目的地。

vertexBuffer 现在从设备本地的内存类型分配,这通常意味着我们无法使用 vkMapMemory。但是,我们可以将数据从 stagingBuffer 复制到 vertexBuffer。我们必须通过为 stagingBuffer 指定传输源标志,为 vertexBuffer 指定传输目标标志,以及顶点缓冲区使用标志,来表明我们打算这样做。

我们现在将编写一个函数,用于将内容从一个缓冲区复制到另一个缓冲区,称为 copyBuffer

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {

}

内存传输操作使用命令缓冲区执行,就像绘制命令一样。因此,我们必须首先分配一个临时命令缓冲区。您可能希望为这些类型的短期缓冲区创建一个单独的命令池,因为实现可能能够应用内存分配优化。在这种情况下,您应该在命令池生成期间使用 VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 标志。

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
}

并立即开始记录命令缓冲区

VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;

vkBeginCommandBuffer(commandBuffer, &beginInfo);

我们只使用一次命令缓冲区,并等待函数返回,直到复制操作完成执行。使用 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT 告诉驱动程序我们的意图是一个好习惯。

VkBufferCopy copyRegion{};
copyRegion.srcOffset = 0; // Optional
copyRegion.dstOffset = 0; // Optional
copyRegion.size = size;
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

缓冲区的内容使用 vkCmdCopyBuffer 命令传输。它以源缓冲区和目标缓冲区作为参数,以及要复制的区域数组。这些区域在 VkBufferCopy 结构中定义,由源缓冲区偏移量、目标缓冲区偏移量和大小组成。与 vkMapMemory 命令不同,此处无法指定 VK_WHOLE_SIZE

vkEndCommandBuffer(commandBuffer);

此命令缓冲区仅包含复制命令,因此我们可以在之后立即停止记录。现在执行命令缓冲区以完成传输

VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);

与绘制命令不同,这次我们不需要等待任何事件。我们只想立即在缓冲区上执行传输。同样,有两种可能的方式可以等待此传输完成。我们可以使用栅栏并使用 vkWaitForFences 等待,或者只是等待传输队列使用 vkQueueWaitIdle 变为闲置状态。栅栏将允许您同时安排多个传输并等待它们全部完成,而不是一次执行一个。这可能会给驱动程序更多优化机会。

vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

不要忘记清理用于传输操作的命令缓冲区。

现在我们可以从 createVertexBuffer 函数中调用 copyBuffer,将顶点数据移动到设备本地缓冲区。

createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

在将数据从暂存缓冲区复制到设备缓冲区后,我们应该清理它。

    ...

    copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

运行你的程序,验证你是否再次看到了熟悉的三角形。 现在的改进可能并不明显,但它的顶点数据现在是从高性能内存中加载的。 当我们开始渲染更复杂的几何体时,这将非常重要。

结论

应该注意的是,在实际应用中,你不应该为每个单独的缓冲区都调用 vkAllocateMemory。 同时内存分配的最大数量受限于物理设备的 maxMemoryAllocationCount 限制,即使在像 NVIDIA GTX 1080 这样的高端硬件上,这个值也可能低至 4096。 同时为大量对象分配内存的正确方法是创建一个自定义分配器,通过使用我们在许多函数中看到的 offset 参数,将单个分配拆分到多个不同的对象中。

你可以自己实现这样一个分配器,或者使用 GPUOpen 计划提供的 VulkanMemoryAllocator 库。 但是,对于本教程,为每个资源使用单独的分配是可以的,因为我们现在不会接近这些限制中的任何一个。

下一章中,我们将学习用于顶点重用的索引缓冲区。