WEBKT

现代渲染器架构:当虚拟纹理遇见 Bindless,如何优雅设计物理页面流式更新?

23 0 0 0

在现代大世界游戏和高精度渲染器中,**虚拟纹理(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 TexturingBindless 架构 深度结合,并设计一套优雅、高效的物理页面异步加载与流式更新管线。


1. 架构重塑:Bindless 驱动的页表设计

在传统的虚拟纹理中,页表(Page Table)的每个像素存储的是物理页面的 UV 偏移和 Mip 级别。

而在 Bindless 架构下,我们可以将页表项(Page Table Entry)定义得更加强大。我们可以使用一个紧凑的 uint32uint64 来编码页表项,使其直接包含物理纹理的 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 集合。

  1. 在光栅化/计算着色器中,通过原子操作(Atomic Or)在 GPU 显存中的一张“反馈活跃图(Feedback Map)”标记请求。
  2. 每一个帧末尾,运行一个轻量级的 Compute Shader,扫描 Feedback Map 并将实际需要加载的 Page ID 写入一个 AppendBuffer
  3. 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)

  1. 不要立刻更新页表: 当一个页面被分配并开始往物理池拷贝时,不要立刻更新 CPU 侧的页表数据。
  2. 使用 Fence 追踪拷贝进度: 为每一次 Copy Queue 上的 Batch 提交插入一个 ID3D12Fence / VkSemaphore
  3. 延迟生效: 在 CPU 端的 Frame 控制循环中,检查 Fence。确认 GPU 拷贝完成后,再在下一帧的命令列表中更新页表纹理(Page Table Texture),或者将新的 Bindless ID 写入页表缓冲。
  4. 过渡状态处理: 在拷贝进行期间,该虚拟页面的页表项依然保持指向其父级 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 的高灵活性,我们可以实施以下优雅的降级策略:

  1. 基于视锥体与距离的优先级队列(Priority Queue):
    在 CPU 端回读反馈后,计算请求页面与摄像机的距离及偏角。高 Mip(细节)且距离远的页面直接丢弃不予加载,只加载近处的、或者低 Mip(基础粗糙)的页面。
  2. 写保护时间戳(Cooldown Timestamp):
    物理页池中的每个 Slot 在写入新页面后,强制获得一个 FrameCooldown(例如 30 帧内不准被剔除)。在此期间,哪怕它在 LRU 队列中排到了最末尾,也不允许将其 Evict。以此牺牲局部的纹理精度,换取帧率和画面的绝对平稳。
  3. 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 同步开销降到最低,从而在现代大世界渲染中,真正实现高画质与高性能的兼得。

吉米渲染美学 虚拟纹理Bindless图形引擎

评论点评