1.2 资源压缩与纹理管线
上一节量的是代码的体积。游戏里真正占空间的是资源,而资源里占大头的又是纹理。一张 1024×1024 的 RGBA 贴图,裸数据就是 4 MiB;一个中等规模的场景轻松带上几十上百张,显存与下载量都会爆炸。所以引擎的资产管线必须在「磁盘上」和「显存里」两个环节都做压缩,而这两件事用的是完全不同的压缩。
磁盘上关心的是传输与存储:文件越小,玩家下载越快、包体越小。这里用无损压缩(PNG、KTX2 里的 zstd)或高质量有损压缩。显存里关心的是带宽与容量:GPU 每帧要从纹理里采样成百上千万次,采样带宽是硬瓶颈,所以显存纹理用的是块压缩(BC / ETC / ASTC)——它把像素按固定大小的块(比如 4×4)编码成定长字节,GPU 能在采样时硬件解码,几乎零成本。
这一节我们拿《微光岛》的地形贴图走一遍完整管线:程序生成一张 256×256 的 RGBA 纹理,分别产出裸 RGBA、真 PNG、BC1 块压缩三种产物,量出它们的真实体积、压缩率、解码耗时,以及 BC1 的 PSNR(信噪比,衡量有损程度)。PNG 与 BC1 都从零用 Node 实现,不依赖任何图像库。
这里还要分清「压缩」的两种含义,很多团队会混。一种是打包期的压缩:PNG、KTX2、zstd,目的是让下载包更小,代价是运行时要先解压。另一种是运行时的压缩:BC/ETC/ASTC,目的是让显存占用与采样带宽更小,代价是有损。两者可以叠加——一张贴图可以既存成压缩过的 KTX2(下载小),又解码成 BC1 上传(显存小)。本节把这两条路都量一遍,看它们各自能省多少、代价是什么。
为什么不用现成的图像库?因为本书的约束是每个代码块都能在本机 Node 直跑。PNG 与 BC1 的格式都不复杂,手写一遍反而能看清「压缩率是从哪来的」——比如 BC1 的 4:1 到底省在哪几个字节,PNG 的 DEFLATE 又是靠什么抓住重复。
三种产物:裸 RGBA、PNG、块压缩
先建立一个体积的直觉。同一张 256×256 的贴图,三种形态的关系大致是:
| 形态 | 每像素 | 这张 256×256 | 说明 |
|---|---|---|---|
| 裸 RGBA | 4 字节 | 262144 字节 | 上传给 GPU 前的样子,也是「不压缩」的基线 |
| PNG(无损) | 变长 | 取决于图像内容 | 磁盘格式,走 DEFLATE,压缩率随内容变化 |
| BC1(块压缩) | 0.5 字节 | 32768 字节 | 显存格式,4×4 块共享两个端点色,硬件解码 |
关键区别在「定长 vs 变长」。PNG 是变长的:一张纯色图能压到几百字节,一张噪声图可能压不动。BC1 是定长的:不管内容是什么,每 4×4 块恒定 8 字节,也就是恒定的 0.5 字节/像素,256×256 永远是 32768 字节。GPU 喜欢定长——它不用先解压整张图再采样,而是采样到哪个块就解哪个块,这正是块压缩能在显存里存在的原因。
定长还有一层工程意义:显存可以精确预算。如果纹理是变长压缩的,引擎没法事先知道一张贴图会占多少显存,分配与换入换出都变得不可预测;而定长格式下「张数 × 单张字节」就是显存占用,写进预算表即可。这也是为什么移动端几乎只认 ETC/ASTC、桌面端只认 BC 系列——它们是 GPU 能直接采样的定长格式,不是「随便一种压缩」。
程序纹理与真 PNG 编码
为了让数字可复现,纹理不用外部图片,而是程序生成:用整数哈希当噪声、两段正弦做岛屿起伏,把高度映射成「深蓝 → 沙黄」的颜色。生成完全确定,同一台机器每次跑出同一张图,体积与 PSNR 才可比。
PNG 我们手写。一个最小 PNG 就是三段:8 字节签名、IHDR(宽高、位深、颜色类型)、IDAT(zlib 压过的扫描行),外加一个空的 IEND。每个 chunk 的格式是「长度 + 类型 + 数据 + CRC32」,其中 CRC32 直接调 node:zlib 的 crc32。扫描行要先在每行前插一个 filter 字节(这里全用 0,即不滤波),再整体 deflateSync。下面这个文件是完整管线,// file: 块可整文件运行。
// file: tools/texture-pipeline.mjs
// tier: L1
// verify: node tools/texture-pipeline.mjs
//
// 《微光岛》地形贴图的离线管线:把一张程序生成的 RGBA 纹理编码成三种真实产物
// 并量体积与解码耗时——原始 RGBA(上传前的样子)、PNG(走 node:zlib 真压真解)、
// BC1 风格块压缩(4×4 块、RGB565 端点 + 2bit 索引,0.5 字节/像素)。
import { deflateSync, inflateSync, crc32 } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
const SIZE = 256;
/** 确定性程序纹理:整数哈希当噪声,两段正弦做起伏,深蓝→沙黄线性插值。 */
export function makeTerrain(size) {
const rgba = new Uint8Array(size * size * 4);
const hash = (x, y) => {
let h = (x * 374761393 + y * 668265263) | 0;
h = (h ^ (h >>> 13)) * 1274126177;
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const h1 = Math.sin(x * 0.05) * Math.cos(y * 0.04);
const h2 = Math.sin((x + y) * 0.013) * 0.5;
const n = hash(x >> 2, y >> 2) * 0.25;
const t = Math.min(1, Math.max(0, (h1 + h2 + n + 1.25) / 2.5));
const i = (y * size + x) * 4;
rgba[i] = Math.round(20 + 190 * t);
rgba[i + 1] = Math.round(40 + 150 * t);
rgba[i + 2] = Math.round(90 + 30 * t);
rgba[i + 3] = 255;
}
}
return rgba;
}
/** 打包一个 PNG chunk:长度 + 类型 + 数据 + CRC32。 */
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body) >>> 0, 0);
return Buffer.concat([len, body, crc]);
}
/** 把 RGBA 编码成一张真 PNG(color type 6,filter 0),返回 { png, idat }。 */
export function encodePng(rgba, size) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6; // bit depth 8、RGBA
const raw = Buffer.alloc(size * (size * 4 + 1)); // 每行前加一个 filter 字节(0=None)
for (let y = 0; y < size; y++) {
Buffer.from(rgba.buffer, rgba.byteOffset + y * size * 4, size * 4).copy(raw, y * (size * 4 + 1) + 1);
}
const idat = deflateSync(raw, { level: 9 });
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const png = Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
return { png, idat };
}
const to565 = (r, g, b) => ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
const from565 = (c) => [((c >> 11) & 31) << 3, ((c >> 5) & 63) << 2, (c & 31) << 3];
/** 4 端点调色板:两实端点 + 两个 2:1 / 1:2 插值端点(BC1 规范)。 */
const palette = (hi, lo) => [hi, lo, [0, 1, 2].map((c) => (2 * hi[c] + lo[c]) / 3), [0, 1, 2].map((c) => (hi[c] + 2 * lo[c]) / 3)];
/** BC1 风格编码:每 4×4 块取最亮/最暗两色当端点,16 像素各选最近端点,8 字节/块。 */
export function encodeBc1(rgba, size) {
const b = size / 4;
const out = new Uint8Array(b * b * 8);
let o = 0;
for (let by = 0; by < b; by++) {
for (let bx = 0; bx < b; bx++) {
const lo = [255, 255, 255];
const hi = [0, 0, 0];
const px = [];
for (let k = 0; k < 16; k++) {
const i = ((by * 4 + (k >> 2)) * size + bx * 4 + (k & 3)) * 4;
const p = [rgba[i], rgba[i + 1], rgba[i + 2]];
px.push(p);
for (let c = 0; c < 3; c++) {
lo[c] = Math.min(lo[c], p[c]);
hi[c] = Math.max(hi[c], p[c]);
}
}
const c0 = to565(hi[0], hi[1], hi[2]);
const c1 = to565(lo[0], lo[1], lo[2]);
const pal = palette(hi, lo);
let idx = 0;
for (let k = 0; k < 16; k++) {
let best = 0;
let bestD = Infinity;
for (let e = 0; e < 4; e++) {
const d = (px[k][0] - pal[e][0]) ** 2 + (px[k][1] - pal[e][1]) ** 2 + (px[k][2] - pal[e][2]) ** 2;
if (d < bestD) { bestD = d; best = e; }
}
idx |= best << (k * 2);
}
out[o] = c0 & 0xff; out[o + 1] = c0 >> 8; out[o + 2] = c1 & 0xff; out[o + 3] = c1 >> 8;
out[o + 4] = idx & 0xff; out[o + 5] = (idx >> 8) & 0xff; out[o + 6] = (idx >> 16) & 0xff; out[o + 7] = (idx >> 24) & 0xff;
o += 8;
}
}
return out;
}
/** BC1 解码:块还原成 RGBA,用于算 PSNR(也模拟硬件解码做的事)。 */
export function decodeBc1(blocks, size) {
const rgba = new Uint8Array(size * size * 4);
const b = size / 4;
let o = 0;
for (let by = 0; by < b; by++) {
for (let bx = 0; bx < b; bx++) {
const pal = palette(from565(blocks[o] | (blocks[o + 1] << 8)), from565(blocks[o + 2] | (blocks[o + 3] << 8)));
const idx = blocks[o + 4] | (blocks[o + 5] << 8) | (blocks[o + 6] << 16) | (blocks[o + 7] << 24);
for (let k = 0; k < 16; k++) {
const i = ((by * 4 + (k >> 2)) * size + bx * 4 + (k & 3)) * 4;
const e = (idx >> (k * 2)) & 3;
rgba[i] = Math.round(pal[e][0]);
rgba[i + 1] = Math.round(pal[e][1]);
rgba[i + 2] = Math.round(pal[e][2]);
rgba[i + 3] = 255;
}
o += 8;
}
}
return rgba;
}
/** PSNR:逐通道均方误差换算成 dB(忽略 alpha,BC1 不带 alpha)。 */
export function psnr(a, b) {
let mse = 0;
let n = 0;
for (let i = 0; i < a.length; i++) {
if (i % 4 === 3) continue;
mse += (a[i] - b[i]) ** 2;
n++;
}
return 10 * Math.log10((255 * 255) / (mse / n));
}
/** 重复跑 fn 取中位耗时(毫秒),排除首次 JIT 抖动。 */
function medianMs(fn, iters = 9) {
const times = [];
for (let i = 0; i < iters; i++) {
const t0 = performance.now();
fn();
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
return times[Math.floor(iters / 2)];
}
if (import.meta.url === `file://${process.argv[1]}`) {
const rgba = makeTerrain(SIZE);
const raw = rgba.length;
const { png, idat } = encodePng(rgba, SIZE);
const bc1 = encodeBc1(rgba, SIZE);
const pct = (n) => `${((n / raw) * 100).toFixed(1)}%`;
console.log(`== 《微光岛》地形贴图 ${SIZE}×${SIZE} ==`);
console.log(`原始 RGBA ${raw} 字节 ${pct(raw)}`);
console.log(`PNG(无损) ${png.length} 字节 ${pct(png.length)}`);
console.log(`BC1(0.5B/px) ${bc1.length} 字节 ${pct(bc1.length)}`);
console.log(`BC1 解码 PSNR ${psnr(rgba, decodeBc1(bc1, SIZE)).toFixed(2)} dB`);
console.log('\n== 解码耗时(中位数,9 次)==');
console.log(`PNG inflate 解压 ${medianMs(() => inflateSync(idat)).toFixed(3)} ms`);
console.log(`BC1 解码 ${medianMs(() => decodeBc1(bc1, SIZE)).toFixed(3)} ms`);
mkdirSync('artifacts', { recursive: true });
writeFileSync('artifacts/island-terrain.png', png);
console.log(`\n写出 artifacts/island-terrain.png(${png.length} 字节)`);
}
BC1 块压缩:体积与质量的权衡
BC1(也叫 DXT1)是块压缩里最简单的一种,理解了它,BC3/BC5/BC7 都是同一套思路的变体。它的每个 4×4 块用 8 字节表示:开头 2 字节是一个 RGB565 的「高」端点色,接着 2 字节是「低」端点色,最后 4 字节是 16 个像素各自的 2 bit 索引(16×2 = 32 bit = 4 字节)。解码时,GPU 由两个端点线性插值出另外两个中间色,构成 4 色调色板,每个像素再按索引取其中一个。
所以 BC1 的压缩率是固定的 4:1(相对于 RGB,不含 alpha):16 像素本来要 48 字节(RGB24),现在只要 8 字节。对上带 alpha 的 RGBA 就是 8:1。代价是每个 4×4 块只能有「一条颜色轴」——块内的颜色必须能近似落在两个端点的连线上,否则失真明显。这就是块压缩的固有局限:块内颜色越丰富,质量掉得越狠。
PSNR 是衡量这种失真的常用指标:它把原图与解码图的逐通道均方误差取对数,单位是 dB。经验上,40 dB 以上人眼几乎看不出差别,35–40 dB 属于「看得出但可接受」,30 dB 以下就有明显色块。本节程序纹理在 BC1 下 PSNR 是 37 dB 上下,正落在「可接受」区间——这也符合地形贴图的特点:颜色变化平缓,4×4 块内基本落在一条色轴上,块压缩正好擅长。
真实管线里还有两个放大体积的因素要记住。其一是 mipmap:为了让远处纹理不闪烁,每张纹理要预生成一串逐级缩小一半的副本,总大小是原图的约 4/3。其二是多张贴图:一个材质通常带 baseColor、normal、roughness/metalness 好几张,helio 的场景里每张都要单独压缩、单独上传。所以「一张 32768 字节」要乘上 mipmap 的 4/3 再乘贴图张数,才是真实显存占用。本节只算单张、不带 mipmap,是为了让 4:1、8:1 这些比例干净可读;真实显存与带宽预算留到第 3 章的性能工程里再算总账。
可运行产物:一条命令跑完管线
node tools/texture-pipeline.mjs 一条命令跑完生成、编码、解码、计时、落盘。本机实跑输出:
== 《微光岛》地形贴图 256×256 ==
原始 RGBA 262144 字节 100.0%
PNG(无损) 64992 字节 24.8%
BC1(0.5B/px) 32768 字节 12.5%
BC1 解码 PSNR 37.33 dB
== 解码耗时(中位数,9 次)==
PNG inflate 解压 0.380 ms
BC1 解码 1.347 ms
写出 artifacts/island-terrain.png(64992 字节)
数字里的几个要点:
- PNG 把 262144 字节压到 64992 字节(24.8%)。这是无损的——解压出来的像素与原图逐字节相同。程序纹理大片是平缓渐变,DEFLATE 抓得住重复,所以压得动;换成一张照片纹理,这个比例会差很多。
- BC1 把同样内容压到 32768 字节(12.5%),是 PNG 的一半,是裸 RGBA 的八分之一。而且它定长:内容再乱也不会变大。
- 解码耗时这一栏要小心解读。PNG 的 inflate 是
node:zlib里的原生 C 代码,0.380 ms;BC1 解码是纯 JS 循环,1.347 ms。看起来 BC1 更慢,但这只是「用 JS 模拟硬件解码」的代价——在真实 GPU 上,BC1 由采样单元硬件解码,开销记在纹理采样里,不是一次性的 CPU 循环。这正说明块压缩的价值不在 CPU 端省时间,而在显存带宽:采样时每像素只取 0.5 字节而不是 4 字节,带宽直接降 8 倍。
最后一件事:管线产出的是 artifacts/island-terrain.png,一个签名合法、能被任何看图工具打开的真 PNG。前面每一步都真压真解,所以这个文件不是「示意」,是能直接用的产物。
把三种产物放在一起看,工程结论其实很清晰:磁盘上放 PNG(或 KTX2),显存里放 BC。前者解决下载与存储,后者解决带宽与容量,两者不是二选一,而是流水线上前后两个阶段。helio 的资产导入器(子系统卷讲过)会在导入时同时产出这两个形态,运行时只做「读文件 → 上传 GPU」两件事。这也是为什么引擎的纹理管线从来不是「压一次就完事」,而是一条从 DCC 导出到显存上传的完整链路。
下一节我们把视角拉回代码,讲怎么把这 187 个模块按「首屏必需 / 懒加载」切开,量出首包到底多大。
阅读导航:上一节:1.1 打包与产物分析 · 下一节:1.3 分包与首包体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。