2.1 资产管线四阶段:DCC → 导出 → 导入 → 运行时

资产从画师手里走到屏幕上要穿过四个阶段:DCC 制作、导出、导入、运行时。本节讲清每个阶段谁负责、产出什么、边界上要交接哪些字段,再用一份可跑的整数账把同一批《星屑旅店》资产在四阶段的顶点数与字节量算出来:顶点因 UV 缝与硬边膨胀多少千分比,运行时字节为什么几乎全由纹理决定。

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-base19621410918720270864
furn_chair-base302340112513808275952
char_innkeeper-idle112012801142520001100576
tile_floor-wood1624150091266448

膨胀千分比不是常数。 家具 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 导出设置:单位、轴向与坐标系对齐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则