WEBKT

WebGPU 浮点纹理混合与过滤的跨平台兼容性实践

4 0 0 0

在 WebGL 时代,实现高动态范围(HDR)渲染、粒子物理模拟或 G-Buffer 存储时,我们习惯了直接开启 OES_texture_float 及其配套的 OES_texture_float_linearWEBGL_color_buffer_float 扩展。

然而迁移到 WebGPU 后,你会发现情况发生了变化。虽然 WebGPU 原生支持 r32floatrgba32float 等高精度格式,但出于移动端硬件(尤其是 Mali GPU 和旧版 iOS 设备)的物理限制,WebGPU 规范默认不允许对 32 位浮点纹理进行硬件线性过滤(Linear Filtering)和管线混合(Pipeline Blending)

如果直接在非主流配置或移动端浏览器上运行标准的 F32 纹理采样代码,轻则报出运行时错误,重则直接导致渲染管线创建失败。本文将深入探讨如何在 WebGPU 中优雅地处理这种跨平台兼容性差异,并给出工业级的降级与适配方案。


核心痛点:为什么 WebGPU 限制了 F32 的混合与过滤?

在现代 GPU 架构中,32 位浮点数的 ALU 运算已经非常高效,但**纹理过滤单元(Texture Filtering Unit)**对 32 位浮点数的双线性插值仍需要消耗巨大的带宽和晶体管资源。

为了保证规范能在包括手机、平板在内的所有设备上无缝运行,WebGPU 规范采取了“最小公分母”原则:

  • F32 格式(如 rgba32float:默认只能使用 unfilterable-float 采样类型,即只能进行近邻采样(Nearest Neighbor),且不支持管线混合。
  • F16 格式(如 rgba16float:在绝大多数设备上默认支持过滤与混合。

如果你强行在没有声明对应特性(Features)的情况下创建具有 minFilter: "linear" 的采样器去采样 rgba32float 纹理,WebGPU 会直接抛出 Validation Error。


兼容性治理的第一步:动态特性检测

不要寄希望于用户的浏览器绝对支持某种高级特性。在初始化 WebGPU 逻辑时,必须通过 GPUAdapter 进行前置特征查询。

async function initWebGPUDevice() {
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
        throw new Error("不支持 WebGPU");
    }

    const requiredFeatures = [];
    
    // 检测是否支持 F32 线性过滤
    const supportsF32Filter = adapter.features.has("float32-filterable");
    if (supportsF32Filter) {
        requiredFeatures.push("float32-filterable");
    }

    // 注意:WebGPU 目前并没有官方的 "float32-blendable" 标准特性
    // 32位浮点混合通常需要依赖非标准的底层驱动支持,或者手动在着色器中实现

    const device = await adapter.requestDevice({
        requiredFeatures
    });

    return { device, supportsF32Filter };
}

终极降级方案一:拥抱 RGBA16Float

在大多数 HDR 和物理模拟场景中,16位半精度浮点数(Half-Float)提供的值域($-65504$ 到 $65504$)和精度(11位尾数)已经绰绰有余。

最稳妥的跨平台实践是:优先使用 rgba16float 代替 rgba32float

// 确定纹理格式与采样方式
const textureFormat = "rgba16float"; 

// rgba16float 默认支持过滤,可以直接创建线性采样器
const sampler = device.createSampler({
    magFilter: "linear",
    minFilter: "linear"
});

在大部分主流移动端(包含 iOS 14+ 的 Safari)和桌面端上,rgba16float 完美支持硬件双线性过滤和管线混合。除非你的算法对数值精度有着极其苛刻的要求(如科学计算、超大尺度的网格形变),否则 F16 是最省心、性能最好的方案。


终极降级方案二:WGSL 手动双线性插值(Shader-side Filtering)

如果业务场景逼着你必须用 rgba32float,而当前运行的设备又不支持 float32-filterable,你只能将采样器设为 nearest,然后在 WGSL 着色器内部手动实现双线性插值。

下面是工业界常用的 WGSL 手动过滤代码实现:

@group(0) @binding(0) var myTexture: texture_2d<f32>;
// 注意:由于不支持 filterable-float,这里必须声明为 non-filtering sampler
@group(0) @binding(1) var mySampler: sampler; 

struct VertexOutput {
    @builtin(position) position: vec4<f32>,
    @location(0) uv: vec2<f32>,
}

// 手动双线性过滤函数
fn manual_bilinear_sample(tex: texture_2d<f32>, uv: vec2<f32>) -> vec4<f32> {
    let tex_size = vec2<f32>(textureDimensions(tex));
    
    // 计算纹素坐标(向左下偏移 0.5 像素以对齐采样中心)
    let texel_coords = uv * tex_size - 0.5;
    let i_coords = vec2<i32>(floor(texel_coords));
    let f = fract(texel_coords);

    // 读取周围 4 个邻近像素
    // 使用 textureLoad 避免通过 sampler 触发过滤校验
    let t00 = textureLoad(tex, i_coords + vec2<i32>(0, 0), 0);
    let t10 = textureLoad(tex, i_coords + vec2<i32>(1, 0), 0);
    let t01 = textureLoad(tex, i_coords + vec2<i32>(0, 1), 0);
    let t11 = textureLoad(tex, i_coords + vec2<i32>(1, 1), 0);

    // 水平与垂直方向插值
    let row0 = mix(t00, t10, f.x);
    let row1 = mix(t01, t11, f.x);
    return mix(row0, row1, f.y);
}

@fragment
fn main_fs(in: VertexOutput) -> @location(0) vec4<f32> {
    // 替代标准的 textureSample(myTexture, mySampler, in.uv)
    return manual_bilinear_sample(myTexture, in.uv);
}

性能权衡:手动插值会带来 4 次纹理寻址(textureLoad)和多次数学运算。只有在带宽不是瓶颈、且 F32 精度属于刚需的情况下,才建议启用此回退机制。


终极降级方案三:Shader 级别的手动混合(Shader-side Blending)

当渲染管线的 Target Format 设为 rgba32float 时,WebGPU 不允许你在 FragmentState.targets.blend 中设置混合因子(会直接报错)。

如果你需要对 F32 纹理进行累加混合(例如在粒子系统或渐进式路径追踪中),有两种规避方式:

1. 读写纹理(Storage Texture)结合 Compute Shader

如果你的逻辑可以在计算着色器(Compute Shader)中完成,可以使用 texture_storage_2d

@group(0) @binding(0) var img: texture_storage_2d<rgba32float, read_write>;

@compute @workgroup_size(16, 16)
fn main_cs(@builtin(global_invocation_id) id: vec3<u32>) {
    let coords = vec2<i32>(id.xy);
    let old_color = textureLoad(img, coords);
    let incoming_color = vec4<f32>(1.0, 0.5, 0.0, 0.5); // 待混合的颜色值
    
    // 手动执行 SrcAlpha + OneMinusSrcAlpha 混合
    let alpha = incoming_color.a;
    let blended = incoming_color.rgb * alpha + old_color.rgb * (1.0 - alpha);
    
    textureStore(img, coords, vec4<f32>(blended, 1.0));
}

提示:使用 read_write 存储纹理需要设备支持 bgra8unorm-storage 等特性,或者直接在支持 Shader2023 规范的环境下工作。

2. 双 Framebuffer Ping-Pong 轮替

如果在 Render Pass 中,可以准备两个相同的 F32 纹理,一个作为只读的 texture_2d<f32> 绑定到 BindGroup,另一个作为渲染附件(renderPassDescriptor.colorAttachments[0].view)。

在 Fragment Shader 中读取前一帧的值,与当前计算的值手动混合后,输出到当前的渲染附件中。下一帧交换两者角色(Ping-Pong 交换)。

@group(0) @binding(0) var lastFrameTexture: texture_2d<f32>;

@fragment
fn main_fs(in: VertexOutput) -> @location(0) vec4<f32> {
    let old_color = textureLoad(lastFrameTexture, vec2<i32>(in.position.xy), 0);
    let current_color = compute_new_lighting(); // 计算当前片元的新光照
    
    // 执行手动混合
    let k = 0.1; // 混合权重
    return mix(old_color, current_color, k);
}

总结:跨平台决策链路

在设计 WebGPU 渲染器时,针对浮点纹理的兼容性决策可参考如下链路:

是否需要对浮点纹理进行 Filtering / Blending?
   │
   ├──> 否:直接放心使用 rgba32float (采样器设为 nearest)
   │
   └──> 是:精度是否必须达到 F32 级别?
         │
         ├──> 否 (HDR/普通特效):直接使用 rgba16float 
         │    (大部分现代设备原生支持过滤和混合,兼容性最好)
         │
         └──> 是 (科学计算/高精度算法):
               │
               ├──> 检查设备是否含有 "float32-filterable" 特性?
               │     ├──> 支持:请求该特性,创建 linear sampler
               │     └──> 不支持:降级为 nearest 采样器,
               │                  在 WGSL 中手动实现双线性插值
               │
               └──> 混合处理:由于 F32 无法通过 Pipeline Blending 混合,
                    一律降级为 Ping-Pong 渲染或使用 Compute Shader 手动混合

通过这一套完整的检测与平滑降级机制,你的 WebGPU 应用就能在高性能的桌面端显卡和各种长尾的移动端浏览器之间,找到体验与稳定性的最佳平衡点。

吉米编程手记 WebGPUWebGL图形学

评论点评