Now Loading ...
-
EasyVulkan资源管理中的内存泄漏问题
近期的某项目中需要在每一帧动态创建新的资源。然而程序执行时内存占用逐渐增加,怀疑出现了内存泄漏问题。因此重新回顾了EasyVulkan的ResourceManager逻辑并进行了优化。
内存泄漏问题
之前的资源创建方式:
ShaderModuleBuilder& ResourceManager::createShaderModule() {
return *new ShaderModuleBuilder(m_device,m_context);
}
这种方式会导致严重的内存泄漏问题:
new ComputePipelineBuilder(…) 在自由存储区(堆)上创建了一个对象,并返回指向该对象的指针。
操作符解引用该指针,得到对象本身。
函数返回这个堆上对象的引用。
问题分析
ComputePipelineBuilder& builder = resourceManager.createComputePipeline();
// ... 使用 builder ...
// 更差的情况,创建了一个副本
ComputePipelineBuilder builder = resourceManager.createComputePipeline();
在这两种情况下,都丢失了 new 返回的原始指针。因为没有指针,永远无法调用 delete 来释放这块在堆上分配的内存。每次调用 createComputePipeline() 都会导致一块无法回收的内存,程序运行时间越长,消耗的内存就越多,最终可能导致程序崩溃。
结论:绝对不要返回一个由 new 在函数内部创建的对象的引用。
解决方法
ComputePipelineBuilder ResourceManager::createComputePipeline() {
// 1. 在函数内部创建一个 ComputePipelineBuilder 临时对象
// 2. 将这个临时对象作为返回值返回
return ComputePipelineBuilder(m_device, m_context);
}
这是现代 C++ 中实现工厂函数(Factory Function的正确、安全且高效的方式。
ComputePipelineBuilder(m_device, m_context) 在函数内创建了一个临时对象。
函数签名表明它将按值返回一个 ComputePipelineBuilder 对象。
返回对象的成本:
C++中的RVO机制(返回值优化 Return Value Optimization):编译器会识别出这种情况,并避免创建中间的临时对象。它会直接在调用方的内存空间(即接收返回值的那个对象的内存位置)上构造这个对象。这样一来,就完全跳过了任何拷贝或移动操作。从效果上看,几乎和返回引用一样快:
// 由于 RVO,ComputePipelineBuilder 对象会直接在 `builder` 的内存上构造
// 没有临时对象,没有拷贝,没有移动
ComputePipelineBuilder builder = resourceManager.createComputePipeline();
移动语义 (Move Semantics): 即使在少数 RVO 无法生效的情况下(例如,函数内有多个返回路径),C++11 的移动语义也会介入。如果 ComputePipelineBuilder 有移动构造函数,那么返回时会调用移动构造函数而非拷贝构造函数。移动通常非常廉价,它只是“窃取”临时对象的内部资源(如指针、句柄),而不需要深拷贝数据。
安全性与所有权
这种方式非常安全。调用者会得到一个全新的、自己拥有的对象。当这个对象离开其作用域时(例如函数结束、{} 块结束),它的析构函数会被自动调用,符合 RAII (Resource Acquisition Is Initialization) 原则。
VMA资源对象管理
任何通过 VMA Create 函数创建的资源,都必须通过与之对应的 VMA Destroy 函数来清理。
vmaCreateImage
当调用 vmaCreateImage() 时,VMA实现如下操作:
分配内存 (Allocate Memory):VMA 从它管理的内存池中找到一块合适的 VkDeviceMemory,并处理所有复杂的内存类型选择和对齐问题。这个内存块由一个 VmaAllocation 对象来代表。
创建映像 (Create Image):VMA 调用标准的 Vulkan 函数 vkCreateImage() 来创建 VkImage 句柄。
绑定内存 (Bind Memory):VMA 调用 vkBindImageMemory() 将前面分配的内存绑定到新创建的映像上。
vmaCreateImage 将这三个步骤封装成了一个原子操作,极大地简化了开发。
因此,当需要销毁这个映像时,也必须执行相反的、对应的操作:解绑内存、销毁映像、释放内存。这正是 vmaDestroyImage() 函数的作用。
vmaDestroyImage(allocator, image, allocation) 会完成:
销毁映像句柄 (内部调用 vkDestroyImage())。
释放内存块 VmaAllocation,将其归还给 VMA 的内存池,以便后续的分配可以重新使用它。
如果不使用 VMA
如果用标准 Vulkan 函数来清理:
只调用 vkDestroyImage(device, image, nullptr):
成功销毁了 VkImage 句柄本身。
但是,VMA 分配给它的那块 VkDeviceMemory (VmaAllocation) 完全没有被释放。VMA 仍然认为这块内存正在被一个(现在已经不存在的)映像使用。
结果:严重的内存泄漏。 VMA 的可用内存池会随着程序运行越来越小,最终可能导致内存耗尽。
只调用 vmaFreeMemory(allocator, allocation):
成功地将 VmaAllocation 归还给了 VMA 的内存池。
但是,VkImage 句柄 没有被销毁。
结果:严重的 Vulkan 资源泄漏。 Vulkan 驱动仍然保留着这个映像句柄的相关资源。Vulkan 的验证层(Validation Layers)会立即报错,提示有一个未被销毁的 VkImage 对象。
结论:只有 vmaDestroyImage() 能够同时、正确地清理映像句柄和它所占用的内存。
正确的生命周期管理
#include <vma/vk_mem_alloc.h>
// ... 假设已有 VmaAllocator allocator 和 VkDevice device ...
VkImage image;
VmaAllocation allocation;
// 1. 创建 Image
VkImageCreateInfo imageInfo = { ... };
VmaAllocationCreateInfo allocInfo = { };
allocInfo.usage = VMA_MEMORY_USAGE_AUTO; // 让VMA自动选择内存类型
VkResult result = vmaCreateImage(
allocator,
&imageInfo,
&allocInfo,
&image, // 输出 VkImage 句柄
&allocation, // 输出 VmaAllocation 句柄
nullptr // 可选的 VmaAllocationInfo
);
if (result == VK_SUCCESS) {
// ... 使用 image ...
}
// 2. 清理 Image (例如在程序退出或资源不再需要时)
// 必须同时传入 image 和 allocation 句柄
if (image != VK_NULL_HANDLE && allocation != VK_NULL_HANDLE) {
vmaDestroyImage(allocator, image, allocation);
}
VMA 的通用配对规则
这个原则适用于 VMA 管理的所有主要资源类型:
vmaCreateImage() -> vmaDestroyImage()
vmaCreateBuffer() -> vmaDestroyBuffer()
vmaAllocateMemory() (如果只分配内存) -> vmaFreeMemory()
vmaCreatePool() -> vmaDestroyPool()
vmaCreateAllocator() -> vmaDestroyAllocator()
始终确保资源创建和销毁调用是成对出现的,这样才能保证Vulkan 应用程序没有资源泄漏。
-
VK_EXT_debug_utils扩展介绍
Vulkan调试利器:深入解析VK_EXT_debug_utils扩展
Vulkan™ 是一款高性能的图形和计算API,赋予了开发者极大的硬件控制自由度。但随之而来的,是调试复杂性的增加。当程序出错时,标准的Vulkan验证层(Validation Layers)虽然会提供错误信息,但有时信息过于抽象和冗长,让开发者难以迅速找到真正的问题所在。
本文将详细介绍一个可大幅提升调试效率的重要扩展:VK_EXT_debug_utils。
令人头疼的错误信息:问题究竟在哪?
在编写Vulkan程序时,如果遇到以下类似的验证层错误信息,想必不少开发者都会感到困惑:
VUID-vkCmdBeginRenderPass-initialLayout-00897(ERROR / SPEC): msgNum: -1777306431 - [AppName: EasyVulkan Application] Validation Error: [ VUID-vkCmdBeginRenderPass-initialLayout-00897 ] Object 0: handle = 0x72303f0000000052, type = VK_OBJECT_TYPE_IMAGE; Object 1: handle = 0xcad092000000000d, type = VK_OBJECT_TYPE_RENDER_PASS; Object 2: handle = 0x4256c1000000005d, type = VK_OBJECT_TYPE_FRAMEBUFFER; Object 3: handle = 0x2a7f70000000053, type = VK_OBJECT_TYPE_IMAGE_VIEW; | MessageID = 0x961074c1 | ...
这段信息精确地指出了错误的原因(例如,RenderPass布局设置与Framebuffer中ImageView的用途不一致),但其列出的对象句柄仅是一系列十六进制的数字。在实际复杂的项目中,迅速定位这些句柄对应的具体对象非常困难,尤其当项目规模庞大时,查找错误源头无异于大海捞针。
VK_EXT_debug_utils:让调试更直观
幸运的是,VK_EXT_debug_utils 扩展提供了一种简单有效的方法来解决这个问题。它允许为Vulkan对象(如VkImage, VkBuffer, VkQueue, VkCommandBuffer)以及命令缓冲区的特定区域赋予直观易懂的名称(name)和标签(tag)。
启用该扩展并为对象赋予名称后,前述复杂难懂的验证层错误信息将变得更加友好:
Object 0: handle = 0x72303f0000000052, name = fs-downsampled-image-pass2-0, type = VK_OBJECT_TYPE_IMAGE; ...
开发者通过自定义的名称(如“fs-downsampled-image-pass2-0”)可立即找到对应的资源,大幅缩短了问题定位时间。
VK_EXT_debug_utils 的核心功能
VK_EXT_debug_utils 扩展提供了以下几个关键功能:
1. 调试信使(Debug Messenger)
通过vkCreateDebugUtilsMessengerEXT创建回调,实时接收验证层和其他来源的调试消息。
开发者可以定义回调函数,自行控制消息处理方式,例如输出到控制台或日志文件,甚至中断程序执行。
2. 对象命名(Object Naming)
利用vkSetDebugUtilsObjectNameEXT,可为Vulkan对象指定人类可读的名称。这些名称将直接显示在验证层和图形调试工具中,极大提高了代码可读性。
3. 对象标记(Object Tagging)
通过vkSetDebugUtilsObjectTagEXT,开发者可以给对象附加少量二进制数据作为标记,用于存放自定义元数据。(但相比对象命名,使用场景较少)
4. 命令缓冲区的标签和标记(Labels & Markers)
使用vkCmdBeginDebugUtilsLabelEXT 和vkCmdEndDebugUtilsLabelEXT标记命令缓冲区内特定的区域,有助于更清晰地分析命令序列。
如何在项目中使用VK_EXT_debug_utils
通常而言,集成该扩展的步骤为:
检查扩展支持:
在创建 VkInstance 之前,检查物理设备是否支持 VK_EXT_DEBUG_UTILS_EXTENSION_NAME。
启用扩展:
在 VkInstanceCreateInfo 的 ppEnabledExtensionNames 数组中加入 VK_EXT_DEBUG_UTILS_EXTENSION_NAME。
加载扩展函数指针:
Vulkan扩展的函数不像核心API那样可以直接调用,你需要通过 vkGetInstanceProcAddr (对于实例级函数) 或 vkGetDeviceProcAddr (对于设备级函数) 来获取它们的函数指针。
例如:
// 实例级函数
PFN_vkCreateDebugUtilsMessengerEXT pfnVkCreateDebugUtilsMessengerEXT = (PFN_vkCreateDebugUtilsMessengerEXT)vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");
PFN_vkDestroyDebugUtilsMessengerEXT pfnVkDestroyDebugUtilsMessengerEXT = (PFN_vkDestroyDebugUtilsMessengerEXT)vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
// 设备级函数 (注意:vkSetDebugUtilsObjectNameEXT 是设备级函数)
PFN_vkSetDebugUtilsObjectNameEXT pfnVkSetDebugUtilsObjectNameEXT = (PFN_vkSetDebugUtilsObjectNameEXT)vkGetDeviceProcAddr(device, "vkSetDebugUtilsObjectNameEXT");
PFN_vkCmdBeginDebugUtilsLabelEXT pfnVkCmdBeginDebugUtilsLabelEXT = (PFN_vkCmdBeginDebugUtilsLabelEXT)vkGetDeviceProcAddr(device, "vkCmdBeginDebugUtilsLabelEXT");
// ... 其他函数
通常,你会将这些函数指针存储在全局变量或特定的结构体中以便后续调用。
创建 Debug Messenger (可选但强烈推荐):
定义一个回调函数,其签名必须符合 PFN_vkDebugUtilsMessengerCallbackEXT。
VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
VkDebugUtilsMessageTypeFlagsEXT messageType,
const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
void* pUserData) {
std::cerr << "Validation layer: " << pCallbackData->pMessage << std::endl;
if (pCallbackData->objectCount > 0) {
for (uint32_t i = 0; i < pCallbackData->objectCount; ++i) {
std::cerr << " Object " << i << ": handle = " << pCallbackData->pObjects[i].objectHandle;
if (pCallbackData->pObjects[i].pObjectName) {
std::cerr << ", name = " << pCallbackData->pObjects[i].pObjectName;
}
// 你还可以根据 pCallbackData->pObjects[i].objectType 打印类型
std::cerr << std::endl;
}
}
return VK_FALSE; // 返回VK_TRUE会中断触发回调的API调用(如果它是验证错误)
}
填充 VkDebugUtilsMessengerCreateInfoEXT 结构体。
VkDebugUtilsMessengerCreateInfoEXT createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
createInfo.pfnUserCallback = debugCallback;
createInfo.pUserData = nullptr; // 可选的用户数据
调用 pfnVkCreateDebugUtilsMessengerEXT 创建信使。记得在程序结束时调用 pfnVkDestroyDebugUtilsMessengerEXT 销毁它。
为对象命名:
在你创建Vulkan对象(如 VkImage, VkBuffer, VkSwapchainKHR, VkQueue, VkCommandBuffer 等)之后,立即使用 pfnVkSetDebugUtilsObjectNameEXT 为它们命名。
// 假设 myImage 是一个 VkImage 句柄,device 是 VkDevice
// pfnVkSetDebugUtilsObjectNameEXT 是已加载的函数指针
VkDebugUtilsObjectNameInfoEXT nameInfo = {};
nameInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT;
nameInfo.objectType = VK_OBJECT_TYPE_IMAGE;
nameInfo.objectHandle = (uint64_t)myImage; // 必须转换为 uint64_t
nameInfo.pObjectName = "MyAwesomeFullscreenTexture";
if (pfnVkSetDebugUtilsObjectNameEXT) { // 确保函数指针有效
pfnVkSetDebugUtilsObjectNameEXT(device, &nameInfo);
}
一个小技巧:你可以封装一个辅助函数,在每次创建Vulkan对象后自动调用命名函数,这样可以避免遗漏。
在命令缓冲区中使用标签和标记:
// 假设 cmdBuffer 是一个 VkCommandBuffer
// pfnVkCmdBeginDebugUtilsLabelEXT 和 pfnVkCmdEndDebugUtilsLabelEXT 是已加载的函数指针
if (pfnVkCmdBeginDebugUtilsLabelEXT) {
VkDebugUtilsLabelEXT labelInfo = {};
labelInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT;
labelInfo.pLabelName = "RenderScene-OpaqueObjects";
labelInfo.color[0] = 0.1f; // 可选的颜色,调试器可能会用
labelInfo.color[1] = 0.8f;
labelInfo.color[2] = 0.1f;
labelInfo.color[3] = 1.0f;
pfnVkCmdBeginDebugUtilsLabelEXT(cmdBuffer, &labelInfo);
}
// ... 记录绘制不透明物体的命令 ...
if (pfnVkCmdEndDebugUtilsLabelEXT) {
pfnVkCmdEndDebugUtilsLabelEXT(cmdBuffer);
}
EasyVulkan中的封装支持
EasyVulkan框架通过VulkanDebug命名空间对上述功能进行了封装,使得调用更简洁明了:
初始化Debug Messenger
// 创建VkInstance时启用扩展和验证层
std::vector<const char*> instanceExtensions = {VK_EXT_DEBUG_UTILS_EXTENSION_NAME};
std::vector<const char*> validationLayers = {"VK_LAYER_KHRONOS_validation"};
if (ev::VulkanDebug::checkValidationLayerSupport(validationLayers)) {
// 设置debug messenger创建信息
VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
ev::VulkanDebug::populateDebugMessengerCreateInfo(debugCreateInfo);
// 创建debug messenger
VkDebugUtilsMessengerEXT debugMessenger;
ev::VulkanDebug::createDebugUtilsMessengerEXT(
instance, &debugCreateInfo, nullptr, &debugMessenger);
}
这段代码完成了以下工作:
检查是否支持所需的验证层
使用默认设置填充debug messenger创建信息
创建debug messenger以接收验证层消息
无需手动获取函数指针,EasyVulkan已在内部处理了这些细节。
为Vulkan对象命名
EasyVulkan提供了简洁的API来为Vulkan对象命名:
// 为缓冲区命名
VkBuffer vertexBuffer = ...; // 假设这是已创建的顶点缓冲区
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_BUFFER,
(uint64_t)vertexBuffer,
"MainVertexBuffer"
);
// 为图像命名
VkImage textureImage = ...; // 假设这是已创建的纹理图像
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_IMAGE,
(uint64_t)textureImage,
"DiffuseTexture"
);
// 为管线命名
VkPipeline graphicsPipeline = ...; // 假设这是已创建的图形管线
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_PIPELINE,
(uint64_t)graphicsPipeline,
"MainRenderPipeline"
);
这种命名方式简化了调试过程,当验证层报告错误时,会显示这些自定义名称而不是难以识别的句柄值。
使用命令缓冲区调试标签
EasyVulkan还提供了添加命令缓冲区调试标签的便捷方法:
// 开始一个命令区域
float shadowPassColor[4] = {0.8f, 0.0f, 0.0f, 1.0f}; // 红色
ev::VulkanDebug::beginDebugLabel(
device,
commandBuffer,
"Shadow Map Pass",
shadowPassColor
);
// 在这里记录阴影贴图渲染命令
vkCmdBeginRenderPass(...);
// ...其他渲染命令...
vkCmdEndRenderPass(...);
// 结束命令区域
ev::VulkanDebug::endDebugLabel(device, commandBuffer);
// 插入单个标记点
float markerColor[4] = {1.0f, 1.0f, 0.0f, 1.0f}; // 黄色
ev::VulkanDebug::insertDebugLabel(
device,
commandBuffer,
"Important Draw Call",
markerColor
);
这些标签在图形调试工具(如RenderDoc)中以彩色区域显示,使得复杂的帧分析变得更加直观。
实际应用示例:调试多重渲染通道
在一个典型的后处理渲染管线中,我们可以这样使用EasyVulkan的调试工具:
// 为所有离屏渲染目标命名
for (size_t i = 0; i < offscreenImages.size(); i++) {
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_IMAGE,
(uint64_t)offscreenImages[i],
"offscreen-rt-" + std::to_string(i)
);
}
// 记录命令时添加调试标签
float gbufferColor[4] = {0.0f, 0.5f, 0.9f, 1.0f}; // 蓝色
ev::VulkanDebug::beginDebugLabel(device, cmd, "G-Buffer Pass", gbufferColor);
// G-Buffer渲染命令...
ev::VulkanDebug::endDebugLabel(device, cmd);
float shadowColor[4] = {0.1f, 0.1f, 0.1f, 1.0f}; // 灰色
ev::VulkanDebug::beginDebugLabel(device, cmd, "Shadow Pass", shadowColor);
// 阴影渲染命令...
ev::VulkanDebug::endDebugLabel(device, cmd);
float lightingColor[4] = {1.0f, 0.8f, 0.0f, 1.0f}; // 金色
ev::VulkanDebug::beginDebugLabel(device, cmd, "Lighting Pass", lightingColor);
// 光照计算命令...
ev::VulkanDebug::endDebugLabel(device, cmd);
float postFxColor[4] = {0.8f, 0.4f, 0.9f, 1.0f}; // 紫色
ev::VulkanDebug::beginDebugLabel(device, cmd, "Post-Processing", postFxColor);
// 后处理命令...
ev::VulkanDebug::endDebugLabel(device, cmd);
这样,在调试工具中查看渲染过程时,不同的渲染阶段会清晰地以不同颜色显示,大大提高了调试效率。
实际使用场景举例
在复杂的渲染流程中,可以使用EasyVulkan便捷地添加调试标签与命名,显著提升分析效率。例如在渲染G-Buffer、阴影映射、光照计算、后处理阶段时,分别设置不同的标签以快速区分。
总结
VK_EXT_debug_utils 扩展为Vulkan开发提供了强有力的调试支持,通过清晰直观的命名与标签体系,有效减少了调试过程中的困难与复杂度,帮助开发者迅速定位并解决问题。尤其配合EasyVulkan的封装使用,更进一步提升了开发效率和调试体验。
-
Vulkan同步机制
Vulkan 通过提供多样且细粒度的同步机制,为开发者在控制渲染和计算流程时带来了极大的灵活性。Barrier、Semaphore、Fence 以及 Subpass Dependencies 各有不同的适用场景和影响:
Barrier 强调 GPU 内部流水线阶段及内存的同步,适合在单个队列内确保读写有序。
Semaphore 强调队列之间的同步,用来连接多队列的工作流。
Fence 强调 CPU 对 GPU 任务完成的可见性,用于资源回收和多帧并行调度。
Subpass Dependencies 强调在同一 Render Pass 内分阶段进行渲染时的同步,更高效地处理共享附件。
Vulkan中的同步机制详解
在传统图形API如OpenGL中,驱动程序会自动处理资源同步,开发者无需关心底层执行顺序。但这种”黑箱”机制带来了两个严重问题:性能损耗不可控和多线程扩展困难。
Vulkan 作为现代图形和计算的低层次API,其设计核心之一就是让开发者可以更细粒度地控制GPU和CPU之间的工作流程,以及不同GPU队列之间的执行顺序。而要实现稳定且高性能的渲染或计算,就必须要合理地利用好各种同步机制。本文将从几个常见的 Vulkan 同步原语(Barrier、Semaphore、Fence、Subpass Dependencies)入手,探讨它们各自的概念、适用场景、性能影响以及使用注意事项。希望通过本文,能为正在使用 Vulkan 或即将使用 Vulkan 的读者提供一些实践上的参考。
命令缓冲与队列:线性流但可乱序完成
在 Vulkan 中,所有命令都要先记录在 VkCommandBuffer 中,再提交到某个 VkQueue。在单个队列中,你提交的命令会按顺序进入 GPU 执行管线;但 GPU 可能在还没完成某个命令的写操作时,就已经开始处理后续命令的读阶段。
逻辑顺序:提交顺序一定依次排队
实际执行:可以重叠 / 并行 / 乱序完成
内存模型与缓存一致性
现代GPU采用分级缓存设计:
DDR显存 → L2缓存 → L1缓存(每个SM) → 寄存器
当计算单元写入L1缓存后,数据不会立即同步到其他缓存层级。这就是非一致性内存访问的根源。
“可用(Available)”意味着数据已经被刷出到更大层级缓存或主存;“可见(Visible)”意味着后续阶段可以读取到最新的数据——需要对读取方无效化缓存或更新缓存。
或者说:
Available:源阶段写完数据后,做缓存 Flush,让数据到了 L2 或更高层共享区域
Visible:目的阶段需要读数据时,做缓存 Invalidate,从而迫使硬件从 L2 或更高层共享区域读取数据,避免读到陈旧缓存
例如:
VkMemoryBarrier memBarrier{
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 1, &memBarrier, 0, nullptr, 0, nullptr);
这个屏障完成:
刷新所有计算阶段的写入到L2缓存(可用性)
使片段着色器能读取最新数据(可见性)
由于非一致性内存访问问题的存在,Vulkan还要求开发者显式地管理内存相关屏障,以确保数据在不同阶段之间的可见性和可用性。
因此,Vulkan的同步机制主要有两个目的:
确保数据在不同阶段之间的可见性和可用性(主要借助内存相关屏障)
确保执行顺序(主要借助pipeline屏障、semaphore、fence等)
Barrier(屏障)
Barrier的概念
Vulkan 中的 Barrier 是一种细粒度的内存和执行顺序同步机制。Barrier 在 GPU 内部起到”分割线”的作用,确保某些阶段的操作在 Barrier 之前完成,才能进行后续的阶段。例如,在进行纹理的读写转换时,需要使用 Pipeline Barrier 来保证图像布局转换或访问掩码的更改已完成,才进行下一步的采样或写入。
Barrier 有多种类型,最常见的包括:
Pipeline Barrier:用于指定源阶段(srcStageMask)到目标阶段(dstStageMask)的内存和执行依赖。
Memory Barrier:作用在整个资源上,用于指定对内存可见性的限制与保证。
Buffer Memory Barrier:只作用在特定的 Buffer 范围上。
Image Memory Barrier:只作用在特定的图像资源上,可以指定图像布局转换(image layout transition)。
Opengl中的Barrier和Vulkan对比
在Opengl中同样存在Barrier的概念,但是Vulkan中的barrier提供了更细粒度的控制。
// OpenGL隐式同步
glDispatchCompute(1024, 1, 1); // 计算着色器写入数据
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
glDrawArrays(GL_TRIANGLES, 0, 3); // 读取计算数据
// Vulkan显式同步
vkCmdDispatch(computeCmd, 1024, 1, 1);
VkMemoryBarrier barrier{...};
vkCmdPipelineBarrier(computeCmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_VERTEX_SHADER_BIT,
0, 1, &barrier);
vkCmdDraw(graphicCmd, 0, 3);
Barrier的适用场景
图像布局转换:如从 VK_IMAGE_LAYOUT_UNDEFINED 转为 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,或者从 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL 转为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,在开始或结束渲染通道时需要合适的图像布局。
内存可见性保证:当一个操作写入资源,另一个操作要读取该资源时,需要添加Barrier确保写入可见并完成。
不同着色阶段间的同步:例如,当顶点着色器阶段写入Buffer后,需要在片元着色器阶段进行读取,可通过Barrier来控制依赖顺序。
管线阶段分解
Vulkan将GPU工作分解为可组合的阶段:
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT: 表示管线的起始阶段。
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT: 间接绘制命令的阶段。
VK_PIPELINE_STAGE_VERTEX_INPUT_BIT: 顶点输入操作的阶段。
VK_PIPELINE_STAGE_VERTEX_SHADER_BIT: 顶点着色器执行的阶段。
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT: 片段着色器执行的阶段。
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT: 写入颜色附件的阶段。
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT: 计算着色器执行的阶段。
VK_PIPELINE_STAGE_TRANSFER_BIT: 内存传输操作的阶段。
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT: 表示管线的结束阶段。
访问掩码
GPU 有多级缓存(L1、L2),不同阶段可能对同一块内存资源有不同的缓存策略。为了避免缓存不一致(Incoherent),Vulkan 提供了 VK_ACCESS_* 标志来精确说明某个阶段对资源的访问类型。通过source access 与 destination access 结合,可以告诉 Vulkan “我要保证前面写的数据,在后面读的时候一定可见(Visible)”。
具体包括:
VK_ACCESS_INDIRECT_COMMAND_READ_BIT: 对间接命令数据的读取访问。
VK_ACCESS_INDEX_READ_BIT: 对索引缓冲区的读取访问。
VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT: 对顶点属性的读取访问。
VK_ACCESS_UNIFORM_READ_BIT: 对统一缓冲区的读取访问。
VK_ACCESS_SHADER_READ_BIT: 对着色器存储的读取访问。
VK_ACCESS_SHADER_WRITE_BIT: 对着色器存储的写入访问。
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT: 对颜色附件的读取访问。
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT: 对颜色附件的写入访问。
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT: 对深度/模板附件的读取访问。
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT: 对深度/模板附件的写入访问。
Barrier的使用
一个Barrier的定义如下:
void vkCmdPipelineBarrier(
VkCommandBuffer commandBuffer, // 记录barrier的命令缓冲区
VkPipelineStageFlags srcStageMask, // 源管线阶段掩码,指定哪些管线阶段必须在barrier之前完成
VkPipelineStageFlags dstStageMask, // 目标管线阶段掩码,指定哪些管线阶段必须等待barrier
VkDependencyFlags dependencyFlags, // 依赖标志,如VK_DEPENDENCY_BY_REGION_BIT表示区域依赖
uint32_t memoryBarrierCount, // 全局内存屏障数量
const VkMemoryBarrier* pMemoryBarriers, // 全局内存屏障数组
uint32_t bufferMemoryBarrierCount, // 缓冲内存屏障数量
const VkBufferMemoryBarrier* pBufferMemoryBarriers, // 缓冲内存屏障数组
uint32_t imageMemoryBarrierCount, // 图像内存屏障数量
const VkImageMemoryBarrier* pImageMemoryBarriers // 图像内存屏障数组
);
VkDependencyFlags主要用于控制屏障的行为,设置为0表示默认行为,同步将在整个渲染区域上全局进行,对于所有区域(所有像素),所有指定的源操作都必须在任何目标操作开始之前完成;设置为VK_DEPENDENCY_BY_REGION_BIT则允许基于区域的依赖,同步只在每个区域内进行,而不是整个渲染目标,不同区域之间可以并行处理,适合TBR架构。
如果不使用内存相关的屏障,该命令定义了一个执行屏障,即在srcStageMask和dstStageMask之间插入一个同步点,确保所有指定源操作都完成,目标操作才开始。
如果使用内存相关的屏障,则屏障会根据自身的srcAccessMask和dstAccessMask的值,在srcStageMask和dstStageMask之间插入一个同步点,确保所有指定源操作都完成,目标操作才开始。
全局内存屏障
// 全局内存屏障:影响所有内存访问
typedef struct VkMemoryBarrier {
VkStructureType sType; // 结构体类型,必须是 VK_STRUCTURE_TYPE_MEMORY_BARRIER
const void* pNext; // 扩展信息指针,通常为nullptr
VkAccessFlags srcAccessMask; // 源访问掩码,指定在barrier之前必须完成的内存访问类型
VkAccessFlags dstAccessMask; // 目标访问掩码,指定必须等待barrier的内存访问类型
} VkMemoryBarrier;
全局内存屏障由 VkMemoryBarrier 结构体描述,用于同步整个 GPU 内存中的所有数据。它并不针对具体的缓冲区或图像,而是作用于全局范围内的内存访问。通过设置源和目标访问掩码(srcAccessMask 和 dstAccessMask),开发者可以确保在某个阶段完成的所有内存写操作对后续阶段的所有内存读取操作可见。
缓冲区内存屏障
// 缓冲内存屏障:针对特定缓冲区的内存访问
typedef struct VkBufferMemoryBarrier {
VkStructureType sType; // 结构体类型,必须是 VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER
const void* pNext; // 扩展信息指针,通常为nullptr
VkAccessFlags srcAccessMask; // 源访问掩码
VkAccessFlags dstAccessMask; // 目标访问掩码
uint32_t srcQueueFamilyIndex; // 源队列族索引,用于队列族所有权转移
uint32_t dstQueueFamilyIndex; // 目标队列族索引,用于队列族所有权转移
VkBuffer buffer; // 受影响的缓冲区对象
VkDeviceSize offset; // 受影响区域的起始偏移
VkDeviceSize size; // 受影响区域的大小
} VkBufferMem
缓冲内存屏障由 VkBufferMemoryBarrier 结构体描述,专门用于同步对特定 VkBuffer 的内存访问。它不仅包含全局内存屏障的所有属性,还能指定屏障所作用的缓冲区、起始偏移量和数据大小。
srcQueueFamilyIndex和dstQueueFamilyIndex用于队列族所有权转移,当使用队列族所有权转移功能时,需要指定源队列族索引和目标队列族索引。如果不涉及队列族所有权转移,设置为VK_QUEUE_FAMILY_IGNORED。当计算队列族和图形队列族分离时,如果需要在图形队列中访问计算队列族的资源,需要使用队列族所有权转移(或者在资源创建时设置资源共享)。
图像内存屏障
// 图像内存屏障:针对特定图像的内存访问
typedef struct VkImageMemoryBarrier {
VkStructureType sType; // 结构体类型,必须是 VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER
const void* pNext; // 扩展信息指针,通常为nullptr
VkAccessFlags srcAccessMask; // 源访问掩码
VkAccessFlags dstAccessMask; // 目标访问掩码
VkImageLayout oldLayout; // 转换前的图像布局
VkImageLayout newLayout; // 转换后的图像布局
uint32_t srcQueueFamilyIndex; // 源队列族索引
uint32_t dstQueueFamilyIndex; // 目标队列族索引
VkImage image; // 受影响的图像对象
VkImageSubresourceRange subresourceRange; // 受影响的图像子资源范围
} VkImageMemoryBarrier;
// 子资源范围
typedef struct VkImageSubresourceRange {
VkImageAspectFlags aspectMask; // 图像方面(颜色、深度、模板等)
uint32_t baseMipLevel; // 基础mip级别
uint32_t levelCount; // mip级别数量
uint32_t baseArrayLayer; // 基础数组层
uint32_t layerCount; // 数组层数量
} VkImageSubresourceRange;
图像内存屏障由 VkImageMemoryBarrier 结构体描述,专门用于同步对特定 VkImage 的内存访问。它不仅包含全局内存屏障的所有属性,还能指定屏障所作用的图像、子资源范围。
VkImageSubresourceRange 用于指定图像的哪些部分受到影响。
aspectMask:指定图像的方面,如颜色、深度、模板等。
baseMipLevel:指定起始的mip级别。
levelCount:指定mip级别的数量。
baseArrayLayer:指定起始的数组层。
layerCount:指定数组层的数量。
除此之外,VkImageMemoryBarrier 还包含oldLayout和newLayout,用于指定图像布局的转换。
图像布局
在 Vulkan 中,图像布局(Image Layout)用于描述 GPU 内部如何组织和访问图像数据。相比 OpenGL 的隐式管理,Vulkan 要求开发者显式指定和转换图像布局,方便GPU明确用途并进行对应的性能优化。
Transient Attachments(转瞬附件),可以利用 Vulkan 的自动转换特性:
Transient Images: 创建时带有 VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT,初始布局设为 VK_IMAGE_LAYOUT_UNDEFINED 表明无需保留内容。
自动转换: Vulkan 驱动会在 render pass 开始前、结束后自动插入外部依赖,完成从 UNDEFINED 到目标布局(如 COLOR_ATTACHMENT_OPTIMAL)以及结束后的转换至 PRESENT_SRC_KHR(用于交换链呈现)。
各个场景的推荐布局:
• 颜色附件: VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
• 深度/模板附件: VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL 或后续只读时使用 VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
• 纹理采样: VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
• 传输操作: VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL / VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
• 交换链图像: VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
• 初始状态或通用用途: VK_IMAGE_LAYOUT_UNDEFINED 或 VK_IMAGE_LAYOUT_GENERAL
部分观察表明英伟达驱动在内部对 layout 的处理较为宽松,因此在英伟达显卡上可以将所有布局均设置为General。但正确区分和管理 image layout 是 Vulkan 规范的要求,并且对跨平台兼容性和未来驱动的稳定性至关重要。因此,这种理论是不正确的,不能因此在开发中忽略布局转换的管理。
Barrier的性能影响
过度使用导致性能损耗:每个 Barrier 都会在流水线上插入一个同步点,如果频繁地插入不必要的Barrier,会增加GPU的停顿并降低并行效率。
恰当使用可以避免错误和竞态:Barrier 在正确的位置使用能够让数据流安全地”串行化”,避免读写冲突。
使用注意事项
阶段掩码精确化:在指定 srcStageMask 和 dstStageMask 时,要尽量精确地指定真实会产生和需要依赖的着色阶段或管线阶段,以减少不必要的同步。
布局转换与访问掩码:Barrier 需要指定图像的布局转换和访问掩码(srcAccessMask/dstAccessMask),要确保与实际使用场景匹配。
批量Barrier:避免在不同的资源上反复调用单个Barrier,可以把多个资源的Barrier一起批量提交,减少命令开销。
Events:更灵活的同步利器
Pipeline Barrier 适用于同一个命令缓冲里强制“先做完 A,再做 B”。但如果你希望在并行中更灵活地“给 GPU 发信号”,可以用 Events:
Set Event (vkCmdSetEvent):在指定的 pipeline 阶段完成后,标记某个事件为已触发
Wait Event (vkCmdWaitEvent):在指定管线阶段等事件触发后,再继续执行
例如:
// A 和 B 两次计算
vkCmdDispatch(...);
vkCmdDispatch(...);
// 设置事件,表示 A 和 B 都完成后触发
vkCmdSetEvent(event, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
// D 是独立计算,可与 A、B 并行
vkCmdDispatch(...);
// 在绘制(C)前等待事件,确保 A、B 完成
vkCmdWaitEvents(1, &event, VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, ...);
// 真正的绘制
vkCmdDraw(...);
这样便可让 D 与 A、B 并行,只有在需要结果依赖的绘制(C)时才去等待。
Semaphore(信号量)
概念
Semaphore(信号量)在 Vulkan 中主要用于队列之间的执行顺序同步。它不能被 CPU 查询,CPU无法通过 Semaphore 判断什么时候 GPU 完成了某一条指令;它更适合在 GPU 内部或不同队列之间”串起”执行顺序。例如,我们可能在一个队列上进行图像后处理,然后在另一个队列上进行呈现;在这两个队列之间需要一个 Semaphore 让呈现等待后处理完成。
Semaphore的适用场景
不同队列间依赖:渲染队列和呈现队列之间交换图像通常会用到 Semaphore 来协调。例如,vkAcquireNextImageKHR 返回的信号量会在图像可用时触发,渲染结束后再用一个信号量告知显示队列可以进行呈现。
多通道并行处理:如果使用多队列同时处理不同任务,在队列间需要明确地指定执行顺序时,使用 Semaphore 进行同步。
Semaphore的性能影响
轻量但仅限 GPU 内部:相比Fence来说,Semaphore 更轻量,因为它无需让 CPU 轮询或等待,也不需要在 CPU 端可见。通常可以让不同队列并发工作,充分利用 GPU 资源。
等待延迟:如果在一个队列上等待另一个队列信号量,会引入一定延迟,要结合管线设计和命令流来优化。
Fence(栅栏)
概念
Fence(栅栏)是另一种同步原语,与 Semaphore 不同的是,Fence 可以被 CPU 端查询。当一个命令缓冲区在 GPU 上执行完成后,Fence 会被置为已信号状态(signaled state),CPU 通过 vkWaitForFences 或 vkGetFenceStatus 等函数能够得知 GPU 的执行状态。
Fence的适用场景
GPU 任务结束的 CPU 检测:在需要 CPU 端等待 GPU 完成某些任务(比如更新一块缓冲区后,需要 CPU 端再做一些操作)时使用。
动态资源回收:如果想知道 GPU 什么时候真正使用完一块资源(例如上一帧的 Uniform Buffer),Fence 可以帮助我们在 CPU 上安全地回收或复用资源。
多缓冲策略中的帧同步:常用在多帧并行(triple buffering 或 double buffering)时,CPU需要知道当前帧是否可以安全地写入时,会查询对应的 Fence。
Fence的性能影响
CPU/GPU 同步开销:Fence 一旦被 CPU 等待(vkWaitForFences),就会造成 CPU 和 GPU 之间的同步停顿,影响并行度。
延迟增加:如果没有必要的地方使用 Fence,会导致 CPU 过度等待,引起帧率下降或延迟增大。
使用注意事项
批量等待:在需要等待多个Fence时,尽量使用批量等待,而不是一个个等待。
复用 Fence:Fence 在被 signaled 之后,可通过 vkResetFences 重置来重复使用,避免频繁创建销毁。
避免无谓等待:在管线设计中,如果可以让 CPU 继续做其他工作,就尽量不要阻塞 CPU,只有在需要保证资源一致性时再使用 Fence。
Queue Submit & 信号量等待会带来隐式内存保证:
当一个队列提交被另一个队列通过信号量(Semaphore)等待时,Vulkan 会隐式地完成所有写入的 flush,使得内存可见给后续队列。
当一个队列提交完成并且 Fence 被置位,表示 GPU -> CPU 的所有工作也可见。
Subpass Dependencies(子通道依赖)
概念
Vulkan 中的 Render Pass 可以由多个 Subpass 组成,每个 Subpass 是一个渲染阶段。Subpass Dependencies 用于指定 Subpass 之间的执行和内存依赖关系,从而在同一个 Render Pass 内实现图像的输入/输出同步。因此在同一个 Render Pass 内,Subpass Dependencies 可以减少外部 Barrier 的使用。
一般地,Subpass Dependencies 会指定:
源子通道(srcSubpass)和目标子通道(dstSubpass)
源阶段与目标阶段(srcStageMask, dstStageMask)
源访问掩码与目标访问掩码(srcAccessMask, dstAccessMask)
依赖标志(dependencyFlags)
Subpass 依赖的适用场景
多渲染阶段共享同一图像:例如,在一个 Subpass 中写入颜色附件,接下来一个 Subpass 需要使用它作为输入附件(input attachment)。
分阶段渲染:如果想在一个 Render Pass 内连续执行多个着色阶段,而这几个阶段都在同一个 GPU 队列上运行,那么 Subpass Dependencies 就是最合适的同步方式。
Subpass 依赖的性能影响
减少开销:使用 Subpass Dependencies 在同一个 Render Pass 内可以减少图像布局切换和相关命令的开销。
提高带宽利用率:Subpass Dependencies 可以帮助 Vulkan 在同一 Render Pass 内合理地利用附件。
使用注意事项
需要在创建 Render Pass 时指定:一旦 Render Pass 的依赖关系确定,就不能再动态修改。
适当规划多 Subpass 结构:过度的 Subpass 拆分会导致复杂的依赖管理,不是所有的场景都适合在一个 Render Pass 内解决。
配合 input attachment 使用:当一个 Subpass 的输出作为下一个 Subpass 的 input attachment 时,需要正确设置依赖,确保不会读写冲突。
同步方法对比
不同的同步机制往往对应着不同的使用层次和需求,下面简要对比:
同步原语
主要作用
适用场景
CPU 可见性
性能影响
Barrier
GPU 内部执行与内存同步
管线阶段、内存访问控制、布局转换
不可见
需要精确指定,过多会影响并行性
Semaphore
队列间同步
多队列交互,如图像获取与提交
不可见
轻量级,主要在GPU端
Fence
CPU 等待 GPU 完成
资源回收、GPU任务结束时需 CPU 介入
可见
CPU 阻塞可能拖慢帧率
Subpass
Render Pass 内部同步
多个渲染阶段共享附件,减少外部Barrier
不可见
在同一 Render Pass 内更高效
设计与实践建议
尽量减少无意义的Barrier
保证数据访问安全的前提下,减少不必要的 Pipeline Barrier,可以合并多个资源的Barrier或者使用更精准的阶段掩码。
巧用Subpass减少外部同步
同一 Render Pass 内的多个阶段尽量用 Subpass Dependencies 处理,可以避免过多的图像布局切换和额外的 Barrier 开销。
合理划分队列并使用Semaphore
如果 GPU 拥有异步计算队列或传输队列,适当将工作分摊在不同队列。使用 Semaphore 进行队列间同步,充分利用 GPU 并行。
Fence用于 CPU/GPU 交互
只有在需要 CPU 等待 GPU 的结果时才使用 Fence,避免无谓的阻塞。要注意等待方式(阻塞或轮询)的选择及资源回收。
监控和调试
使用 Vulkan 的验证层(Validation Layers)或 GPU 调试工具,来确认 Barrier 和同步的正确性,避免出现 GPU 死锁或数据争用。
同步示例
假设当前任务中需要四个阶段:计算任务A和B、图形渲染任务C,显示任务D,并且存在依赖关系A->B->C->D。
任务在不同队列上执行:
计算队列:任务 A 和 B
图形队列:任务 C
展示队列:任务 D
同步设计分类
同步设计主要分为两类:
队列内部顺序:同一队列中提交的命令缓冲区会按提交顺序依次执行,无需额外的同步。
跨队列同步:不同队列之间必须显式使用同步原语(主要是信号量)来保证执行顺序,同时在命令缓冲区内部也可能需要插入 pipeline barrier 以保证内存访问顺序。
设计方案详解
1. 任务 A 和 B(计算任务)
同一队列或同一命令缓冲区的情况
如果 A 和 B 都在同一计算队列中,并且可以放入同一个命令缓冲区,那么 Vulkan 隐式保证它们的顺序执行。如果 A 的输出要供 B 使用,则在 A 与 B 之间插入一个 pipeline barrier,用于:
确保 A 的写操作在 B 开始前完成
做好内存可见性和资源状态转换(例如 buffer/image 的 layout 转换)
分成两个命令缓冲区的情况
如果你希望将 A 和 B 分开提交,也可以让同一队列的提交依赖于前一次提交的结束,这时队列内部的隐式顺序即可保证(也可以用 fence 在 CPU 侧等待 A 完成后再提交 B,但一般不需要额外的 GPU 信号量)。
2. 任务 B → 任务 C(计算到图形的跨队列同步)
由于任务 B 在计算队列执行,任务 C 在图形队列执行,所以需要使用信号量来跨队列同步:
在提交任务 B 的命令缓冲区时,在 VkSubmitInfo 中指定一个信号量(例如 sem_compute2graphics),当 B 完成时,信号量会被触发
在提交任务 C 的命令缓冲区时,在 VkSubmitInfo 中设置等待 sem_compute2graphics。这样可以确保任务 C 开始之前,计算队列上任务 B 已经完全结束,并且相关数据已经正确写入
3. 任务 C → 任务 D(图形到展示的跨队列同步)
类似地,任务 C 在图形队列执行,而任务 D(例如呈现操作)在展示队列上执行,同样需要使用信号量进行跨队列同步:
在提交任务 C 时,在 VkSubmitInfo 中指定一个信号量(例如 sem_graphics2present),在任务 C 完成后该信号量被触发
在提交任务 D(通常是在呈现队列上调用 vkQueuePresentKHR 时),在 VkPresentInfoKHR 中设置等待 sem_graphics2present。这样能确保任务 D(图像展示)开始前,图形渲染任务 C 已完全完成,并且渲染结果已经准备好用于展示
4. 总体提交流程示例
假设你已经创建好两个信号量:sem_compute2graphics 和 sem_graphics2present。整个提交流程可以大致描述为:
提交任务 A 和 B 到计算队列
如果在同一命令缓冲区内:
录制命令:先执行任务 A
插入合适的 pipeline barrier(保证 A 的结果对 B 可见)
执行任务 B
在命令缓冲区末尾,通过 VkSubmitInfo 指定在 B 结束时信号 sem_compute2graphics
如果分为两个提交:
第一个提交(任务 A)直接提交
第二个提交(任务 B)可以通过队列隐式顺序保证(或使用 fence 确保 A 完成),并在提交时信号 sem_compute2graphics
提交任务 C 到图形队列
在 VkSubmitInfo 中设置等待信号量 sem_compute2graphics(对应等待阶段:等待 B 完成)
录制任务 C 的渲染命令
在任务 C 命令缓冲区结束时,指定信号 sem_graphics2present,用于通知下一阶段
提交任务 D(展示)
在展示操作时(例如 vkQueuePresentKHR 调用时),在 VkPresentInfoKHR 中设置等待信号量 sem_graphics2present,保证任务 D 执行前图形渲染任务 C 已完成
栅栏的应用
在渲染循环中,通常会有多帧同时处于 “in-flight” 状态。如果你在开始一帧之前需要确保上一帧(或同一缓冲区对应的上一个帧)的所有 GPU 操作已经完成,那么你就需要在该帧开始前检查并等待相应的栅栏。因此,每一帧一开始需要vkWaitForFences。
EasyVulkan中的同步管理
EasyVulkan通过封装 Vulkan 的同步对象,提供了一整套简单而灵活的同步管理方案。核心类 SynchronizationManager 就是这一解决方案的代表。下面我们从几个方面来介绍它的设计理念与实现思路。
统一创建与管理
SynchronizationManager 封装了创建信号量和栅栏的过程,允许开发者通过简单的接口来创建同步对象,而无需关心底层的 Vulkan 调用细节。例如:
// 创建自定义的信号量和栅栏
auto transferComplete = syncManager->createSemaphore("transferComplete");
auto cmdFence = syncManager->createFence(false, "cmdBufferFence");
这里,createSemaphore 与 createFence 接口不仅简化了对象创建,还支持为同步对象命名,这有助于调试和资源追踪。
帧同步管理
在现代图形应用中,为了实现流畅的多缓冲(如三重缓冲)渲染,通常需要为每一帧分别创建同步对象。SynchronizationManager 内置了 createFrameSynchronization 接口,可以一次性为所有并行帧创建所需的信号量和栅栏。内部会为每一帧创建:
图像可用信号量:确保交换链图像获取到位。
渲染完成信号量:通知呈现引擎渲染工作已经结束。
帧内栅栏:用于 CPU 等待 GPU 渲染完成。
// 设置三重缓冲,每帧同步对象自动创建
syncManager->createFrameSynchronization(3);
在渲染循环中,可以直接通过索引获取对应帧的同步对象:
auto imageAvailable = syncManager->getImageAvailableSemaphore(currentFrame);
auto renderFinished = syncManager->getRenderFinishedSemaphore(currentFrame);
auto inFlightFence = syncManager->getInFlightFence(currentFrame);
自动资源清理与异常处理
SynchronizationManager 在内部维护了所有同步对象的生命周期。在其析构函数中,会自动调用 cleanup 方法,确保所有 Vulkan 同步对象得到正确释放,从而避免内存泄漏和资源错误。此外,各接口均提供了异常处理机制,当遇到同步对象创建失败或参数错误时,会抛出异常,帮助开发者及时定位问题。
-
Vulkan渲染通道
引言
在Vulkan图形API的渲染管线中,渲染通道(Render Pass)是构建高效渲染流程的核心组件,它不仅描述了一次渲染操作中的渲染目标(attachments)的使用方式,还决定了多个渲染阶段(subpass)之间的执行顺序和数据依赖关系。本文将深入探讨Vulkan渲染通道的工作原理及其关键要素的实现细节。
渲染通道的本质
渲染通道(VkRenderPass)定义了渲染操作期间使用的帧缓冲附件集合及其使用方式。它通过明确指定附件的生命周期和依赖关系,允许驱动进行深层次优化。相较于传统图形API的隐式状态管理,Vulkan的显式声明机制可降低内存带宽消耗达30%以上。
渲染通道主要负责描述:
渲染目标(Attachments) 的格式、加载/存储操作、采样数等属性;
子通道(Subpasses) 中每个阶段如何使用这些渲染目标;
子通道间的依赖关系(Pipeline Dependencies),用于保证数据正确性和同步。
设计哲学
显式控制:开发者必须明确指定所有附件和子流程
执行优化:提前声明渲染流程使驱动能优化资源布局
依赖管理:精确控制子流程间的内存和执行顺序
Attachment简介与创建方法
Attachment 通常指的是帧缓冲区中的渲染目标,比如颜色缓冲、深度缓冲或模板缓冲。每个attachment都需要在创建渲染通道时进行详细的描述,主要包括以下几个方面:
格式(Format):如 VK_FORMAT_B8G8R8A8_UNORM、VK_FORMAT_D32_SFLOAT 等。
采样数(Samples):多重采样时使用的采样数。
加载/存储操作(Load/Store Operations):如在渲染开始时是清除还是保留已有数据,在渲染结束时是存储还是丢弃数据。
初始与最终布局(InitialLayout/FinalLayout):表明attachment在渲染开始前和结束后的内存布局状态,便于Vulkan内部进行布局转换。
在创建渲染通道时,需要通过一个 VkAttachmentDescription 数组来描述所有的attachment。例如:
VkAttachmentDescription colorAttachment = {};
colorAttachment.format = swapchainImageFormat; // 指定Format
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT; // 指定采样数
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 指定加载操作
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; // 指定存储操作
// For depth/stencil attachments
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 指定模板加载操作
colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 指定模板存储操作
// End for depth/stencil attachments
colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 指定初始布局
colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; // 指定最终布局
Subpass简介与创建方法
Subpass 是渲染通道中的一个阶段,每个subpass描述了在该阶段中如何使用和依赖attachment。
在创建渲染通道时,需要使用 VkSubpassDescription 结构体来描述每个subpass。一个subpass通常至少需要描述以下信息:
Pipeline Bind Point:通常为 VK_PIPELINE_BIND_POINT_GRAPHICS,指明当前subpass将用于图形管线。
颜色附件引用(Color Attachments):指明渲染阶段中将写入颜色数据的attachment。
输入附件引用(Input Attachments):在一个subpass中可以读取之前subpass生成的数据。
深度/模板附件引用(Depth/Stencil Attachment):如果需要使用深度或模板测试,则需要指定对应的attachment。
如下代码创建了一个简单的subpass:
VkAttachmentReference colorAttachmentRef = {};
colorAttachmentRef.attachment = 0; // 引用上面定义的第一个attachment
colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
VkSubpassDescription subpass = {};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorAttachmentRef;
在这个例子中,我们创建了一个渲染阶段,该阶段会把渲染结果写入第0号attachment,并且要求该attachment处于 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL 布局状态。
为什么需要subpass?
Vulkan采用subpass的设计模式,除了在API层面给出了更明确的渲染流程以外,还带来了以下好处:
利用On-chip memory,减少内存带宽消耗。
在移动端等功耗敏感型设备上,GPU普遍采用了TBR设计,减少对内存带宽的占用,其主要思想是在小块(tile)区域内完成大部分渲染操作,然后统一写回到内存。subpass 非常适合这种架构:在同一个 render pass 内的多个 subpass 可以在同一tile 的生命周期内连续处理(数据传输发生在On-chip memory上),不必频繁地将数据在片上和内存之间来回传输。
避免全局同步。
传统渲染流水线中,可能需要使用全局的内存屏障来确保数据一致性,而在 subpass 内部,由于数据依赖关系已被明确定义,驱动和硬件就可以局部地处理同步,减少不必要的等待。
Subpass Dependency简介与创建方法
在一个渲染通道中,多个subpass之间或subpass与外部操作之间往往存在数据依赖关系。为了确保数据的正确性和避免竞态条件,需要在渲染通道中明确声明这些依赖关系。这就是管线Dependency(Pipeline Dependency)的作用。
管线Dependency 允许开发者在subpass之间定义内存屏障和执行屏障,确保:
某个subpass的写操作完成后,下一个subpass读取数据时能够获得最新的结果;
在执行特定渲染操作前,所有前置的操作已经完成并且内存访问已经同步。
在Vulkan中,这种依赖关系通过 VkSubpassDependency 结构体进行描述。
如何使用管线依赖
假设有两个subpass:subpass0写入颜色数据,而subpass1需要读取这些数据作为输入attachment。在这种场景下,我们需要确保subpass0的写操作在subpass1开始读取之前已经完成。可以通过如下方式定义一个依赖:
VkSubpassDependency dependency = {};
dependency.srcSubpass = 0; // 依赖源subpass
dependency.dstSubpass = 1; // 依赖目标subpass
// 指定依赖的阶段与访问类型
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
dependency.dstAccessMask = VK_ACCESS_INPUT_ATTACHMENT_READ_BIT;
// 对于跨subpass依赖,通常设置dependency.flags为0或VK_DEPENDENCY_BY_REGION_BIT
dependency.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT;
在上述例子中:
srcSubpass 指定了依赖的来源,即subpass0;
dstSubpass 指定了依赖的目标,即subpass1;
srcStageMask 和 dstStageMask 指明了涉及的管线阶段;
srcAccessMask 和 dstAccessMask 则描述了内存访问的类型。
此外,对于一些特殊情况(如初始状态与最终状态的同步),也可以将 srcSubpass 或 dstSubpass 设置为 VK_SUBPASS_EXTERNAL,以描述与渲染通道外部的依赖关系。
例如,定义一个计算预处理 -> 图形渲染的依赖:
VkSubpassDependency compToGraphic = {
.srcSubpass = VK_SUBPASS_EXTERNAL, // 表示计算阶段
.dstSubpass = 0, // 图形子流程索引
.srcStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
.dstStageMask = VK_PIPELINE_STAGE_VERTEX_INPUT_BIT |
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT |
VK_ACCESS_SHADER_READ_BIT,
.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT
};
又或者定义一个图形渲染 -> 计算后处理的依赖:
VkSubpassDependency graphicToComp = {
.srcSubpass = 1, // 最后一个图形子流程
.dstSubpass = VK_SUBPASS_EXTERNAL, // 后续计算阶段
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT
};
EasyVulkan中的RenderPass
构建一个含有颜色和深度附件的 Renderpass:
// 创建一个简单的 Renderpass,其中包含颜色和深度附件
auto renderPass = renderPassBuilder
.addColorAttachment(swapchainFormat) // 添加颜色附件
.addDepthStencilAttachment(depthFormat) // 添加深度/模板附件
.beginSubpass() // 开始一个子通道
.addColorReference(0) // 子通道引用第 0 个附件作为颜色附件
.setDepthStencilReference(1) // 子通道引用第 1 个附件作为深度/模板附件
.endSubpass() // 结束子通道
.build("mainRenderPass"); // 构建 Renderpass,并命名为 "mainRenderPass"
对于复杂的渲染流程,经常需要设置多个子通道以及它们之间的依赖关系。以下示例展示了如何配置多个子通道,并在它们之间添加依赖:
// 构建一个拥有多个子通道的 Renderpass
auto renderPass = renderPassBuilder
.addColorAttachment(colorFormat) // 添加颜色附件
.addDepthStencilAttachment(depthFormat) // 添加深度/模板附件
// 第一个子通道配置:渲染到颜色附件和深度附件
.beginSubpass()
.addColorReference(0)
.setDepthStencilReference(1)
.endSubpass()
// 第二个子通道配置:使用第一个子通道的颜色输出作为输入附件,同时写入到另一个颜色附件(例如后续处理)
.beginSubpass()
.addInputReference(0)
.addColorReference(2)
.endSubpass()
// 添加子通道间依赖:确保第一个子通道的写入操作完成后,第二个子通道才能读取
.addDependency(0, 1,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_SHADER_READ_BIT)
.build("multiPassRender"); // 构建 Renderpass
-
Vulkan描述符集
Vulkan描述符集的简化之道:探索EasyVulkan的实现
在使用Vulkan时,需要使用描述符集来管理资源。描述符集是Vulkan中的一种资源管理机制,用于管理资源(如纹理、缓冲区等)的绑定和使用。然而,描述符集的创建和使用需要大量的代码操作,包括创建描述符池、创建layout binding、创建描述符池、创建和更新descriptorSet等。并且,增加新的资源时,也需要修改大量的代码,这无疑增加了开发者的负担。
为了简化这个过程,EasyVulkan提供了DescriptorSetBuilder类,它采用了构建器模式,大大简化了描述符集的创建和管理过程。让我们一起深入了解这个实现。
DescriptorSetBuilder的核心设计
1. 构建器模式的应用
EasyVulkan的DescriptorSetBuilder采用了构建器模式,这使得描述符集的创建过程变得更加流畅和直观。主要体现在:
DescriptorSetBuilder builder(device, context);
VkDescriptorSet descriptorSet = builder
.addBinding(0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, VK_SHADER_STAGE_VERTEX_BIT)
.addBufferDescriptor(0, buffer, 0, bufferSize, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER)
.buildWithLayout("myDescriptorSet");
2. 资源绑定的简化
DescriptorSetBuilder提供了多个直观的方法来添加不同类型的资源,这些方法中封装了原本复杂的Vulkan API调用操作:
addBinding: 添加描述符布局绑定
addBufferDescriptor: 添加缓冲区描述符
addImageDescriptor: 添加图像描述符
addStorageImageDescriptor: 添加存储图像描述符
3. 自动化的资源管理
DescriptorSetBuilder还提供了自动的资源管理功能:
自动创建和管理描述符池
自动验证绑定的正确性
自动注册资源到资源管理器
自动处理错误情况
实现细节解析
1. 描述符池的创建
描述符池的创建在build方法中调用createPool方法完成。该方法的实现如下:
VkDescriptorPool DescriptorSetBuilder::createPool() const {
// 统计每种描述符类型的数量
std::unordered_map<VkDescriptorType, uint32_t> typeCount;
for (const auto &binding : m_layoutBindings) {
typeCount[binding.descriptorType] += binding.descriptorCount;
}
// 创建池大小信息
std::vector<VkDescriptorPoolSize> poolSizes;
for (const auto &[type, count] : typeCount) {
poolSizes.push_back({type, count});
}
// ... 创建描述符池
}
2. 绑定验证机制
为了确保描述符集的正确性,DescriptorSetBuilder实现了完善的验证机制:
void DescriptorSetBuilder::validateBindings() const {
// 检查是否存在绑定
if (m_layoutBindings.empty()) {
throw std::runtime_error("No descriptor set bindings specified");
}
// 检查重复绑定
std::unordered_map<uint32_t, VkDescriptorType> bindingTypes;
for (const auto &binding : m_layoutBindings) {
auto [it, inserted] = bindingTypes.insert({binding.binding, binding.descriptorType});
if (!inserted) {
throw std::runtime_error("Duplicate binding number in descriptor set layout");
}
}
// 验证写入描述符与绑定的匹配性
// ...
}
3. 资源更新机制
描述符集的更新过程也被简化:
void DescriptorSetBuilder::updateDescriptorSet(VkDescriptorSet descriptorSet) const {
std::vector<VkWriteDescriptorSet> writes = m_writes;
for (auto &write : writes) {
write.dstSet = descriptorSet;
}
vkUpdateDescriptorSets(m_device->getLogicalDevice(),
static_cast<uint32_t>(writes.size()),
writes.data(), 0, nullptr);
}
EasyVulkan中的DescriptorSet
// 创建一个包含uniform buffer和纹理的描述符集
auto descriptorSet = builder
// 添加uniform buffer绑定
.addBinding(0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1,
VK_SHADER_STAGE_VERTEX_BIT)
// 添加纹理绑定
.addBinding(1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1,
VK_SHADER_STAGE_FRAGMENT_BIT)
// 添加uniform buffer描述符
.addBufferDescriptor(0, uniformBuffer, 0, sizeof(UniformBufferObject),
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER)
// 添加纹理描述符(Sampler可以通过SamplerBuilder创建)
.addImageDescriptor(1, textureImageView, textureSampler,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
// 构建描述符集(name用于资源追踪)
.buildWithLayout("myMaterialDescriptorSet");
-
Vulkan命令缓冲区
命令池与命令缓冲区
在Vulkan的渲染架构中,命令池(Command Pool)和命令缓冲区(Command Buffer)构成了GPU指令管理的核心机制。
命令池 (Command Pool): 命令池是命令缓冲区的内存分配器和管理器。你可以把它想象成一个命令缓冲区的“工厂”。 每个命令池都与一个特定的队列族索引 (Queue Family Index) 关联。这意味着从该命令池分配的命令缓冲区只能提交到与该队列族索引对应的队列中。
内存分配: 命令池负责分配命令缓冲区所需的内存。Vulkan 允许驱动程序在命令池级别进行内存管理优化,例如预分配内存,从而提高命令缓冲区分配和释放的效率。
生命周期管理: 命令池管理着它所分配的命令缓冲区的生命周期。你可以重置整个命令池,一次性释放所有命令缓冲区,也可以单独重置和重新使用命令缓冲区。
命令缓冲区 (Command Buffer): 命令缓冲区是实际存储 GPU 指令的容器。它记录了一系列图形或计算操作,例如:
渲染指令: 设置渲染状态、绑定描述符集、绑定顶点缓冲区和索引缓冲区、绘制调用等。
计算指令: 分发计算着色器、绑定计算描述符集等。
传输指令: 缓冲区和图像的拷贝、填充、更新等。
同步指令: 设置事件、栅栏、管线屏障等。
可以将命令缓冲区类比为一条“指令流水线”,GPU 会按照命令缓冲区中指令的顺序逐条执行。
特性
命令池
命令缓冲区
生命周期管理
手动创建/销毁
由命令池分配/回收
线程关联性
绑定到特定队列族
继承所属命令池的队列族属性
重置行为
可批量重置所有关联命令缓冲区
支持单独或批量重置
内存管理
控制底层内存分配策略
使用预分配的内存空间
图 1:CommandPool 、CommandBuffer 、QueueFamily 、 Queue 的关系。
回顾在Vulkan初始化中,我们提到命令池创建时需要指定队列族,由该命令池创建的命令缓冲区也只能使用该队列族的队列来执行。
创建和使用
命令池创建
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = queueFamilyIndex; // 指定队列族索引 (例如图形队列族)
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool);
标志位解析:
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT: 适用于高频更新的短期命令。提示驱动程序命令缓冲区是短暂的,可能可以进行一些优化,但实际效果取决于驱动程序。
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT: 允许单独重置命令缓冲区。强烈建议设置此标志位。它允许你单独重置命令池中分配的命令缓冲区,以便重复使用,而无需重新分配。
命令缓冲区分配
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; // 或者 VK_COMMAND_BUFFER_LEVEL_SECONDARY
allocInfo.commandBufferCount = 1; // 分配的命令缓冲区数量
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
commandPool: 指定命令缓冲区从哪个命令池分配。
level: 指定命令缓冲区的级别,可以是 VK_COMMAND_BUFFER_LEVEL_PRIMARY 或 VK_COMMAND_BUFFER_LEVEL_SECONDARY。
commandBufferCount: 指定要分配的命令缓冲区数量。可以一次性分配多个命令缓冲区。
开始和结束记录
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; // 提示缓冲区将被提交一次并立即重置
vkBeginCommandBuffer(commandBuffer, &beginInfo);
// ... 在这里记录你的 Vulkan 指令 (例如 vkCmdBindPipeline, vkCmdDraw 等) ...
vkEndCommandBuffer(commandBuffer);
beginInfo.flags: 可以设置一些标志位来提示驱动程序命令缓冲区的用途,常用的标志位包括:
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT: 提示缓冲区将被提交一次,然后立即重置或释放。
VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT: 指示后续渲染通道的状态将继承自这个命令缓冲区之前的渲染通道。
VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT: 指示命令缓冲区可以多次提交,直到被显式重置。
提交
记录完成的命令缓冲区需要提交到队列才能被 GPU 执行。
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
throw std::runtime_error("failed to submit command buffer!");
}
// 可选: 等待队列完成执行 (同步操作)
vkQueueWaitIdle(graphicsQueue);
submitInfo.pCommandBuffers: 指向要提交的命令缓冲区数组。
vkQueueSubmit: 将命令缓冲区提交到指定的队列 (graphicsQueue 在这里是图形队列)。
vkQueueWaitIdle: 等待队列中的所有命令缓冲区执行完成。通常用于同步操作,例如等待渲染完成才能进行后续操作。
释放和重置命令缓冲区
使用完命令缓冲区后,你可以选择释放或重置它。
释放命令缓冲区: 将命令缓冲区返回给命令池,可以再次分配新的命令缓冲区。
vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
重置命令缓冲区: 清除命令缓冲区中的所有指令,使其可以重新记录。重置操作比重新分配更高效。
vkResetCommandBuffer(commandBuffer, 0); // 或者 VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT
VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT: 提示驱动程序释放命令缓冲区内部使用的资源,可以节省内存,但可能会降低性能。
销毁命令池
当不再需要命令池时,需要销毁它,释放其占用的资源。
vkDestroyCommandPool(device, commandPool, nullptr);
高级用法-二级缓冲区被主命令缓冲区调用
Vulkan 将命令缓冲区分为两种级别:
主命令缓冲区 (Primary Command Buffer, VK_COMMAND_BUFFER_LEVEL_PRIMARY): 主命令缓冲区可以提交到队列执行,并且可以调用二级命令缓冲区。它通常用于组织应用程序的主要渲染或计算流程。
二级命令缓冲区 (Secondary Command Buffer, VK_COMMAND_BUFFER_LEVEL_SECONDARY): 二级命令缓冲区不能直接提交到队列执行, 必须由主命令缓冲区调用才能被执行。二级命令缓冲区常用于:
组织复杂的渲染流程: 将渲染流程分解成多个逻辑模块,每个模块用一个二级命令缓冲区表示,提高代码可读性和可维护性。
并行命令缓冲区记录: 多个线程可以并行记录二级命令缓冲区,然后由主命令缓冲区按顺序调用,利用多核 CPU 提升命令缓冲区记录效率。
命令复用: 对于一些重复使用的命令序列,可以将其记录到二级命令缓冲区中,然后在多个主命令缓冲区中复用,减少重复记录的工作。
调用二级命令缓冲区
步骤:
创建二级命令缓冲区: 按照之前的方法,创建一个 VK_COMMAND_BUFFER_LEVEL_SECONDARY 级别的命令缓冲区。
记录二级命令缓冲区: 在二级命令缓冲区中记录你希望复用或并行记录的命令序列。
在主命令缓冲区中调用二级命令缓冲区: 在主命令缓冲区的记录过程中,使用 vkCmdExecuteCommands 命令来调用二级命令缓冲区。
// 假设 primaryCmdBuffer 是主命令缓冲区,secondaryCmdBuffer 是二级命令缓冲区
vkBeginCommandBuffer(primaryCmdBuffer, &primaryBeginInfo);
// ... 主命令缓冲区中的其他指令 ...
// 调用二级命令缓冲区
vkCmdExecuteCommands(primaryCmdBuffer, 1, &secondaryCmdBuffer);
// ... 主命令缓冲区中的其他指令 ...
vkEndCommandBuffer(primaryCmdBuffer);
二级命令缓冲区的继承 (Inheritance)
当二级命令缓冲区在渲染通道内执行时,需要设置继承信息,例如渲染通道 (Render Pass) 和帧缓冲区 (Framebuffer)。这通过 VkCommandBufferInheritanceInfo 结构体在分配二级命令缓冲区时指定。
VkCommandBufferInheritanceInfo inheritanceInfo{};
inheritanceInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO;
inheritanceInfo.renderPass = renderPass; // 继承的渲染通道
inheritanceInfo.framebuffer = framebuffer; // 继承的帧缓冲区
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_SECONDARY;
allocInfo.commandBufferCount = 1;
allocInfo.pInheritanceInfo = &inheritanceInfo; // 设置继承信息
vkAllocateCommandBuffers(device, &allocInfo, &secondaryCmdBuffer);
当前 Render Pass:确保次级缓冲区的操作与主缓冲区的渲染流程兼容。
当前 Framebuffer:明确操作的目标附件(如颜色/深度附件)。
子通道(Subpass):若次级缓冲区在某个子通道内执行,需指定子通道索引。
Vulkan 会基于这些信息验证次级缓冲区的操作是否合法。如果未正确配置,可能导致验证层错误或运行时崩溃:
如果未正确配置,Vulkan 会抛出以下错误:
VUID-VkCommandBufferBeginInfo-flags-00053(Render Pass 未匹配)
VUID-vkCmdExecuteCommands-pCommandBuffers-00088(Framebuffer 不兼容)
主命令缓冲会在记录时显式的在VkRenderPassBeginInfo中指定VkRenderPass和VkFramebuffer。
// 主缓冲区记录 Render Pass
VkRenderPassBeginInfo renderPassInfo{};
renderPassInfo.renderPass = myRenderPass; // 在此处指定 Render Pass
renderPassInfo.framebuffer = myFramebuffer;
vkBeginCommandBuffer(primaryCmdBuffer, ...);
vkCmdBeginRenderPass(primaryCmdBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
// 调用次级缓冲区或记录绘制命令
vkCmdEndRenderPass(primaryCmdBuffer);
vkEndCommandBuffer(primaryCmdBuffer);
高级用法-条件执行模式
代码逻辑
// 1. 定义可能执行的命令缓冲区(此处为两个候选)
VkCommandBuffer conditionalBuffer = ...;
// 2. 配置条件渲染信息
VkConditionalRenderingBeginInfoEXT condInfo{};
condInfo.sType = VK_STRUCTURE_TYPE_CONDITIONAL_RENDERING_BEGIN_INFO_EXT;
condInfo.buffer = conditionBuffer; // 存储条件值的缓冲区
condInfo.offset = 0; // 条件值在缓冲区中的偏移量
// 3. 开启条件渲染范围
vkCmdBeginConditionalRenderingEXT(primaryBuffer, &condInfo);
// 4. 在条件范围内执行命令
vkCmdExecuteCommands(primaryBuffer, 1, conditionalBuffer);
// 5. 结束条件渲染范围
vkCmdEndConditionalRenderingEXT(primaryBuffer);
解析
条件值的判定规则
GPU 会从 conditionBuffer 的指定 offset 处读取一个 32位无符号整数值。
判定逻辑:
若值 ≠ 0 → 执行条件范围内的命令
若值 = 0 → 跳过所有条件范围内的命令
执行范围的作用域
条件渲染的影响范围严格限定在 vkCmdBeginConditionalRenderingEXT 和 vkCmdEndConditionalRenderingEXT 之间的命令。
嵌套支持:Vulkan 允许条件渲染的嵌套使用,内层条件可以覆盖外层条件。
次级命令缓冲区的特殊性
示例中通过 vkCmdExecuteCommands 调用的次级命令缓冲区会整体受条件值控制。
若条件不满足,次级缓冲区的所有命令将被跳过,如同未被调用。
场景
动态遮挡剔除(Occlusion Culling)
// 步骤:
// 1. 第一帧:执行遮挡查询,将结果写入 conditionBuffer
// 2. 后续帧:根据查询结果决定是否绘制物体
vkCmdBeginConditionalRenderingEXT(cmdBuffer, &condInfo);
vkCmdDrawIndexed(cmdBuffer, ...); // 仅当物体可见时执行绘制
vkCmdEndConditionalRenderingEXT(cmdBuffer);
多方案动态切换
uint32_t conditionValue = useTechniqueA ? 1 : 0;
CopyDataToBuffer(conditionBuffer, &conditionValue); // 更新条件值
vkCmdBeginConditionalRenderingEXT(cmdBuffer, &condInfo);
if (useTechniqueA) {
vkCmdExecuteCommands(cmdBuffer, 1, &techACmdBuffer);
} else {
vkCmdExecuteCommands(cmdBuffer, 1, &techBCmdBuffer);
}
vkCmdEndConditionalRenderingEXT(cmdBuffer);
GPU-Driven 渲染决策
// 通过计算着色器生成条件值
vkCmdDispatch(computeCmdBuffer, ...);
// 在渲染流程中根据计算结果决策
vkCmdBeginConditionalRenderingEXT(renderCmdBuffer, &condInfo);
vkCmdDraw(renderCmdBuffer, ...); // 由 GPU 计算的结果控制是否绘制
vkCmdEndConditionalRenderingEXT(renderCmdBuffer);
何时使用二级命令缓冲区?
1. 复杂场景分解
将复杂的渲染流程分解成多个二级命令缓冲区,例如将不同的物体或渲染阶段分别用不同的二级命令缓冲区表示,可以提高代码组织性。
2. 并行记录
如果你的应用程序有复杂的场景,命令缓冲区记录成为瓶颈,可以考虑使用多线程并行记录二级命令缓冲区,然后在一个主命令缓冲区中按顺序调用这些二级命令缓冲区。这可以有效利用多核 CPU 的性能。
3. 命令复用
对于重复使用的渲染或计算序列,将其记录到二级命令缓冲区中,在多个主命令缓冲区中复用,可以减少重复记录的工作量。
打包提交CommandBuffer
在Vulkan中,提交多个不同的command buffer到同一个队列与使用单个command buffer相比,性能差异主要受以下因素影响:
1. CPU开销
多次提交多个command buffer:
若每次提交均调用vkQueueSubmit(尤其是分散的多次调用),会增加CPU负担。驱动需要为每次提交处理验证、同步资源及命令传输,频繁的小批次提交可能导致CPU成为瓶颈。
单次提交单个command buffer:
减少vkQueueSubmit调用次数可降低CPU开销。驱动优化空间更大,可能合并内部操作,提升效率。
2. GPU执行效率
状态切换与批处理:
多个command buffer可能导致频繁的状态切换(如管线绑定、资源更新)。若这些command buffer未优化,GPU可能在执行时产生空闲。而单个command buffer可通过连续记录减少状态切换,提升吞吐量。
提交批次的影响:
GPU通常以提交批次为单位调度任务。多次提交可能分割任务,导致GPU无法充分并行;而单次提交(或一次提交多个command buffer)可能形成更大的批次,利于硬件优化。
3. 同步与依赖
显式同步需求:
多次提交常需依赖信号量或栅栏确保执行顺序,可能引入GPU等待。单次提交内部命令天然有序,减少同步需求,降低延迟。
4. 驱动与硬件的优化
驱动处理差异:
部分驱动可能优化多command buffer的合并执行(尤其在单次vkQueueSubmit提交多个时),性能接近单个command buffer。但多次分散提交可能无法享受此类优化。
硬件特性:
某些GPU架构更擅长处理大命令流,而小批次可能导致调度开销。
实践建议
优先减少提交次数:通过单次vkQueueSubmit提交多个command buffer(而非多次调用),可平衡CPU/GPU效率,接近单一大command buffer的性能。
合并录制需权衡:若多个command buffer内容固定且需重用,分开录制可能更灵活;若内容动态变化,合并录制可能减少状态切换,但需评估CPU录制开销。
场景依赖:对实时渲染等高吞吐场景,倾向于减少提交次数与状态切换;对复杂依赖或并行录制需求,可接受适度性能损失以换取灵活性。
EasyVulkan中的CommandBuffer
在EasyVulkan中,使用CommandBufferBuilder来创建和记录命令缓冲区,在注册到ResourceManager中时,会绑定对应的CommandPool。即name-> (CommandBuffer,CommandPool)
例如创建单个CommandBuffer:
// 假设 graphicsPool 已经正确创建并初始化
auto cmdBuffer = commandBufferBuilder
->setCommandPool(graphicsPool)
->setLevel(VK_COMMAND_BUFFER_LEVEL_PRIMARY)
->build("mainCommandBuffer");
创建多个CommandBuffer:
// swapchainImageCount 为交换链图像数量
auto cmdBuffers = commandBufferBuilder
->setCommandPool(graphicsPool)
->setCount(swapchainImageCount)
->buildMultiple({"frame0", "frame1", "frame2"});
创建多个二级CommandBuffer:
// 假设 threadCount 是线程数量
auto secondaryCmdBuffers = commandBufferBuilder
->setCommandPool(graphicsPool)
->setLevel(VK_COMMAND_BUFFER_LEVEL_SECONDARY)
->setCount(threadCount)
->buildMultiple();
-
VMA
引言:为何需要VMA?
Vulkan内存显式控制
在传统的图形API(如OpenGL)中,内存管理被API层完全封装,开发者无需关心底层细节。但Vulkan将内存控制权完全下放给开发者,暴露了显式的内存管理机制。这种设计带来了两个核心挑战:
多类型内存堆:现代GPU通常包含4-8种内存类型(如DEVICE_LOCAL、HOST_VISIBLE等),分布在不同的内存堆中
手动生命周期管理:开发者需要自行处理内存分配、绑定、映射和释放的全过程
一个典型的Vulkan内存分配流程需要:
vkGetBufferMemoryRequirements(...);
vkAllocateMemory(...);
vkBindBufferMemory(...);
vkMapMemory(...); // 可选
// 使用内存...
vkDestroyBuffer(...);
vkFreeMemory(...);
这种显式控制虽然提升了性能,但带来了极高的开发复杂度。根据Khronos的统计,超过60%的Vulkan内存相关BUG源于不正确的内存类型选择或生命周期管理。
Sub-allocation
考虑驱动开销:Vulkan最推荐使用sub-allocat,但是sub-allocation的内存分配原则,即尽可能减少Memory和Buffer的数量。
图 1:sub-allocation。
“The Good” ——在一大块内存里对子资源进行子分配
思路:
• 只向驱动/操作系统申请一块较大的 VkDeviceMemory,只创建一个buffer;
• 运行时将该buffer“切割”成若干子区间,每个子区间存储不同的数据。
• 这样可以显著减少真正的“分配调用次数”,也不会超出 maxMemoryAllocationCount,同时也可以减少内存绑定次数。
// 1. 创建一个“大 Buffer”以获取内存需求(包含所有用途)
VkBufferCreateInfo bigBufferCI = {};
bigBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bigBufferCI.size = totalBufferSize; // 总大小(包含 Index/Vertex/Uniform)
bigBufferCI.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT |
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
vkCreateBuffer(device, &bigBufferCI, nullptr, &bigBuffer);
VkMemoryRequirements memReqBigBuffer;
vkGetBufferMemoryRequirements(device, bigBuffer, &memReqBigBuffer);
// 2. 计算各用途的偏移和对齐
VkDeviceSize offsetIndex = 0;
VkDeviceSize offsetVertex = AlignUp(offsetIndex + indexBufferSize, memReqBigBuffer.alignment);
VkDeviceSize offsetUniform = AlignUp(offsetVertex + vertexBufferSize, memReqBigBuffer.alignment);
VkDeviceSize totalSize = AlignUp(offsetUniform + uniformBufferSize, memReqBigBuffer.alignment);
// 3. 只申请一次设备内存
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = totalSize;
allocInfo.memoryTypeIndex = FindMemoryType(memReqBigBuffer.memoryTypeBits, desiredProperties);
VkDeviceMemory bigMemory;
vkAllocateMemory(device, &allocInfo, nullptr, &bigMemory);
// 4. 将整个 bigMemory 绑定到“大 Buffer”
vkBindBufferMemory(device, bigBuffer, bigMemory, 0);
// 5. 将数据拷贝到 Buffer 的不同偏移处
void* mappedMemory = nullptr;
vkMapMemory(device, bigMemory, 0, VK_WHOLE_SIZE, 0, &mappedMemory);
// -- 将 Index 数据拷贝到对应偏移
std::memcpy((uint8_t*)mappedMemory + offsetIndex, localIndexData, indexBufferSize);
// -- 将 Vertex 数据拷贝到对应偏移
std::memcpy((uint8_t*)mappedMemory + offsetVertex, localVertexData, vertexBufferSize);
// -- 将 Uniform 数据拷贝到对应偏移
std::memcpy((uint8_t*)mappedMemory + offsetUniform, localUniformData, uniformBufferSize);
vkUnmapMemory(device, bigMemory);
// 6. 使用时指定偏移
// -- 绑定 Index Buffer
vkCmdBindIndexBuffer(cmdBuffer, bigBuffer, offsetIndex, VK_INDEX_TYPE_UINT16);
// -- 绑定 Vertex Buffer
VkDeviceSize vertexBufferOffset = offsetVertex;
vkCmdBindVertexBuffers(cmdBuffer, 0, 1, &bigBuffer, &vertexBufferOffset);
// -- 更新 DescriptorSet,指定 Uniform Buffer 的偏移和范围
VkDescriptorBufferInfo uniformBufferInfo = {};
uniformBufferInfo.buffer = bigBuffer;
uniformBufferInfo.offset = offsetUniform;
uniformBufferInfo.range = uniformBufferSize;
VkWriteDescriptorSet writeDesc = {};
writeDesc.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writeDesc.dstSet = descriptorSet;
writeDesc.dstBinding = uniformBinding;
writeDesc.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writeDesc.descriptorCount = 1;
writeDesc.pBufferInfo = &uniformBufferInfo;
vkUpdateDescriptorSets(device, 1, &writeDesc, 0, nullptr);
这种方式:
• 减少 vkAllocateMemory / vkBindBufferMemory 调用次数(只分配和绑定一次);
• 通过应用层自己维护 offset 来在同一个 Buffer 内划分出 Index/Vertex/Uniform 等数据区域;
• 大幅降低驱动层管理负担,符合 Vulkan 中鼓励的“子分配”思路,从而达到图示所说的 “The Good”。
“The Bad” ——单块显存 + 单个大 Buffer + 手动管理 offset
思路:
• 只向驱动/操作系统申请一块较大的 VkDeviceMemory;
• 运行时将这块大内存“切割”成若干子区间,每个子区间绑定到不同的 Buffer(如 Index/Vertex/Uniform)上;
• 自己管理这块内存中各个子区间的偏移与大小。
• 这样可以显著减少真正的“分配调用次数”,也不会超出 maxMemoryAllocationCount。
// 1. 分别创建需要的 Buffer 以获取各自需求(但先不真正分配内存)
// -- 例子:Index Buffer
VkBufferCreateInfo indexBufferCI = {};
indexBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
indexBufferCI.size = indexBufferSize;
indexBufferCI.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT;
vkCreateBuffer(device, &indexBufferCI, nullptr, &indexBuffer);
VkMemoryRequirements memReqIndex;
vkGetBufferMemoryRequirements(device, indexBuffer, &memReqIndex);
// -- 例子:Vertex Buffer
VkBufferCreateInfo vertexBufferCI = {};
vertexBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
vertexBufferCI.size = vertexBufferSize;
vertexBufferCI.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
vkCreateBuffer(device, &vertexBufferCI, nullptr, &vertexBuffer);
VkMemoryRequirements memReqVertex;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memReqVertex);
// -- 例子:Uniform Buffer
VkBufferCreateInfo uniformBufferCI = {};
uniformBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
uniformBufferCI.size = uniformBufferSize;
uniformBufferCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
vkCreateBuffer(device, &uniformBufferCI, nullptr, &uniformBuffer);
VkMemoryRequirements memReqUniform;
vkGetBufferMemoryRequirements(device, uniformBuffer, &memReqUniform);
// 2. 计算总共需要的内存大小与对齐(实际需要根据对齐做更严谨的计算)
// 比如令 offsets 为对齐后得到的各个起始偏移
VkDeviceSize offsetIndex = 0;
VkDeviceSize offsetVertex = AlignUp(offsetIndex + memReqIndex.size, memReqVertex.alignment);
VkDeviceSize offsetUniform = AlignUp(offsetVertex + memReqVertex.size, memReqUniform.alignment);
VkDeviceSize totalSize = offsetUniform + memReqUniform.size;
// 3. 只申请一次设备内存
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = totalSize;
allocInfo.memoryTypeIndex = FindMemoryType(
memReqIndex.memoryTypeBits & memReqVertex.memoryTypeBits & memReqUniform.memoryTypeBits,
desiredProperties // 比如 HOST_VISIBLE | HOST_COHERENT 等
);
VkDeviceMemory bigMemory;
vkAllocateMemory(device, &allocInfo, nullptr, &bigMemory);
// 4. 将同一个 bigMemory 不同的偏移绑定给不同 Buffer
vkBindBufferMemory(device, indexBuffer, bigMemory, offsetIndex);
vkBindBufferMemory(device, vertexBuffer, bigMemory, offsetVertex);
vkBindBufferMemory(device, uniformBuffer, bigMemory, offsetUniform);
这样所有的 Index/Vertex/Uniform Buffer 都共享了同一个 VkDeviceMemory,而我们只跟驱动真正打了一次“分配”的交道。
“The ?!? # Δt” ——极度碎片化或疯狂分配
思路:
• 每个小对象都单独分配,甚至更糟:同一个对象反复频繁地分配和释放;
• 导致显存碎片化、分配次数超标、或大幅度浪费显存;
典型反面案例:
• 你的场景中有非常多的微小 Buffer(例如粒子、分块地形中大量细分)却从未做子分配;
• 或者在帧间频繁地 vkFreeMemory / vkAllocateMemory,引起驱动层不断地做大开销的操作;
• 在高并发或高频率下,性能和可用内存都崩溃式下降。
数据传输
独立显卡
有专用的显存(VRAM)
数据传输过程:
CPU (Host) → PCIe总线 → GPU显存(Device Local Memory)
需要创建staging buffer作为中间缓冲
数据传输会受限于PCIe总线带宽
集成显卡
CPU和GPU共享系统内存
数据传输过程:
直接在共享内存中访问,无需跨PCIe传输
不需要staging buffer
通过VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 标识
Lazily Allocated Memory
移动端GPU上的on-chip memory
这种内存不会立即分配物理内存
通常用于移动设备的 transient attachments(如深度缓冲区)
实际的内存分配会推迟到真正需要时
在某些架构上可能完全不会分配物理内存
这种内存在渲染时可以被保留在GPU上,显著降低带宽。
补充内容,VMA相关请跳转到“VMA的诞生”
补充:VkPhysicalDeviceMemoryProperties
typedef struct VkPhysicalDeviceMemoryProperties {
// 可用的内存类型数量
uint32_t memoryTypeCount;
// 内存类型数组,最大长度为 VK_MAX_MEMORY_TYPES (32)
VkMemoryType memoryTypes[VK_MAX_MEMORY_TYPES];
// 可用的内存堆数量
uint32_t memoryHeapCount;
// 内存堆数组,最大长度为 VK_MAX_MEMORY_HEAPS (16)
VkMemoryHeap memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;
memoryTypeCount指明该设备支持的内存类型数量。
VkMemoryType
其中 VkMemoryType 结构体定义为:
typedef struct VkMemoryType {
// 内存属性标志(VkMemoryPropertyFlags)
VkMemoryPropertyFlags propertyFlags;
// 此内存类型使用的堆的索引
uint32_t heapIndex;
} VkMemoryType;
一个VkMemoryType结构体对应GPU支持的一种内存类型,比如:
// memoryTypes[0] - 设备本地内存(VRAM)
propertyFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
heapIndex = 0 // 指向VRAM堆
// memoryTypes[1] - CPU可见的系统内存
propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
heapIndex = 1 // 指向系统内存堆
// memoryTypes[2] - CPU可见且带缓存的系统内存
propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_CACHED_BIT
heapIndex = 1 // 同样指向系统内存堆
VkMemoryPropertyFlags的常见值包括:
typedef enum VkMemoryPropertyFlagBits {
// 设备本地内存,通常是GPU最高效的内存类型
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT = 0x00000001,
// CPU可见内存,可以使用vkMapMemory映射
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT = 0x00000002,
// CPU写入立即可见,不需要手动flush
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT = 0x00000004,
// CPU写入被缓存,需要手动flush和invalidate
VK_MEMORY_PROPERTY_HOST_CACHED_BIT = 0x00000008,
// 用于tile-based GPU的延迟分配内存
VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT = 0x00000020,
// 受保护的内存,用于安全内容
VK_MEMORY_PROPERTY_PROTECTED_BIT = 0x00000040,
// RDMA可访问的内存
VK_MEMORY_PROPERTY_DEVICE_COHERENT_BIT_AMD = 0x00000040,
// 设备本地且RDMA可访问
VK_MEMORY_PROPERTY_DEVICE_UNCACHED_BIT_AMD = 0x00000080,
// 可以原子访问的RDMA内存
VK_MEMORY_PROPERTY_RDMA_CAPABLE_BIT_NV = 0x00000100,
} VkMemoryPropertyFlagBits;
常见的内存标识(flag常见组合):
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT //设备本地内存(GPU 专用)
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT //CPU 可见的暂存缓冲区
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT |
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT //集成显卡的共享内存
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_CACHED_BIT //带缓存的 CPU 访问内存
VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT |
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT //移动设备的临时附件(如深度缓冲)
VkMemoryHeap
typedef struct VkMemoryHeap {
VkDeviceSize size; // 堆的大小(字节)
VkMemoryHeapFlags flags; // 堆的属性标志
} VkMemoryHeap;
VkMemoryHeapFlags的常见值包括:
VK_MEMORY_HEAP_DEVICE_LOCAL_BIT //设备本地内存(通常是显卡的 VRAM)
VK_MEMORY_HEAP_MULTI_INSTANCE_BIT //多实例内存(在多 GPU 设置中,标记某个内存堆可以被多个物理设备同时访问)
findMemoryType
uint32_t findMemoryType(VkPhysicalDevice physicalDevice,
uint32_t typeFilter,
VkMemoryPropertyFlags properties) {
// 获取物理设备的内存属性
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
// 遍历所有内存类型
for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
// 检查两个条件:
// 1. typeFilter 中的位是否设置 (通过位运算)
// 2. 内存类型是否具有我们需要的所有属性
if ((typeFilter & (1 << i)) &&
(memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
return i;
}
}
// 如果没找到合适的内存类型,抛出错误
throw std::runtime_error("failed to find suitable memory type!");
}
// 创建缓冲区时
VkBuffer buffer;
VkBufferCreateInfo bufferInfo = {...};
vkCreateBuffer(device, &bufferInfo, nullptr, &buffer);
// 获取缓冲区的内存需求
VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, buffer, &memRequirements);
// 分配内存
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
// 查找合适的内存类型
allocInfo.memoryTypeIndex = findMemoryType(
physicalDevice,
memRequirements.memoryTypeBits, // typeFilter:缓冲区支持的内存类型
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT // 所需属性
);
在为缓冲区分配内存时,需要满足:
缓冲区支持的内存类型(typeFilter)
所需属性(properties)
即在VkPhysicalDeviceMemoryProperties中找到一个内存类型,它满足typeFilter和properties的要求。
VMA的诞生
Vulkan Memory Allocator(VMA)库应运而生,它通过以下核心设计解决了上述痛点:
智能内存类型选择:基于资源使用特性自动选择最佳内存类型
生命周期自动化:统一管理资源对象及其关联内存的生命周期
高级内存策略:提供内存池、碎片整理等高级功能
诊断工具集成:内置内存统计、泄漏检测等调试功能
初始化:构建内存管理基石
环境配置
使用VMA需要:
项目集成:要在项目中使用VMA,首先需要将其源代码或库文件包含进工程中,并正确链接(link)。
选择Vulkan版本:VMA需要配置Vulkan的目标版本,以便启用或禁用特定的Vulkan函数和扩展。
导入Vulkan函数:VMA自身需要调用大量Vulkan函数,这些函数需通过VmaVulkanFunctions结构体向VMA提供。可通过手动设置或者自动加载方式(如使用Vulkan loader)来实现。
启用扩展:如果需要使用诸如VK_KHR_dedicated_allocation等Vulkan扩展,则需要在创建VmaAllocator时告知VMA以便充分利用这些扩展。
配置选项:在初始化VMA时,可指定各种标志(Flags)与配置,如线程安全(是否启用互斥锁)等。
初始化流程
#include "vk_mem_alloc.h"
VmaAllocatorCreateInfo allocatorInfo = {};
allocatorInfo.vulkanApiVersion = VK_API_VERSION_1_2;
allocatorInfo.physicalDevice = physicalDevice;
allocatorInfo.device = device;
allocatorInfo.instance = instance;
VmaAllocator allocator;
vmaCreateAllocator(&allocatorInfo, &allocator);
关键配置项说明:
typedef struct VmaAllocatorCreateInfo {
VkPhysicalDevice physicalDevice;
VkDevice device;
// 启用高级特性
VmaAllocatorCreateFlags flags;
// 自定义CPU内存分配器
const VmaAllocationCallbacks* pAllocationCallbacks;
// 设备内存限制
VkDeviceSize heapSizeLimit[VK_MAX_MEMORY_HEAPS];
} VmaAllocatorCreateInfo;
推荐开启的标志位:
VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT:支持设备地址捕获
VMA_ALLOCATOR_CREATE_EXT_MEMORY_BUDGET_BIT:显存预算监控
AllocatorCreateInfo中的flags:
VMA_ALLOCATOR_CREATE_EXTERNALLY_SYNCHRONIZED_BIT
表示在多线程环境下,由用户负责同步
可以提高性能,但需要用户确保分配器的线程安全
如果设置此标志,用户必须在外部进行同步,确保对同一个 VmaAllocator 的调用不会并发执行
VMA_ALLOCATOR_CREATE_KHR_DEDICATED_ALLOCATION_BIT
启用 VK_KHR_dedicated_allocation 扩展功能
允许为某些特定资源分配专用内存块
适用于大型资源(如大纹理)的优化
VMA_ALLOCATOR_CREATE_KHR_BIND_MEMORY2_BIT
启用 VK_KHR_bind_memory2 扩展
提供更灵活的内存绑定选项
允许一次绑定多个内存对象
VMA_ALLOCATOR_CREATE_EXT_MEMORY_BUDGET_BIT
启用 VK_EXT_memory_budget 扩展
允许查询当前内存使用情况和预算
有助于更好地管理内存资源
VMA_ALLOCATOR_CREATE_AMD_DEVICE_COHERENT_MEMORY_BIT
启用 VK_AMD_device_coherent_memory 扩展
支持 AMD 设备一致性内存
提供更高效的内存访问
VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT
启用缓冲区设备地址功能
支持 VK_KHR_buffer_device_address 扩展
允许在着色器中直接访问缓冲区
VMA_ALLOCATOR_CREATE_EXT_MEMORY_PRIORITY_BIT
启用 VK_EXT_memory_priority 扩展
允许设置内存分配的优先级
有助于优化内存管理策略
基础功能:从入门到精通
1. 资源生命周期管理
缓冲区创建范例:
VkBufferCreateInfo bufferInfo = { VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
bufferInfo.size = 1024 * 1024; // 1MB
bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
VmaAllocationCreateInfo allocInfo = {};
allocInfo.usage = VMA_MEMORY_USAGE_AUTO;
allocInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT;
VkBuffer buffer;
VmaAllocation allocation;
vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, &buffer, &allocation, nullptr);
VmaMemoryUsage枚举:
typedef enum VmaMemoryUsage {
VMA_MEMORY_USAGE_UNKNOWN = 0,
VMA_MEMORY_USAGE_GPU_ONLY, // 纯设备内存
VMA_MEMORY_USAGE_CPU_ONLY, // 可映射主机内存
VMA_MEMORY_USAGE_CPU_TO_GPU, // 频繁上传
VMA_MEMORY_USAGE_GPU_TO_CPU, // 回读数据
VMA_MEMORY_USAGE_AUTO = 7 // 自动决策(推荐)
} VmaMemoryUsage;
VmaAllocationCreateFlags 枚举值说明:
VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT
指示内存将被主机按顺序写入
适用于单次或连续写入的缓冲区
可能影响内存类型选择以优化顺序访问
例如每帧都需要更新的动态 uniform 数据和需要被频繁更新的顶点数据
VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT
指示内存将被主机随机读写访问
适用于需要频繁更新的动态缓冲区
会选择支持随机访问的内存类型
VMA_ALLOCATION_CREATE_HOST_ACCESS_ALLOW_TRANSFER_INSTEAD_BIT
当主机直接访问不可用时允许使用传输操作
提供内存访问的备选方案
增加分配的灵活性
VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT
强制为此分配使用独立的内存块
适用于大型资源或特殊用途
可能增加内存碎片
VMA_ALLOCATION_CREATE_NEVER_ALLOCATE_BIT
仅在现有内存块中查找空间
如果没有合适的空间则失败
用于严格控制内存分配
VMA_ALLOCATION_CREATE_MAPPED_BIT
创建时自动执行内存映射
避免手动映射/解映射操作
适用于需要持续访问的资源
VMA_ALLOCATION_CREATE_USER_DATA_COPY_STRING_BIT
为用户数据创建字符串的深拷贝
确保字符串数据的独立性和安全性
方便资源追踪和调试
VMA_ALLOCATION_CREATE_UPPER_ADDRESS_BIT
尝试在较高的 GPU 地址空间分配
可能影响某些特定硬件的性能
用于特殊的内存布局需求
内存分配策略标志
VMA_ALLOCATION_CREATE_STRATEGY_BEST_FIT_BIT
VMA_ALLOCATION_CREATE_STRATEGY_WORST_FIT_BIT
VMA_ALLOCATION_CREATE_STRATEGY_FIRST_FIT_BIT
用于控制内存分配算法的选择,影响分配效率和内存碎片
2. 内存映射与访问
安全的内存访问模式:
void* mappedData;
vmaMapMemory(allocator, allocation, &mappedData);
// 写入数据(建议使用memcpy而非直接指针操作)
memcpy(mappedData, sourceData, dataSize);
vmaUnmapMemory(allocator, allocation);
持久映射优化技巧:
allocInfo.flags |= VMA_ALLOCATION_CREATE_MAPPED_BIT;
// 创建后直接访问
VmaAllocationInfo allocInfo;
vmaGetAllocationInfo(allocator, allocation, &allocInfo);
void* persistentPtr = allocInfo.pMappedData;
1.使用VMA进行数据拷贝时无需创建和操作staging buffer,VMA会自动选择最佳内存类型,并进行数据传输。(依赖于创建buffer时正确指定usage和flags)
2.对于 Host 可见的内存,VMA 也提供 vmaFlushAllocation, vmaInvalidateAllocation 等接口,用于在需要时清理或无效化 CPU/GPU 缓存,确保数据一致性。
高级用法:突破性能瓶颈
1. 内存池(Memory Pools)
专用内存池配置:
VmaPoolCreateInfo poolInfo = {};
poolInfo.memoryTypeIndex = ...; // 指定内存类型
poolInfo.blockSize = 64 * 1024 * 1024; // 64MB块
poolInfo.minBlockCount = 1;
poolInfo.maxBlockCount = 8;
VmaPool pool;
vmaCreatePool(allocator, &poolInfo, &pool);
// 在池中分配资源
VmaAllocationCreateInfo poolAllocInfo = {};
poolAllocInfo.pool = pool; // 指定内存池
poolAllocInfo.usage = VMA_MEMORY_USAGE_AUTO; // 自动选择内存类型
poolAllocInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT; // 指定内存访问模式
vmaCreateBuffer(allocator, &bufferInfo, &poolAllocInfo, &buffer, &allocation, nullptr);
2. 高级分配策略
优先设备本地内存:
allocInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE; //更灵活的策略,如果设备本地内存不足或不适用,会自动选择次优的内存类型
延迟内存分配:
allocInfo.flags |= VMA_ALLOCATION_CREATE_CAN_BECOME_LOST_BIT;
VMA_ALLOCATION_CREATE_CAN_BECOME_LOST_BIT 是 VMA 中一个特殊的内存分配标志,用于创建可能会”丢失”的内存分配。这是一个高级功能,主要用于内存管理优化。
这种分配可能在内存压力大时被VMA回收
需要定期检查分配是否还有效
通常配合 VMA_ALLOCATION_CREATE_CAN_MAKE_OTHER_LOST_BIT 使用
典型应用场景:
缓存数据
非关键资源
可重新生成的资源
最佳实践:
// 创建可丢失且可导致其他分配丢失的分配
VmaAllocationCreateInfo allocInfo = {};
allocInfo.flags = VMA_ALLOCATION_CREATE_CAN_BECOME_LOST_BIT |
VMA_ALLOCATION_CREATE_CAN_MAKE_OTHER_LOST_BIT;
allocInfo.priority = 0.5f; // 设置优先级
// 定期检查和维护
void maintainResources() {
for (auto& resource : resources) {
VmaAllocationInfo allocInfo;
vmaGetAllocationInfo(allocator, resource.allocation, &allocInfo);
if (allocInfo.deviceMemory == VK_NULL_HANDLE) {
// 重新创建资源
recreateResource(resource);
}
}
}
3. 内存碎片整理
碎片整理可以显著减少内存碎片,从而腾出连续的大块空间,避免频繁出现 OOM (Out Of Memory,内存耗尽)或内存分配失败的情况。当应用程序长期运行时,频繁的内存分配和释放可能导致内存碎片化,使得即使总的可用内存充足,也无法分配较大的连续内存块。
VMA 提供了一整套接口来执行碎片整理:
vmaBeginDefragmentation():初始化碎片整理上下文
vmaBeginDefragmentationPass() / vmaEndDefragmentationPass():执行碎片整理的一个或多个 Pass
vmaEndDefragmentation():结束碎片整理进程
vmaDefragment():单次执行碎片整理
注意:碎片整理期间,某些资源的内存可能会被移动,需要确保资源处于安全状态(通常在 GPU 空闲或可被重新绑定时进行)。
单次碎片整理流程:
VmaDefragmentationInfo defragInfo = {};
defragInfo.flags = VMA_DEFRAGMENTATION_FLAG_ALGORITHM_FAST;
VmaDefragmentationStats stats;
vmaDefragment(allocator, nullptr, 0, nullptr, &defragInfo, &stats);
printf("Freed %llu bytes, moved %u allocations\n",
stats.bytesFreed, stats.allocationsMoved);
更复杂的场景需要使用vmaBeginDefragmentation()和vmaEndDefragmentation(),以及vmaBeginDefragmentationPass()和vmaEndDefragmentationPass()。
4. 稀疏资源管理
稀疏纹理分配示例:
VkImageCreateInfo sparseImageInfo = { VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
sparseImageInfo.flags = VK_IMAGE_CREATE_SPARSE_BINDING_BIT;
sparseImageInfo.extent = {8192, 8192, 1}; // 8K*8K纹理
VmaAllocationCreateInfo sparseAllocInfo = {};
sparseAllocInfo.flags = VMA_ALLOCATION_CREATE_SPARSE_BINDING_BIT;
vmaCreateImage(allocator, &sparseImageInfo, &sparseAllocInfo, &image, &allocation, nullptr);
5.内存预算管理
内存预算管理主要包含两个关键功能:
查询预算信息
通过 vmaGetBudget 接口可查询各个显存堆的预算和使用情况:
VmaBudget budgets[VK_MAX_MEMORY_HEAPS];
vmaGetHeapBudgets(allocator, budgets);
// 检查第一个堆的使用情况
printf("Heap 0: Usage %llu MB / Budget %llu MB\n",
budgets[0].usage >> 20,
budgets[0].budget >> 20);
预算控制
使用 VMA_ALLOCATION_CREATE_WITHIN_BUDGET_BIT 标志可限制内存分配在预算范围内:
VmaAllocationCreateInfo allocInfo = {};
allocInfo.flags = VMA_ALLOCATION_CREATE_WITHIN_BUDGET_BIT;
// 若超出预算,vmaCreateBuffer 将返回 VK_ERROR_OUT_OF_DEVICE_MEMORY
VkResult result = vmaCreateBuffer(
allocator, &bufferInfo, &allocInfo,
&buffer, &allocation, nullptr);
6.虚拟分配器
虚拟分配器的核心思想是在不实际分配物理设备内存的情况下,模拟内存分配的行为。这对于以下场景特别有用:
内存分配策略的预演和验证
资源布局的优化
自定义内存管理系统的实现
例如:
// 模拟不同的资源分配方案
void SimulateResourceLayout() {
VmaVirtualBlock block;
vmaCreateVirtualBlock(&VmaVirtualBlockCreateInfo{
.size = 1024 * 1024 * 64 // 64MB
}, &block);
struct AllocationRecord {
VmaVirtualAllocation allocation;
VkDeviceSize offset;
VkDeviceSize size;
const char* resourceName;
};
std::vector<AllocationRecord> allocations;
// 模拟分配各种资源
auto allocateResource = [&](VkDeviceSize size, const char* name) {
VmaVirtualAllocationCreateInfo allocInfo = {};
allocInfo.size = size;
allocInfo.alignment = 256;
AllocationRecord record = {};
record.size = size;
record.resourceName = name;
if (vmaVirtualAllocate(block, &allocInfo, &record.allocation, &record.offset) == VK_SUCCESS) {
allocations.push_back(record);
return true;
}
return false;
};
// 分配各种资源
allocateResource(1024 * 1024, "Texture1");
allocateResource(512 * 1024, "Vertex Buffer");
allocateResource(256 * 1024, "Index Buffer");
// 分析内存布局
VmaStatInfo stats;
vmaCalculateVirtualBlockStats(block, &stats);
// 输出内存使用情况
for (const auto& record : allocations) {
printf("Resource: %s, Offset: %llu, Size: %llu\n",
record.resourceName, record.offset, record.size);
}
// 清理
for (const auto& record : allocations) {
vmaVirtualFree(block, record.allocation);
}
vmaDestroyVirtualBlock(block);
}
关键数据结构
VmaAllocator
VMA 的核心对象
代表一个全局或应用级别的内存分配器
VmaAllocation
代表一次内存分配
对应底层 Vulkan Device Memory 中的一块区域
VmaAllocationCreateInfo
创建分配时的配置结构
包含 VmaMemoryUsage、映射选项、独立分配等参数
VmaAllocationInfo
分配完成后返回的详细信息
包含偏移量、实际大小、映射指针等数据
VmaMemoryUsage
指定内存分配的用途
如 GPU_ONLY、CPU_ONLY 等类型
VmaPool
自定义内存池对象
用于统一管理多种内存分配
VmaPoolCreateInfo
内存池的创建参数
配置池的属性和行为
VmaBudget
内存预算管理结构
跟踪内存使用量和可用预算
VmaStatistics & VmaDetailedStatistics
内存使用统计信息
提供详细的内存分配状态
VmaVirtualAllocation & VmaVirtualBlock
虚拟内存分配相关结构
用于无物理内存的资源规划
推荐使用模式
VMA 官方文档中针对常见资源使用模式(如 GPU-only 资源、上传缓冲、回读缓冲、以及高级数据传输模式)都给出了对应的 VmaMemoryUsage 和配置建议。例如:
GPU-only 资源:
VMA_MEMORY_USAGE_GPU_ONLY
VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT(可选)
CPU -> GPU 上传:
VMA_MEMORY_USAGE_CPU_TO_GPU
VMA_ALLOCATION_CREATE_MAPPED_BIT(可选)
GPU -> CPU 读取:
VMA_MEMORY_USAGE_GPU_TO_CPU
先进的上传数据管理:
结合自定义内存池
使用线性分配算法提升效率
自动映射
VMA_ALLOCATION_CREATE_MAPPED_BIT 是一个在创建 VMA 内存分配时使用的标志位,它的主要功能是在分配内存的同时自动将其映射到 CPU 可访问的地址空间。这样可以省去手动调用 vmaMapMemory 的步骤。
// 不使用 VMA_ALLOCATION_CREATE_MAPPED_BIT 的传统方式
{
VmaAllocationCreateInfo allocInfo = {};
allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
VmaAllocation allocation;
VkBuffer buffer;
// 创建buffer和分配内存
vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, &buffer, &allocation, nullptr);
// 需要手动映射内存
void* mappedData;
vmaMapMemory(allocator, allocation, &mappedData);
// 使用映射的内存
memcpy(mappedData, sourceData, dataSize);
// 需要手动解除映射
vmaUnmapMemory(allocator, allocation);
}
// 使用 VMA_ALLOCATION_CREATE_MAPPED_BIT 的方式
{
VmaAllocationCreateInfo allocInfo = {};
allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; // 自动映射
VmaAllocation allocation;
VkBuffer buffer;
VmaAllocationInfo allocInfo;
// 创建buffer和分配内存,同时获取分配信息
vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, &buffer, &allocation, &allocInfo);
// 直接通过 allocInfo.pMappedData 访问映射的内存
memcpy(allocInfo.pMappedData, sourceData, dataSize);
// 不需要手动解除映射,会在内存释放时自动处理
}
性能优化实践
通过合理使用VMA的高级特性,在真实项目中可实现:
内存分配耗时降低70%(对比原生Vulkan接口)
显存碎片率控制在5%以下
内存泄漏检测效率提升90%
典型案例:
《赛博朋克2077》:使用VMA管理超过20GB的显存资源
Unreal Engine 5:集成VMA实现跨平台内存管理
DOOM Eternal:通过VMA内存池技术降低8%的显存占用
EasyVulkan中的VMA
在EasyVulkan中,Buffer和Image的内存分配都使用了VMA。
Buffer Builder
根据上文的问题,在创建Buffer时比较重要的信息包括:
Buffer size。
Buffer usage。
Buffer memory usage。
Buffer memory flags。
Buffer memory type index(property,optional)。
一个Buffer的创建流程可以简化为:
// Create a vertex buffer
auto vertexBuffer = bufferBuilder
->setSize(sizeof(vertices))
->setUsage(VK_BUFFER_USAGE_VERTEX_BUFFER_BIT)
->setMemoryUsage(VMA_MEMORY_USAGE_CPU_TO_GPU)
->setMemoryFlags(VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
VMA_ALLOCATION_CREATE_MAPPED_BIT)
->build("myVertexBuffer");
// Create a storage buffer used on GPU only
auto storageBuffer = bufferBuilder
->setSize(sizeof(storageData))
->setUsage(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT)
->setMemoryUsage(VMA_MEMORY_USAGE_GPU_ONLY)
->build("myStorageBuffer");
VMA PDF文档|Generated by Doxygen
-
Vulkan初始化
最开始接触Vulkan时,通常会被其复杂的概念和庞大的API所吓到。无法理解window、instance、surface等概念的关系,不能区分物理设备和逻辑设备的区别。本文将介绍Vulkan的初始化过程,并解释各个概念之间的关系。最后,本文将介绍EasyVulkan项目的VulkanDevice和VulkanContext对这些概念的封装。
整体流程
创建 Window
创建 Instance
检查和启用必要的validation layers(如果在debug模式下)
设置必要的instance extensions,特别是GLFW要求的extensions
创建 Window Surface
获取物理设备
检查物理设备是否支持所需的features和extensions
检查物理设备是否适合(比如是否为独立显卡、是否支持所需的图形特性等)
获取队列族索引
创建逻辑设备
创建队列创建信息
启用必要的device extensions(比如VK_KHR_swapchain)
指定设备features
创建命令池
1.创建Window
使用 GLFW 创建窗口,这是显示 Vulkan 渲染结果的基础:
glfwInit();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // 不创建 OpenGL 上下文
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); // 暂时禁用窗口大小调整
window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
由于历史原因,GLFW 最初是为 OpenGL 设计的窗口管理库,默认情况下,当你创建 GLFW 窗口时,它会自动创建一个 OpenGL 上下文。因此需要指定GLFW_CLIENT_API为GLFW_NO_API,只创建窗口,而不创建 OpenGL 上下文。
2.创建 Instance
Instance 是应用程序与 Vulkan 库之间的连接:
VkApplicationInfo appInfo{};
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
appInfo.pApplicationName = "Vulkan App";
appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.pEngineName = "No Engine";
appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.apiVersion = VK_API_VERSION_1_0;
VkInstanceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo = &appInfo;
// 获取 GLFW 需要的 extension
uint32_t glfwExtensionCount = 0;
const char** glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);
createInfo.enabledExtensionCount = glfwExtensionCount;
createInfo.ppEnabledExtensionNames = glfwExtensions;
vkCreateInstance(&createInfo, nullptr, &instance);
Instance 代表了一个 Vulkan 应用程序的实例,它主要负责:
告诉 Vulkan 驱动程序我们要使用哪些全局扩展(比如与窗口系统的集成)
告诉驱动程序我们的应用程序信息(名称、版本等)
设置调试回调
枚举系统中可用的物理设备(GPU)
Instance的创建过程恰恰说明了他的角色:
VkInstanceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
// 告诉 Vulkan 我们的应用程序信息
createInfo.pApplicationInfo = &appInfo;
// 告诉 Vulkan 我们需要哪些扩展
createInfo.enabledExtensionCount = glfwExtensionCount;
createInfo.ppEnabledExtensionNames = glfwExtensions;
// 告诉 Vulkan 我们需要哪些验证层(用于调试)
createInfo.enabledLayerCount = validationLayers.size();
createInfo.ppEnabledLayerNames = validationLayers.data();
可以把 Instance 想象成一个”门户”或”接待员”:
// 没有 Instance 之前,我们无法调用大多数 Vulkan 函数
// 创建 Instance 后,我们可以做这些事:
vkEnumeratePhysicalDevices(instance, ...); // 查询 GPU
vkCreateDebugUtilsMessengerEXT(instance, ...); // 设置调试
// 等等
Instance可以被理解为一个“配置中心”,我们可以通过他告诉Vulkan:
这是我的应用程序
这是我需要的功能
这是我的调试需求
3. 创建 Window Surface
Window Surface 提供了 Vulkan 与窗口系统的连接:
VkSurfaceKHR surface;
if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS) {
throw std::runtime_error("failed to create window surface!");
}
Vulkan 是与平台无关的图形 API,不直接处理窗口系统。Window Surface 是 Vulkan 和窗口系统之间的桥梁,它提供了一个可以渲染到的目标平面.
工作流程
Vulkan 渲染流程 → Swapchain → Surface → 窗口系统 → 显示到屏幕
// 1. 创建 Surface
VkSurfaceKHR surface;
glfwCreateWindowSurface(instance, window, nullptr, &surface);
// 2. Surface 用于创建 Swapchain
VkSwapchainCreateInfoKHR createInfo{};
createInfo.surface = surface; // Surface 告诉 Swapchain 渲染目标在哪里
// 3. 渲染时
vkAcquireNextImageKHR(...); // 从 Swapchain 获取下一个可用的图像
// 渲染到图像
vkQueuePresentKHR(...); // 通过 Surface 将渲染结果显示到窗口
Surface的作用
提供图像呈现能力
决定支持的图像格式
决定支持的呈现模式
处理平台差异
Windows:使用 Win32 窗口系统
Linux:使用 X11 或 Wayland
macOS:使用 Metal 层
可以把 Surface 想象成一个”画布”:
Vulkan 是画家(渲染器)
Window 是画框(显示窗口)
Surface 是画布,它把画家的作品(渲染结果)放在画框中展示
图 1:Surface 的作用。
4. 获取物理设备
选择合适的物理设备(显卡):
uint32_t deviceCount = 0;
vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
std::vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());
// 选择第一个适合的设备
VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
for (const auto& device : devices) {
if (isDeviceSuitable(device)) {
physicalDevice = device;
break;
}
}
isDeviceSuitable 函数通常会检查以下几个关键方面来确定物理设备是否满足应用需求:
基本设备信息检查
bool isDeviceSuitable(VkPhysicalDevice device) {
// 获取设备基本属性
VkPhysicalDeviceProperties deviceProperties;
vkGetPhysicalDeviceProperties(device, &deviceProperties);
// 获取设备特性
VkPhysicalDeviceFeatures deviceFeatures;
vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
// 检查是否为独立显卡
bool isDiscreteGPU = deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU;
}
队列族支持检查
bool checkQueueFamilySupport(VkPhysicalDevice device) {
QueueFamilyIndices indices = findQueueFamilies(device);
// 检查是否支持所需的所有队列族
// - 图形队列族
// - 计算队列族
// - 显示队列族
return indices.isComplete();
}
设备扩展支持检查
bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
// 获取设备支持的扩展
uint32_t extensionCount;
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);
std::vector<VkExtensionProperties> availableExtensions(extensionCount);
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());
// 检查必要的扩展是否被支持
// 比如 VK_KHR_swapchain
std::set<std::string> requiredExtensions = {
VK_KHR_SWAPCHAIN_EXTENSION_NAME
};
for (const auto& extension : availableExtensions) {
requiredExtensions.erase(extension.extensionName);
}
return requiredExtensions.empty();
}
Swapchain 适配性检查
bool checkSwapChainAdequate(VkPhysicalDevice device) {
// 检查 surface 格式
uint32_t formatCount;
vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);
// 检查显示模式
uint32_t presentModeCount;
vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);
return formatCount > 0 && presentModeCount > 0;
}
内存属性检查
bool checkMemoryProperties(VkPhysicalDevice device) {
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(device, &memProperties);
// 检查是否有足够的显存
// 检查是否支持所需的内存类型
return true; // 根据具体需求判断
}
综合评分系统(可选)
int rateDeviceSuitability(VkPhysicalDevice device) {
int score = 0;
// 基础分:独立显卡加分
VkPhysicalDeviceProperties deviceProperties;
vkGetPhysicalDeviceProperties(device, &deviceProperties);
if (deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
score += 1000;
}
// 性能分:根据最大纹理大小加分
score += deviceProperties.limits.maxImageDimension2D;
// 特性分:支持几何着色器加分
VkPhysicalDeviceFeatures deviceFeatures;
vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
if (deviceFeatures.geometryShader) {
score += 100;
}
return score;
}
最终的设备选择函数如下:
bool isDeviceSuitable(VkPhysicalDevice device) {
bool extensionsSupported = checkDeviceExtensionSupport(device);
bool swapChainAdequate = false;
if (extensionsSupported) {
swapChainAdequate = checkSwapChainAdequate(device);
}
return checkQueueFamilySupport(device) &&
extensionsSupported &&
swapChainAdequate &&
checkMemoryProperties(device) &&
rateDeviceSuitability(device) > minRequiredScore;
}
5. 获取队列族索引
查找支持所需操作的队列族:
struct QueueFamilyIndices {
std::optional<uint32_t> graphicsAndComputeFamily; // 图形和计算共用一个队列族
std::optional<uint32_t> presentFamily;
bool isComplete() {
return graphicsAndComputeFamily.has_value() && presentFamily.has_value();
}
};
QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
QueueFamilyIndices indices;
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());
// 查找支持图形和计算的队列族
// 查找支持显示的队列族
// ... 具体实现略
return indices;
}
队列族
队列族是一组具有相同功能的队列(Queue),可以理解为是物理设备的一部分,每个队列族支持特定类型的操作,比如:
图形操作(绘制命令)
计算操作(计算着色器)
传输操作(内存复制)
显示操作(显示到屏幕)
物理设备(GPU)
├── 队列族 0(支持图形+计算+传输)
│ ├── 队列 0
│ └── 队列 1
├── 队列族 1(仅支持传输)
│ └── 队列 0
└── 队列族 2(支持显示)
└── 队列 0
可以查询每个队列族的队列数量、支持的特性,比如:
// 获取队列族属性
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, nullptr);
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, queueFamilies.data());
// 遍历每个队列族,查看其中的队列数量
for (uint32_t i = 0; i < queueFamilyCount; i++) {
const auto& queueFamily = queueFamilies[i];
// queueCount 就是该队列族中的队列数量
uint32_t numQueues = queueFamily.queueCount;
// 打印队列族信息
std::cout << "Queue Family " << i << ":\n";
std::cout << " Number of queues: " << numQueues << "\n";
std::cout << " Supports graphics: " << (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT ? "yes" : "no") << "\n";
std::cout << " Supports compute: " << (queueFamily.queueFlags & VK_QUEUE_COMPUTE_BIT ? "yes" : "no") << "\n";
std::cout << " Supports transfer: " << (queueFamily.queueFlags & VK_QUEUE_TRANSFER_BIT ? "yes" : "no") << "\n";
}
队列族作用
在创建逻辑设备时需要制定使用的队列族,比如:
// 创建队列信息
float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = graphicsFamily; // 指定队列族索引
queueCreateInfo.queueCount = 1; // 使用的队列数量
queueCreateInfo.pQueuePriorities = &queuePriority; // 队列优先级
不同队列族
不同队列族的命令可以并行执行
专用队列族(如只支持传输的队列族)通常性能更好
需要在不同队列族之间同步操作时会有性能开销
相同队列族的不同队列
可以使用同一个队列族的两个队列提交命令:
// 获取同一队列族的两个队列
VkQueue queue1, queue2;
vkGetDeviceQueue(device, graphicsFamilyIndex, 0, &queue1);
vkGetDeviceQueue(device, graphicsFamilyIndex, 1, &queue2);
// 这两个队列可以并行执行命令
vkQueueSubmit(queue1, 1, &submitInfo1, fence1); // 在队列1提交命令
vkQueueSubmit(queue2, 1, &submitInfo2, fence2); // 在队列2提交命令
// 这两个提交会并行执行,不需要等待队列1完成
同一队列族的所有队列具有相同的能力(比如都支持图形操作)
每个队列都有自己独立的命令流
每个队列都可以独立提交命令缓冲区
队列之间的执行是异步的(并行执行,没有先后顺序),除非使用同步原语(使用同步原语,可以实现队列2等待队列1完成)
队列优先级和同步
// 创建队列时可以指定不同的优先级
float priorities[] = { 1.0f, 0.5f }; // 两个队列,不同优先级
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.queueCount = 2;
queueCreateInfo.pQueuePriorities = priorities;
// 如果需要队列间同步,可以使用信号量
VkSubmitInfo submitInfo{};
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &waitSemaphore; // 等待其他队列的信号量
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &signalSemaphore; // 发出完成信号
并行渲染举例
// 场景1:并行渲染多个对象
void renderScene() {
// 队列1渲染地形
vkQueueSubmit(queue1, 1, &terrainSubmitInfo, terrainFence);
// 同时,队列2渲染角色
vkQueueSubmit(queue2, 1, &characterSubmitInfo, characterFence);
// 两个渲染任务并行执行
}
// 场景2:一个队列处理主要渲染,另一个处理后期效果
void render() {
// 队列1执行主要渲染
vkQueueSubmit(queue1, 1, &mainRenderSubmitInfo, mainRenderFence);
// 设置依赖关系
waitSemaphores = mainRenderComplete;
// 队列2执行后期处理
vkQueueSubmit(queue2, 1, &postProcessSubmitInfo, postProcessFence);
}
6. 创建逻辑设备
创建队列创建信息
如前文所说,逻辑设备的创建需要指定使用的队列族。为每个唯一的队列族创建创建信息结构体:
std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;
std::set<uint32_t> uniqueQueueFamilies = {
indices.graphicsAndComputeFamily.value(),
indices.presentFamily.value()
};
float queuePriority = 1.0f;
for (uint32_t queueFamily : uniqueQueueFamilies) {
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = queueFamily; // 指定队列族索引
queueCreateInfo.queueCount = 1; // 使用的队列数量
queueCreateInfo.pQueuePriorities = &queuePriority; // 队列优先级
queueCreateInfos.push_back(queueCreateInfo);
}
创建逻辑设备
VkDeviceCreateInfo deviceCreateInfo{};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceCreateInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); // 指定队列创建信息
VkPhysicalDeviceFeatures deviceFeatures{};
deviceCreateInfo.pEnabledFeatures = &deviceFeatures; // 指定设备特性
VkDevice device;
if (vkCreateDevice(physicalDevice, &deviceCreateInfo, nullptr, &device) != VK_SUCCESS) {
throw std::runtime_error("failed to create logical device!");
}
如前面提到的,队列族是物理设备的一部分,逻辑设备创建时需要指定使用的队列族和队列的数量,因此可以将逻辑设备理解为建立在队列族上对物理设备的抽象。
即:
物理设备(GPU)
└── 逻辑设备(对GPU的抽象接口)
├── 队列族 0
│ ├── 队列 0
│ └── 队列 1
└── 队列族 1
└── 队列 0
上面展示了如何指定队列族和队列数量来创建逻辑设备,下面展示如何从逻辑设备获取队列:
// 从逻辑设备获取队列
VkQueue graphicsQueue;
vkGetDeviceQueue(logicalDevice, // 逻辑设备句柄
graphicsFamilyIndex, // 队列族索引
0, // 队列索引
&graphicsQueue); // 获取到的队列
逻辑设备的功能
创建和管理各种 Vulkan 资源(缓冲区、图像等)
提供队列访问接口
启用设备特性和扩展
控制设备内存分配
例如:
// 使用逻辑设备创建资源
VkBuffer buffer;
vkCreateBuffer(logicalDevice, &bufferInfo, nullptr, &buffer);
// 使用逻辑设备分配内存
VkDeviceMemory memory;
vkAllocateMemory(logicalDevice, &allocInfo, nullptr, &memory);
不同的逻辑设备
一个应用程序可以创建多个逻辑设备
每个逻辑设备都有自己的队列和资源
不同逻辑设备间的资源不能直接共享
逻辑设备销毁时,其创建的所有资源也会被销毁
7. 创建命令池
创建用于管理命令缓冲区的命令池:
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = indices.graphicsAndComputeFamily.value(); // 指定队列族索引
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; // 允许单独重置命令缓冲区
VkCommandPool commandPool;
if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
throw std::runtime_error("failed to create command pool!");
}
命令池的作用
命令池用于管理命令缓冲区的内存
每个命令池只能分配给特定的队列族使用
从同一个命令池分配的命令缓冲区只能提交到同一队列族的队列中(因为第二点指定了队列族的类型)
命令池标志
// 常用的命令池标志
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT // 提示命令缓冲区会频繁重录制,可以优化内存分配
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT // 允许单独重置命令缓冲区,而不是只能重置整个池
VK_COMMAND_POOL_CREATE_PROTECTED_BIT // 创建受保护的命令缓冲区
分配命令缓冲
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool; // 指定命令池
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; // 主要或次要命令缓冲区
allocInfo.commandBufferCount = 1; // 分配数量
VkCommandBuffer commandBuffer;
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
命令池(Command Pool)和命令缓冲区(Command Buffer)
命令池是内存池
命令缓冲区是从这个内存池分配的内存块
所有命令缓冲区共享命令池的属性(如队列族绑定)
命令池管理着所有命令缓冲区的生命周期
内存管理关系
// 命令池负责管理命令缓冲区的内存分配
VkCommandPool commandPool;
std::vector<VkCommandBuffer> commandBuffers;
// 从命令池分配命令缓冲区
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool; // 指定从哪个命令池分配
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1; // 分配数量
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
生命周期关系
命令池控制着其分配的所有命令缓冲区的生命周期
销毁命令池时会自动销毁其分配的所有命令缓冲区
void cleanup() {
// 不需要单独释放命令缓冲区
vkDestroyCommandPool(device, commandPool, nullptr); // 会自动释放所有命令缓冲区
}
重置关系
// 重置整个命令池(影响所有命令缓冲区)
vkResetCommandPool(device, commandPool, VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT);
// 如果命令池创建时指定了 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT
// 则可以单独重置命令缓冲区
vkResetCommandBuffer(commandBuffer, VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT);
队列族关系
// 命令池绑定到特定队列族
VkCommandPoolCreateInfo poolInfo{};
poolInfo.queueFamilyIndex = graphicsQueueFamily; // 指定队列族
// 从该命令池分配的命令缓冲区只能提交到同一队列族的队列
VkSubmitInfo submitInfo{};
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
vkQueueSubmit(graphicsQueue, 1, &submitInfo, fence); // 队列必须属于同一队列族
实例扩展和设备扩展
实例扩展(Instance Extensions):
作用范围:作用于整个 Vulkan 实例(VkInstance),影响全局功能
主要用途:
提供跨平台功能,如窗口系统集成(WSI)
添加调试和验证层支持
提供实例级别的新功能
加载时机:在创建 VkInstance 时通过 vkCreateInstance 启用
常见的实例扩展:
VK_KHR_surface
最基础的窗口系统接口扩展
定义了创建和管理平台无关的窗口表面的基础功能
几乎所有需要显示的应用都会用到
平台特定的 surface 扩展:
VK_KHR_win32_surface (Windows)
VK_KHR_xlib_surface (X11/Linux)
VK_KHR_wayland_surface (Wayland/Linux)
VK_KHR_android_surface (Android)
VK_MVK_macos_surface (macOS)
VK_EXT_debug_utils
提供调试功能
允许为 Vulkan 对象添加标签和名称
支持调试信息的回调
VK_KHR_get_physical_device_properties2
获取物理设备的额外属性信息
常用于查询新功能的支持情况
设备扩展(Device Extensions):
作用范围:作用于特定的物理设备(VkPhysicalDevice)和逻辑设备(VkDevice)
主要用途:
提供特定硬件功能支持
启用设备特定的渲染特性
添加新的设备级API功能
加载时机:在创建 VkDevice 时通过 vkCreateDevice 启用
常见的设备扩展:
VK_KHR_swapchain
最基础的显示相关扩展
用于创建和管理交换链
实现帧缓冲和显示同步
VK_KHR_maintenance1/2/3
提供各种 API 改进和补充功能
修复早期版本的一些限制和问题
VK_KHR_dynamic_rendering
简化渲染流程
无需创建 render pass 对象
更灵活的渲染配置
VK_KHR_multiview
支持单次渲染传递到多个视图
用于 VR 等立体渲染场景
VK_KHR_shader_*系列:
VK_KHR_shader_float16_int8 // 支持16位浮点和8位整数
VK_KHR_shader_non_semantic_info // 着色器附加信息
VK_KHR_shader_draw_parameters // 绘制参数访问
EasyVulkan的初始化设计
在EasyVulkan中,初始化主要由VulkanContext和VulkanDevice两个类完成。
VulkanContext
主要用于管理各种Vulkan的对象和资源。
创建 Vulkan 实例,可选择设置验证层和调试回调。
拥有对 VulkanDevice、SwapchainManager、ResourceManager、CommandPoolManager 和可选的 SynchronizationManager 的引用。
协调高级生命周期(初始化、清理)。
VulkanDevice
VulkanDevice类主要对物理设备和逻辑设备进行管理。
选择具有所需功能的物理设备,创建逻辑设备。
维护队列句柄(图形、计算、传输)。
集成 Vulkan 内存分配器(VMA)。
使用说明
借助VulkanDevice本身就是VulkanContext的成员,因此初始化可以简化为:
VulkanContext context;
context.initialize();
在VulkanContext中,我们可以借助Manager类来管理各种Vulkan的对象和资源。
例如,使用SwapchainManager:
创建和管理交换链
处理窗口调整事件
管理交换链图像和图像视图
提供图像获取和呈现功能
使用ResourceManager:
所有主要 Vulkan 资源的生成器接口(如BufferBuilder、ImageBuilder、ShaderModuleBuilder等)
自动资源跟踪和清理
基于名称的资源查找
Touch background to close