《游戏引擎实现原理》1.2 资源压缩与纹理管线

同一张 256×256 地形贴图,在显存里是 262144 字节的裸 RGBA,无损 PNG 压到 64992 字节,BC1 块压缩只留 32768 字节但 PSNR 掉到 37 dB。本节用 Node 从零实现 PNG 编码器与 BC1 编解码,量出三种产物的真实体积、压缩率与解码耗时,全部本机实跑。

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说明
裸 RGBA4 字节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 字节)

数字里的几个要点:

  1. PNG 把 262144 字节压到 64992 字节(24.8%)。这是无损的——解压出来的像素与原图逐字节相同。程序纹理大片是平缓渐变,DEFLATE 抓得住重复,所以压得动;换成一张照片纹理,这个比例会差很多。
  2. BC1 把同样内容压到 32768 字节(12.5%),是 PNG 的一半,是裸 RGBA 的八分之一。而且它定长:内容再乱也不会变大。
  3. 解码耗时这一栏要小心解读。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 分包与首包体 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径