Vulkan Sparse Residency 实战:构建超大虚拟纹理(Virtual Texturing)的显存管理方案
在开放世界游戏或高精细度场景渲染中,超大纹理(如 16K 或 32K 的地表贴图)的使用非常普遍。传统的纹理流送(Texture Streaming)采用整张贴图或不同 Mip 级别进行粗粒度切换,这在面对超大纹理时会带来巨大的显存浪费和 I/O 抖动。
虚拟纹理(Virtual Texturing) 技术将超大纹理切分成一个个小尺寸的瓦片(Tiles / Pages,通常为 128x128 或 256x256 像素),仅将当前视锥体内可见的瓦片加载到显存中。
Vulkan 提供的 稀疏驻留(Sparse Residency) 特性,在硬件层面上为虚拟纹理提供了最直接的支持。它允许我们创建一个巨大的虚拟物理资源(VkImage),而不必在一开始就为其分配完整的物理显存。只有当某个区域需要显示时,我们才将实际的物理内存绑定到该区域的虚页面上。
本文将深入探讨如何在 Vulkan 中利用 Sparse Residency 实现一套类似虚拟纹理的显存动态分配方案。
1. 核心概念:虚拟内存与物理内存的解耦
在传统的 Vulkan 内存分配中,我们创建 VkImage,然后通过 vkBindImageMemory 将整块 VkDeviceMemory 绑定上去。
而在 Sparse Residency 模式下:
- 虚拟地址空间:我们创建的
VkImage拥有巨大的逻辑尺寸,占用连续的虚拟地址空间,但此时它没有绑定任何物理显存。 - 物理内存页:我们分配一个或多个
VkDeviceMemory块,并将其视为一个“物理内存页池(Page Pool)”。 - 映射表:在运行时,根据视口和相机的反馈,动态地将物理内存页绑定到
VkImage的特定区域(Tile)。不需要的区域则解除绑定,释放物理显存。
2. 准备工作:开启稀疏特性
在初始化 Vulkan 逻辑设备(VkDevice)之前,必须显式启用 Sparse Binding 和 Sparse Residency 特性。
VkPhysicalDeviceFeatures deviceFeatures{};
vkGetPhysicalDeviceFeatures(physicalDevice, &deviceFeatures);
if (!deviceFeatures.sparseBinding || !deviceFeatures.sparseResidencyImage2D) {
throw std::runtime_error("当前显卡不支持 2D 稀疏纹理驻留!");
}
VkPhysicalDeviceFeatures enabledFeatures{};
enabledFeatures.sparseBinding = VK_TRUE;
enabledFeatures.sparseResidencyImage2D = VK_TRUE;
VkDeviceCreateInfo createInfo{};
createInfo.pEnabledFeatures = &enabledFeatures;
// ... 其他初始化代码 ...
注意:sparseBinding 是基础,允许非连续内存绑定;sparseResidencyImage2D 则是允许 2D 贴图部分驻留在显存中的关键。
3. 创建稀疏 Image
创建 VkImage 时,必须传入特殊的 Flag:
VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
imageInfo.extent.width = 16384; // 16K 巨大的虚拟纹理
imageInfo.extent.height = 16384;
imageInfo.extent.depth = 1;
imageInfo.mipLevels = 15; // 完整的 Mip 链
imageInfo.arrayLayers = 1;
imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
imageInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
// 关键标志位
imageInfo.flags = VK_IMAGE_CREATE_SPARSE_BINDING_BIT | VK_IMAGE_CREATE_SPARSE_RESIDENCY_BIT;
VkImage virtualImage;
vkCreateImage(device, &imageInfo, nullptr, &virtualImage);
此时,这个 virtualImage 只是一个虚拟的外壳,不消耗任何实际的物理显存。
4. 获取稀疏内存对齐要求(Granularity)
硬件对于稀疏纹理的瓦片大小(Page Size)有严格的对齐要求。我们需要查询该 Image 在物理设备上的对齐单位(通常以像素宽、高、深表示)。
uint32_t requirementsCount = 0;
vkGetImageSparseMemoryRequirements(device, virtualImage, &requirementsCount, nullptr);
std::vector<VkSparseImageMemoryRequirements> sparseRequirements(requirementsCount);
vkGetImageSparseMemoryRequirements(device, virtualImage, &requirementsCount, sparseRequirements.data());
VkExtent3D imageGranularity;
for (const auto& req : sparseRequirements) {
if (req.formatProperties.aspectMask & VK_IMAGE_ASPECT_COLOR_BIT) {
// 获取色彩平面的瓦片粒度
imageGranularity = req.formatProperties.imageGranularity;
break;
}
}
通常,对于大多数主流 GPU,在 R8G8B8A8 格式下,imageGranularity 的大小通常是 64x64 或 128x128 像素。
这意味着:我们进行显存绑定和更新的最小单位就是这个 imageGranularity。
5. 处理 Mipmap 的特殊区域(Mip Tail)
稀疏纹理的一个坑在于:当 Mipmap 的分辨率缩小到一定程度时(例如小于一个瓦片粒度,比如 16x16),它们会在物理上被打包存储在一个统称为 Mip Tail 的区域中。
在 VkSparseImageMemoryRequirements 中,有以下几个关键字段:
imageMipTailFirstLod:第一个进入 Mip Tail 的 Mip 级别。imageMipTailSize:Mip Tail 区域所需的物理内存大小。imageMipTailOffset:Mip Tail 在该物理资源中的偏移。
对于大于等于 imageMipTailFirstLod 的 Mip 级别,不能按瓦片进行稀疏更新,必须一次性为整个 Mip Tail 绑定物理显存。
6. 运行时动态绑定物理内存(Queue Bind Sparse)
当我们在运行时通过 Camera 剔除或着色器反馈(Feedback Buffer)得知某个位置(如 Mip 0, 坐标为 [x, y])的瓦片需要显示时,我们需要执行以下操作:
Step 6.1: 分配物理内存页
从我们自定义的 Page Pool 里申请一块 VkDeviceMemory(大小必须是瓦片所需内存的整数倍)。
Step 6.2: 准备绑定信息
使用 VkSparseImageMemoryBind 结构体来定义虚拟 Image 区域与物理内存页的映射关系。
VkSparseImageMemoryBind imageBind{};
imageBind.subresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
imageBind.subresource.mipLevel = 0; // 目标 Mip 级别
imageBind.subresource.arrayLayer = 0;
imageBind.offset = { x * imageGranularity.width, y * imageGranularity.height, 0 };
imageBind.extent = imageGranularity;
imageBind.memory = physicalPageMemory; // 我们分配的物理显存
imageBind.memoryOffset = pageMemoryOffset; // 在该物理显存块中的偏移
imageBind.flags = 0;
Step 6.3: 提交绑定命令
在 Vulkan 中,内存绑定的映射关系改变是在队列中异步执行的,需要提交到支持 VK_QUEUE_SPARSE_BINDING_BIT 的队列中。
VkSparseImageMemoryBindInfo imageBindInfo{};
imageBindInfo.image = virtualImage;
imageBindInfo.bindCount = 1;
imageBindInfo.pBinds = &imageBind;
VkBindSparseInfo bindInfo{};
bindInfo.sType = VK_STRUCTURE_TYPE_BIND_SPARSE_INFO;
bindInfo.imageBindCount = 1;
bindInfo.pImageBinds = &imageBindInfo;
// 我们可以引入信号量来同步绑定和后续的渲染
// bindInfo.signalSemaphoreCount = 1;
// bindInfo.pSignalSemaphores = &bindSemaphore;
// 提交到支持 Sparse Binding 的队列中
vkQueueBindSparse(sparseQueue, 1, &bindInfo, VK_NULL_HANDLE);
一旦 vkQueueBindSparse 在 GPU 上执行完毕,该块虚拟区域就拥有了物理内存的支持。此时,你可以安全地通过 vkCmdCopyBufferToImage 将真正的纹理像素从暂存缓冲区(Staging Buffer)拷贝到这个区域。
7. 虚拟纹理完整链路与系统整合
要搭建起一个工业级的虚拟纹理(Virtual Texturing)系统,除了上述 Vulkan API 调用外,还需要在引擎层构建以下两个核心机制:
+-------------------------------------------------------------+
| 1. Shader 采样与反馈 |
| 在 Render Pass 中,着色器检测所需的虚拟纹理坐标,写出反馈图 |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| 2. CPU 回读与分析 (Feedback) |
| 读取反馈图,得知当前哪些 Tile 缺失,哪些 Tile 需要被淘汰 |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| 3. I/O 线程加载与分配 |
| 从磁盘异步加载 Tile 像素,同时从物理 Page Pool 申请显存 |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| 4. Vulkan 队列执行 |
| 调用 vkQueueBindSparse,随后将解压后的像素上传至对应 Tile |
+-------------------------------------------------------------+
优化性能的关键建议
- 批量提交绑定:
vkQueueBindSparse的提交开销较高,切忌一针更新一个 Tile 就调用一次。应该在 CPU 端收集一帧中所有需要更新的 Tile,通过VkBindSparseInfo的数组一次性批量提交。 - 多线程异步上传:由于物理显存的绑定是在独立队列中进行的,图像数据的传输(
vkCmdCopy)应当使用专门的 Transfer Queue,实现计算与 I/O 的完全异步重叠(Overlap)。 - 安全释放与重映射:在解除绑定(即回收物理 Page)时,若该物理内存仍在使用中(例如前几帧的绘制还在读取它),直接解除映射或释放内存会导致 GPU Crash(页缺失错误)。务必配合
VkFence或VkSemaphore确保 GPU 侧不再消费该 Tile,才能执行解绑操作。
通过 Vulkan 的 Sparse Residency 机制,引擎能够以极小且恒定的显存开销,在场景中渲染出近乎无限精度的地表和模型纹理。这正是现代大作实现“无缝、大视角、高精细度”画面的基石技术之一。