6.2 纹理格式、压缩与 mipmap
6.1 给了顶点,这一节给顶点上贴的纹理。纹理是显存里最贵的一类资源:它不像顶点缓冲那样几 KB、几 MB 就够,一张 4096² 的未压缩 RGBA 纹理就是 64 MiB,再加上 mipmap 还要多出约三分之一。所以「一张纹理占多少显存、能不能压、压完还能不能看」是渲染器必须算清的账。
本节产出一个可跑的
src/render/texture/texture-format.ts(L1,Node 直跑)与一个 headless Chromium 的 L2 扩展探测脚本。文中所有字节数、可用扩展、格式创建结果都来自本机实跑。
格式族:颜色、sRGB 与深度
WebGPU 的纹理格式可以按用途分成三类,取舍各不相同:
| 类别 | 代表格式 | 每像素 | 什么时候用 |
|---|---|---|---|
| 线性颜色 | rgba8unorm、bgra8unorm | 4 B | 法线图、遮罩、UI 等「数值」数据 |
| sRGB 颜色 | rgba8unorm-srgb、bgra8unorm-srgb | 4 B | 美术出的颜色贴图,采样时硬件自动转线性 |
| 浮点 | rgba16float、r32float | 8 / 4 B | HDR 中间目标、需要精度的数据图 |
| 深度 | depth16unorm、depth24plus、depth32float | 2 / 4 / 4 B | 深度缓冲,只做比较不做采样 |
sRGB 是这里最容易混的一对。rgba8unorm 与 rgba8unorm-srgb 的字节数完全一样(都是 4 B/像素),区别只在采样时是否做 sRGB→线性转换。把颜色贴图当 rgba8unorm 读,画面会偏暗;把法线图当 rgba8unorm-srgb 读,法线会被「去 gamma」而失真。格式选错的代价不是显存,是画面悄悄错掉。
深度格式则是另一条线:depth24plus 的 plus 表示「至少 24 位,具体由实现决定」,depth32float 是确定的 32 位。二者在本机都占 4 B/像素,但语义不同——depth32float 可采样可比较,depth24plus 在 WebGPU 里只作深度附件。
块压缩:BC / ETC2 / ASTC
未压缩纹理的带宽是渲染器的大头,块压缩把它压下去:GPU 不解压成 RGBA,而是在采样时直接对压缩块解码。主流有三族,各自绑定一块硬件版图:
| 族 | 典型格式 | 4×4 块字节 | 生态 |
|---|---|---|---|
| BC | bc1 / bc3 / bc7 | 8 / 16 / 16 | 桌面 PC、Windows、Xbox |
| ETC2 | etc2-rgb8a1 / etc2-rgba8 | 8 / 16 | 移动、OpenGL ES |
| ASTC | astc-4x4 | 16 | 移动、可变速块尺寸 |
关键点是块压缩格式不是「核心格式」,它们各自需要一条 WebGPU 扩展:texture-compression-bc、texture-compression-etc2、texture-compression-astc。适配器不提供,createTexture 会直接抛错——这正是下一节的 L2 要实测的。取舍的办法通常是:按平台准备多套压缩纹理(PC 用 BC、移动用 ASTC / ETC2),或用 ASTC 一统移动端;graphics/ 的 /graphics-texture-memory/
讲了各引擎的选型细节,本节只算字节账、只验扩展可用性。
可运行产物:格式表与 mip 链的字节账
mipmap 是同一张图逐层减半的预计算金字塔:采样时按屏幕上的覆盖面积挑层,远处用高层(小图)以抑制闪烁、也省带宽。它的代价很直白——整条链的字节数是 level 0 的约 4/3。
// file: src/render/texture/texture-format.ts
// tier: L1
// verify: node src/render/texture/texture-format.ts
//
// helio 渲染卷:纹理格式族与 mip 链的字节数计算。
// 只做纯计算(L1):给定格式与尺寸,算出单层/整条 mip 链的字节数,
// 并区分「未压缩」「块压缩」「深度」三类,供显存预算使用。
export type TextureFormat =
| 'r8unorm' | 'rg8unorm' | 'rgba8unorm' | 'rgba8unorm-srgb'
| 'bgra8unorm' | 'bgra8unorm-srgb' | 'rgb10a2unorm'
| 'rgba16float' | 'r32float'
| 'depth16unorm' | 'depth24plus' | 'depth32float'
| 'bc1-rgba-unorm' | 'bc3-rgba-unorm' | 'bc7-rgba-unorm'
| 'etc2-rgb8a1unorm' | 'etc2-rgba8unorm' | 'astc-4x4-unorm';
export type FormatClass = 'unorm' | 'float' | 'depth' | 'block';
export type FormatInfo = {
readonly cls: FormatClass;
/** 未压缩格式:每像素字节数(可为小数,如 16 位/像素 = 2)。 */
readonly bytesPerPixel: number;
/** 块压缩格式的块尺寸(4x4 或 5x4 等),未压缩为 1x1。 */
readonly blockW: number;
readonly blockH: number;
/** 块压缩格式每块的字节数;未压缩为 0。 */
readonly blockBytes: number;
readonly srgb: boolean;
};
/** WebGPU 的纹理格式表子集。深度格式按实现常见宽度估算字节数。 */
export const TEXTURE_FORMATS: Readonly<Record<TextureFormat, FormatInfo>> = {
r8unorm: { cls: 'unorm', bytesPerPixel: 1, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
rg8unorm: { cls: 'unorm', bytesPerPixel: 2, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
rgba8unorm: { cls: 'unorm', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
'rgba8unorm-srgb': { cls: 'unorm', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: true },
bgra8unorm: { cls: 'unorm', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
'bgra8unorm-srgb': { cls: 'unorm', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: true },
rgb10a2unorm: { cls: 'unorm', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
rgba16float: { cls: 'float', bytesPerPixel: 8, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
r32float: { cls: 'float', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
depth16unorm: { cls: 'depth', bytesPerPixel: 2, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
depth24plus: { cls: 'depth', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
depth32float: { cls: 'depth', bytesPerPixel: 4, blockW: 1, blockH: 1, blockBytes: 0, srgb: false },
'bc1-rgba-unorm': { cls: 'block', bytesPerPixel: 0.5, blockW: 4, blockH: 4, blockBytes: 8, srgb: false },
'bc3-rgba-unorm': { cls: 'block', bytesPerPixel: 1, blockW: 4, blockH: 4, blockBytes: 16, srgb: false },
'bc7-rgba-unorm': { cls: 'block', bytesPerPixel: 1, blockW: 4, blockH: 4, blockBytes: 16, srgb: false },
'etc2-rgb8a1unorm': { cls: 'block', bytesPerPixel: 0.5, blockW: 4, blockH: 4, blockBytes: 8, srgb: false },
'etc2-rgba8unorm': { cls: 'block', bytesPerPixel: 1, blockW: 4, blockH: 4, blockBytes: 16, srgb: false },
'astc-4x4-unorm': { cls: 'block', bytesPerPixel: 1, blockW: 4, blockH: 4, blockBytes: 16, srgb: false },
};
/** 该格式属于哪条 WebGPU 扩展(未压缩 / 深度返回 null)。 */
export function requiredFeature(fmt: TextureFormat): string | null {
const f = TEXTURE_FORMATS[fmt];
if (f.cls !== 'block') return null;
if (fmt.startsWith('bc')) return 'texture-compression-bc';
if (fmt.startsWith('etc2')) return 'texture-compression-etc2';
if (fmt.startsWith('astc')) return 'texture-compression-astc';
return null;
}
/** mip 链层数:floor(log2(max(w,h))) + 1,最少 1 层。 */
export function mipLevelCount(width: number, height: number): number {
return Math.floor(Math.log2(Math.max(width, height))) + 1;
}
/** 第 level 层的尺寸(向下取整,最小 1)。 */
export function mipSize(base: number, level: number): number {
return Math.max(1, base >> level);
}
/** 单层字节数:块压缩按块向上取整,未压缩按像素。 */
export function mipLevelBytes(fmt: TextureFormat, width: number, height: number): number {
const f = TEXTURE_FORMATS[fmt];
if (f.cls === 'block') {
const bw = Math.ceil(width / f.blockW);
const bh = Math.ceil(height / f.blockH);
return bw * bh * f.blockBytes;
}
return width * height * f.bytesPerPixel;
}
/** 整条 mip 链的字节数(含 level 0)。 */
export function mipChainBytes(fmt: TextureFormat, width: number, height: number): number {
const levels = mipLevelCount(width, height);
let total = 0;
for (let l = 0; l < levels; l++) {
total += mipLevelBytes(fmt, mipSize(width, l), mipSize(height, l));
}
return total;
}
/** 相对 rgba8unorm 的压缩率(>1 表示更省)。 */
export function compressionRatio(fmt: TextureFormat): number {
return TEXTURE_FORMATS.rgba8unorm.bytesPerPixel / TEXTURE_FORMATS[fmt].bytesPerPixel;
}
// ---- 直接运行本文件:打印 1024² 各格式的整条 mip 链字节数 ----
if ((import.meta as { main?: boolean }).main) {
const rows: readonly TextureFormat[] = [
'rgba8unorm', 'rgba8unorm-srgb', 'rgba16float', 'depth32float',
'bc1-rgba-unorm', 'bc3-rgba-unorm', 'bc7-rgba-unorm', 'astc-4x4-unorm',
];
console.log(`mip levels(1024) = ${mipLevelCount(1024, 1024)}`);
for (const f of rows) {
const b = mipChainBytes(f, 1024, 1024);
console.log(`${f.padEnd(18)} chain ${String(b).padStart(8)}B ${(b / 1024).toFixed(1)}KiB ratio x${compressionRatio(f)}`);
}
}
本机实跑(node src/render/texture/texture-format.ts):
mip levels(1024) = 11
rgba8unorm chain 5592404B 5461.3KiB ratio x1
rgba8unorm-srgb chain 5592404B 5461.3KiB ratio x1
rgba16float chain 11184808B 10922.7KiB ratio x0.5
depth32float chain 5592404B 5461.3KiB ratio x1
bc1-rgba-unorm chain 699064B 682.7KiB ratio x8
bc3-rgba-unorm chain 1398128B 1365.4KiB ratio x4
bc7-rgba-unorm chain 1398128B 1365.4KiB ratio x4
astc-4x4-unorm chain 1398128B 1365.4KiB ratio x4
读几个关键数:1024² 的 rgba8unorm 整条链是 5592404 字节,正好是 level 0(4194304)的 1.333 倍,验证了「mip 多出约 1/3」。bc1 是 699064 字节、压缩率 x8;bc3 / bc7 / astc-4x4 都是 1398128 字节、x4。注意 bc1 的 699064 并不是 level 0 的精确 1/8:mip 尾端小于 4×4 的层仍各占一个整块,所以比理论值多出 13 字节——块压缩的账必须按块算,不能按像素算。
可运行产物:WebGPU 上哪些压缩格式可用
字节账是纸面的,能不能用要看适配器有没有那条扩展。下面这个脚本在 headless Chromium 里取两个 device:一个不请求任何扩展,一个请求适配器支持的全部扩展,然后各建一遍纹理,看谁成功、谁抛错。
// file: tools/l2-texture-format.mjs
// tier: L2
// verify: node tools/l2-texture-format.mjs
//
// 6.2 的 L2 验收:在 headless Chromium 里查 adapter/device 的压缩纹理扩展,
// 并实测「有扩展能建 bc 纹理、没扩展建不了」「深度/sRGB 格式可建」。
import http from 'node:http';
import { chromium } from '/opt/homebrew/lib/node_modules/playwright/index.mjs';
const PAGE = `<!doctype html><meta charset=utf-8>
<script type="module">
window.__run = async () => {
const out = {};
const adapter = await navigator.gpu.requestAdapter();
out.adapterFeatures = Array.from(adapter.features).sort();
// 默认 device(不请求任何扩展)
const plain = await adapter.requestDevice();
out.plainFeatures = Array.from(plain.features).sort();
out.limits = {
maxTextureDimension2D: plain.limits.maxTextureDimension2D,
maxTextureArrayLayers: plain.limits.maxTextureArrayLayers,
};
const tryCreate = (device, format) => {
try {
device.createTexture({ size: [64, 64], format,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
return 'ok';
} catch (e) { return 'throw: ' + String(e.message).split('.')[0]; }
};
out.plain = {
rgba8unorm: tryCreate(plain, 'rgba8unorm'),
'rgba8unorm-srgb': tryCreate(plain, 'rgba8unorm-srgb'),
depth24plus: tryCreate(plain, 'depth24plus'),
depth32float: tryCreate(plain, 'depth32float'),
'bc1-rgba-unorm': tryCreate(plain, 'bc1-rgba-unorm'),
'astc-4x4-unorm': tryCreate(plain, 'astc-4x4-unorm'),
};
// 全量 device:adapter 用一次即「消耗」,需另取一个 adapter
const adapter2 = await navigator.gpu.requestAdapter();
const all = await adapter2.requestDevice({ requiredFeatures: Array.from(adapter2.features) });
out.allFeatures = Array.from(all.features).sort();
out.all = {
'bc1-rgba-unorm': tryCreate(all, 'bc1-rgba-unorm'),
'bc7-rgba-unorm': tryCreate(all, 'bc7-rgba-unorm'),
'astc-4x4-unorm': tryCreate(all, 'astc-4x4-unorm'),
'etc2-rgba8unorm': tryCreate(all, 'etc2-rgba8unorm'),
};
return out;
};
</script>`;
const srv = http.createServer((_req, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.end(PAGE);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${srv.address().port}/`;
let failures = 0;
const check = (name, ok, detail) => {
if (!ok) failures++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${detail ?? ''}`);
};
const browser = await chromium.launch({ headless: true,
args: ['--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'] });
try {
const page = await browser.newPage();
await page.goto(url);
const r = await page.evaluate(() => window.__run());
console.log('adapterFeatures', JSON.stringify(r.adapterFeatures));
console.log('plainFeatures', JSON.stringify(r.plainFeatures));
console.log('limits', JSON.stringify(r.limits));
console.log('plain', JSON.stringify(r.plain));
console.log('allFeatures', JSON.stringify(r.allFeatures));
console.log('all', JSON.stringify(r.all));
const hasBc = r.adapterFeatures.includes('texture-compression-bc');
check('plain.no-compression-feature',
!r.plainFeatures.some((f) => f.startsWith('texture-compression')), JSON.stringify(r.plainFeatures));
check('plain.unorm-ok', r.plain.rgba8unorm === 'ok' && r.plain['rgba8unorm-srgb'] === 'ok', '');
check('plain.depth-ok', r.plain.depth24plus === 'ok' && r.plain.depth32float === 'ok', '');
check('plain.bc-needs-feature', !hasBc || r.plain['bc1-rgba-unorm'].startsWith('throw'),
r.plain['bc1-rgba-unorm']);
check('all.bc-ok-if-feature', !hasBc || r.all['bc1-rgba-unorm'] === 'ok', r.all['bc1-rgba-unorm']);
} finally {
await browser.close();
srv.close();
}
console.log(`\n=== l2-texture-format: ${failures === 0 ? 'ALL PASS' : failures + ' FAILED'} ===`);
process.exit(failures === 0 ? 0 : 1);
本机实跑(SwiftShader,adapterFeatures 共 18 条,此处只列与压缩相关的):
plainFeatures ["core-features-and-limits"]
limits {"maxTextureDimension2D":8192,"maxTextureArrayLayers":256}
plain {"rgba8unorm":"ok","rgba8unorm-srgb":"ok","depth24plus":"ok","depth32float":"ok",
"bc1-rgba-unorm":"throw: ...requires the 'texture-compression-bc' feature...",
"astc-4x4-unorm":"throw: ...requires the 'texture-compression-astc' feature..."}
all {"bc1-rgba-unorm":"ok","bc7-rgba-unorm":"ok","astc-4x4-unorm":"ok","etc2-rgba8unorm":"ok"}
PASS plain.no-compression-feature ["core-features-and-limits"]
PASS plain.unorm-ok
PASS plain.depth-ok
PASS plain.bc-needs-feature throw: ...requires the 'texture-compression-bc' feature...
PASS all.bc-ok-if-feature ok
结论很干净:本机适配器同时提供 BC、ETC2、ASTC 三条扩展(adapterFeatures 里能查到),但默认 device 一条也没启用——plainFeatures 只有一条基线的 core-features-and-limits。于是同一个 bc1-rgba-unorm 纹理,在默认 device 上 createTexture 直接抛错,在请求了扩展的 device 上建得出来。而 rgba8unorm / rgba8unorm-srgb / depth24plus / depth32float 在默认 device 上都能建,因为它们是核心格式。
这就是渲染器的资源层必须在启动时探测扩展的原因:同一条代码,在没请求 texture-compression-* 的设备上会在第一次建压缩纹理时崩掉。helio 的做法是把「适配器支持什么」记进一份能力表,纹理加载器按能力表决定「用哪套压缩纹理」,而不是硬编码 BC。
小结
这一节把纹理拆成了三笔账:格式(每像素几字节、sRGB 与否、深度与否)、压缩(BC / ETC2 / ASTC 各自要一条扩展)、mip(整条链约 4/3)。三笔账合起来,才回答得了「这张纹理占多少显存、在这个设备上能不能用」。
纹理有了,下一节处理它的搭档:采样器。采样器不占显存,却决定纹理在越界、缩放、倾斜时怎么被读——三种寻址模式、两种过滤、以及各向异性,每一个都会直接改变画面。
阅读导航:上一节:6.1 顶点格式、属性布局与程序化网格 · 下一节:6.3 采样器、寻址与各向异性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。