WebGPU 存储纹理写入详解与避坑指南
在 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 驱动的兼容,仅允许一部分特定的纹理格式作为存储纹理。
- 支持的常用格式:
rgba8unorm、rgba8snorm、rgba16float、rgba32float、r32float等。 - 致命痛点:完全不支持
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_A 和 Texture_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。
- 在 Compute Shader 中将 Buffer 视为一个一维数组进行任意读写。
- 计算完成后,利用 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。这是目前兼容性最广、性能最优的标准化实践路径。