《游戏引擎实现原理》6.2 纹理格式、压缩与 mipmap

把纹理的显存账算清楚:颜色 / sRGB / 深度格式各占多少字节、BC / ETC2 / ASTC 三族块压缩如何取舍、mipmap 链为什么让显存多出约三分之一;并实测 WebGPU 上哪些压缩格式必须靠扩展才能创建、哪些属于核心格式开箱即用。

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、bgra8unorm4 B法线图、遮罩、UI 等「数值」数据
sRGB 颜色rgba8unorm-srgb、bgra8unorm-srgb4 B美术出的颜色贴图,采样时硬件自动转线性
浮点rgba16float、r32float8 / 4 BHDR 中间目标、需要精度的数据图
深度depth16unorm、depth24plus、depth32float2 / 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 块字节生态
BCbc1 / bc3 / bc78 / 16 / 16桌面 PC、Windows、Xbox
ETC2etc2-rgb8a1 / etc2-rgba88 / 16移动、OpenGL ES
ASTCastc-4x416移动、可变速块尺寸

关键点是块压缩格式不是「核心格式」,它们各自需要一条 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 采样器、寻址与各向异性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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