《游戏引擎实现原理》2.1 资源导入器与离线处理

美术交给引擎的是源资源,运行时却不能直接吃:本节用 TypeScript 从零实现 helio 的资源导入器与离线处理——注册表按扩展名分派、版本号驱动失效、PPM 解码、BC1 块压缩、mip 链生成、自描述二进制产物与元数据,并实测真实压缩比与 BC1 往返误差。

2.1 资源导入器与离线处理

美术交给引擎的是源资源:一张 .ppm 位图、一个 .obj 网格。运行时却不能直接吃它们——位图要先降采样出 mip 链、按平台压成块格式,网格要先交错成 GPU 认的步长。这些活全在离线期做完,运行时只加载现成的二进制产物。这一节就把这条「源资源 → 运行时格式」的流水线搭起来。

本节产出一个可跑的 src/subsys/asset/importer.ts(L1,Node 直跑)与它的 vitest 用例。文中所有字节数都来自本机实跑,不含任何估算。

本章与站内既有内容的分工

本卷只做「在 helio 上从零实现、每节跑出真实产物」,资源管线的原理不重推,一律外链:

既有文章它负责什么本节只做什么
/graphics-gltf-asset-pipeline/glTF 规范与资产导入的完整流程只做 PPM / OBJ 两个最小容器的导入器
/graphics-texture-memory/纹理格式族与显存组织把压缩后的字节真正算出来
/defold-editor-asset-pipeline/引擎侧资产管线的工程组织只实现注册表 + 导入器两个原语

导入器、注册表与版本

导入器是一个纯函数:(源字节, 路径, 上下文) -> 产物 + 元数据。它必须声明三样东西:

  • id:写进元数据,供依赖图索引(下一节用它算缓存键);
  • version:导入算法一变就 +1,让构建系统知道「所有用它的资源都脏了」;
  • extensions:注册表据此分派,扩展名冲突在离线期就报错。

注册表按扩展名建索引,resolve() 找不到就抛错——宁可离线期早失败,也不要运行时才猜。元数据里同时记下源字节数与产物字节数,压缩比一目了然。

纹理导入器的三步是「解码 → 生成 mip 链 → 逐级编码」。mip 链直接复用渲染卷上传路径里的 generateMipChain(同样是 box filter 逐级取平均),保证离线产物与运行时上传口径一致;压缩这一步是本节的原创:一个真的 BC1(DXT1)编码器。

可运行产物:从 PPM / OBJ 到运行时格式

下面是完整的 src/subsys/asset/importer.ts。它把 PPM 解成 RGBA、压成 BC1、打成带目录的自描述载荷,再把 OBJ 交错成 24 字节步长的顶点缓冲,最后用注册表把两者按扩展名分派。解码(BC1 → RGBA)故意不放在模块里:GPU 采样时由硬件做,CPU 侧只留编码。

// file: src/subsys/asset/importer.ts
// tier: L1
// verify: npx vitest run src/subsys/asset/importer.test.ts
//
// 资源管线的「离线半边」:把源资源(PPM 位图 / OBJ 网格)在导入期一次转成运行时
// 格式——mip 链、BC1 块压缩、交错顶点缓冲——并产出可索引的元数据。运行时只认产物,
// 不认源文件;导入器升版只需重跑离线,运行时一行不改。
//
// 源格式刻意选成两种半文本的真实容器:PPM P6(`P6\n<w> <h>\n255\n` + 裸 RGB)
// 与 OBJ 子集(`v x y z` / `f a b c`),好让整条管线在 Node 里可复现。
// 解码(BC1 → RGBA)不在本模块:GPU 采样时由硬件做,CPU 侧只留编码。

import { generateMipChain } from '../../render/upload/mipmap.ts';

export type ImportContext = { readonly target: 'desktop' | 'mobile'; readonly compress: boolean };
export type AssetMeta = {
  readonly kind: 'texture' | 'mesh';
  readonly format: string;
  readonly width: number;
  readonly height: number;
  readonly mipCount: number;
  readonly vertexCount: number;
  readonly triangleCount: number;
  readonly sourceBytes: number;
  readonly payloadBytes: number;
};
export type ImportedAsset = {
  readonly importerId: string;
  readonly importerVersion: number;
  readonly runtimeExt: string;
  readonly payload: Uint8Array;
  readonly meta: AssetMeta;
};
export interface Importer {
  readonly id: string;
  readonly version: number;
  readonly extensions: readonly string[];
  import(source: Uint8Array, path: string, ctx: ImportContext): ImportedAsset;
}

// ---- PPM P6:头部是 ASCII,像素是裸 RGB;解码成紧凑 RGBA8。----
export type Raster = { readonly width: number; readonly height: number; readonly rgba: Uint8Array };

export function decodePpm6(bytes: Uint8Array): Raster {
  let i = 0;
  const token = (): string => {
    while (i < bytes.length && /\s/.test(String.fromCharCode(bytes[i]!))) i++;
    const s = i;
    while (i < bytes.length && !/\s/.test(String.fromCharCode(bytes[i]!))) i++;
    return String.fromCharCode(...bytes.subarray(s, i));
  };
  if (token() !== 'P6') throw new Error('不是 P6 PPM');
  const width = Number(token()), height = Number(token()), maxval = Number(token());
  if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error(`非法尺寸 ${width}x${height}`);
  if (maxval !== 255) throw new Error(`只支持 maxval=255,得到 ${maxval}`);
  i++; // 跳过分隔 maxval 与像素的那一个空白字符
  const need = width * height * 3;
  if (bytes.length - i < need) throw new Error(`像素数据不足:需要 ${need} 字节,实有 ${bytes.length - i}`);
  const rgba = new Uint8Array(width * height * 4);
  for (let p = 0; p < width * height; p++) {
    rgba[p * 4] = bytes[i + p * 3]!; rgba[p * 4 + 1] = bytes[i + p * 3 + 1]!;
    rgba[p * 4 + 2] = bytes[i + p * 3 + 2]!; rgba[p * 4 + 3] = 255;
  }
  return { width, height, rgba };
}

// ---- BC1(DXT1):4x4 块 → 8 字节。端点取每通道包围盒并量化到 RGB565,----
// ---- 四色模式下 c0 > c1,索引按最近色取;解码端展开同一调色板,误差只来自 565。----
function expand565(c: number): [number, number, number] {
  const r = (c >> 11) & 0x1f, g = (c >> 5) & 0x3f, b = c & 0x1f;
  return [(r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)];
}
function pack565(r: number, g: number, b: number): number { return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); }
export function palette(c0: number, c1: number): [number, number, number][] {
  const a = expand565(c0), b = expand565(c1);
  const mix = (t: number): [number, number, number] => [
    Math.round(((4 - t) * a[0] + t * b[0]) / 3), Math.round(((4 - t) * a[1] + t * b[1]) / 3),
    Math.round(((4 - t) * a[2] + t * b[2]) / 3)];
  return [a, b, mix(1), mix(2)];
}
function encodeBlock(rgba: Uint8Array, w: number, x0: number, y0: number): Uint8Array {
  let rmin = 255, gmin = 255, bmin = 255, rmax = 0, gmax = 0, bmax = 0;
  const px: number[] = [];
  for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
    const o = (Math.min(x0 + x, w - 1) + (y0 + y) * w) * 4; // 越界采样到边缘像素
    const r = rgba[o]!, g = rgba[o + 1]!, b = rgba[o + 2]!;
    px.push(r, g, b);
    rmin = Math.min(rmin, r); gmin = Math.min(gmin, g); bmin = Math.min(bmin, b);
    rmax = Math.max(rmax, r); gmax = Math.max(gmax, g); bmax = Math.max(bmax, b);
  }
  let c0 = pack565(rmax, gmax, bmax), c1 = pack565(rmin, gmin, bmin);
  if (c0 === c1) { if (c0 > 0) c1 = c0 - 1; else c0 = 1; } // 单色块退一位,保证走四色模式
  if (c0 < c1) { const t = c0; c0 = c1; c1 = t; }
  const pal = palette(c0, c1);
  let bits = 0;
  for (let p = 0; p < 16; p++) {
    const r = px[p * 3]!, g = px[p * 3 + 1]!, b = px[p * 3 + 2]!;
    let best = 0, bestD = Infinity;
    for (let k = 0; k < 4; k++) {
      const dr = r - pal[k]![0], dg = g - pal[k]![1], db = b - pal[k]![2];
      const d = dr * dr + dg * dg + db * db;
      if (d < bestD) { bestD = d; best = k; }
    }
    bits |= best << (2 * p);
  }
  return new Uint8Array([c0 & 255, c0 >> 8, c1 & 255, c1 >> 8, bits & 255, (bits >> 8) & 255, (bits >> 16) & 255, (bits >>> 24) & 255]);
}
export function encodeBc1(rgba: Uint8Array, width: number, height: number): Uint8Array {
  const bw = Math.ceil(width / 4), bh = Math.ceil(height / 4);
  const out = new Uint8Array(bw * bh * 8);
  let w = 0;
  for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) { out.set(encodeBlock(rgba, width, bx * 4, by * 4), w); w += 8; }
  return out;
}

// ---- 纹理运行时载荷:magic / 宽高 / 格式 / mip 数 / 各 mip 偏移 + 拼接数据。----
export function packTexture(format: string, levels: readonly { readonly width: number; readonly height: number }[], blocks: readonly Uint8Array[]): Uint8Array {
  const head = new Uint8Array(16), hv = new DataView(head.buffer);
  hv.setUint32(0, 0x31584548, true); // "HEX1"
  hv.setUint16(4, levels[0]!.width, true); hv.setUint16(6, levels[0]!.height, true);
  hv.setUint8(8, format === 'bc1' ? 1 : 0); hv.setUint8(9, blocks.length);
  const dir = new Uint8Array(blocks.length * 4), dv = new DataView(dir.buffer);
  let offset = 16 + dir.length;
  for (let i = 0; i < blocks.length; i++) { dv.setUint32(i * 4, offset, true); offset += blocks[i]!.length; }
  const out = new Uint8Array(offset);
  out.set(head, 0); out.set(dir, 16);
  let w = 16 + dir.length;
  for (const b of blocks) { out.set(b, w); w += b.length; }
  return out;
}

export const textureImporter: Importer = {
  id: 'helio.texture', version: 3, extensions: ['.ppm'],
  import(source: Uint8Array, _path: string, ctx: ImportContext): ImportedAsset {
    const raster = decodePpm6(source);
    const levels = generateMipChain(raster.rgba, raster.width, raster.height, 4);
    const format = ctx.compress ? 'bc1' : 'rgba8';
    const blocks = levels.map((l) => (ctx.compress ? encodeBc1(l.data, l.width, l.height) : l.data));
    const payload = packTexture(format, levels, blocks);
    return {
      importerId: this.id, importerVersion: this.version, runtimeExt: '.htex', payload,
      meta: { kind: 'texture', format, width: raster.width, height: raster.height, mipCount: levels.length, vertexCount: 0, triangleCount: 0, sourceBytes: source.length, payloadBytes: payload.length },
    };
  },
};

// ---- 网格导入器:OBJ 子集 → 交错顶点缓冲(position + normal 占位 + 索引)。----
export type ObjMesh = { readonly positions: Float32Array; readonly indices: Uint16Array; readonly vertexCount: number; readonly triangleCount: number };

export function parseObj(text: string): ObjMesh {
  const verts: number[] = [], tris: number[] = [];
  for (const raw of text.split('\n')) {
    const line = raw.trim();
    if (line.startsWith('v ')) { const [x, y, z] = line.slice(2).trim().split(/\s+/).map(Number); verts.push(x!, y!, z!); }
    else if (line.startsWith('f ')) {
      const idx = line.slice(2).trim().split(/\s+/).map((t) => Number(t.split('/')[0]) - 1);
      if (idx.length !== 3) throw new Error(`只支持三角形面,得到 ${idx.length} 个顶点`);
      tris.push(idx[0]!, idx[1]!, idx[2]!);
    }
  }
  const vertexCount = verts.length / 3;
  for (const i of tris) if (i < 0 || i >= vertexCount) throw new Error(`面索引越界:${i}`);
  return { positions: new Float32Array(verts), indices: new Uint16Array(tris), vertexCount, triangleCount: tris.length / 3 };
}

const MESH_STRIDE = 24; // position(12) + normal 占位(12)

export function packMesh(mesh: ObjMesh): Uint8Array {
  const head = new Uint8Array(12), hv = new DataView(head.buffer);
  hv.setUint32(0, 0x3158454d, true); // "MEX1"
  hv.setUint32(4, mesh.vertexCount, true); hv.setUint32(8, mesh.triangleCount, true);
  const vbuf = new Uint8Array(MESH_STRIDE * mesh.vertexCount), dv = new DataView(vbuf.buffer);
  for (let v = 0; v < mesh.vertexCount; v++) for (let k = 0; k < 3; k++) dv.setFloat32(v * MESH_STRIDE + k * 4, mesh.positions[v * 3 + k]!, true);
  const ibuf = new Uint8Array(mesh.indices.buffer, mesh.indices.byteOffset, mesh.indices.byteLength);
  const out = new Uint8Array(head.length + vbuf.length + ibuf.length);
  out.set(head, 0); out.set(vbuf, head.length); out.set(ibuf, head.length + vbuf.length);
  return out;
}

export const meshImporter: Importer = {
  id: 'helio.mesh', version: 2, extensions: ['.obj'],
  import(source: Uint8Array, _path: string, _ctx: ImportContext): ImportedAsset {
    const mesh = parseObj(new TextDecoder().decode(source));
    const payload = packMesh(mesh);
    return {
      importerId: this.id, importerVersion: this.version, runtimeExt: '.hmesh', payload,
      meta: { kind: 'mesh', format: 'interleaved', width: 0, height: 0, mipCount: 0, vertexCount: mesh.vertexCount, triangleCount: mesh.triangleCount, sourceBytes: source.length, payloadBytes: payload.length },
    };
  },
};

// ---- 注册表:按扩展名分派,冲突即报错(离线期早失败胜过运行时猜)。----
export class ImporterRegistry {
  private readonly byExt = new Map<string, Importer>();
  register(imp: Importer): void {
    for (const ext of imp.extensions) {
      const key = ext.toLowerCase(), prev = this.byExt.get(key);
      if (prev) throw new Error(`扩展名 ${key} 已被 ${prev.id} 占用,${imp.id} 冲突`);
      this.byExt.set(key, imp);
    }
  }
  resolve(path: string): Importer {
    const dot = path.lastIndexOf('.');
    const imp = this.byExt.get(dot >= 0 ? path.slice(dot).toLowerCase() : '');
    if (!imp) throw new Error(`没有导入器认领 ${path}`);
    return imp;
  }
  run(path: string, source: Uint8Array, ctx: ImportContext): ImportedAsset { return this.resolve(path).import(source, path, ctx); }
}

export function defaultRegistry(): ImporterRegistry {
  const reg = new ImporterRegistry();
  reg.register(textureImporter); reg.register(meshImporter);
  return reg;
}

// ---- 直接运行:一张 256x256 渐变图 + 一个四面体,走完整条离线管线。----
if ((import.meta as { main?: boolean }).main) {
  const W = 256, H = 256, rgba = new Uint8Array(W * H * 4);
  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const o = (x + y * W) * 4; rgba[o] = x; rgba[o + 1] = y; rgba[o + 2] = 128; rgba[o + 3] = 255; }
  const head = `P6\n${W} ${H}\n255\n`, hb = new Uint8Array([...head].map((c) => c.charCodeAt(0)));
  const src = new Uint8Array(hb.length + W * H * 3); src.set(hb, 0);
  for (let p = 0; p < W * H; p++) { src[hb.length + p * 3] = rgba[p * 4]!; src[hb.length + p * 3 + 1] = rgba[p * 4 + 1]!; src[hb.length + p * 3 + 2] = rgba[p * 4 + 2]!; }
  const reg = defaultRegistry(), ctx: ImportContext = { target: 'desktop', compress: true };
  const bc = reg.run('glow.ppm', src, ctx), raw = reg.run('glow.ppm', src, { ...ctx, compress: false });
  const obj = 'v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\nf 1 2 3\nf 1 2 4\nf 1 3 4\nf 2 3 4\n';
  const mesh = reg.run('rock.obj', new TextEncoder().encode(obj), ctx);
  console.log(`texture ${W}x${H} mips=${bc.meta.mipCount}  src=${src.length}B  bc1=${bc.payload.length}B  rgba8=${raw.payload.length}B`);
  console.log(`mesh    verts=${mesh.meta.vertexCount} tris=${mesh.meta.triangleCount}  src=${mesh.meta.sourceBytes}B  payload=${mesh.payload.length}B`);
}

离线产出的账

在工程目录里 node src/subsys/asset/importer.ts(本机实测):

texture 256x256 mips=9  src=196623B  bc1=43756B  rgba8=349576B
mesh    verts=4 tris=4  src=64B  payload=132B

几个值得记住的数:BC1 固定每 4x4 块 8 字节,即 0.5 字节/像素,正好是 rgba8 的八分之一;整条 mip 链再多约三分之一,于是 256² 的图从 196623 B 的 PPM 压到 43756 B。不压缩的 rgba8 反而比源文件还大(349576 B)——源是 3 字节/像素,而 rgba8 是 4 字节/像素还叠了 mip。

BC1 不是无损的。32x32 的陡峭渐变(每像素跳 8 级)往返后每通道最大误差 25、平均 6.167,误差全部来自端点被量化到 RGB565。真实贴图更平滑、误差更小;但硬边缘与陡渐变正是 BC1 出块状伪影的地方,这也是为什么选格式要看内容(BC1 / BC3 / BC7)而不只看压缩比。

网格侧的数字很小:一个四面体 64 B 的 OBJ 变成 132 B 的交错缓冲(12 B 头 + 4×24 B 顶点 + 4×3×2 B 索引)。顶点被补齐到 24 字节步长后比源文本还大,但运行时能直接整块拷进 GPU 缓冲,再也不解析文本。

这两样产物都是自描述的:头部写死 magic、尺寸、格式与 mip 数,运行时读 16 字节就知道怎么切。下一节要回答的新问题是:产物这么多,怎么知道哪一个该重导。

阅读导航:上一节:回到目录 · 下一节:2.2 缓存、热重载与增量构建 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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