暂存缓冲
前言
我们现在的顶点缓冲已经可以正常工作了,但它的内存类型还不是最佳选择。
对于显卡而言,最佳内存类型应该有vk::MemoryPropertyFlagBits::eDeviceLocal
标志。
也就是设备本地的内存,实际是指显卡上的内存(显存),通常无法被CPU直接访问。
容易理解,CPU访问内存更快、而GPU访问显存更快,交叉访问却比较麻烦,所以我们最好需要两个顶点缓冲。
- 位于内存,像上一节一样,我们称其为暂存缓冲(Staging Buffer)。
- 位于显存,这里称它为最终顶点缓冲,或是设备本地缓冲(DeviceLocal)。
我们的CPU向暂存缓冲写数据,然后通过某种方式将数据从暂存缓冲复制到最终缓冲,然后GPU从最终缓冲中读取。
转移队列
在缓冲之间复制数据需要支持相关操作的队列族,附带 vk::QueueFlagBits::eTransfer
标志位。
好消息是满足 eGraphics
或 eCompute
的队列族已经隐式支持了 eTransfer
操作。
所以我们可以不创建新队列族。
如果您喜欢挑战,那么仍可以尝试使用专用于传输操作的队列族。这将需要您对程序进行以下修改
-
修改
QueueFamilyIndices
和findQueueFamilies
以显式查找具有eTransfer
但没有eGraphics
标志位的队列族。 -
修改
createLogicalDevice
以获取传输队列的句柄 -
为传输队列族需要的命令缓冲创建第二个命令池
-
将资源的
sharingMode
更改为vk::SharingMode::eConcurrent
,并指定图形和传输队列族 -
将任何传输命令(例如
copyBuffer
,我们将在本章中使用它)提交到传输队列,而不是图形队列
这有点工作量,但它会教您很多关于资源如何在队列族之间共享的知识。
修改缓冲创建
因为我们需要创建多个缓冲,最好将单个缓冲创建的代码移入一个辅助函数。
现在创建一个新函数 createBuffer
,并将 createVertexBuffer
的部分代码写入此函数:
void createBuffer(
vk::DeviceSize size,
vk::BufferUsageFlags usage,
vk::MemoryPropertyFlags properties,
vk::raii::Buffer& buffer,
vk::raii::DeviceMemory& bufferMemory
) {
vk::BufferCreateInfo bufferInfo;
bufferInfo.size = size;
bufferInfo.usage = usage;
bufferInfo.sharingMode = vk::SharingMode::eExclusive;
buffer = m_device.createBuffer(bufferInfo);
vk::MemoryRequirements memRequirements = buffer.getMemoryRequirements();
vk::MemoryAllocateInfo allocInfo;
allocInfo.allocationSize = memRequirements.size;
allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);
bufferMemory = m_device.allocateMemory( 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,
m_vertexBuffer,
m_vertexBufferMemory
);
void* data = m_vertexBufferMemory.mapMemory(0, bufferSize);
memcpy(data, vertices.data(), static_cast<size_t>(bufferSize));
m_vertexBufferMemory.unmapMemory();
}
现在运行程序,保证程序依然正常工作。
使用暂存缓冲
现在我们修改 createVertexBuffer
,主机可见缓冲将只作为临时缓冲,然后创建设备本地缓冲作为实际的顶点缓冲。
void createVertexBuffer() {
vk::DeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
vk::raii::DeviceMemory stagingBufferMemory{ nullptr };
vk::raii::Buffer stagingBuffer{ nullptr };
createBuffer(bufferSize,
vk::BufferUsageFlagBits::eTransferSrc,
vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent,
stagingBuffer,
stagingBufferMemory
);
void* data = stagingBufferMemory.mapMemory(0, bufferSize);
memcpy(data, vertices.data(), static_cast<size_t>(bufferSize));
stagingBufferMemory.unmapMemory();
createBuffer(bufferSize,
vk::BufferUsageFlagBits::eTransferDst |
vk::BufferUsageFlagBits::eVertexBuffer,
vk::MemoryPropertyFlagBits::eDeviceLocal,
m_vertexBuffer,
m_vertexBufferMemory
);
}
m_vertexBuffer
的内存类型现在变成了 eDeviceLocal
,无法直接被CPU访问,所以我们不能简单地使用 mapMemory
。
但是我们可以将数据从 stagingBuffer
拷贝到 m_vertexBuffer
。所以我们修改了 BufferUsageFlags
标志位:
eTransferSrc
表示缓冲可以作为内存传输操作的源eTransferDst
表示缓冲可以作为内存传输操作的目的地
缓冲拷贝函数
我们现在创建一个新函数 copyBuffer
,用于缓冲之间的数据拷贝,
void copyBuffer(vk::raii::Buffer& srcBuffer, vk::raii::Buffer& dstBuffer, vk::DeviceSize size) {
}
内存传输操作需要使用命令缓冲,我们必须分配一个临时的命令缓冲用于命令的录制和提交。
同时最好为这些临时命令缓冲创建一个独立的命令池,程序可以更好进行资源分配的优化,此时需要使用vk::CommandBufferLevel::ePrimary
标记。
void copyBuffer(vk::raii::Buffer& srcBuffer, vk::raii::Buffer& dstBuffer, vk::DeviceSize size) {
vk::CommandBufferAllocateInfo allocInfo;
allocInfo.level = vk::CommandBufferLevel::ePrimary;
allocInfo.commandPool = m_commandPool;
allocInfo.commandBufferCount = 1;
// std::vector<vk::raii::CommandBuffer>
auto commandBuffers = m_device.allocateCommandBuffers(allocInfo);
vk::raii::CommandBuffer commandBuffer = std::move(commandBuffers.at(0));
}
然后开始录制命令缓冲
vk::CommandBufferBeginInfo beginInfo;
beginInfo.flags = vk::CommandBufferUsageFlagBits::eOneTimeSubmit;
commandBuffer.begin(beginInfo);
我们只使用此命令缓冲一次,所以使用 eOneTimeSubmit
标志位。
使用 copyBuffer
成员函数录制拷贝命令,输入源缓冲和目标缓冲,以及需要拷贝的区域。
vk::BufferCopy copyRegion;
copyRegion.srcOffset = 0; // optional
copyRegion.dstOffset = 0; // optional
copyRegion.size = size;
commandBuffer.copyBuffer(srcBuffer, dstBuffer, copyRegion);
然后就可以结束录制了,我们只需要这一个操作。
commandBuffer.end();
与绘制命令不同,我们只希望它立刻执行内存传输命令。
我们至少有两种方式等待内存传输完成,使用围栏Fence
进行同步或直接waitIdle
,这里使用后者:
vk::SubmitInfo submitInfo;
submitInfo.setCommandBuffers( *commandBuffer );
m_graphicsQueue.submit(submitInfo);
m_graphicsQueue.waitIdle();
使用
Fence
更精准,允许你同时同步地进行多条传输命令,可以带来更好的性能优化。
最后
现在在 createVertexBuffer
函数末尾调用 copyBuffer
,将暂存缓冲的数据拷贝到设备本地缓冲。
copyBuffer(stagingBuffer, m_vertexBuffer, bufferSize);
现在尝试运行程序,保证程序可以正常执行。
注意
在实际应用中,你不应该给每个缓冲都调用一次allocateMemory
。
同时为大量对象分配内存的正确方法是创建一个自定义分配器,该分配器通过使用我们在许多函数中看到的 offset
参数将单个资源拆分到许多不同的对象中。
你可以自己实现这样的分配器,也可以使用 VulkanMemoryAllocator-Hpp 这样的第三方库。
但是对于本教程,为每个资源使用单独的分配是可以的。因为我们的数据量很小,不会触发相关限制。