WebGPU 进阶:Basis Universal 压缩纹理的转码与渲染管线整合实践
在 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 上传压缩纹理时,传递给 writeTexture 的 size 参数必须是块大小(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多线程转码) |
生产环境架构建议
多线程转码 (Web Workers)
WASM 转码是一个典型的 CPU 密集型任务。强烈建议创建一个 Worker 池(Worker Pool),将拉取到的 Basis 原始二进制数据送入 Worker 中转码,生成ArrayBuffer后再通过Transferable Objects零拷贝传回主线程。UASTC 与 ETC1S 的选型
- ETC1S:压缩率极高,文件体积非常小。适用于手绘风格、卡通渲染或对细节要求不高的自然纹理(如泥土、岩石)。
- UASTC:保留了极其丰富的法线、金属度与粗糙度高频细节。对于法线贴图(Normal Map)和 PBR 材质,建议必须使用 UASTC 模式,否则会出现明显的色块和法线精度丢失问题。