现代渲染器架构:当虚拟纹理遇见 Bindless,如何优雅设计物理页面流式更新?
在现代大世界游戏和高精度渲染器中,**虚拟纹理(Virtual Texturing, VT)**早已成为标配。然而,传统的 VT 实现(如早期基于 Megatexture 概念的方案)通常依赖于一个巨大的 Texture2DArray 作为物理页面池(Physical Page Pool)。这种设计存在一个致命痛点:所有物理页面必须拥有完全相同的分辨率和格式。
当你在同一个场景中混用 BC7(高精度颜色)、BC5(法线)以及单通道的 BC4(粗糙度/金属度)时,强行统一格式会导致极其严重的显存浪费,或者迫使你维护多套平行的物理物理页面池,极大地增加了架构的复杂度。
Bindless Texture(无绑定纹理)的引入彻底颠覆了这一现状。
借助 Bindless 架构,我们不再受制于单一 Texture2DArray 的格式限制。我们可以将不同的物理页面池注册到全局描述符堆(Descriptor Heap)中,甚至让页表(Page Table)直接索引不同的物理纹理资源。
本文将深入探讨在现代图形 API(Vulkan / Direct3D 12)下,如何将 Virtual Texturing 与 Bindless 架构 深度结合,并设计一套优雅、高效的物理页面异步加载与流式更新管线。
1. 架构重塑:Bindless 驱动的页表设计
在传统的虚拟纹理中,页表(Page Table)的每个像素存储的是物理页面的 UV 偏移和 Mip 级别。
而在 Bindless 架构下,我们可以将页表项(Page Table Entry)定义得更加强大。我们可以使用一个紧凑的 uint32 或 uint64 来编码页表项,使其直接包含物理纹理的 Bindless 索引以及页面的内偏移。
页表项(Page Table Entry)编码设计
假设我们使用一个 uint32 来表示页表项:
| 位区间 (Bits) | 占用位数 | 含义 |
|---|---|---|
0 - 15 |
16 bits | Bindless Texture ID (指向具体的物理 Texture Array 或独立纹理) |
16 - 23 |
8 bits | Physical Page X Offset (物理页面在目标纹理中的横向格数) |
24 - 31 |
8 bits | Physical Page Y Offset (物理页面在目标纹理中的纵向格数) |
通过这种编码,我们在 Shader 中采样时,可以实现极高自由度的动态分支:
// HLSL SM 6.6+ 伪代码:解包页表并采样 Bindless 物理页面
struct PageTableEntry
{
uint rawData;
uint GetBindlessIndex() { return rawData & 0xFFFF; }
float2 GetPageOffset() { return float2((rawData >> 16) & 0xFF, (rawData >> 24) & 0xFF); }
};
// 全局描述符堆
Texture2D g_BindlessTextures[] : register(t0, space1);
SamplerState g_LinearClampSampler : register(s0);
float4 SampleVirtualTexture(float2 uv, Texture2D pageTable, float2 virtualTextureSize, float2 tileSize)
{
// 1. 采样页表
uint rawEntry = pageTable.SampleLevel(g_LinearClampSampler, uv, 0).r;
PageTableEntry entry;
entry.rawData = rawEntry;
uint bindlessIdx = entry.GetBindlessIndex();
// 如果无有效页面(未加载),重定向到基础低精度 Mip 或默认纹理
if (bindlessIdx == INVALID_BINDLESS_INDEX)
{
return g_BindlessTextures[fallbackTextureIdx].Sample(g_LinearClampSampler, uv);
}
// 2. 计算物理页面的实际 UV 坐标
float2 pageOffset = entry.GetPageOffset();
float2 localUV = frac(uv * (virtualTextureSize / tileSize)); // 页面内局部 UV
// 假设物理页面池单张纹理的大小和瓦片大小已知
float2 physicalUV = (pageOffset * tileSize + localUV * tileSize) / PhysicalTextureSize;
// 3. 直接通过 Bindless 索引进行采样
return g_BindlessTextures[bindlessIdx].Sample(g_LinearClampSampler, physicalUV);
}
这样设计的最大优势在于:你可以在不同的虚拟纹理间共享同一套页表更新逻辑,且物理页面可以存放在任意格式、任意大小的 Bindless 纹理中。
2. 物理页面流式更新流水线(Streaming Pipeline)
要让这套系统跑起来,最核心的挑战在于如何避免 CPU-GPU 同步导致的管线停顿(Stall)。一个优雅的流式更新流水线应当是完全异步且双缓冲(或环形缓冲)的。
以下是推荐的完整生命周期闭环:
[ GPU Shader ] -> 1. 写入反馈缓冲 (Feedback Buffer)
|
v
[ CPU Worker ] -> 2. 回读反馈并去重 (Readback & Deduplicate)
|
v
[ Disk / Asset ] -> 3. 异步 I/O 加载 & 线程池解压 (DirectStorage / ISP)
|
v
[ GPU Uploader ] -> 4. 拷贝至暂存缓冲 (Staging Buffer -> Upload Heap)
|
v
[ GPU Queue ] -> 5. 异步 Compute Shader 执行物理页面拷贝 (Copy Queue)
|
v
[ CPU/GPU Sync ] -> 6. 更新页表与 Bindless 描述符
关键步骤一:高效的反馈回读(Feedback Processing)
传统的 VT 反馈直接往一个低分辨率的 Render Target 写入需要的 Page ID,然后 CPU 回读。为了减少回读带宽和 CPU 端的去重压力,我们可以采用 GPU 驱动的去重方案。
利用 GPU Compute Shader 结合 AppendStructuredBuffer 或原子操作,在 GPU 端维护一个本帧请求的 Page 集合。
- 在光栅化/计算着色器中,通过原子操作(Atomic Or)在 GPU 显存中的一张“反馈活跃图(Feedback Map)”标记请求。
- 每一个帧末尾,运行一个轻量级的 Compute Shader,扫描 Feedback Map 并将实际需要加载的 Page ID 写入一个
AppendBuffer。 - CPU 只需回读这个极小的
AppendBuffer及其 Count,大大减少了 PCIe 传输带宽。
关键步骤二:无锁 LRU 物理页池管理器
在 CPU 端,我们需要一个物理页面管理器(Page Pool Manager)来决定哪些旧页面应该被剔除(Evict),哪些新页面应该被载入。
利用 LRU(Least Recently Used)缓存淘汰算法 是最经典的方案。在 Bindless 架构下,我们的物理页池是由多个不同格式的 Texture2D(每个充当一个 Page 容器)组成的。
struct PhysicalTileSlot {
uint32_t bindlessTextureId;
uint16_t offsetX;
uint16_t offsetY;
uint64_t lastUsedFrame; // 用于 LRU 淘汰
bool isOccupied;
};
class PhysicalPageAllocator {
public:
PhysicalTileSlot Allocate(uint64_t currentFrame) {
if (!freeSlots.empty()) {
auto slot = freeSlots.back();
freeSlots.pop_back();
slot.lastUsedFrame = currentFrame;
return slot;
}
// 运行 LRU 剔除
return EvictLeastRecentlyUsed(currentFrame);
}
void Free(const PhysicalTileSlot& slot) {
freeSlots.push_back(slot);
}
private:
std::vector<PhysicalTileSlot> freeSlots;
std::list<PhysicalTileSlot> activeSlots; // 按使用时间排序
PhysicalTileSlot EvictLeastRecentlyUsed(uint64_t currentFrame);
};
关键步骤三:异步拷贝与 Hazard(屏障)处理
当 I/O 线程将纹理数据加载到 CPU 内存并解压完毕后,我们需要将其上传到 GPU。这里推荐使用现代 API 的 Async Copy Queue(异步拷贝队列)。
传统的 UpdateSubresources 会在 Direct 队列上引起管线等待,而使用独立的 Copy Queue 可以实现完全与渲染并行的显存传输。
优雅的避免 Data Race(写时读冲突)
这是最容易写出 Bug 的地方:GPU 还在渲染上一帧,而 Copy Queue 已经开始覆盖该虚拟页面对应的物理显存了。
优雅的解决方案 是引入 双缓冲物理页面(Double-Buffered Pages) 或 延迟页表更新(Deferred Page Table Update):
- 不要立刻更新页表: 当一个页面被分配并开始往物理池拷贝时,不要立刻更新 CPU 侧的页表数据。
- 使用 Fence 追踪拷贝进度: 为每一次 Copy Queue 上的 Batch 提交插入一个
ID3D12Fence/VkSemaphore。 - 延迟生效: 在 CPU 端的 Frame 控制循环中,检查 Fence。确认 GPU 拷贝完成后,再在下一帧的命令列表中更新页表纹理(Page Table Texture),或者将新的 Bindless ID 写入页表缓冲。
- 过渡状态处理: 在拷贝进行期间,该虚拟页面的页表项依然保持指向其父级 Mip(低精度页面),这虽然会造成短暂的视觉变糊(Mip-map transition),但绝不会出现画面撕裂或纹理花屏。
3. 页表的高效更新:Compute Shader 批量刷写
当大量的页面加载完毕,我们需要集中更新页表。如果每个页面都用 CPU 调用 CopyTextureRegion 去修改页表纹理对应的像素,会导致极高的 CPU 开销。
更优雅的做法:通过 Structured Buffer 收集更新请求,一个 Compute Shader 搞定。
// 页表更新 Compute Shader (HLSL)
struct PageUpdateInfo
{
uint2 virtualCoord; // 虚拟页表中的坐标 (X, Y)
uint physicalEntry; // 编码后的物理页面信息 (Bindless ID + Offsets)
uint mipLevel; // 要更新的 Mip 层级
};
StructuredBuffer<PageUpdateInfo> g_UpdateQueue : register(t0);
RWTexture2D<uint> g_PageTableMip0 : register(u0);
RWTexture2D<uint> g_PageTableMip1 : register(u1); // 支持多级页表更新
[numthreads(64, 1, 1)]
void CSMain(uint3 dispatchThreadID : SV_DispatchThreadID)
{
// 获取更新请求
uint requestCount;
uint dummy;
g_UpdateQueue.GetDimensions(requestCount, dummy);
if (dispatchThreadID.x >= requestCount) return;
PageUpdateInfo info = g_UpdateQueue[dispatchThreadID.x];
// 根据 Mip 写入对应的页表纹理
if (info.mipLevel == 0)
{
g_PageTableMip0[info.virtualCoord] = info.physicalEntry;
}
else if (info.mipLevel == 1)
{
g_PageTableMip1[info.virtualCoord >> 1] = info.physicalEntry;
}
// ... 依次类推
}
在 CPU 端,你只需要把本帧所有完成加载的页面信息整理成一个一维数组,上传到一个 Transient Buffer 中,然后 Dispatch 一次这个 CS。这种设计对于多核多线程渲染器极其友好,完全避免了繁琐的 Render Target 切换。
4. 极端情况优化:规避页面抖动(Page Thrashing)
在玩家视角快速转动时,可能会出现请求的页面数量远超物理页面池承载能力的情况。这会导致页面刚刚被加载进来,下一帧又被剔除,产生严重的页面抖动,表现为游戏画面疯狂闪烁、卡顿。
针对这种场景,结合 Bindless 的高灵活性,我们可以实施以下优雅的降级策略:
- 基于视锥体与距离的优先级队列(Priority Queue):
在 CPU 端回读反馈后,计算请求页面与摄像机的距离及偏角。高 Mip(细节)且距离远的页面直接丢弃不予加载,只加载近处的、或者低 Mip(基础粗糙)的页面。 - 写保护时间戳(Cooldown Timestamp):
物理页池中的每个 Slot 在写入新页面后,强制获得一个FrameCooldown(例如 30 帧内不准被剔除)。在此期间,哪怕它在 LRU 队列中排到了最末尾,也不允许将其 Evict。以此牺牲局部的纹理精度,换取帧率和画面的绝对平稳。 - Bindless 占位纹理(Null Descriptor / Fallback):
得益于 Bindless,如果某个页面的显存实在腾不出来,我们可以直接把页表项中的 Bindless Index 指向一个统一的、已经常驻显存的低分辨率兜底纹理(Fallback Texture)。Shader 依然正常执行,不会发生空指针(Null Descriptor)引发的 TDR 崩溃。
总结
将 Bindless Texture 引入 虚拟纹理(VT) 架构,彻底解放了传统物理页面池在格式和分辨率上的桎梏。
通过:
- 使用单
uint32编码 Bindless Index 与物理偏移的页表设计; - 配合异步 Copy Queue 与 Fence 组成的无锁流式加载管线;
- 以及 Compute Shader 批量刷写页表。
我们不仅能够优雅地支持 BC7/BC5/BC4 等多种不同纹理格式的混合流式加载,更能将运行时 CPU 维护开销和 GPU 同步开销降到最低,从而在现代大世界渲染中,真正实现高画质与高性能的兼得。