2.1 资产管线四阶段:DCC → 导出 → 导入 → 运行时
一件美术资产从画师手里走到屏幕上要穿过四个阶段:DCC 制作 → 导出 → 导入 → 运行时。出事故的地方往往不在阶段内部,而在阶段之间那道缝上:DCC 里看着好好的模型,导出之后多了两百个顶点;导出时单位是厘米,导入时引擎按米理解,一盏落地灯变成十七米高。站内 glTF 资产管线那篇 讲的是交换格式与渲染侧的原理(节点树、PBR 材质、动画采样),那是「原理的家」,本节不重推原理;本节只做美术侧必须交代清楚的事:边界上谁把什么交给谁,交接清单少哪个字段会在下游变成事故,并把这套交接算成一份能跑的整数账。
2.1.1 四个阶段,三种「家」
| 阶段 | 归属 | 本节展开到什么程度 |
|---|---|---|
| DCC 制作 | 美术 | 只讲交付契约(要带哪些字段出来),不讲建模与雕刻技法 |
| 导出 | 技术美术 | 本节主体:交换格式、单位、轴向、拆点 |
| 导入 | 技术美术 | 本节主体:引擎侧重编码与 .meta(详见 3.1) |
| 运行时 | 程序 | 只讲美术侧的预算后果(字节、显存),不讲 RHI 与合批 |
渲染原理与纹理压缩格式属于 graphics 专题;资源加载与上传属于《渲染引擎》《引擎内核》那几卷。本节的自留地是边界契约 + 可算的整数账。
2.1.2 阶段契约表(D 层)
下面这个块是 D 层(静态 schema),不产出数字,只定义四个阶段的负责人、产出物与交接字段,并附带后面两处消费共用的整数模型常数:
{
"specVersion": 1,
"note": "资产管线四阶段的契约表 + 顶点/字节账的整数模型常数。所有量是整数,Python 与 TS 两侧逐位一致。阶段 0(DCC)的工程文件字节刻意不进账:无 DCC 环境不可复现,见 2.3。",
"stages": [
{"index": 0, "id": "dcc", "name": "DCC 制作", "owner": "美术", "artifact": "工程文件", "carriedFields": ["mesh", "uv", "material", "origin", "unit"], "checks": 0, "reproducible": "requires-real-env"},
{"index": 1, "id": "export", "name": "导出", "owner": "技术美术", "artifact": "交换格式", "carriedFields": ["pos", "normal", "uv", "index", "materialSlot", "unit", "upAxis"], "checks": 6, "reproducible": "requires-real-env"},
{"index": 2, "id": "import", "name": "导入", "owner": "技术美术", "artifact": "引擎资产 + .meta", "carriedFields": ["guid", "importSettings", "scaleFactor", "pivot", "materialRemap"], "checks": 5, "reproducible": "cli"},
{"index": 3, "id": "runtime", "name": "运行时", "owner": "程序", "artifact": "顶点/索引缓冲 + 纹理", "carriedFields": ["vbo", "ibo", "texture", "bonePalette"], "checks": 4, "reproducible": "cli"}
],
"byteModel": {
"sourceStride": 36,
"exportStride": 32,
"indexBytes": 2,
"metaBytes": 512,
"textureBytesPerPixel": 4
},
"assets": [
{"id": "furn_lamp-base", "tris": 312, "verts": 196, "seamVerts": 18, "hardEdgeVerts": 0, "texturePx": 256},
{"id": "furn_chair-base", "tris": 488, "verts": 302, "seamVerts": 26, "hardEdgeVerts": 12, "texturePx": 256},
{"id": "char_innkeeper-idle", "tris": 1840, "verts": 1120, "seamVerts": 96, "hardEdgeVerts": 64, "texturePx": 512},
{"id": "tile_floor-wood", "tris": 24, "verts": 16, "seamVerts": 0, "hardEdgeVerts": 8, "texturePx": 128}
]
}
carriedFields 是这张表真正的价值:它是阶段之间的交接清单。DCC 阶段交出「网格 + UV + 材质 + 原点 + 单位」五样;导出阶段要在这五样之外补上 unit 与 upAxis 两个显式声明——这正是 2.2 的主题。reproducible 一列提前标出本机做不了的事:前两阶段是 requires-real-env(真 DCC 导出不可复现,见 2.3),后两阶段可纯命令行复现。byteModel 里 sourceStride 36 是单份顶点(位置 12 + 法线 12 + UV 8 + 顶点色 4),exportStride 32 是导出后单份顶点(顶点色不导出),indexBytes 2 是 uint16 索引宽度,metaBytes 512 是导入期每个资产新增的 .meta,textureBytesPerPixel 4 是未压缩 RGBA8。这些常数全是整数,公式链里没有一处浮点——这是本套跨语言纪律的第一条:预算与阈值一律整数,除法一律整数地板除。
2.1.3 S 层:四阶段顶点/字节账
下面这段是 S 层脚本,读上面那份配置,把每个资产在四个阶段的顶点数与字节量算出来,落盘 JSON + CSV + SVG:
#!/usr/bin/env python3
# file: sim/ta02_stage_bytes.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta02_stage_bytes.py
"""四阶段顶点/字节账(S 层):读 config/ta02_pipeline.json,算 4 个资产在
DCC → 导出 → 导入 → 运行时 的顶点数与字节量,落 out/ta02_stage_bytes.json + CSV + SVG。
整数模型(与 core/src/ta02/pipeline.ts 同源,逐位一致):
vertsExport = verts + seamVerts + hardEdgeVerts # 导出按 UV 缝 / 硬边拆点
bytesSource = verts * sourceStride # 阶段 0 的模型内插值,非 DCC 实测
bytesExport = vertsExport * exportStride + tris * 3 * indexBytes
bytesImport = bytesExport + metaBytes # 导入只加 .meta,不重编码
bytesRuntime = bytesExport + texturePx * texturePx * textureBytesPerPixel
expansionPermille = vertsExport * 1000 // verts # 整数地板除
除法一律整数地板除(Python ``//``);阶段 0 的真实 DCC 工程文件字节不在账内。
"""
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta02_pipeline.json")
OUT = os.path.join(ROOT, "out")
def stage_bytes(asset, model):
"""把一个资产的声明规格摊成四阶段的整数账。"""
verts_export = asset["verts"] + asset["seamVerts"] + asset["hardEdgeVerts"]
bytes_source = asset["verts"] * model["sourceStride"]
bytes_export = verts_export * model["exportStride"] + asset["tris"] * 3 * model["indexBytes"]
bytes_import = bytes_export + model["metaBytes"]
bytes_runtime = bytes_export + asset["texturePx"] * asset["texturePx"] * model["textureBytesPerPixel"]
return {
"id": asset["id"],
"verts": asset["verts"],
"vertsExport": verts_export,
"expansionPermille": verts_export * 1000 // asset["verts"],
"bytes": [bytes_source, bytes_export, bytes_import, bytes_runtime],
}
def validate_stage_order(stages):
"""阶段必须从 0 连续递增,且 index 唯一——乱序即返错。"""
errs = []
idx = [s["index"] for s in stages]
if idx != list(range(len(stages))):
errs.append("stage index 非 0..n-1 连续递增")
if len(set(s["id"] for s in stages)) != len(stages):
errs.append("stage id 重复")
return errs
def write_svg(path, stages, totals):
"""纯 xml.etree 画阶段条形图:条高按该阶段总字节的整数千分比。"""
w, h, gap, top = 720, 240, 12, 48
bar_w = (w - gap * (len(stages) + 1)) // len(stages)
peak = max(totals) or 1
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(w), "height": str(h)})
ET.SubElement(root, "rect", {"width": str(w), "height": str(h), "fill": "#1f2933"})
ET.SubElement(root, "text", {"x": str(gap), "y": "28", "fill": "#eceff1",
"font-size": "14"}).text = "stardust-inn 四阶段字节账(模型内插值)"
for i, st in enumerate(stages):
x = gap + i * (bar_w + gap)
bar_h = (h - top - 56) * totals[i] // peak
ET.SubElement(root, "rect", {"x": str(x), "y": str(h - 40 - bar_h), "width": str(bar_w),
"height": str(bar_h), "fill": "#f59e0b"})
ET.SubElement(root, "text", {"x": str(x + bar_w // 2), "y": str(h - 22), "fill": "#94a3b8",
"font-size": "12", "text-anchor": "middle"}).text = f"{st['index']} {st['name']}"
ET.SubElement(root, "text", {"x": str(x + bar_w // 2), "y": str(h - 46 - bar_h), "fill": "#eceff1",
"font-size": "11", "text-anchor": "middle"}).text = f"{totals[i]}B"
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=False)
def main():
cfg = json.load(open(CFG, encoding="utf-8"))
model, stages = cfg["byteModel"], cfg["stages"]
errs = validate_stage_order(stages)
if errs:
for e in errs:
print(f"FAIL {e}")
return 1
rows = [stage_bytes(a, model) for a in cfg["assets"]]
totals = [sum(r["bytes"][i] for r in rows) for i in range(len(stages))]
verts_total = sum(r["verts"] for r in rows)
export_total = sum(r["vertsExport"] for r in rows)
os.makedirs(OUT, exist_ok=True)
summary = {
"project": "stardust-inn",
"stageIds": [s["id"] for s in stages],
"byteModel": model,
"rows": rows,
"totals": {"verts": verts_total, "vertsExport": export_total,
"expansionPermille": export_total * 1000 // verts_total,
"bytesByStage": totals},
}
with open(os.path.join(OUT, "ta02_stage_bytes.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "ta02_stage_bytes.csv"), "w", encoding="utf-8", newline="") as f:
wtr = csv.writer(f)
wtr.writerow(["id", "verts", "vertsExport", "expansionPermille"] + [s["id"] + "Bytes" for s in stages])
for r in rows:
wtr.writerow([r["id"], r["verts"], r["vertsExport"], r["expansionPermille"]] + r["bytes"])
write_svg(os.path.join(OUT, "ta02_stages.svg"), stages, totals)
print(f"assets={len(rows)} verts={verts_total} vertsExport={export_total} "
f"expansionPermille={summary['totals']['expansionPermille']}")
for i, s in enumerate(stages):
print(f" {s['id']:8s} {totals[i]:>9d} B")
print("wrote out/ta02_stage_bytes.json + .csv + ta02_stages.svg")
return 0
if __name__ == "__main__":
sys.exit(main())
顶点公式 vertsExport = verts + seamVerts + hardEdgeVerts 不是「导出凭空造顶点」,而是导出必须按 UV 缝与硬边把共享顶点拆开:DCC 里一个顶点可以同时属于两个 UV 岛、或一边光滑一边硬边,但 GPU 顶点缓冲里这些属性必须绑在同一个顶点上,拆不开就只能复制。拆点率是 DCC→导出这条缝上最容易被忽略、也最能解释「导出后顶点数对不上」的一笔账。账里 bytesSource 是模型内插值而非 DCC 实测值——无 DCC 环境连工程文件多大都拿不到,所以阶段 0 的真实字节不进账,这条边界见 2.3。
2.1.4 P 层:同一份配置的 TS 侧
同一份配置,TypeScript 侧独立实现一遍。它不 import Python 的任何东西,只读同一份 JSON:
// file: core/src/ta02/pipeline.ts
// tier: P
// verify: node --test 'core/test/ta02_*.test.ts'
// 资产管线四阶段的 TS 侧实现:与 sim/ta02_stage_bytes.py 共用 config/ta02_pipeline.json。
// 顶点数与字节数全是整数(地板除),故两侧逐位一致。只允许可擦除 TS 语法。
export type Stage = { index: number; id: string; name: string; owner: string; artifact: string;
carriedFields: string[]; checks: number; reproducible: string };
export type ByteModel = { sourceStride: number; exportStride: number; indexBytes: number;
metaBytes: number; textureBytesPerPixel: number };
export type Asset = { id: string; tris: number; verts: number; seamVerts: number;
hardEdgeVerts: number; texturePx: number };
export type Row = { id: string; verts: number; vertsExport: number;
expansionPermille: number; bytes: number[] };
/** 四阶段整数账:与 Python stage_bytes() 同一条公式链。 */
export function stageBytes(a: Asset, m: ByteModel): Row {
const vertsExport = a.verts + a.seamVerts + a.hardEdgeVerts;
const bytesSource = a.verts * m.sourceStride;
const bytesExport = vertsExport * m.exportStride + a.tris * 3 * m.indexBytes;
const bytesImport = bytesExport + m.metaBytes;
const bytesRuntime = bytesExport + a.texturePx * a.texturePx * m.textureBytesPerPixel;
return {
id: a.id,
verts: a.verts,
vertsExport,
expansionPermille: Math.floor((vertsExport * 1000) / a.verts),
bytes: [bytesSource, bytesExport, bytesImport, bytesRuntime],
};
}
/** 阶段必须 0..n-1 连续递增且 id 唯一;乱序返回错误列表。 */
export function validateStageOrder(stages: Stage[]): string[] {
const errs: string[] = [];
const idx = stages.map((s) => s.index);
const ok = idx.every((v, i) => v === i);
if (!ok) errs.push("stage index 非 0..n-1 连续递增");
if (new Set(stages.map((s) => s.id)).size !== stages.length) errs.push("stage id 重复");
return errs;
}
/** 每阶段总字节。 */
export function stageTotals(rows: Row[], n: number): number[] {
const t: number[] = [];
for (let i = 0; i < n; i += 1) t.push(rows.reduce((s, r) => s + r.bytes[i], 0));
return t;
}
/** 顶点膨胀千分比(全体合计)。 */
export function totalExpansionPermille(rows: Row[]): number {
const v = rows.reduce((s, r) => s + r.verts, 0);
const e = rows.reduce((s, r) => s + r.vertsExport, 0);
return Math.floor((e * 1000) / v);
}
2.1.5 P 层测试:读 Python 的产物做对照
「跨语言一致」不能靠「两边写得像」。测试读的是 Python 落盘的 JSON,再断言 TS 复算的账与它逐位相同:
// file: core/test/ta02_pipeline.test.ts
// tier: P
// verify: node --test 'core/test/ta02_*.test.ts'
// 跨语言对照:读 sim/ta02_stage_bytes.py 落盘的 out/ta02_stage_bytes.json,断言 TS 侧
// 复算的四阶段整数账与之一致(整数类,逐位一致),并带负例断言:阶段乱序必须被判错。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { stageBytes, validateStageOrder, stageTotals, totalExpansionPermille } from "../src/ta02/pipeline.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta02_pipeline.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta02_stage_bytes.json"), "utf8"));
const rows = cfg.assets.map((a: object) => stageBytes(a, cfg.byteModel));
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
const line = (r: { id: string; vertsExport: number; expansionPermille: number; bytes: number[] }): string =>
`${r.id}:${r.vertsExport}:${r.expansionPermille}:${r.bytes.join(",")}`;
test("跨语言:TS 四阶段账 == Python 四阶段账(逐位一致)", () => {
assert.deepEqual(rows.map(line), py.rows.map(line));
assert.equal(totalExpansionPermille(rows), py.totals.expansionPermille);
assert.equal(digest(rows.map(line)), digest(py.rows.map(line)));
console.log(`stage bytes digest = ${digest(rows.map(line))}`);
});
test("每阶段合计与 Python 一致,且运行时 > 导出(纹理进账后)", () => {
const t = stageTotals(rows, cfg.stages.length);
assert.deepEqual(t, py.totals.bytesByStage);
assert.ok(t[3] > t[1], `runtime ${t[3]} 应大于 export ${t[1]}`);
assert.equal(t[2] - t[1], cfg.byteModel.metaBytes * rows.length);
});
test("负例断言:阶段乱序 / index 重复必须被判错", () => {
assert.deepEqual(validateStageOrder(cfg.stages), []);
const swapped = [cfg.stages[1], cfg.stages[0], cfg.stages[2], cfg.stages[3]];
assert.ok(validateStageOrder(swapped).includes("stage index 非 0..n-1 连续递增"));
const dup = [cfg.stages[0], cfg.stages[1], cfg.stages[1], cfg.stages[3]];
assert.ok(validateStageOrder(dup).length === 2);
});
test("负例断言:顶点膨胀千分比必须随 seamVerts 单调不减", () => {
const base = { id: "x", tris: 10, verts: 100, seamVerts: 0, hardEdgeVerts: 0, texturePx: 64 };
const a = stageBytes(base, cfg.byteModel).expansionPermille;
const b = stageBytes({ ...base, seamVerts: 40 }, cfg.byteModel).expansionPermille;
assert.equal(a, 1000);
assert.ok(b > a, `${b} 应大于 ${a}`);
});
2.1.6 实跑输出
S 层 /opt/homebrew/bin/python3 sim/ta02_stage_bytes.py:
assets=4 verts=1634 vertsExport=1858 expansionPermille=1137
dcc 58824 B
export 75440 B
import 77488 B
runtime 1713840 B
wrote out/ta02_stage_bytes.json + .csv + ta02_stages.svg
P 层 node --test 'core/test/ta02_*.test.ts':pass 11 / fail 0。把每条账按 id:vertsExport:expansionPermille:bytes 排序拼接后取 sha256 前 16 位,两侧算出的 digest 都是 9522b438af64c8a8——顶点数与字节数是整数类,逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见卷一第 4 章)。四个资产逐条摊开(来自 out/ta02_stage_bytes.csv):
| 资产 | DCC 顶点 | 导出顶点 | 膨胀千分比 | 导出字节 | 运行时字节 |
|---|---|---|---|---|---|
furn_lamp-base | 196 | 214 | 1091 | 8720 | 270864 |
furn_chair-base | 302 | 340 | 1125 | 13808 | 275952 |
char_innkeeper-idle | 1120 | 1280 | 1142 | 52000 | 1100576 |
tile_floor-wood | 16 | 24 | 1500 | 912 | 66448 |
膨胀千分比不是常数。 家具 1091‰、角色 1142‰、地板 tile 1500‰。地板膨胀最凶(16 → 24),因为它是个平面盒子,几乎每个顶点都压在硬边上;角色反而最温和,因为它的 UV 岛虽多,但顶点基数大,缝上的比例被摊薄了。同一个「导出」动作,对不同资产类型的代价差一倍以上——所以「导出后顶点数变多」这句话必须带资产类型才有意义。
运行时字节几乎全由纹理决定。 四个资产导出字节合计 75440,运行时字节合计 1713840,差 1638400 字节,正好等于四张未压缩 RGBA8 纹理之和(256² + 256² + 512² + 128² = 409600 像素 × 4 字节 = 1638400)。几何在这条账上是零头,这是美术预算的重心所在:真正要盯的是贴图分辨率与压缩格式(卷一第 8 章),不是模型面数。
2.1.7 一个常被争论的点:按工具切还是按责任切
有团队按工具切(Blender 阶段、Unity 阶段),有团队按责任切(美术、技术美术、程序)。我们按责任切,因为出事故时要找的是人:upAxis 声明错了,责任在导出设置(技术美术),不在 Blender;而「导出」之所以独立成一个阶段,是因为它的产物是可交付的中间文件(能进版本库、能发给外包),导入的产物则是引擎内部资产(换引擎就没了),两者生命周期不同。
下一节处理这张表里最要命的一列:unit 与 upAxis。它们看着只是两个字符串,写错了就是「落地灯十七米高」与「角色躺在地上」。
阅读导航:上一节:1.3 TA 与美术、程序、策划的接口 · 下一节:2.2 导出设置:单位、轴向与坐标系对齐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。