16.1 管线装配与检查表

前十五章各写了一段管线,却没人说清谁先谁后、谁的输出喂给谁。本节把它们串成一条有向链,给出一张能跑的装配检查表:每个阶段标注它由本卷哪一节提供可跑脚本与档位,再用一份 S 层脚本与 P 层 TS 内核做四类对账——出处节是否真实、链路是否首尾相接、阶段号是否连续、首尾类型是否匹配。

16.1 管线装配与检查表

15.3 供应商验收与质量门 讲完了外包这最后一环,卷二到这一节收口。前十五章各写了一段管线:导入、引用、图集、材质、几何、动画、UI、清单、门禁——但它们是一堆散落的脚本,没人说清「谁先谁后、谁的输出喂给谁」。本节做一件事:把它们串成一条有向链,并给出一张能跑的装配检查表。引擎侧怎么消费这条管线属于实现,外链 《渲染引擎》的资产管线入口 ;本节只做装配对账这一层。

判据先立好:「装配正确」= 阶段号连续 + 每个阶段的输入等于上一阶段的输出 + 首尾是 raw-asset 与 deliverable。全是字符串相等,与文件系统、引擎都无关,所以 Python 与 TypeScript 能各算一遍再逐位对照。

16.1.1 D 层:检查表的结构

config/ta16_pipeline.json 是检查表的静态形态。stages 是这条链的十个阶段,每个阶段声明它由本卷哪一节提供可跑脚本(section,必须在本卷 48 节白名单里)、该脚本的档位(tier)、以及输入/输出数据类型(consumes / produces)。下面只截取头部与两个阶段,完整十阶段与 48 节白名单见落盘文件:

{
  "project": "stardust-inn",
  "volume": "技术美术与管线工程",
  "entryKind": "raw-asset",
  "exitKind": "deliverable",
  "allowedTiers": ["S", "P", "W", "D", "X", "B"],
  "allowedSections": ["art-ta-03-2-import-time-validation", "art-ta-04-2-cycle-detection-dangling-ref", "art-ta-05-1-packing-algorithm-ratio"],
  "stages": [
    { "no": 1, "id": "import", "section": "art-ta-03-2-import-time-validation", "tier": "P", "consumes": "raw-asset", "produces": "imported-asset" },
    { "no": 2, "id": "reference", "section": "art-ta-04-2-cycle-detection-dangling-ref", "tier": "P", "consumes": "imported-asset", "produces": "ref-graph" }
  ]
}

16.1.2 S 层:装配对账

下面这段是 S 层脚本,读检查表做四类对账:出处节是否在本卷白名单、档位是否合法、阶段号是否 1..N 连续、相邻阶段是否首尾相接,外加首尾类型匹配。违例字符串排序去重后落 out/ta16_pipeline.json:

#!/usr/bin/env python3
# file: sim/ta16_pipeline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta16_pipeline.py
"""卷二 16.1 管线装配检查表的 S 层校验器。

读 config/ta16_pipeline.json,把前十五章的管线环节当成一条有向链来对账:
  - 每个阶段的 section 必须在本卷 48 节白名单里(否则 UNKNOWN_SECTION)
  - 每个阶段的 tier 必须是合法档位(否则 BAD_TIER)
  - 阶段号必须是 1..N 连续(否则 STAGE_GAP)
  - 阶段 id 不得重复(DUPLICATE_STAGE)
  - 相邻阶段首尾相接:第 i 阶段的 consumes 必须等于第 i-1 阶段的 produces(否则 CHAIN_BREAK)
  - 首阶段 consumes == entryKind、末阶段 produces == exitKind(否则 ENTRY/EXIT_MISMATCH)
违例字符串排序后落 out/ta16_pipeline.json 供 P 层跨语言对照。全是字符串与集合运算,逐位一致。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def check(pipe: dict) -> list:
    stages = pipe["stages"]
    allowed_sections = set(pipe["allowedSections"])
    allowed_tiers = set(pipe["allowedTiers"])
    ids = [s["id"] for s in stages]
    nos = [s["no"] for s in stages]
    viol = []

    if nos != list(range(1, len(stages) + 1)):
        viol.append("STAGE_GAP:" + ",".join(str(n) for n in nos))
    for s in stages:
        if s["section"] not in allowed_sections:
            viol.append(f"UNKNOWN_SECTION:{s['no']}:{s['section']}")
        if s["tier"] not in allowed_tiers:
            viol.append(f"BAD_TIER:{s['no']}:{s['tier']}")
        if ids.count(s["id"]) > 1:
            viol.append(f"DUPLICATE_STAGE:{s['id']}")
    for i in range(1, len(stages)):
        prev, cur = stages[i - 1], stages[i]
        if cur["consumes"] != prev["produces"]:
            viol.append(f"CHAIN_BREAK:{cur['id']}:{cur['consumes']}!={prev['produces']}")
    if stages and stages[0]["consumes"] != pipe["entryKind"]:
        viol.append(f"ENTRY_MISMATCH:{stages[0]['consumes']}!={pipe['entryKind']}")
    if stages and stages[-1]["produces"] != pipe["exitKind"]:
        viol.append(f"EXIT_MISMATCH:{stages[-1]['produces']}!={pipe['exitKind']}")
    return sorted(set(viol))


def digest(pipe: dict, viol: list) -> str:
    parts = [f"{s['no']}:{s['id']}:{s['section']}:{s['tier']}:{s['consumes']}>{s['produces']}"
             for s in pipe["stages"]]
    return hashlib.sha256(";".join(parts + viol).encode("utf-8")).hexdigest()[:16]


def main() -> int:
    pipe = json.load(open(os.path.join(ROOT, "config/ta16_pipeline.json"), encoding="utf-8"))
    viol = check(pipe)
    stages = pipe["stages"]
    chain = [stages[0]["consumes"]] + [s["produces"] for s in stages]
    by_tier = {}
    for s in stages:
        by_tier[s["tier"]] = by_tier.get(s["tier"], 0) + 1
    report = {"project": pipe["project"], "volume": pipe["volume"],
              "pipelineVersion": pipe["pipelineVersion"], "stageCount": len(stages),
              "entryKind": pipe["entryKind"], "exitKind": pipe["exitKind"],
              "chain": chain, "byTier": by_tier, "violations": viol, "count": len(viol)}
    report["digest"] = digest(pipe, viol)
    with open(os.path.join(ROOT, "out/ta16_pipeline.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"stages={report['stageCount']} tiers={by_tier} violations={report['count']}")
    print("chain=" + " -> ".join(chain))
    for s in stages:
        print(f"  {s['no']:>2} {s['id']:<10} [{s['tier']}] {s['section']}")
    for v in viol:
        print(f"  VIOLATION {v}")
    print(f"digest={report['digest']}")
    print("wrote out/ta16_pipeline.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta16_pipeline.py:

stages=10 tiers={'P': 6, 'S': 3, 'W': 1} violations=0
chain=raw-asset -> imported-asset -> ref-graph -> atlas -> material-set -> geom-budget -> anim-set -> ui-layout -> manifest -> gate-report -> deliverable
   1 import     [P] art-ta-03-2-import-time-validation
   2 reference  [P] art-ta-04-2-cycle-detection-dangling-ref
   3 atlas      [S] art-ta-05-1-packing-algorithm-ratio
   4 material   [P] art-ta-06-2-param-alignment-packing
   5 geometry   [S] art-ta-07-2-screen-ratio-triangle-budget
   6 animation  [P] art-ta-09-1-animation-naming-state-binding
   7 ui         [W] art-ta-10-1-safearea-notch-adaptation
   8 manifest   [P] art-ta-11-1-manifest-diff-computation
   9 gate       [P] art-ta-12-1-rule-engine-naming-size-ref-budget
  10 delivery   [S] art-ta-16-3-deliverable-handoff-volume3
digest=cf654ed171a4bdd0
wrote out/ta16_pipeline.json

chain 那一行就是这条管线:从 raw-asset 进,一路 imported-asset → ref-graph → atlas → … → gate-report,最后出 deliverable。十个阶段里 P 档 6 个、S 档 3 个、W 档 1 个——这就是「每一条判据都有可跑脚本兜底」的清单化。

16.1.3 P 层:同一套判据的 TS 内核

下面这段是 P 层 TS 内核 core/src/ta16/assembly.ts,与 Python 逐条对应——出处节合法、档位合法、阶段号连续、id 唯一、相邻首尾相接、entry/exit 匹配,全部一致:

// file: core/src/ta16/assembly.ts
// tier: P
// verify: node --test 'core/test/ta16_*.test.ts'
// 卷二 16.1 管线装配检查表的 P 层内核。与 sim/ta16_pipeline.py 同一套规则:section 合法、
// tier 合法、阶段号连续、id 唯一、相邻阶段首尾相接、entry/exit 匹配。只允许可擦除 TS 语法
// (禁 enum / namespace / 参数属性 / 装饰器)。

export type Stage = {
  no: number;
  id: string;
  section: string;
  tier: string;
  consumes: string;
  produces: string;
};

export function checkAssembly(pipe: any): string[] {
  const stages: Stage[] = pipe.stages;
  const allowedSections = new Set<string>(pipe.allowedSections);
  const allowedTiers = new Set<string>(pipe.allowedTiers);
  const ids = stages.map((s) => s.id);
  const nos = stages.map((s) => s.no);
  const viol: string[] = [];

  const expected = stages.map((_, i) => i + 1);
  if (nos.join(",") !== expected.join(",")) viol.push(`STAGE_GAP:${nos.join(",")}`);
  for (const s of stages) {
    if (!allowedSections.has(s.section)) viol.push(`UNKNOWN_SECTION:${s.no}:${s.section}`);
    if (!allowedTiers.has(s.tier)) viol.push(`BAD_TIER:${s.no}:${s.tier}`);
    if (ids.filter((x) => x === s.id).length > 1) viol.push(`DUPLICATE_STAGE:${s.id}`);
  }
  for (let i = 1; i < stages.length; i += 1) {
    const prev = stages[i - 1];
    const cur = stages[i];
    if (cur.consumes !== prev.produces) {
      viol.push(`CHAIN_BREAK:${cur.id}:${cur.consumes}!=${prev.produces}`);
    }
  }
  if (stages.length > 0 && stages[0].consumes !== pipe.entryKind) {
    viol.push(`ENTRY_MISMATCH:${stages[0].consumes}!=${pipe.entryKind}`);
  }
  if (stages.length > 0 && stages[stages.length - 1].produces !== pipe.exitKind) {
    viol.push(`EXIT_MISMATCH:${stages[stages.length - 1].produces}!=${pipe.exitKind}`);
  }
  return [...new Set(viol)].sort();
}

export function chainOf(pipe: any): string[] {
  const stages: Stage[] = pipe.stages;
  return [stages[0].consumes, ...stages.map((s) => s.produces)];
}

core/test/ta16_assembly.test.ts 读 Python 产物做跨语言对照,并构造四条负例——未知出处节、链路断开、阶段号不连续、entry 不匹配,每条都必须真的 fail:

// file: core/test/ta16_assembly.test.ts
// tier: P
// verify: node --test 'core/test/ta16_*.test.ts'
// 卷二 16.1 跨语言对照:读 sim/ta16_pipeline.py 落盘的 out/ta16_pipeline.json,断言 TS 侧的
// 管线装配校验与 Python 逐位一致,并比对 sha256 指纹。负例:未知出处节、链路断开、阶段号
// 不连续、entry 不匹配——每条都必须真的 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkAssembly, chainOf } from "../src/ta16/assembly.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const pipe = JSON.parse(readFileSync(resolve(ROOT, "config/ta16_pipeline.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta16_pipeline.json"), "utf8"));
const clone = (): any => JSON.parse(JSON.stringify(pipe));

test("管线装配:TS 与 Python 违例集合逐位一致且为空", () => {
  assert.deepEqual(checkAssembly(pipe), py.violations);
  assert.deepEqual(py.violations, []);
  assert.equal(py.stageCount, pipe.stages.length);
  assert.deepEqual(chainOf(pipe), py.chain);
});

test("sha256 指纹跨语言一致", () => {
  const parts = pipe.stages.map((s: any) => `${s.no}:${s.id}:${s.section}:${s.tier}:${s.consumes}>${s.produces}`);
  const all = [...parts, ...checkAssembly(pipe)];
  const digest = createHash("sha256").update(all.join(";"), "utf8").digest("hex").slice(0, 16);
  assert.equal(digest, py.digest);
});

test("负例:出处节不在白名单 → UNKNOWN_SECTION", () => {
  const b = clone();
  b.stages[2].section = "art-ta-99-9-ghost";
  assert.ok(checkAssembly(b).includes("UNKNOWN_SECTION:3:art-ta-99-9-ghost"));
});

test("负例:链路断开 → CHAIN_BREAK", () => {
  const b = clone();
  b.stages[4].consumes = "wrong-kind";
  assert.ok(checkAssembly(b).includes("CHAIN_BREAK:geometry:wrong-kind!=material-set"));
});

test("负例:阶段号不连续与 entry 不匹配都必须 fail", () => {
  const b = clone();
  b.stages[1].no = 5;
  assert.ok(checkAssembly(b).some((x) => x.startsWith("STAGE_GAP:")), "阶段号未报");
  const e = clone();
  e.stages[0].consumes = "other";
  assert.ok(checkAssembly(e).includes("ENTRY_MISMATCH:other!=raw-asset"));
});

实跑 node --test 'core/test/ta16_*.test.ts'(此处只摘本节的 5 条;三节合计 14 条):

✔ 管线装配:TS 与 Python 违例集合逐位一致且为空 (0.948875ms)
✔ sha256 指纹跨语言一致 (0.353791ms)
✔ 负例:出处节不在白名单 → UNKNOWN_SECTION (0.157ms)
✔ 负例:链路断开 → CHAIN_BREAK (0.08725ms)
✔ 负例:阶段号不连续与 entry 不匹配都必须 fail (0.13625ms)
ℹ tests 14
ℹ pass 14
ℹ fail 0

16.1.4 检查表:十个阶段与出处

把 stages 摊成一张人读的检查表。每一条都能顺着「出处」翻到给它的那份可跑脚本与档位:

#阶段出处(本卷第 X.Y 节)档位输入 → 输出
1导入3.2 导入期校验Praw-asset → imported-asset
2引用4.2 环检测与悬空引用Pimported-asset → ref-graph
3图集5.1 装箱算法与装箱率Sref-graph → atlas
4材质6.2 参数对齐与打包Patlas → material-set
5几何预算7.2 屏幕占比与三角预算Smaterial-set → geom-budget
6动画9.1 动画资源命名与状态绑定Pgeom-budget → anim-set
7UI10.1 安全区与刘海屏适配Wanim-set → ui-layout
8清单11.1 清单与差异计算Pui-layout → manifest
9门禁12.1 规则引擎:命名/尺寸/引用/预算Pmanifest → gate-report
10交付16.3 交付物与卷三衔接Sgate-report → deliverable

16.1.5 判读与边界

样本里 violations=0,因为这条链是完整的。真正值钱的是四条负例,它们证明这张检查表不是「打印一行 OK」的摆设:

  • **链路断开(CHAIN_BREAK)**最隐蔽:把某阶段的输入类型写错一位,单看那一节脚本照样能跑,只有和上一阶段的输出对齐才发现对不上。这就是「一条链」相对「一堆脚本」的意义——它把「接没接上」从口头承诺变成了字符串相等。
  • **未知出处(UNKNOWN_SECTION)**挡住「引用一个不存在的章节当挡箭牌」;出处节必须真在本卷 48 节白名单里,指向别处的引用一律 fail。
  • 真实资产的物理流转(把源文件、贴图、模型真正从一段管线搬到下一段)需要真实工程环境,命令行不可复现,标 X;本节只对链的接口做对账,不搬文件。
  • 这张表是本机相对量级的结构账,与项目无关,可移植;换一套管线只需改 config/ta16_pipeline.json 的 stages。

阅读导航:上一节:15.3 供应商验收与质量门 · 下一节:16.2 双案例回放:6 主题走同一条管线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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