WEBKT

WebGPU 存储纹理写入详解与避坑指南

8 0 0 0

在 WebGPU 中,除了常规的渲染管线输出外,存储纹理(Storage Texture) 是我们在 Compute Shader(计算着色器)中直接输出图像数据、运行 GPGPU 算法(如图像处理、粒子物理模拟、光线追踪)的核心工具。

然而,WebGPU 作为现代图形 API,为了兼顾多平台(Vulkan、Metal、DirectX 12)的硬件安全与性能一致性,对存储纹理的设计施加了诸多严格的限制。

1. 存储纹理写操作的基本实现

在 WebGPU 中往存储纹理写入数据,通常发生在计算管线(Compute Pipeline)中。以下是完整的核心实现步骤。

WGSL 着色器端声明与写入

在 WGSL 中,存储纹理需要通过 texture_storage_2d 类型来声明。我们必须显式指定其像素格式访问模式(通常是 write)。

// 声明一个 2D 存储纹理,格式为 rgba8unorm,访问模式为只写 (write)
@group(0) @binding(0) var myStorageTexture: texture_storage_2d<rgba8unorm, write>;

@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
    let coords = id.xy;
    
    // 简单的颜色渐变计算
    let color = vec4<f32>(f32(coords.x) / 512.0, f32(coords.y) / 512.0, 0.5, 1.0);
    
    // 使用 textureStore 写入像素数据
    textureStore(myStorageTexture, coords, color);
}

JavaScript/TypeScript 客户端配置

在 JS 端,创建纹理时必须带有 GPUTextureUsage.STORAGE_BINDING 标记,并且格式必须与 WGSL 中声明的完全一致。

// 1. 创建纹理
const storageTexture = device.createTexture({
    size: [512, 512, 1],
    format: 'rgba8unorm', // 必须是支持 STORAGE 的格式
    usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
});

// 2. 创建 BindGroupLayout
const bindGroupLayout = device.createBindGroupLayout({
    entries: [{
        binding: 0,
        visibility: GPUShaderStage.COMPUTE,
        storageTexture: {
            access: 'write-only', // 对应 WGSL 中的 write
            format: 'rgba8unorm',
            viewDimension: '2d'
        }
    }]
});

// 3. 创建 BindGroup
const bindGroup = device.createBindGroup({
    layout: bindGroupLayout,
    entries: [{
        binding: 0,
        resource: storageTexture.createView()
    }]
});

2. 核心限制与痛点

在实际开发中,直接使用存储纹理会遇到三大“硬伤”限制:

限制一:格式支持极度苛刻(不支持 sRGB)

WebGPU 规范出于对移动端和老旧 GPU 驱动的兼容,仅允许一部分特定的纹理格式作为存储纹理。

  • 支持的常用格式rgba8unormrgba8snormrgba16floatrgba32floatr32float 等。
  • 致命痛点完全不支持 sRGB 格式(如 rgba8unorm-srgb)。如果你的渲染管线最终输出需要 sRGB 格式,你无法直接将存储纹理作为最终的 Render Target,否则会在创建 Pipeline 时直接报错。

限制二:默认不支持“读写共存”(Read-Write)

在标准的 WebGPU 1.0 规范中,存储纹理在同一个 Bind Group 绑定中要么是 write-only(只写),要么是 read-only(只读)。

  • 你不能在同一个 Shader 阶段中先读取某个像素的值,经过计算后再写回该像素。
  • 试图声明 texture_storage_2d<rgba8unorm, read_write> 会直接导致编译失败。

限制三:写入坐标越界行为未定义

不像普通的纹理采样(Sampler)可以设置 Clamp 或 Repeat 等包裹模式,textureStore 的写入坐标超出纹理实际分辨率时,其行为是未定义的。部分硬件会直接静默丢弃,而部分硬件可能会引起 GPU Crash,因此必须在 Shader 中手动做边界保护。


3. 应对与解决策略

针对上述限制,开发者在生产环境中通常采用以下几种架构设计来绕过限制:

解决方案 A:解决 sRGB 写入问题

由于不能直接写入 rgba8unorm-srgb,标准的做法是先在线性空间写入 rgba8unorm,在渲染呈现阶段手动做伽马矫正(Gamma Correction),或者通过一个简单的 Pass 将其转存。

  • 方案一(Shader 手动矫正)
    在 Compute Shader 中计算出线性颜色后,写入前进行伽马 2.2 转换,最后写入非 sRGB 的 rgba8unorm 纹理:
    let linearColor = vec3<f32>(0.5, 0.2, 0.1);
    let srgbColor = pow(linearColor, vec3<f32>(1.0 / 2.2)); // 模拟 sRGB 编码
    textureStore(myStorageTexture, coords, vec4<f32>(srgbColor, 1.0));
    
  • 方案二(管线转换)
    存储纹理依然使用 rgba8unorm。在最后一级 Presentation Render Pass 中,将该纹理作为普通 texture_2d<f32> 绑定,渲染到一个格式为 bgra8unorm-srgb 的 Canvas Texture 上。GPU 会自动在硬件层级进行线性到 sRGB 的转换。

解决方案 B:解决“无法同时读写”问题(三种方案)

1. 乒乓双缓冲(Ping-Pong Buffer)

这是图像模糊、流体模拟等算法的经典做法。创建两张配置完全相同的纹理:Texture_ATexture_B

  • 第 N 帧:把 Texture_A 绑定为只读(texture_2d<f32>),把 Texture_B 绑定为只写存储(texture_storage_2d<..., write>)。
  • 第 N+1 帧:调换两者的身份。
    通过每帧交换 Bind Group,实现实质上的读写迭代。

2. 利用最新扩展:read-write-storage-textures

WebGPU 正在逐步推进对存储纹理读写的支持。如果你的应用运行在支持该特性的浏览器上(例如 Chrome 124+),可以通过在创建 Device 时请求特定的 Feature 来开启该功能:

// 在请求设备时检测并启用特性
const hasReadWrite = adapter.features.has('read-write-storage-textures');
const device = await adapter.requestDevice({
    requiredFeatures: hasReadWrite ? ['read-write-storage-textures'] : []
});

如果成功启用,你便可以在 WGSL 中直接声明:

// 必须在 WGSL 顶部启用扩展(若规范要求)
enable chromium_experimental_read_write_storage_texture; 

@group(0) @binding(0) var myTex: texture_storage_2d<r32float, read_write>;

注意:目前 read_write 模式支持的格式极为有限,通常仅限于 r32float 等单通道 32 位浮点格式。对于 rgba8unorm 这样的格式,依然无法直接使用 read_write

3. 使用 Storage Buffer(存储缓冲区)替代

如果算法需要对像素进行极其复杂的随机读写、原子操作(Atomic Operations),使用纹理并不是一个好主意。更高效的做法是直接在内存中开辟一维的 Storage Buffer

  1. 在 Compute Shader 中将 Buffer 视为一个一维数组进行任意读写。
  2. 计算完成后,利用 WebGPU 的 commandEncoder.copyBufferToTexture 方法,以极高的硬件效率将 Buffer 数据直接同步到展示用的纹理中。这种做法规避了所有存储纹理的格式和读写限制。

4. 边界安全保护最佳实践

在向存储纹理写入时,务必加上尺寸边界判定,避免因溢出导致的未定义错误:

@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
    let coords = id.xy;
    
    // 获取存储纹理的实际宽高
    let size = textureDimensions(myStorageTexture);
    
    // 严格的边界检查
    if (coords.x >= size.x || coords.y >= size.y) {
        return; 
    }
    
    textureStore(myStorageTexture, coords, vec4<f32>(1.0));
}

总结

存储纹理是 WebGPU 释放现代 GPU 计算潜力的利器,但其严格的格式和读写限制往往让刚从 WebGL 转过来的开发者感到困惑。

在日常开发中,优先使用 rgba8unorm 进行物理计算与存储,通过 Ping-Pong 双纹理机制做逻辑迭代,并在最终呈现时通过 Render Pass 转换为 sRGB。这是目前兼容性最广、性能最优的标准化实践路径。

显卡探险家 WebGPUWGSL前端图形学

评论点评