WEBKT

WebGPU 进阶:Basis Universal 压缩纹理的转码与渲染管线整合实践

10 0 0 0

在 Web3D 研发中,纹理内存(VRAM)的占用往往是制约场景复杂度的最大瓶颈。传统的 PNG/JPG 图片虽然在网络传输时体积较小,但解码后在 GPU 内存中必须以未压缩的 RGBA8 格式展开,一个 2048x2048 的纹理会直接暴涨到 16MB 的显存占用。

Basis Universal (简称 BasisU) 是目前 Web 3D 领域事实上的下一代超级压缩纹理标准(通常打包为 .basis 或带有 KTX2 容器的 .ktx2 文件)。它在网络传输时体积与 JPG 相当,但在运行时不需要完全解压为 RGBA8,而是通过 WebAssembly (WASM) 快速**转码(Transcode)**为当前 GPU 硬件原生支持的压缩纹理格式(如 BC7、ASTC 或 ETC2),直接送入显存,显存占用可直接骤降 75% 以上。

本文将深入探讨如何在 WebGPU 渲染管线中,从底层实现 Basis Universal 纹理的检测、转码、创建及绑定渲染的全流程。


1. WebGPU 压缩纹理格式支持检测

WebGPU 并没有像 WebGL 那样默认开放所有的压缩格式,而是将它们作为可选特性(Features)。在初始化 GPUDevice 时,必须显式声明需要启用的压缩纹理扩展。

目前主流的硬件压缩格式与 WebGPU 特性对应关系如下:

压缩格式族 适用平台 WebGPU Feature 字段 对应转码目标格式 (BasisU)
BC7 / BC1 PC 端 (DirectX / Vulkan) "texture-compression-bc" BASIS_FORMAT_BC7_RGBA
ASTC 移动端 (iOS / Android / Mac M系列) "texture-compression-astc" BASIS_FORMAT_ASTC_4x4_RGBA
ETC2 Android / 绝大多数移动/桌面设备 "texture-compression-etc2" BASIS_FORMAT_ETC2_RGBA

设备初始化阶段的特征请求

async function initWebGPUDevice() {
    const adapter = await navigator.gpu?.requestAdapter();
    if (!adapter) throw new Error("WebGPU not supported");

    // 检测当前硬件支持哪些压缩格式
    const requiredFeatures = [];
    if (adapter.features.has("texture-compression-bc")) {
        requiredFeatures.push("texture-compression-bc");
    }
    if (adapter.features.has("texture-compression-astc")) {
        requiredFeatures.push("texture-compression-astc");
    }
    if (adapter.features.has("texture-compression-etc2")) {
        requiredFeatures.push("texture-compression-etc2");
    }

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

    return { device, enabledFeatures: requiredFeatures };
}

2. 基于 WASM 的 BasisU 转码适配器设计

Basis Universal 核心机制是:在 CPU 端读取其特有的中介格式(ETC1S 或 UASTC),然后通过轻量级 WASM 转码器,将其转换为 GPU 硬件支持的格式。

我们需要设计一个决策器,根据 WebGPU 启用的 Feature,决定转码的目标格式:

// 模拟 BasisU 转码器格式枚举 (具体值参考 basis_transcoder.js)
const BasisTargetFormat = {
    BC7_RGBA: 6,
    ASTC_4x4_RGBA: 10,
    ETC2_RGBA: 3,
    RGBA32: 13 // 兜底的未压缩格式
};

function selectTranscodeFormat(enabledFeatures) {
    if (enabledFeatures.includes("texture-compression-astc")) {
        return {
            basisFormat: BasisTargetFormat.ASTC_4x4_RGBA,
            webgpuFormat: "astc-4x4-unorm"
        };
    }
    if (enabledFeatures.includes("texture-compression-bc")) {
        return {
            basisFormat: BasisTargetFormat.BC7_RGBA,
            webgpuFormat: "bc7-rgba-unorm"
        };
    }
    if (enabledFeatures.includes("texture-compression-etc2")) {
        return {
            basisFormat: BasisTargetFormat.ETC2_RGBA,
            webgpuFormat: "etc2-rgba8unorm"
        };
    }
    // 兜底方案:硬件不支持任何压缩格式时,转码为常规 RGBA8
    return {
        basisFormat: BasisTargetFormat.RGBA32,
        webgpuFormat: "rgba8unorm"
    };
}

3. 载入并转码 BasisU 数据

以下是核心的转码流程。假设我们使用的是官方的 basis_transcoder.js 库:

async function transcodeBasisBuffer(basisArrayBuffer, transcoderWasm, enabledFeatures) {
    // 1. 初始化 Basis 转码模块
    const { BasisFile, initializeBasis } = transcoderWasm;
    initializeBasis();

    const basisData = new Uint8Array(basisArrayBuffer);
    const basisFile = new BasisFile(basisData);

    if (!basisFile.startTranscoding()) {
        basisFile.close();
        throw new Error("Basis transcoding failed to start");
    }

    const width = basisFile.getImageWidth(0, 0);
    const height = basisFile.getImageHeight(0, 0);
    const levels = basisFile.getNumLevels(0);

    // 2. 匹配最优格式
    const { basisFormat, webgpuFormat } = selectTranscodeFormat(enabledFeatures);

    const mipmaps = [];

    for (let level = 0; level < levels; level++) {
        const transcodeSize = basisFile.getImageTranscodedSizeInBytes(0, level, basisFormat);
        const outBuf = new Uint8Array(transcodeSize);

        const success = basisFile.transcodeImage(
            outBuf,
            0, // image index
            level, // mip index
            basisFormat,
            0, // unused
            0  // unused
        );

        if (!success) {
            basisFile.close();
            throw new Error(`Transcoding level ${level} failed`);
        }

        mipmaps.push({
            data: outBuf,
            width: basisFile.getImageWidth(0, level),
            height: basisFile.getImageHeight(0, level)
        });
    }

    basisFile.close();

    return {
        width,
        height,
        webgpuFormat,
        mipmaps
    };
}

4. WebGPU 纹理创建与数据写入

在 WebGPU 中,硬件压缩纹理有一些严格的限制。例如,所有压缩格式的纹理尺寸必须是 4 像素对齐的(4x4 块级对齐)。此外,写入压缩数据时,需要准确计算每一行的字节数(bytesPerRow)。

格式对应的 Block 尺寸规范

  • BC7: 每个 4x4 像素块占用 16 字节。
  • ASTC 4x4: 每个 4x4 像素块占用 16 字节。
  • ETC2 RGBA8: 每个 4x4 像素块占用 16 字节。
function getBlockInfo(format) {
    if (format.startsWith("bc") || format.startsWith("astc-4x4") || format.startsWith("etc2-rgba")) {
        return { blockWidth: 4, blockHeight: 4, blockSize: 16 };
    }
    // 未压缩的 rgba8unorm
    return { blockWidth: 1, blockHeight: 1, blockSize: 4 };
}

function uploadCompressedTexture(device, transcodedResult) {
    const { width, height, webgpuFormat, mipmaps } = transcodedResult;
    const blockInfo = getBlockInfo(webgpuFormat);

    // 1. 创建 GPUTexture
    const texture = device.createTexture({
        size: [width, height, 1],
        mipLevelCount: mipmaps.length,
        format: webgpuFormat,
        usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
    });

    // 2. 逐级写入 Mipmap 数据
    for (let level = 0; level < mipmaps.length; level++) {
        const mip = mipmaps[level];
        
        // 核心计算:每一行的字节数必须是 256 字节的整数倍(WebGPU 限制)吗?
        // 注意:device.queue.writeTexture 写入 compressed 格式时,
        // bytesPerRow 必须满足:向上取整到 blockWidth,再乘以单个 block 的 size。
        const blocksPerRow = Math.ceil(mip.width / blockInfo.blockWidth);
        const bytesPerRow = blocksPerRow * blockInfo.blockSize; 

        device.queue.writeTexture(
            { 
                texture: texture, 
                mipLevel: level 
            },
            mip.data,
            {
                offset: 0,
                bytesPerRow: bytesPerRow,
                rowsPerImage: Math.ceil(mip.height / blockInfo.blockHeight)
            },
            {
                width: Math.ceil(mip.width / blockInfo.blockWidth) * blockInfo.blockWidth,
                height: Math.ceil(mip.height / blockInfo.blockHeight) * blockInfo.blockHeight,
                depthOrArrayLayers: 1
            }
        );
    }

    return texture;
}

开发避坑提示:在 WebGPU 中,使用 writeTexture 上传压缩纹理时,传递给 writeTexturesize 参数必须是块大小(4像素)的倍数。如果你的纹理长宽不是 4 的倍数(例如 510x510),在传参给 copySize 时,必须将其向上舍入为 512x512(即块对齐尺寸),否则 WebGPU 校验层会直接报错拦截。


5. 渲染管线(Render Pipeline)整合

在 WebGPU 着色器(WGSL)中,采样压缩纹理与采样常规未压缩纹理的代码是完全一致的。压缩细节由 GPU 硬件底层处理,对于着色器是透明的。

WGSL 着色器代码

@group(0) @binding(0) var mySampler: sampler;
@group(0) @binding(1) var myTexture: texture_2d<f32>;

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

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return textureSample(myTexture, mySampler, in.uv);
}

JS/TS 侧绑定组(BindGroup)设置

function createTextureBindGroup(device, pipeline, sampler, textureView) {
    return device.createBindGroup({
        layout: pipeline.getBindGroupLayout(0),
        entries: [
            {
                binding: 0,
                resource: sampler,
            },
            {
                binding: 1,
                resource: textureView, // 这里的 view 指向我们上面创建的压缩纹理
            }
        ],
    });
}

6. 生产环境最佳实践与性能对比

数据指标对比

在同一款大型 3D 场景应用中,使用一组 4K 分辨率(4096 x 4096)贴图进行测试:

指标 未压缩 RGBA8 格式 Basis Universal (UASTC模式) Basis Universal (ETC1S模式)
网络下载体积 ~18.5 MB (PNG) ~3.2 MB ~1.1 MB
显存(VRAM)占用 64 MB 16 MB (BC7 / ASTC) 8 MB (ETC2)
主线程转码耗时 0 ms (但解码PNG需约400ms) ~110 ms (WASM多线程转码) ~60 ms (WASM多线程转码)

生产环境架构建议

  1. 多线程转码 (Web Workers)
    WASM 转码是一个典型的 CPU 密集型任务。强烈建议创建一个 Worker 池(Worker Pool),将拉取到的 Basis 原始二进制数据送入 Worker 中转码,生成 ArrayBuffer 后再通过 Transferable Objects 零拷贝传回主线程。

  2. UASTC 与 ETC1S 的选型

    • ETC1S:压缩率极高,文件体积非常小。适用于手绘风格、卡通渲染或对细节要求不高的自然纹理(如泥土、岩石)。
    • UASTC:保留了极其丰富的法线、金属度与粗糙度高频细节。对于法线贴图(Normal Map)和 PBR 材质,建议必须使用 UASTC 模式,否则会出现明显的色块和法线精度丢失问题。
极客渲染说 WebGPU压缩纹理

评论点评