暂存缓冲区
介绍
我们当前的顶点缓冲区工作正常,但允许我们从 CPU 访问的内存类型可能不是显卡本身读取的最优内存类型。最优的内存具有 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 标志,通常在专用显卡上无法被 CPU 访问。在本章中,我们将创建两个顶点缓冲区:一个在 CPU 可访问的内存中的 暂存缓冲区,用于从顶点数组上传数据;另一个在设备本地内存中的最终顶点缓冲区。然后,我们将使用缓冲区复制命令将数据从暂存缓冲区移动到实际的顶点缓冲区。
传输队列
缓冲区复制命令需要一个支持传输操作的队列族,这是通过 VK_QUEUE_TRANSFER_BIT 指示的。好消息是,任何具有 VK_QUEUE_GRAPHICS_BIT 或 VK_QUEUE_COMPUTE_BIT 能力的队列族已经隐式支持 VK_QUEUE_TRANSFER_BIT 操作。在这些情况下,实现不需要在 queueFlags 中明确列出它。
如果你喜欢挑战,仍然可以尝试专门为传输操作使用不同的队列族。这将需要对你的程序进行以下修改:
-
修改
QueueFamilyIndices和findQueueFamilies,明确查找具有VK_QUEUE_TRANSFER_BIT但不具有VK_QUEUE_GRAPHICS_BIT的队列族。 -
修改
createLogicalDevice以请求传输队列的句柄 -
为在传输队列族上提交的命令缓冲区创建第二个命令池
-
将资源的
sharingMode更改为VK_SHARING_MODE_CONCURRENT,并指定图形和传输队列族 -
将任何传输命令(如
vkCmdCopyBuffer,我们将在本章中使用)提交到传输队列而不是图形队列
这需要一些工作,但它会让你学到很多关于资源如何在队列族之间共享的知识。
抽象缓冲区创建
因为我们将在本章中创建多个缓冲区,所以将缓冲区创建移到辅助函数中是一个好主意。创建一个新函数 createBuffer,并将 createVertexBuffer 中的代码(映射除外)移到其中。
void createBuffer(vk::DeviceSize size, vk::BufferUsageFlags usage, vk::MemoryPropertyFlags properties, vk::raii::Buffer& buffer, vk::raii::DeviceMemory& bufferMemory) {
vk::BufferCreateInfo bufferInfo{ .size = size, .usage = usage, .sharingMode = vk::SharingMode::eExclusive };
buffer = vk::raii::Buffer(device, bufferInfo);
vk::MemoryRequirements memRequirements = buffer.getMemoryRequirements();
vk::MemoryAllocateInfo allocInfo{ .allocationSize = memRequirements.size, .memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties) };
bufferMemory = vk::raii::DeviceMemory(device, allocInfo);
buffer.bindMemory(*bufferMemory, 0);
}
确保为缓冲区大小、内存属性和用途添加参数,以便我们可以使用此函数创建许多不同类型的缓冲区。最后两个参数是输出变量,用于写入句柄。
你现在可以从 createVertexBuffer 中删除缓冲区创建和内存分配代码,只需调用 createBuffer:
void createVertexBuffer() {
vk::DeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
createBuffer(bufferSize, vk::BufferUsageFlagBits::eVertexBuffer, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, vertexBuffer, vertexBufferMemory);
void* data = vertexBufferMemory.mapMemory(0, bufferSize);
memcpy(data, vertices.data(), (size_t) bufferSize);
vertexBufferMemory.unmapMemory();
}
运行你的程序以确保顶点缓冲区仍然正常工作。
使用暂存缓冲区
我们现在将修改 createVertexBuffer,仅使用主机可见的缓冲区作为临时缓冲区,并使用设备本地的缓冲区作为实际的顶点缓冲区。
void createVertexBuffer() {
vk::DeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
vk::BufferCreateInfo stagingInfo{ .size = bufferSize, .usage = vk::BufferUsageFlagBits::eTransferSrc, .sharingMode = vk::SharingMode::eExclusive };
vk::raii::Buffer stagingBuffer(device, stagingInfo);
vk::MemoryRequirements memRequirementsStaging = stagingBuffer.getMemoryRequirements();
vk::MemoryAllocateInfo memoryAllocateInfoStaging{ .allocationSize = memRequirementsStaging.size, .memoryTypeIndex = findMemoryType(memRequirementsStaging.memoryTypeBits, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent) };
vk::raii::DeviceMemory stagingBufferMemory(device, memoryAllocateInfoStaging);
stagingBuffer.bindMemory(stagingBufferMemory, 0);
void* dataStaging = stagingBufferMemory.mapMemory(0, stagingInfo.size);
memcpy(dataStaging, vertices.data(), stagingInfo.size);
stagingBufferMemory.unmapMemory();
vk::BufferCreateInfo bufferInfo{ .size = bufferSize, .usage = vk::BufferUsageFlagBits::eVertexBuffer | vk::BufferUsageFlagBits::eTransferDst, .sharingMode = vk::SharingMode::eExclusive };
vertexBuffer = vk::raii::Buffer(device, bufferInfo);
vk::MemoryRequirements memRequirements = vertexBuffer.getMemoryRequirements();
vk::MemoryAllocateInfo memoryAllocateInfo{ .allocationSize = memRequirements.size, .memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, vk::MemoryPropertyFlagBits::eDeviceLocal) };
vertexBufferMemory = vk::raii::DeviceMemory( device, memoryAllocateInfo );
vertexBuffer.bindMemory( *vertexBufferMemory, 0 );
copyBuffer(stagingBuffer, vertexBuffer, stagingInfo.size);
}
我们现在使用一个新的 stagingBuffer 和 stagingBufferMemory 来映射和复制顶点数据。在本章中,我们将使用两个新的缓冲区用途标志:
-
VK_BUFFER_USAGE_TRANSFER_SRC_BIT:缓冲区可以用作内存传输操作中的源。 -
VK_BUFFER_USAGE_TRANSFER_DST_BIT:缓冲区可以用作内存传输操作中的目标。
vertexBuffer 现在是从设备本地内存类型分配的,这通常意味着我们无法使用 vkMapMemory。但是,我们可以将数据从 stagingBuffer 复制到 vertexBuffer。我们必须通过为 stagingBuffer 指定传输源标志和为 vertexBuffer 指定传输目标标志(以及顶点缓冲区用途标志)来表明我们打算这样做。
我们现在将编写一个函数来将一个缓冲区的内容复制到另一个缓冲区,称为 copyBuffer。
void copyBuffer(vk::raii::Buffer & srcBuffer, vk::raii::Buffer & dstBuffer, vk::DeviceSize size) {
}
内存传输操作是使用命令缓冲区执行的,就像绘制命令一样。因此,我们必须首先分配一个临时命令缓冲区。你可能希望为这些短寿命的缓冲区创建一个单独的命令池,因为实现可能能够应用内存分配优化。在这种情况下,你应该在生成命令池时使用 VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 标志。
void copyBuffer(vk::raii::Buffer & srcBuffer, vk::raii::Buffer & dstBuffer, vk::DeviceSize size) {
vk::CommandBufferAllocateInfo allocInfo{ .commandPool = commandPool, .level = vk::CommandBufferLevel::ePrimary, .commandBufferCount = 1 };
vk::raii::CommandBuffer commandCopyBuffer = std::move(device.allocateCommandBuffers(allocInfo).front());
}
并立即开始记录命令缓冲区:
commandCopyBuffer.begin(vk::CommandBufferBeginInfo { .flags = vk::CommandBufferUsageFlagBits::eOneTimeSubmit });
我们只会使用命令缓冲区一次,并在返回函数之前等待复制操作完成执行。使用 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT 告诉驱动程序我们的意图是一个好习惯。
commandCopyBuffer.copyBuffer(srcBuffer, dstBuffer, vk::BufferCopy(0, 0, size));
缓冲区的内容是使用 vkCmdCopyBuffer 命令传输的。它接受源和目标缓冲区作为参数,以及一个要复制的区域数组。区域在 VkBufferCopy 结构体中定义,由源缓冲区偏移量、目标缓冲区偏移量和大小组成。与 vkMapMemory 命令不同,这里不能指定 VK_WHOLE_SIZE。
commandCopyBuffer.end();
此命令缓冲区仅包含复制命令,因此我们可以在之后立即停止记录。现在执行命令缓冲区以完成传输:
graphicsQueue.submit(vk::SubmitInfo{ .commandBufferCount = 1, .pCommandBuffers = &*commandCopyBuffer }, nullptr);
graphicsQueue.waitIdle();
与绘制命令不同,这次没有我们需要等待的事件。我们只想立即在缓冲区上执行传输。再次有两种方法可以等待此传输完成。我们可以使用栅栏并使用 vkWaitForFences 等待,或者简单地使用 vkQueueWaitIdle 等待传输队列变为空闲状态。栅栏允许你同时调度多个传输并等待它们全部完成,而不是一次执行一个。这可能给驱动程序更多的优化机会。
我们现在可以从 createVertexBuffer 函数调用 copyBuffer,将顶点数据移动到设备本地缓冲区:
vk::BufferCreateInfo bufferInfo{ .size = bufferSize, .usage = vk::BufferUsageFlagBits::eVertexBuffer | vk::BufferUsageFlagBits::eTransferDst, .sharingMode = vk::SharingMode::eExclusive };
vertexBuffer = vk::raii::Buffer(device, bufferInfo);
vk::MemoryRequirements memRequirements = vertexBuffer.getMemoryRequirements();
vk::MemoryAllocateInfo memoryAllocateInfo{ .allocationSize = memRequirements.size, .memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, vk::MemoryPropertyFlagBits::eDeviceLocal) };
vertexBufferMemory = vk::raii::DeviceMemory( device, memoryAllocateInfo );
vertexBuffer.bindMemory( *vertexBufferMemory, 0 );
copyBuffer(stagingBuffer, vertexBuffer, bufferSize);
在将数据从暂存缓冲区复制到设备缓冲区后,RAII 缓冲区对象将自行清理并释放内存。
运行你的程序以验证你是否再次看到熟悉的三角形。现在可能看不到改进,但其顶点数据现在是从高性能内存加载的。当我们开始渲染更复杂的几何体时,这将很重要。
结论
需要注意的是,在现实世界的应用程序中,你不应该为每个单独的缓冲区调用 vkAllocateMemory。同时内存分配的最大数量受 maxMemoryAllocationCount 物理设备限制的限制,即使在像 NVIDIA GTX 1080 这样的高端硬件上,这个限制也可能低至 4096。为大量对象同时分配内存的正确方法是创建一个自定义分配器,通过使用我们在许多函数中看到的 offset 参数,将单个分配分割到许多不同的对象中。
你可以自己实现这样的分配器,或者使用 GPUOpen 计划提供的 VulkanMemoryAllocator 库。然而,对于本教程来说,为每个资源使用单独的分配是可以的,因为我们目前不会接近达到这些限制。
在 下一章 中,我们将学习用于顶点重用的索引缓冲区。
C++ 代码 / slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器