第 5 章. Vulkan 中的命令缓冲区以及内存管理
命令缓冲区是若干命令的集合,它会被提交给适当的硬件队列供 GPU 进行处理。 然后,在真正的 GPU 处理开始之前,驱动程序会提取命令缓冲区并对其进行验证和编译。
本章将介绍命令缓冲区的概念。 我们将学习命令池的创建,命令缓冲区的分配、释放以及命令的记录。 我们会实现命令缓冲区并在下一章中使用它们驱动交换链。 交换链抽象了与平台界面交互的机制,并提供一组可用于执行渲染的图像。 一旦完成渲染,图像就会显示到本地的窗口系统。
在本章的后半部分,我们会了解 Vulkan 中的内存管理。 我们会讨论主机内存和设备内存的概念。 我们会研究内存分配器来管理主机内存的分配。 在本章的最后,我们将深入研究设备内存,并学习分配、释放以及通过映射使用它的方式。
本章将介绍有关命令缓冲区和内存分配的如下主题:
- 命令缓冲区入门
- 了解命令池和命令缓冲区 API
- 录制命令缓冲区
- 实现命令缓冲区包装类
- 管理 Vulkan 中的内存
命令缓冲区入门
顾名思义,命令缓冲区是一个缓冲区或者一个单元中若干命令的集合。 命令缓冲区记录了应用程序预计执行的各种 Vulkan API 命令。 一旦命令缓冲区经过了烘焙,就可以一遍又一遍地重复使用。 他们按照应用程序指定的顺序记录这些命令。 这些命令用于执行不同类型的作业;这其中包括绑定顶点缓冲区,管线绑定,记录 Render Pass 命令,设置视口和裁剪,指定绘图命令,控制图像和缓冲区内容的复制操作等。
有两种类型的命令缓冲区:主命令缓冲区和辅助命令缓冲区:
- 主命令缓冲区:这些是辅助命令缓冲区的所有者并负责它们的执行;主命令缓冲区被直接提交给队列 。
- 辅助命令缓冲区:它们通过主命令缓冲区执行,不能被直接提交给队列。
应用程序中的命令缓冲区的数量可以从几百到几千不等。 Vulkan API 旨在提供最大的性能;因此,命令缓冲区是从命令池中进行分配的,以分摊跨多个命令缓冲区创建资源的成本(在多线程环境中使用时(请参阅本章中的“显式同步”部分))。 不能直接创建命令缓冲区, 相反,它是从命令池分配的:
命令缓冲区是持久的;它们会被创建一次并且可以连续重用。 此外,如果一个命令缓冲区不再有用,可以用一个简单的重置命令来更新,并准备好进行另一次记录。 与销毁并为同一个目的创建新的缓冲区相比,这是一种很有效的方法。
显式同步
在多线程环境中创建多个命令缓冲区时,建议通过为每个线程引入单独的命令池来分隔多个同步域。 这使命令缓冲区分配的代价更加高效,因为应用程序不需要在不同线程中进行显式同步。
然而,管理在多个线程之间共享的若干命令缓冲区之间的同步,是应用程序的责任。
与此相反,OpenGL 是一个隐式的同步模型。 在 OpenGL 中,很多事情都是自动完成的,这是以大量的资源跟踪、缓存刷新以及依赖链的构造为代价得来的。 所有这些都是在幕后完成的,这确实是 CPU 的开销。 Vulkan 在这方面就相当简单;显式同步保证了没有隐藏的机制,也没有意外的因素。
应用程序可以更好地了解其资源,并因此了解其使用情况和依赖性。 驱动程序不太可能准确地查明依赖关系。 因此,OpenGL 实现最终会带来意想不到的着色器重新编译、缓存刷新等问题。
Vulkan 的显式同步使其不受这些限制,从而使硬件更具生产力。
另一个区别是在 OpenGL 中命令缓冲区的提交:命令缓冲区在幕后 push,并且不受应用程序的控制。 提交命令的应用程序无法保证何时执行这些作业。 这是因为 OpenGL 批量执行命令缓冲区,它等待大量的命令来构建批处理,然后 OpenGL 将它们分配到一起。 另一方面,Vulkan 对命令缓冲区提供了显式的控制,以便允许进行预先的处理 ------ 通过将命令缓冲区提交给所需的队列。
命令缓冲区中命令的类型
命令缓冲区由一个或多个命令组成。 这些命令可以分为三类:
- 动作:该类命令执行诸如绘图、调度、清除、复制、查询、时间戳操作以及开始、结束子通道 subpass 等操作。
- 状态管理:这其中包括描述符集、绑定管线和缓冲区,它用于设置动态的状态,push 常量和 Render Pass / subpass 状态。
- 同步:这些命令用于同步:管线屏障、设置事件、等待事件以及渲染通道 Render Pass/ 子通道 subpass 的依赖关系。
命令缓冲区和队列
命令缓冲区被提交到一个硬件队列,在那里会对它们进行异步处理。 通过对命令缓冲区批处理化操作并对它们执行一次,队列的提交可以变得更加高效。 Vulkan 有一个延迟命令模式,其中命令缓冲区的若干绘制调用的集合以及多个提交操作是分开完成的,并认为它们是两种不同的操作。 从应用程序的角度来看这很有帮助, 这是因为它能够事先了解大部分场景,这可以作为一个契机来给提交操作增加适当的优化,这在 OpenGL 中是很难实现的。
Vulkan 提供了硬件队列的逻辑视图,其中每个逻辑视图紧密地连接到一个硬件队列。 一个 Vulkan 硬件队列可以由多个逻辑队列表示,其中每个队列都是基于该队列的属性创建的。 例如,用于渲染的交换链图像的呈现可能需要命令缓冲区将它们提交给图形队列,这种图形队列也能够呈现内容。
执行顺序
可以把命令缓冲区提交给单个队列或多个队列:
- 单个队列的提交:提交给单个队列的多个命令缓冲区可能会被执行或重叠(overlapped)。 在单个队列的提交中,命令缓冲区必须遵守操作执行的顺序(按照命令的顺序和 API 的顺序规范)。 本书仅涵盖了用于 vkQueueSubmit 的提交命令;其中不包括稀疏内存绑定命令缓冲区(通过 vkQueueBindSparse)。
- 多个队列的提交:提交给多个队列的命令缓冲区可以按任意顺序执行,除非通过同步机制(比如信号量和栏栅)显式地应用了排序约束。
理解命令池和缓冲区 API
本节将介绍可用于管理命令池和命令缓冲区的不同 API。 在这里,我们将了解创建命令缓冲池的过程,这个池子会用于命令缓冲区的分配。 我们还会看到重置和销毁 API 的过程。
本章的下一部分将根据现有的包装类在其中实现这些 API。 在本书的其余章节中,包装器实现会非常有用,因为我们会使用大量的命令缓冲区。
提示
作为后续章节的先决条件,在 Vulkan 章节中“实现命令池和命令缓冲区”,“记录命令缓冲区”以及“管理内存” 的内容都是非常重要的。
创建命令池
使用 vkCreateCommandPool()API 创建命令池。 它接受一个 VkCommandPoolCreateInfo 控制结构,该结构会指导从这个缓冲池中要分配的命令缓冲区的特性相关的实现信息,同时 它还指示这个命令缓冲区应该属于哪个队列族。 预先提供的这个信息对分配兼容的命令池非常有用;这些池可用来优化(用于典型队列提交的)命令缓冲区的分配过程。下面是其语法:
typedef struct VkCommandPoolCreateInfo {
VkStructureType sType;
const void* next;
VkCommandPoolCreateFlags flags;
uint32_t queueFamilyIndex;
} VkCommandPoolCreateInfo;
下表描述了 VkCommandPoolCreateInfo 结构的各个字段:
type:这是这个控制结构的类型信息。 必须将其指定为 VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO。
next : 这可能是一个指向特定于扩展结构的有效指针或 NULL。
flag | 这表示一个按位枚举标志,用于指示命令池的使用情况以及从中分配的命令缓冲区的行为。 此枚举标志的类型为 VkCommandPoolFlag,并且可以将 VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 和 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 作为可能的输入值。 有关这些标志值的更多信息,请参阅以下提示。
queueFamilyIndex :这表示要把命令缓冲区提交到其中的队列族。
提示
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 枚举标志表示从该池分配的命令缓冲区会被经常更改并且寿命较短。 这意味着缓冲区会在相对较短的时间内被重置或释放。 该标志告知了实现有关命令缓冲区的性质,并且这可以用来控制池内的内存分配行为。
当设置 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志时,它表示从池中分配的命令缓冲区可以通过两种方式分别重置:显式调用 vkResetCommandBuffer 或通过调用 vkBeginCommandBuffer 隐式重置。
如果未设置此标志,则不能对从池中分配的可执行命令缓冲区调用这两个 API。 这表明只能通过调用 vkResetCommandPool 对它们进行批量重置。
Vulkan 中的命令池由 VkCommandPool 对象表示, 它是使用 vkCreateCommandPool()API 创建的。 以下是该 API 的语法以及描述:
VkResult vkCreateCommandPool(
VkDevice device,
const VkCommandPoolCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkCommandPool* pCommandPool
);
下表介绍了此 API 的各个参数:
device :这是要创建命令池的、设备的句柄。
pCreateInfo :该参数引用 VkCommandPoolCreateInfo 对象,该对象指示了命令池中命令缓冲区的性质。
pAllocator:这个用来控制主机的内存分配。
pCommandPool :这代表 API 返回的 VkCommandPool 对象。
注意
有关如何使用 VkAllocationCallbacks * allocator 控制主机内存分配的更多信息,请参阅本章最后一节中的“主机内存”主题,即“管理 Vulkan 中的内存”。
重置命令池
命令池(VkCommandPool)可以使用 vkResetCommandPool()API 重置。 这是该 API 的语法:
VkResult vkResetCommandPool (
VkDevice device,
VkCommandPool commandPool,
VkCommandPoolResetFlags flags
);
以下是此 API 的参数:
device :这是拥有命令池的设备的句柄。
commandPool :这指的是需要重置的 VkCommandPool 句柄。
flags :该标志控制重置池的行为。
销毁命令池
命令池可以使用 vkDestroyCommandPool()API 销毁。 这是它的语法:
VkResult vkDestroyCommandPool(
VkDevice device,
VkCommandPool commandPool,
const VkAllocationCallbacks* allocator
);
以下是此 API 的参数:
device :这是要销毁命令池的设备的句柄。
commandPool :这是指需要销毁的 VkCommandPool 句柄。
allocator :这个参数控制主机内存的分配。 有关更多信息,请参阅第 5 章中的“主机内存”部分,“Vulkan 中的命令缓冲区以及内存管理”。
命令缓冲区的分配
命令缓冲区是使用 vkAllocateCommandBuffers()API 从命令缓冲池(VkCommandPool)分配的。 该 API 需要 VkCommandBufferAllocateInfo 控制结构对象,该结构对象指定了在分配过程中有用的各种参数。 成功执行 API 后,它会返回 VkCommandBuffer 对象。 以下是此 API 的语法:
VkResult vkAllocateCommandBuffers(
VkDevice device,
const VkCommandBufferAllocateInfo* pAllocateInfo,
VkCommandBuffer* pCommandBuffers
);
我们来看看 API 的各个参数:
device :这是拥有命令池的逻辑设备对象的句柄。 pAllocateInfo | 这是一个指针,指向描述分配参数的 VkCommandBufferAllocateInfo 结构;请参阅下表了解更多信息。 pCommandBuffers :该参数引用了分配的、命令缓冲区对象的数组。
VkCommandBufferAllocateInfo 结构具有以下字段:
sType : 这个字段指的是类型信息,告诉 Vulkan 它是一个 VkCommandBufferAllocate 结构,它必须是 VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO 的类型:
pNext :这个字段为 NULL 或引用一个扩展特定的结构。
commandPool :这是命令池的句柄,需要从该命令缓冲池中为请求的命令缓冲区分配内存。
level :这是 VkCommandBufferLevel 类型的按位枚举标志,指示命令缓冲区是处于主级还是次级。 以下是 VkCommandBufferLevel 的语法:
typedef enum VkCommandBufferLevel {
VK_COMMAND_BUFFER_LEVEL_PRIMARY=0,
VK_COMMAND_BUFFER_LEVEL_SECONDARY=1,
} VkCommandBufferLevel;
commandBufferCount: 这是指需要分配的命令缓冲区的数量。
重置命令缓冲区
分配的命令缓冲区可以使用 vkResetCommandBuffer()API 重置。 该 API 接受需要重置的 VkCommandBuffer 对象作为第一个参数。 第二个参数是位掩码 VkCommandBufferResetFlag,它控制重置操作的行为。 该结构具有一个枚举值,即 VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT。 当这个值被设置时,这意味着命令缓冲区保存的内存会被返回到父级命令池。
以下是 API 的语法:
VkResult vkResetCommandBuffer( VkCommandBuffer commandBuffer, VkCommandBufferResetFlags flags);
释放命令缓冲区
使用 vkFreeCommandBuffer()API 可以释放一个或多个命令缓冲区。 以下是此 API 的语法:
void vkFreeCommandBuffers(
VkDevice device,
VkCommandPool commandPool,
uint32_t commandBufferCount,
const VkCommandBuffer* pCommandBuffers);
以下是 vkFreeCommandBuffers()API 的参数及其各自的描述:
device : 这引用的是保存命令池的逻辑设备。
commandPool : 这引用的是必须从中进行内存释放的、关联的命令池。
commandBufferCount :这是指需要释放的命令缓冲区的数量。
pCommandBuffers :这是需要释放的命令缓冲区句柄的一个数组。
记录命令缓冲区
使用 vkBeginCommandBuffer()和 vkEndCommandBuffer()API 记录命令缓冲区。 这些 API 定义了一个范围,该范围中所有指定的 Vulkan 命令都会被记录起来。 以下示例显示了这两个 API 之间 Render Pass 实例创建的记录过程,这两个 API 用作开始和结束范围。 有关创建渲染通道的更多信息,请参阅第 7 章“缓冲区资源,渲染通道,帧缓冲区以及使用 SPIR-V 的着色器”中的“了解渲染通道”部分。
记录的开始是使用 vkBeginCommandBuffer()API 执行的。 该 API 定义了开始范围,在此范围之后,指定的任何调用都被视为需要记录的内容,直到到达结束范围(vkEndCommandBuffer())。
以下是此 API 的语法,然后是必要参数的说明:
VkResult vkBeginCommandBuffer(
VkCommandBuffer commandBuffer,
const VkCommandBufferBeginInfo* pBeginInfo);
这个 API 接受两个参数。 第一个是命令缓冲区的句柄,其中是要记录的所有调用。 第二个参数是一个 VkCommandBufferBeginInfo 结构对象,它定义了附加信息,告诉你如何开始命令缓冲区的记录过程。
以下是 VkCommandBufferBeginInfo 结构的 API 语法:
typedef struct VkCommandBufferBeginInfo {
VkStructureType sType;
const void* pNext;
VkCommandBufferUsageFlags flags;
const VkCommandBufferInheritanceInfo* pInheritanceInfo;
} VkCommandBufferBeginInfo;
这个结构中接受的字段描述如下:
sType : 这是这个控制结构的类型信息。 必须将其指定为 VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO。
pNext : 该字段可以是一个指向特定于扩展结构的有效指针或 NULL。
flags : 这是 VkC :如果该字段不为 NULL,则在命令缓冲区是辅助命令缓冲区时使用该字段。 它包含 VkCommandBufferInheritanceInfo 结构。
pInheritanceInfo | 如果该字段不为 NULL,则在命令缓冲区是辅助命令缓冲区时使用该字段。 它包含 VkCommandBufferInheritanceInfo 结构。
命令缓冲区记录是使用 vkEndCommandBuffer()API 执行的。 它接受一个参数,该参数指定了要对其进行停止记录操作的命令缓冲区对象。 这是它的语法:
VkResult vkEndCommandBuffer(VkCommandBuffer commandBuffer);
队列提交
一旦记录了命令缓冲区(VkCommandBuffer),就可以将其提交到队列中。 vkQueueSubmit()API 有助于把作业提交到一个适当的队列中。 让我们来看看这个函数的语法:
VkResult vkQueueSubmit(
VkQueue queue,
uint32_t submitCount,
const VkSubmitInfo* pSubmitInfo,
VkFence fence);
接受的参数如下所示:
queue :这是要提交到命令缓冲区的队列句柄。
submitCount :这指的是 submitInfo 对象的数量。
submitInfo :这指的是 VkSubmitInfo 指针。 它包含有关每个工作提交的重要信息,工作提交的数量(由 submitCount 表示)。 后面会谈到它的 API 规范。
fence : 这被用作一种信号机制,用来指示命令缓冲区执行的完成情况。 如果 fence 为非 null 并且 submitCount 为非 0,那么当执行了 submitInfo 的 VkSubmitInfo :: pCommandBuffers 成员中指定的所有命令缓冲区时,fence 会获得信号通知(fence gets signaled)。 如果 fence 不为 null,但 submitCount 为 0,有信号的 fence (the signaled fence)则表明以前提交到队列的所有工作都已完成。
我们来看看 VkSubmitInfo。 这个结构将多个信息嵌入到自身当中。 提交过程使用此信息来处理单个 VkSubmitInfo 对象,其中包含了一个或者多个命令缓冲区。 以下是它的语法:
typedef struct VkSubmitInfo { VkStructureType type;
const void* pNext;
uint32_t waitSemaphoreCount;
const VkSemaphore* pWaitSemaphores;
const VkPipelineStageFlags* pWaitDstStageMask; uint32_t commandBufferCount;
const VkCommandBuffer* pCommandBuffers; uint32_t signalSemaphoreCount;
const VkSemaphore* pSignalSemaphores;
} VkSubmitInfo;
接受的字段描述如下所示:
sType :这是 VkSumbitInfo 结构的类型,它必须是 VK_STRUCTURE_TYPE_SUBMIT_INFO。
pNext :这是一个指向特定于扩展的结构的指针或 NULL。
waitSemaphoreCount :指的是在执行命令缓冲区之前被迫等待的信号量数量。
pWaitSemaphores :这是一个指向在命令缓冲区被批处理执行之前被迫等待的信号量的数组的指针。
pWaitDstStageMask :这是一个指向管线阶段的数组的指针,在这些阶段中,每个相应的信号量的等待情况将会发生。
commandBufferCount:这是指批处理中要执行的命令缓冲区的数量。
pCommandBuffers :这是一个指向批处理中要执行的命令缓冲区数组的指针。
signalSemaphoreCount :这指的是一旦 commandBuffers 中指定的命令完成执行,将被发送信号的信号量的数量。
pSignalSemaphores :这是一个指向信号量数组的指针,当给定的批处理的命令缓冲区执行时,这些信号量将被发送信号。
队列等待
一旦提交到队列中,应用程序必须等待队列完成提交的作业并准备好下一个批处理操作。 等待队列的过程可以使用 vkQueueWaitIdle()API 完成。 该 API 会阻塞,直到队列中的所有命令缓冲区和稀疏绑定操作完成。 此 API 接受一个参数,该参数指定要等待完成操作的队列的句柄。 以下是此 API 的语法:
VkResult vkQueueWaitIdle(VkQueue queue);
实现命令缓冲区的包装类
本节实现名为 CommandBufferMgr 的命令缓冲区的包装类。 这个类包含静态函数,可以像工具函数一样直接使用而不需要类对象。 该类在一个名为 wrapper.h / .cpp 的新文件中实现;文件中会包含多个实用方法。
该类中大部分实现的函数都提供了默认实现。 这意味着每个函数都为用户提供了从函数调用外部更改控制结构参数的灵活性,并将它们作为参数传递。 函数参数有固有的默认值,所以如果你没有指定任何自定义的参数,函数会为你完成所有默认的工作。
以下是 CommandBufferMgr 类的头文件实现,声明了四个静态函数,负责分配内存、记录命令缓冲区以及将命令缓冲区提交给命令队列:
/**************** Wrapper.h ******************/
class CommandBufferMgr{ public:
// Allocate memory for command buffers from the command pool
static void allocCommandBuffer(const VkDevice* device, const VkCommandPool cmdPool, VkCommandBuffer* cmdBuf, const VkCommandBufferAllocateInfo* commandBufferInfo);
// Start the command buffer recording
static void beginCommandBuffer(VkCommandBuffer cmdBuf, VkCommandBufferBeginInfo* inCmdBufInfo = NULL);
// End the command buffer recording
static void endCommandBuffer(VkCommandBuffer cmdBuf);
// Submit the command buffer for execution
static void submitCommandBuffer(const VkQueue& queue, const VkCommandBuffer* cmdBufList, const VkSubmitInfo* submit- Info = NULL, const VkFence& fence = VK_NULL_HANDLE);
};
实现命令缓冲区的分配过程
allocCommandBuffer()函数从指定的命令池(cmdPool)分配命令缓冲区(cmdBuf)。 分配行为可以使用 VkCommandBufferAllocateInfo 指针参数进行控制。 当此函数的最后一个参数具有其默认参数值(NULL)时,则 VkCommandBufferAllocateInfo 在内部进行实现,如以下代码所示,并用于命令缓冲区的分配:
void CommandBufferMgr::allocCommandBuffer(const VkDevice* device, const VkCommandPool cmdPool, VkCommandBuffer* cmdBuf, const VkCommandBufferAllocateInfo* commandBufferInfo){
VkResult result;
// If command information is available use it as it is.
if (commandBufferInfo) {
result = vkAllocateCommandBuffers
(*device, commandBufferInfo, cmdBuf); assert(!result);
return;
}
// Default implementation, create the command buffer
// allocation info and use the supplied parameter into it
VkCommandBufferAllocateInfo cmdInfo = {};
cmdInfo.sType = VK_STRUCTRE_TYPE_COMMAND_BUFFER_ALOCATE_INFO;
cmdInfo.pNext = NULL; cmdInfo.commandPool = cmdPool;
cmdInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cmdInfo.commandBufferCount = (uint32_t) sizeof(cmdBuf)/
sizeof(VkCommandBuffer);
// Allocate the memory
result = vkAllocateCommandBuffers(device, &cmdInfo, cmdBuf); assert(!result);
}
记录命令缓冲区的分配过程
使用简单的 beginCommandBuffer()和 endCommandBuffer()包装函数可以轻松记录 API 的调用情况。 请参阅以下代码了解实现:
void CommandBufferMgr::beginCommandBuffer(VkCommandBuffer cmdBuf,
VkCommandBufferBeginInfo inCmdBufInfo){
VkResult result;
// If the user has specified the custom command buffer use it
if (inCmdBufInfo) {
result = vkBeginCommandBuffer(cmdBuf, inCmdBufInfo); assert(result == VK_SUCCESS);
return;
}
// otherwise, use the default implementation. VkCommandBufferInheritanceInfo cmdBufInheritInfo = {}; cmdBufInheritInfo.sType = VK_STRUCTURE_TYPE_COMMAND-
_BUFFER_INHERITANCE_INFO;
cmdBufInheritInfo.pNext = NULL; cmdBufInheritInfo.renderPass = VK_NULL_HANDLE; cmdBufInheritInfo.subpass = 0; cmdBufInheritInfo.framebuffer = VK_NULL_HANDLE; cmdBufInheritInfo.occlusionQueryEnable = VK_FALSE; cmdBufInheritInfo.queryFlags = 0;
cmdBufInheritInfo.pipelineStatistics = 0;
VkCommandBufferBeginInfo cmdBufInfo = {}; cmdBufInfo.sType=VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
cmdBufInfo.pNext = NULL; cmdBufInfo.flags = 0;
cmdBufInfo.pInheritanceInfo = &cmdBufInheritInfo;
result = vkBeginCommandBuffer(cmdBuf, &cmdBufInfo); assert(result == VK_SUCCESS);
}
以下代码描述了命令缓冲区记录的结束:
void CommandBufferMgr::endCommandBuffer(VkCommandBuffer commandBuffer){ VkResult result;
result = vkEndCommandBuffer(commandBuffer); assert(result == VK_SUCCESS);
}
如何使用命令缓冲区记录函数
在本节中,我们将使用第 7 章的“缓冲区资源、渲染通道、帧缓冲区以及使用 SPIR-V 的着色器”中的代码片段,并演示如何使用实现的命令缓冲区管理这些工具函数。 这些函数可以简化命令缓冲区创建和提交过程的代码。
在下面的代码中,Render Pass 实例是使用命令缓冲区创建的。 首先,创建命令池(cmdPool)并使用 allocCommandBuffer()分配命令缓冲区(vecCmdDraw)。 命令缓冲区记录的范围在 beginCommandBuffer()和 endCommandBuffer()函数之间确定。 在这些范围定义函数之间,记录了一系列的命令(vkCmdBeginRenderPass,vkCmdBindPipeline,vkCmdDraw 等)。 现在忽略用于 Render Pass 实例创建命令的相关操作;我们会在第 7 章“缓冲区资源”,“渲染通道”,“帧缓冲区”和“使用 SPIR-V 的着色器”中对它们进行详细介绍。 此处,我们的目的只是记录。
最后,使用 submitCommandBuffer()提交命令缓冲区;这个函数在下一节详细介绍:
vkCreateCommandPool(device, & cmdPoolInfo, NULL, & cmdPool);
CommandBufferMgr::allocCommandBuffer(& device, cmdPool,vecCmdDraw);
// Start recording the command buffer CommandBufferMgr::beginCommandBuffer(vecCmdDraw);
// Render pass instance vkCmdBeginRenderPass( . . . ); vkCmdBindPipeline(. . .); vkCmdBindDescriptorSets(. . .); vkCmdBindVertexBuffers(. . .); vkCmdSetViewport(. . .); vkCmdSetScissor(. . .); vkCmdDraw(. . .); vkCmdEndRenderPass(. . .);
// End recording the command buffer CommandBufferMgr::endCommandBuffer(vecCmdDraw); CommandBufferMgr::submitCommandBuffer(queue, & vecCmdDraw);
把命令提交到队列
准备就绪的命令缓冲区的提交操作是借助 submitCommandBuffer()函数实现的。 它需要四个参数。 第一个参数指定了第二个参数要提交到的队列(queue),第二个参数,即命令缓冲区(cmdBuffer),会被提交给该队列执行操作。 第三个参数(inSubmitInfo)指定在控制提交过程中涉及的行为。 最后一个参数表示提交的命令缓冲区(fence)操作的完成:
void CommandBufferMgr::submitCommandBuffer(const VkQueue& queue, const VkCommandBuffer cmdBuffer, const
VkSubmitInfo* inSubmitInfo, const VkFence& fence){ VkResult result;
// If Submit information is available use it as it is.
// This assumes that the commands are already specified
// in the structure, hence ignore command buffer
if (inSubmitInfo){
vkQueueSubmit(queue, 1, inSubmitInfo, fence); result = vkQueueWaitIdle(queue);
return;
}
// Else, create the submit info with specified buffer commands
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; submitInfo.pNext = NULL; submitInfo.waitSemaphoreCount = 0; submitInfo.pWaitSemaphores = NULL; submitInfo.pWaitDstStageMask = NULL; submitInfo.commandBufferCount = (uint32_t)
sizeof(commandBuffer)/sizeof(VkCommandBuffer); submitInfo.pCommandBuffers = commandBuffer; submitInfo.signalSemaphoreCount = 0; submitInfo.pSignalSemaphores = NULL;
result = vkQueueSubmit(queue, 1, &submitInfo, fence); assert(!result);
result = vkQueueWaitIdle(queue); assert(!result);
}
Vulkan 中管理内存
Vulkan 将内存大致分为两类:主机内存和设备内存。 此外,每种类型的内存可以根据属性和内存类型进行唯一的分解。 Vulkan 提供了一种透明的机制来查看内部的内存细节和相关属性。 这种暴露的类型在 OpenGL 中是不可能的,因此应用程序无法显式地控制内存区域和布局。
在这些内存类型中,主机内存比设备内存慢。 但是,主机内存的空间可能更多。 另一方面,设备内存对物理设备是直接可见的,使其更高效,更快速。 在本节中,我们会了解主机内存和设备内存以及访问它们的方法。
主机内存
在实现中,Vulkan 利用主机内存存储 API 的内部数据结构。 Vulkan 提供了分配器,允许应用程序控制内存的分配,代表了主机内存。 如果应用程序不使用分配器,那么 Vulkan 实现就会使用默认的分配方案为其数据结构预留内存槽。
主机内存由 VkAllocationCallbacks 控制结构管理,该结构传递给 Vulkan API 用于自定义的主机内存管理。 例如,命令缓冲池的创建(vkCreateCommandPool)和销毁(vkDestroyCommandPool)API 接受最后一个参数作为主机内存的分配器(VkAllocationCallbacks 指针)。
以下是这个控制结构的语法:
typedef struct VkAllocationCallbacks {
void* pUserData;
PFN_vkAllocationFunction pfnAllocation;
PFN_vkReallocationFunction pfnReallocation;
PFN_vkFreeFunction pfnFree;
PFN_vkInternalAllocationNotification pfnInternalAlloc;
PFN_vkInternalFreeNotification pfnInternalFree;
} VkAllocationCallbacks;
接受的参数描述如下:
pUserData :此字段表示传入的用户数据,用来操作内存管理的回调(alloc / realloc / free)。 即使使用相同的对象,每次执行回调时,用户数据都可能会发生变化。
pfnAllocation :这是签名类型 PFN_vkAllocationFunction 的用户定义的函数指针。 它定义了一个自定义的分配函数,可用于分配主机内存来管理使用此分配器的 Vulkan API 的数据结构。
pfnReallocation :这是签名类型 PFN_vkReallocationFunction 的用户定义的函数指针。 它定义了一个自定义的重新分配函数,可以用来重新分配 Vulkan API 数据结构的主机内存。 pfnFree :这个字段引用指的是函数指针 PFN_vkfreeFunction,它指向一个自定义的内存释放函数。
pfnInternalAlloc :这个函数指针用来执行内部的分配。 它通知应用程序已经完成的存储空间分配。 使用的函数指针应该与 PFN_vkInternalAllocationNotification 签名匹配。
pfnInternalFree :当实现释放内部分配的存储空间时,它会调用函数指针 PFN_vkInternalFreeAllocationNotification,并通知应用程序释放分配的内存。该函数指针用来释放原来分配的内部存储。 它会在内存释放时通知应用程序。 使用的函数指针应该与 PFN_vkInternalFreeAllocationNotification 签名匹配。
鉴于本书的范围和页面限制,如上表中所讨论的,关于内存分配扩展功能的内容只有这么多。 有关更多信息,请参阅 https://www.khronos.org/registry/vulkan/specs/1.0/apispec.html。
设备内存
设备内存是物理设备可见的 GPU 内存。 物理设备可以直接读取其内存区域。 设备内存非常靠近物理设备,因此提供了比主机内存更快的性能。 图像对象、缓冲区对象和 Uniform 缓冲区对象都分配在设备内存上。
一个物理设备可能有不同类型的内存;它们根据其堆和属性还可以进一步区分。 vkGetPhysicalDeviceMemoryProperties()API 查询物理设备可用的堆内存和内存属性。 对于应用程序来说,了解其内存特性非常重要。 这样可以更好地分配资源,具体取决于应用程序逻辑或资源类型。
以下是 vkGetPhysicalDeviceMemoryProperties()API 的语法:
void vkGetPhysicalDeviceMemoryProperties (
VkPhysicalDevice physicalDevice,
VkPhysicalDeviceMemoryProperties* pMemoryProperties );
以下 vkGetPhysicalDeviceMemoryProperties 可用的参数描述:
physicalDevice :这是指需要查询其内存属性的物理设备句柄。
pMemoryProperties :要查询的内存属性会被检索到 VkPhysicalDeviceMemoryProperties 数据结构指针中。 接下来是对这个结构的描述。
VkPhysicalDeviceMemoryProperties 结构描述如下:
typedef struct VkPhysicalDeviceMemoryProperties {
uint32_t memoryTypeCount;
VkMemoryType memoryTypes[VK_MAX_MEMORY_TYPES];
uint32_t memoryHeapCount;
VkMemoryHeap memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;
vkGetPhysicalDeviceMemoryProperties 结构提供了以下信息:
- 内存类型 Memory types:这个字段包含可用于访问物理设备上可用的内存类型(VkMemoryType)的内存类型数量(memoryTypeCount)。 内存类型通过描述一组内存属性来提供重要的信息,例如,主机内存是否被缓存了。 某个内存类型的分配与特定的堆内存相关联,该堆内存由这个结构中称之为堆索引的另一个字段所指示。
- 堆内存 Memory heaps:此字段提供物理设备上可用的堆内存的数量(memoryHeapCount)。 这些信息用于访问堆中分配的内存空间。 每个堆都由 VkMemoryHeap 表示,它描述了一定大小的内存资源。 每个堆可能共享多个内存类型。
注意
物理内存资源的确切尺寸可以通过内存类型和堆内存提供的机制进行准确查询。 这允许内存与各种各样的属性一起使用。
以下是 VkMemoryHeap 数据结构的语法:
typedef struct VkMemoryHeap {
VkDeviceSize size;
VkMemoryHeapFlags flags;
} VkMemoryHeap;
第一个字段 size 表示堆中内存的总大小;此内存在堆中占用空间的大小以字节为单位。 第二个字段 flag 是 VkMemoryHeapFlagBits 类型的位掩码,用于指示堆属性。下面是其语法:
typedef enum VkMemoryHeapFlagBits {
VK_MEMORY_HEAP_DEVICE_LOCAL_BIT = 0x00000001,
} VkMemoryHeapFlagBits;
VK_MEMORY_HEAP_DEVICE_LOCAL_BIT 的标志值表示相应的堆属于设备的本地内存。 与主机本地内存相比,此类内存可能具有不同的内存属性标志(VkMemoryPropertyFlagBits)和性能特征。
VkMemoryType 通过按位标志(propertyFlags)和堆本身的索引(heapIndex)提供此内存类型属性相关的信息。以下是其语法:
typedef struct VkMemoryType {
VkMemoryPropertyFlags propertyFlags;
uint32_t heapIndex;
} VkMemoryType;
以下是可能的 VkMemoryPropertyFlagBits 枚举标志:
VK_MEMORY_PROPERTY_ DEVICE_LOCAL_BIT :使用此标志类型执行的内存分配被认为最适合用于对设备内存的访问。
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT:使用此标志类型分配的内存可供主机访问。 主机可以使用映射函数(vkMapMemory())来访问其内容。
VK_MEMORY_PROPERTY_ HOST_COHERENT_BIT:此标志表示不需要主机缓存管理命令 vkFlushMappedMemoryRanges 和 vkInvalidateMappedMemoryRanges,来使主机写入对设备可见或设备写入对主机可见。
VK_MEMORY_PROPERTY_ HOST_CACHED_BIT :使用此内存类型分配的内存会被缓存在主机上。对未缓存内存的主机内存的访问要比访问访问缓存过的内存要慢;不过,未缓存的内存始终保持主机一致性。
VK_MEMORY_PROPERTY_ LAZILY_ALLOCATED_BIT :内存只能通过设备进行访问。 确保未使用此内存类型 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 和 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 进行设置。
分配设备内存
可以使用 vkAllocateMemory()API 在 Vulkan 中分配设备内存。 如果设备内存分配成功,此 API 会返回 VkDeviceMemory 对象。 该对象在应用程序中用于访问、操作设备内存的数据。 以下是其语法:
VkResult vkAllocateMemory(
VkDevice device,
const VkMemoryAllocateInfo* allocateInfo,
const VkAllocationCallbacks* allocator,
VkDeviceMemory* memory);
我们来看看这个 API 的各个参数:
device :这代表拥有内存的逻辑设备。
allocateInfo :这是一个指向 VkMemoryAllocateInfo 的指针;描述了设备内存分配的参数。
allocator :这或者是 NULL 或是一个指向 VkAllocationCallbacks 结构的指针;控制着主机内存的分配。
memory :这是 VkMemoryHandle 类型的分配内存的句柄。
VkMemoryAllocateInfo 结构定义如下:
typedef struct VkMemoryAllocateInfo {
VkStructureType type;
const void* pNext;
VkDeviceSize allocationSize;
uint32_t memoryTypeIndex;
} VkMemoryAllocateInfo;
下表描述了该结构的每个字段:
type :这代表了这个结构的类型信息;一定是 VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO。
pNext :这是指向特定于扩展结构的指针。
allocationSize :指的是需要分配的内存大小。
memoryTypeIndex :这是用于选择内存属性和堆内存的、内存类型的索引。
注意
该实现很好地支持“二次分配”。 例如,如果图像的内存需求是 512 字节对齐,而且缓冲区对象是 64 字节对齐,则 vkAllocateMemory 保证,分配的内存会满足指定的要求。 它会返回 512 字节对齐的设备内存,可用于分配任何对象类型。 虽然分配了内存,不过它仍然没有进行初始化。 应用程序应该创建大型的 VkDeviceMemory 对象并重新分配它们的存储区域,从而获得良好的性能。
在物理设备内存上执行的分配数量取决于具体的实现,可以使用 VkPhysicalDeviceLimits 的 maxMemoryAllocationCount 成员进行查询。 当超过 maxMemoryAllocationCount 时,vkAllocateMemory 会返回 VK_ERROR_TOO_MANY_OBJECTS。
释放设备内存
分配的设备内存可以使用 vkFreeMemory()API 释放。 以下是其语法:
void vkFreeMemory(
VkDevice device,
VkDeviceMemory memory,
const VkAllocationCallbacks* allocator);
下表介绍了此函数的每个参数:
device:这是指拥有内存的逻辑设备。
memory :这是指需要释放的 VkDeviceMemory 对象。
allocator :这个参数控制主机内存的释放。
从主机访问设备内存
使用 vkAllocateMemory API 分配的设备内存仅对设备可见;它对主机不可见或不可访问。 主机只能访问那些被映射的、已分配的设备内存区域;使用内存属性 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 分配的内存被认为是可映射的。
使用 vkMapMemory()API,主机就可以访问映射的设备内存。 该 API 会返回一个虚拟的地址指针,映射到已分配的设备内存区域。 以下是 API 的语法:
VkResult vkMapMemory(
VkDevice device,
VkDeviceMemory memory,
VkDeviceSize offset,
VkDeviceSize size,
VkMemoryMapFlags flags,
void** ppData);
接受的参数描述如下:
device :这是指拥有内存的逻辑设备。
Memory :这是指需要映射的设备对象的内存。
offset :这是指从内存的开始处以字节为单位的内存偏移量。
Size :这是指需要映射的内存范围的大小。
flags :这是保留供将来使用。
ppData :这将返回映射的内存地址。 这是一个指向指针的指针,即二级指针,它包含了一个主机可访问的指针,指向映射范围的起始位置。
注意
vkMapMemory API 会立即返回指向映射内存的指针。 它不检查内存是否已经做好了映射的准备。 因此,管理映射内存是应用程序的责任。 访问未做好映射准备的这类内存可能会导致未定义的行为;驱动程序可能会在堆中出现问题。
内存映射后,就可以像普通主机内存一样使用了,并且在其上可以更新数据。 内存更新后,需要将其取消映射,以便设备内存可以反射最新的更改。 使用 vkUnmapMemory()API 执行内存解映射。 它接受两个参数。 第一个是 VkDevice 类型,指示拥有内存的逻辑设备对象句柄,第二个参数是需要取消映射的设备内存句柄(VkDeviceMemory)。
其语法如下:
void vkUnMapMemory(VkDevice device, VkDeviceMemory memory);
随着本书的深入探讨,我们将学习如何在 Vulkan API 中使用 uniform(一致)。 在第 10 章“描述符和 push 常量”中,我们将实现设备内存的分配以及映射,以此来更新一致缓冲区 uniform buffer。
惰性分配内存
使用 VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT 位标志分配的内存不是基于请求的大小预先分配的,而是以一种单调的方式进行分配,内存会随应用程序需求线性地逐渐增加。 内存可能从一开始就从零字节开始,随着使用而增长。
懒惰分配的内存只能由 VkImage 类型的图像对象使用。 这些图像对象必须包含内存类型 VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT。
在任何给定的时刻,可以使用 vkGetDeviceMemoryCommitment()API 查询当前提交给特定内存对象(VkDeviceMemory)的延迟分配内存的尺寸。 以下是它的语法:
void vkGetDeviceMemoryCommitment(
VkDevice device, VkDeviceMemory memory,
VkDeviceSize* pCommittedMemoryInBytes);
该 API 接受三个参数,如下表所示:
device :这是拥有内存的逻辑设备。
memory :这是需要查询大小的设备对象内存。
pCommittedMemoryInBytes:这将返回设备内存中当前提交的大小。
总结
在本章中,我们知道了 Vulkan API 中的命令缓冲区和内存分配。 我们理解了命令缓冲区和命令池的概念,并在此过程中,通过这些内容我们创建了池并分配了命令缓冲区对象。 我们亲自实践并构建了命令缓冲区的包装类,并实现了命令池和命令缓冲区的创建,重置和销毁。 我们实现了命令缓冲区的记录,并将它们提交给适当的队列进行处理。
本章的第二部分充满了大量有关主机内存和设备内存的内存管理概念。 我们知道了分配器及其 API 来管理主机存储空间的分配。 我们还研究了设备内存并清楚了 Vulkan API 如何进行分配,映射以及释放设备内存。
Vulkan 有两种类型的资源:缓冲区和图像。 在下一章中,我们将学习 Vulkan 图像资源并在命令缓冲区的帮助下使用它们创建交换链。 第 7 章将介绍缓冲区资源,“缓冲区资源”,“渲染通道”,“帧缓冲区”以及“使用 SPIR-V 的着色器”。