暂存缓冲区
简介
我们现在使用的顶点缓冲工作正常,但是允许我们从 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(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);
}
我们现在使用新的 stagingBuffer
和 stagingBufferMemory
进行映射和复制顶点数据。在本章中,我们将使用两个新的缓冲使用标志
-
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, ©Region);
缓冲的内容使用 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 库。但是,对于本教程,为每个资源使用单独的分配是可以的,因为我们现在不会接近达到任何这些限制。