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 缓存、热重载与增量构建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。