WEBKT

WebGPU无界地形渲染:软件虚拟纹理(Virtual Texturing)深度设计与落地实践

7 0 0 0

在Web端实现无界地形(Boundless Terrain)时,最大的瓶颈往往不在于几何体的渲染(依靠Clipmap或Quadtree LOD可以很好地控制顶点数量),而在于海量高分辨率地表纹理对显存的无限索求

WebGPU作为新一代Web图形API,带来了更接近底层的GPU掌控力。然而,截至目前,WebGPU规范尚未原生支持硬件级的稀疏纹理(Sparse Textures / Tiled Resources)。要在WebGPU中渲染TB级的无界地形,我们必须在软件层面构建一套高效的虚拟纹理(Virtual Texturing, VT)系统

本文将剖析如何在WebGPU中从零构建一套生产级可用的软件虚拟纹理系统,解决多级LOD裁剪、显存物理缓存管理以及跨Tile缝隙消除等核心落地痛点。


一、 软件虚拟纹理系统的核心架构

软件虚拟纹理的核心思想是:用一张虚拟的“超级大纹理”(如 $262144 \times 262144$ 像素)来映射整个无界地形,但实际显存中只保留当前摄像机可视区域内的、对应LOD层级的纹理切片(Tiles)。

整个系统由四个核心模块构成的闭环运行:

+-------------------------------------------------------------+
|                                                             |
|   +------------------+     Render      +----------------+   |
|   |  Feedback Pass   | --------------> | Feedback Buf   |   |
|   +------------------+                 +----------------+   |
|            ^                                   |            |
|            | GPU                               | GPU-to-CPU |
|            |                                   v            |
|   +------------------+  Update Atlas   +----------------+   |
|   |  Page Table/Atlas| <-------------- |  Page Manager  |   |
|   +------------------+                 +----------------+   |
|                                                             |
+-------------------------------------------------------------+
  1. Virtual Address Space(虚拟地址空间):定义逻辑上的超大纹理。例如,定义一个 18 级的金字塔,顶层 $1 \times 1$ 个 Tile,底层 $262144 \times 262144$ 个 Tile。每个 Tile 大小通常为 $128 \times 128$ 或 $256 \times 256$ 像素。
  2. Physical Texture Pool(物理纹理缓存/Atlas):在显存中开辟的一个固定大小的 GPUTexture(通常使用 Texture2DArray)。例如,分配一个 $64 \times 64$(共4096个槽位)的 $256 \times 256$ 像素的纹理阵列。
  3. Page Table(页表纹理):一张多级渐远(Mipmapped)的 GPUTexture,用来记录虚拟 Tile 到物理 Tile 的映射关系。像素格式通常为 rgba8unorm
    • R, G 通道:存储物理 Tile 在 Atlas 中的 X, Y 坐标。
    • B 通道:存储该虚拟 Tile 实际加载的 Mip 层级。
    • A 通道:标记该页状态(如:是否有效、是否正在加载)。
  4. Feedback Buffer(反馈缓冲区):用于将屏幕空间中实际需要的 Tile 信息异步回传给 CPU,告知 CPU 哪些 Tile 需要被加载或释放。

二、 WebGPU落地关键技术设计

1. 物理缓存(Physical Texture Array)的规划

在WebGPU中,推荐使用 Texture2DArray 替代 2D 纹理拼图(Texture Atlas),因为这可以彻底避免由于 Mipmap 过滤导致的不同 Tile 之间的**边缘渗色(Bleeding)**问题。

const physicalTextureDesc: GPUTextureDescriptor = {
  size: { width: 256, height: 256, depthOrArrayLayers: 1024 }, // 1024个物理槽位
  format: 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
};
const physicalTexture = device.createTexture(physicalTextureDesc);

2. 反馈通道(Feedback Pass)设计

渲染地形时,我们很难在 CPU 端精确计算哪些 Mipmap 级别的纹理是可见的(尤其是视角倾斜、被遮挡时)。因此,我们需要让 GPU 在绘制地形的同时,将所需的虚拟 Tile 信息写入一张低分辨率的反馈纹理(例如,视口分辨率的 $1/16$)。

在地形的渲染 Shader(WGSL)中,除了输出最终颜色外,还要额外渲染到一个 r32uint 格式的 Render Target。

WGSL 反馈计算 Shader 示例:

struct VertexOutput {
  @builtin(position) position: vec4f,
  @location(0) uv: vec2f, // 虚拟纹理的全局 UV
};

struct FeedbackUniform {
  virtualSizeInTiles: f32, // 例如 1024.0 (即 1024x1024 个 Tile)
  maxMipLevel: f32,
};
@group(0) @binding(0) var<uniform> cfg: FeedbackUniform;

@fragment
fn fs_main(in: VertexOutput) -> @location(0) u32 {
    // 1. 计算当前的偏导数,确定所需的 Mipmap Level
    let dx = dpdx(in.uv * cfg.virtualSizeInTiles);
    let dy = dpdy(in.uv * cfg.virtualSizeInTiles);
    let delta = max(dot(dx, dx), dot(dy, dy));
    let mip = clamp(0.5 * log2(delta), 0.0, cfg.maxMipLevel);
    
    // 2. 计算当前 UV 落在哪个虚拟 Tile 坐标上
    let mipScale = 1.0 / pow(2.0, floor(mip));
    let tileX = u32(in.uv.x * cfg.virtualSizeInTiles * mipScale);
    let tileY = u32(in.uv.y * cfg.virtualSizeInTiles * mipScale);
    let mipLevel = u32(floor(mip));

    // 3. 将 TileX (10位), TileY (10位), Mip (4位) 压缩编码为一个 32 位无符号整数
    let feedbackValue = (tileX << 14u) | (tileY << 4u) | mipLevel;
    return feedbackValue;
}

为了避免帧率下降,不能在每一帧都用 mapAsync 同步读取这个 Feedback 缓冲区。

最佳实践

  • 每隔 3~5 帧,将反馈纹理 Copy 到一个可读 Buffer(GPUBuffer)中。
  • 使用 device.queue.submit() 提交后,异步调用 buffer.mapAsync(GPUMapMode.READ)
  • 在 CPU 端解码读取到的数据,维护一个 LRU(最近最少使用) 队列,决定哪些 Tile 需要从服务端拉取,哪些可以被逐出物理缓存。

三、 页表(Page Table)的查询与纹理重构

当 CPU 把数据加载好并拷入物理纹理对应的槽位(Layer)后,需要更新 GPU 端的页表(Page Table)。页表负责告诉 Shader :“你想要的虚拟 UV 坐标,现在在物理纹理阵列的第 N 层。”

由于 WebGPU 无法像 CUDA 那样进行底层指针操作,我们用一张格式为 rgba8unorm 的多级纹理来实现这套间接寻址。

WGSL 间接寻址纹理采样算法:

@group(1) @binding(0) var pageTableTex: texture_2d<f32>;
@group(1) @binding(1) var physicalTexArray: texture_2d_array<f32>;
@group(1) @binding(2) var linearSampler: sampler;

fn sample_virtual_texture(uv: vec2f) -> vec4f {
    // 1. 从页表中采样,获取物理纹理映射关系
    // 注意:这里必须使用 mip level 0 或者手动计算过的 mip 寻址,防止页表自身采样出现环形引用偏导数问题
    let pageData = textureSampleLevel(pageTableTex, linearSampler, uv, 0.0);
    
    // 解码页表数据
    // pageData.r -> 物理槽位在 Atlas X
    // pageData.g -> 物理槽位在 Atlas Y (如果用 3D/Array 纹理,则直接映射为 Layer 索引)
    // pageData.b -> 当前映射所使用的虚拟 Mip 等级
    
    let physicalLayer = pageData.r * 255.0; // 假设对应存储的 Layer 索引
    let scaleExp = pageData.b * 255.0;
    
    // 2. 将全局虚拟 UV 转换到该 Tile 内部的局部 UV
    let mipScale = pow(2.0, scaleExp);
    let tileUV = fract(uv * mipScale);
    
    // 3. 采样物理纹理数组
    return textureSample(physicalTexArray, linearSampler, tileUV, i32(physicalLayer));
}

四、 无界地形中的无缝拼接与过滤(Bleeding & Seams)

在真正的工程落地中,按照上述基础算法渲染,你会遇到两个致命的视觉 Bug:

  1. Tile 边缘接缝(Seams):当跨越 Tile 边界时,由于 fract(uv) 导致的数值突变,GPU 在计算导数(Mipmap 级别)时会产生一个巨大的跳跃,在接缝处画出一条白线或黑线。
  2. 过滤渗透(Bleeding):在 Tile 的边缘进行双线性插值(Bilinear Filtering)采样时,会采样到邻近 Tile 的像素。

工业级解决方案:带边界保护的 Tile(Border Tiles)

在生成地表纹理切片时,每个 Tile(假设名义尺寸是 $256 \times 256$)在边缘都向外扩充 $4$ 像素(即实际纹理大小为 $264 \times 264$),这 $4$ 像素拷贝自相邻 Tile 的对应区域。

同时,在 Shader 采样时,需要将 fract(uv) 缩放到该保护边界内部的有效区间:

fn get_tile_uv_with_border(uv: vec2f, tileSize: f32, borderSize: f32) -> vec2f {
    let localUV = fract(uv);
    // 将 [0, 1] 映射到 [border / total, (border + tile) / total]
    let totalSize = tileSize + borderSize * 2.0;
    let minUV = borderSize / totalSize;
    let maxUV = (borderSize + tileSize) / totalSize;
    return mix(vec2(minUV), vec2(maxUV), localUV);
}

此外,必须使用 textureSampleGrad 显式传递导数,或者使用 textureSampleLevel 手动控制 Mip 采样。手动计算并传入屏幕空间的偏导数(Gradient),可以彻底干掉跨 Tile 边界时产生的过滤黑缝。

let dx = dpdx(uv);
let dy = dpdy(uv);
// 显式传入偏导数,避免在 fract(uv) 边界处产生错误的梯度评估
let color = textureSampleGrad(physicalTexArray, linearSampler, tileUV, i32(physicalLayer), dx, dy);

五、 落地生产踩坑指南与优化实践

  1. JS-GPU 异步通信延迟(Ping-Pong Buffer)
    从反馈纹理生成,到 CPU 读取、生成 Tile、回传至 WebGPU 队列,至少存在 2~3 帧的延迟。如果摄像机移动过快,会出现地表“瞬间模糊后变清晰”的拉扯感。

    • 优化手段:构建页表的预载(Preload)机制。根据摄像机速度向量,提前在 CPU 端计算并向外多请求 1~2 圈的 Tiles。
  2. 多线程并行解码(Web Workers)
    从网络加载的纹理切片通常是 KTX2 或 Basis Universal 格式。千万不要在 JS 主线程中执行解码。利用 Web Worker 池进行多线程解压,在 Worker 中将数据转换成 ImageBitmap,主线程直接通过 device.queue.copyExternalImageToTexture 异步提交给 WebGPU,可实现完全无卡顿的运行时加载。

  3. 页表的多级缓存更新(Incremental Updates)
    如果地形非常庞大,页表本身的尺寸也会很大(例如 $2048 \times 2048$)。千万不要在每次有 Tile 更新时重新上传整张页表。可以使用 device.queue.writeTexture 仅更新页表中发生变化的那个像素或区域(Sub-resource Update)。

通过上述架构在 WebGPU 中实现的软件虚拟纹理系统,能够支撑起百公里的无界精细地形。不仅能显著降低显存开销,还能在移动端浏览器上以 60 FPS 稳定运行,是 Web 端大型开放世界、三维数字地球(如 Cesium、三维 GIS)的核心底层优化利器。

Web3D探路者 WebGPU虚拟纹理地形渲染

评论点评