Vulkan® 内存分配器
VMA 是我们单文件头、MIT 许可的 C++ 库,用于轻松高效地为您的 Vulkan® 游戏和应用程序管理内存分配。
学习 Vulkan® API 的一个重要部分——就像学习任何其他 API 一样——是了解其中定义了哪些类型的对象,它们代表什么以及它们之间如何关联。为了帮助您理解,我们创建了一个图表,显示了所有 Vulkan 对象以及它们之间的一些关系,特别是您创建它们先后的顺序。
每个 Vulkan 对象都是特定类型的值,其前缀为 Vk。为了清晰起见,图表中省略了这些前缀,就像函数名称省略了 vk 前缀一样。例如,图表中的 Sampler 表示存在一个名为 VkSampler 的 Vulkan 对象类型。这些类型不应被视为指针或序数。您不应以任何方式解释它们的值。只需将它们视为不透明句柄,在函数之间传递它们,当然,别忘了在不再需要它们时销毁它们。背景为绿色的对象没有自己的类型。相反,它们由其父对象内的 uint32_t 类型的数字索引表示,例如 QueryPool 中的 Queries。
带箭头的实线表示创建顺序。例如,在创建 DescriptorSet 之前,您必须有一个已存在的 DescriptorPool。带有菱形的实线表示组合,这意味着您不必创建该对象,但它已经存在于其父对象内部,并且可以从中获取。例如,您可以从 Instance 对象枚举 PhysicalDevice 对象。虚线表示其他关系,例如将各种命令提交到 CommandBuffer。
图表分为三个部分。每个部分都有一个主要对象,以红色显示。一个部分中的所有其他对象都是直接或间接从该主要对象创建的。例如,vkCreateSampler——用于创建 Sampler 的函数——将其第一个参数指定为 VkDevice。为了清晰起见,图表中未绘制与主对象的关系。

以下是所有对象的简要说明
Instance 是您创建的第一个对象。它代表您的应用程序与 Vulkan 运行时之间的连接,因此在您的应用程序中应该只有一个。它还存储了使用 Vulkan 所需的所有应用程序特定状态。因此,在创建 Instance 时,您必须指定要启用的所有层(例如 Validation Layer)和所有扩展。
PhysicalDevice 代表一个特定的 Vulkan 兼容设备,例如图形卡。您可以从“Instance”枚举这些设备,然后查询它们的 vendorID、deviceID 以及支持的功能,以及其他属性和限制。
PhysicalDevice 可以枚举所有可用的 **Queue Families** 类型。图形队列是主要的队列,但您可能还会有其他只支持计算或传输的队列。
PhysicalDevice 还可以枚举其中的 Memory Heaps 和 Memory Types。**Memory Heap** 代表一个特定的 RAM 池。它可以抽象您主板上的系统 RAM,或者专用图形卡上特定内存空间中的视频 RAM,或者实现想要暴露的任何其他主机或设备特定内存。在分配内存时,您必须指定 **Memory Type**。它包含内存块的具体要求,例如对主机的可见性、一致性(CPU 和 GPU 之间)以及缓存。根据设备驱动程序,可能会有这些的任意组合。
Device 可以被认为是逻辑设备或已打开的设备。它是代表已初始化的 Vulkan 设备的主要对象,该设备已准备好创建所有其他对象。这与 DirectX® 中的 Device 对象概念相似。在设备创建期间,您需要指定要启用的功能,其中一些功能是基础性的,例如各向异性纹理过滤。您还必须声明将要使用的所有队列、它们的数量以及它们的 Queue Families。
Queue 是一个代表要设备上执行的命令队列的对象。GPU 要完成的所有实际工作都是通过填充 CommandBuffers 并将它们提交到 Queues 来请求的,使用函数 vkQueueSubmit。如果您有多个队列,例如主图形队列和计算队列,您可以将不同的 CommandBuffers 提交给每个队列。这样,您就可以启用异步计算,如果做得好,这可以带来显著的速度提升。
CommandPool 是一个用于分配 CommandBuffers 的简单对象。它连接到特定的 Queue Family。
CommandBuffer 从特定的 CommandPool 分配。它代表一个要由逻辑设备执行的各种命令的缓冲区。您可以在命令缓冲区上调用各种函数,所有这些函数都以 vkCmd 开头。它们用于指定当 CommandBuffer 被提交到 Queue 并最终由 Device 使用时应执行的任务的顺序、类型和参数。
Sampler 未绑定到任何特定的 Image。它更像是一组状态参数,例如过滤模式(最近或线性)或寻址模式(重复、钳位到边缘、钳位到边框等)。
Buffer 和 Image 是两种占用设备内存的资源类型。**Buffer** 更简单。它是一个包含任何二进制数据的容器,只有一个长度,以字节为单位。另一方面,**Image 代表一组像素。这是在其他图形 API 中称为纹理的对象。指定 Image 的创建需要更多的参数。它可以是 1D、2D 或 3D,具有各种像素格式(如 R8G8B8A8_UNORM 或 R32_SFLOAT),并且还可以由许多离散图像组成,因为它有多个数组层或 MIP 级别(或两者兼有)。Image 是一个单独的对象类型,因为它不一定只由可以直接访问的线性像素集组成。图像可以具有由图形驱动程序管理的、实现特定的不同内部格式(平铺和布局)。
创建具有特定长度的 Buffer 或具有特定尺寸的 Image 不会自动为其分配内存。这是一个必须由您手动执行的 3 步过程。您也可以选择使用我们的 Vulkan Memory Allocator 库,它会为您处理分配。
vkBindBufferMemory 或 vkBindImageMemory 将它们绑定在一起。这就是为什么您还必须创建 **DeviceMemory** 对象。它代表从特定内存类型(由 PhysicalDevice 支持)分配的内存块,以字节为单位指定长度。您不应为每个 Buffer 或 Image 分配单独的 DeviceMemory。相反,您应该分配更大的内存块,并将部分内存分配给您的 Buffers 和 Images。分配是一项成本高昂的操作,并且最大分配次数也有限制,所有这些都可以从您的 PhysicalDevice 中查询。
创建 Swapchain 是一个例外,不需要为每个 Image 分配和绑定 DeviceMemory。这是一个用于在屏幕或您正在绘制的操作系统窗口中显示最终图像的概念。因此,它的创建方式取决于平台。如果您已经使用系统 API 初始化了一个窗口,则需要先创建一个 **SurfaceKHR** 对象。它需要 Instance 对象以及一些系统相关的参数。例如,在 Windows 上,它们是:实例句柄 (HINSTANCE) 和窗口句柄 (HWND)。您可以将 SurfaceKHR 对象想象成 Vulkan 对窗口的表示。
由此,您可以创建 **SwapchainKHR**。此对象需要一个 Device。它代表一组可以显示在 Surface 上的图像,例如使用双缓冲或三缓冲。您可以从 swapchain 查询它包含的 Images。这些图像已经由系统分配了其底层内存。
Buffer 和 Image 并不总是直接用于渲染。在它们之上是另一层,称为 views。您可以将它们类比为数据库中的视图——一组参数,用于以期望的方式查看一组底层数据。**BufferView** 是基于特定 buffer 创建的对象。您可以在创建期间传递偏移量和范围,以将视图限制为仅部分 buffer 数据。类似地,**ImageView** 是一组指向特定 image 的参数。在这里,您可以将像素解释为具有其他(兼容)格式,对任何组件进行混洗,并将视图限制为特定的 MIP 级别或数组层范围。
着色器访问这些资源(Buffers、Images 和 Samplers)的方式是通过描述符。描述符不能单独存在,而是总是被分组到描述符集中。但在创建描述符集之前,其布局必须通过创建 **DescriptorSetLayout** 来指定,它就像描述符集模板一样。例如,您的渲染通道用于绘制 3D 几何的着色器可能会期望:
| 绑定槽 | 资源 |
| 0 | 一个 uniform buffer(在 DirectX 中称为 constant buffer),可供顶点着色器阶段使用。 |
| 1 | 另一个 uniform buffer,可供片段着色器阶段使用。 |
| 2 | 一个采样图像。 |
| 3 | 一个采样器,也可用作片段着色器阶段。 |
您还需要创建一个 **DescriptorPool**。它是一个用于分配描述符集的简单对象。在创建描述符池时,您必须指定将从其中分配的最大描述符集数量和各种类型的描述符数量。
最后,您分配一个 **DescriptorSet**。您需要 DescriptorPool 和 DescriptorSetLayout 才能做到这一点。DescriptorSet 代表存储实际描述符的内存,并且可以进行配置,使描述符指向特定的 Buffer、BufferView、Image 或 Sampler。您可以通过使用函数 vkUpdateDescriptorSets 来完成此操作。
多个 DescriptorSets 可以作为活动集绑定到 CommandBuffer 中,以供渲染命令使用。为此,请使用函数 vkCmdBindDescriptorSets。此函数还需要另一个对象——**PipelineLayout**——因为可能绑定多个 DescriptorSets,而 Vulkan 希望提前知道它应该期望多少以及什么类型的描述符集。PipelineLayout 代表渲染管线的配置,即绑定到 CommandBuffer 的描述符集类型。您从 DescriptorSetLayouts 数组创建它。
在其他图形 API 中,您可以采用即时模式方法,只需渲染列表中的下一个内容即可。这在 Vulkan 中是不可能的。相反,您需要提前计划渲染您的帧,并将其组织成 passes 和 subpasses。Subpasses 不是独立的对象,所以我们在这里不讨论它们,但它们是 Vulkan 渲染系统中一个重要的组成部分。幸运的是,在准备工作负载时,您不需要知道所有细节。例如,您可以在提交时指定要渲染的三角形数量。在 Vulkan 中定义 **RenderPass** 时,关键部分是该 pass 将使用的附件的数量和格式。
Attachment 是 Vulkan 对您可能称为 render target 的东西的称呼——一个用作渲染输出的 Image。在这里您不指向特定的 Image——您只描述它们的格式。例如,一个简单的渲染 pass 可能使用具有 R8G8B8A8_UNORM 格式的颜色附件和一个具有 D16_UNORM 格式的深度-模板附件。您还指定在 pass 开始时是否应保留、丢弃或清除附件的内容。
Framebuffer(不要与 SwapchainKHR 混淆)代表实际可用作附件(渲染目标)的 Image 的链接。您通过指定 RenderPass 和一组 ImageViews 来创建 Framebuffer 对象。当然,它们的数量和格式必须与 RenderPass 的规范匹配。Framebuffer 是 Image 之上的另一层,基本上是将这些 ImageViews 组合在一起,以便在特定 RenderPass 渲染期间作为附件绑定。每当您开始渲染 RenderPass 时,都会调用函数 vkCmdBeginRenderPass,并将 Framebuffer 传递给它。
Pipeline 是一个重要的概念,因为它组合了之前列出的许多对象。它代表整个管线的配置,并且有很多参数。其中一个参数是 PipelineLayout——它定义了描述符和 push constants 的布局。有两种类型的 Pipeline——ComputePipeline 和 GraphicsPipeline。ComputePipeline 更简单,因为它只支持计算专用程序(有时称为计算着色器)。GraphicsPipeline 更复杂,因为它包含了顶点、片段、几何、计算和适用的镶嵌化等所有参数,以及诸如顶点属性、图元拓扑、背面剔除和混合模式等内容,仅举几例。所有那些在更早的图形 API(DirectX 9、OpenGL)中曾是独立设置的参数,在 API 发展过程中(DirectX 10 和 11)被分组到更少的状态对象中,而在今天像 Vulkan 这样的现代 API 中,这些参数必须被烘焙到一个大的、不可变的单一对象中。对于在渲染过程中需要的每一组不同的参数,您都必须创建一个新的 Pipeline。然后,您可以通过调用函数 vkCmdBindPipeline 将其设置为 CommandBuffer 中当前活动的 Pipeline。
着色器编译是 Vulkan 中的一个多阶段过程。首先,Vulkan 不支持任何高级着色语言,如 GLSL 或 HLSL。相反,Vulkan 接受一种称为 SPIR-V 的中间格式,任何高级语言都可以生成它。使用 SPIR-V 格式数据的缓冲区用于创建 **ShaderModule**。此对象代表一段着色器代码,可能处于部分编译状态,但它还不是 GPU 可以执行的任何内容。只有在创建您将使用的每个着色器阶段(顶点、镶嵌控制、镶嵌评估、几何、片段或计算)的 Pipeline 时,您才会指定 ShaderModule 以及入口点函数的名称(例如“main”)。
还有一个称为 PipelineCache 的辅助对象,可用于加速 Pipeline 创建。它是一个简单的对象,您可以在创建 Pipeline 时选择性地传递它,但它确实有助于通过减少内存使用量和缩短 Pipeline 编译时间来提高性能。驱动程序可以在内部使用它来存储一些中间数据,以便创建类似的 Pipeline 可能更快。您还可以将 PipelineCache 对象的状态保存和加载到二进制数据缓冲区中,以便将其保存在磁盘上并在下次执行应用程序时使用。我们建议您使用它们!
Query 是 Vulkan 中的另一种对象类型。它可以用于将 GPU 写入的某些数字值读回。有不同类型的查询,例如 Occlusion(告诉您某些像素是否被渲染,即它们是否通过了所有预着色和后着色测试并进入了帧)或 Timestamp(来自某些 GPU 硬件计数器的值)。Query 没有自己的类型,因为它始终存在于 QueryPool 内,只是由一个 uint32_t 索引表示。**QueryPool** 可以通过指定查询类型和数量来创建。然后可以使用它们向 CommandBuffer 发出命令,例如 vkCmdBeginQuery、vkCmdEndQuery 或 vkCmdWriteTimestamp。
最后,还有用于同步的对象:Fence、Semaphore 和 Event。**Fence** 向主机发出任务执行已完成的信号。它可以被等待、轮询,并在主机上手动解除信号。它没有自己的命令函数,但可以在调用 vkQueueSubmit 时传递。一旦提交的队列完成,相应的 fence 就会被信号。
**Semaphore** 在没有配置参数的情况下创建。它可用于控制跨多个队列的资源访问。它可以在命令缓冲区提交时被信号或等待,同样通过调用 vkQueueSubmit,并且它可以被一个队列(例如计算)信号,而在另一个队列(例如图形)上等待。
Event 也是在没有参数的情况下创建的。它可以在 GPU 上被等待或信号,作为通过函数 vkCmdSetEvent、vkCmdResetEvent 和 vkCmdWaitEvents 提交到 CommandBuffer 的独立命令。它也可以被设置、重置并在 CPU 线程上(通过调用 vkGetEventStatus 进行轮询)等待。如果同步发生在 GPU 的单个点上,也可以使用 vkCmdPipelineBarrier,或者在 render pass 内可以使用 subpass 依赖项。
之后,您只需要学习如何调用 Vulkan 函数(可能以并行方式!)来让 GPU 每帧执行实际工作。然后,您就可以充分利用现代 GPU 提供的强大、灵活的计算能力了。
HelloVulkan 是一个小型、入门级的 Vulkan®“Hello Triangle”示例,展示了如何设置窗口、设置 Vulkan 上下文以及渲染一个三角形。
Barrier 控制 Vulkan 应用程序中的资源和命令同步,并且对性能和正确性至关重要。在此处了解更多。
VMA 是我们单文件头、MIT 许可的 C++ 库,用于轻松高效地为您的 Vulkan® 游戏和应用程序管理内存分配。