2.3 X 范式:真实 DCC 不可复现
前面两节都在算「能算的部分」:顶点拆点账、字节账、单位与轴向的置换账。但管线图上还有整整一个阶段算不了——真打开 DCC、真按导出设置导出一份文件。本机(以及这套书稿的构建环境)没有任何 DCC:Blender、Maya、3ds Max、Substance、ZBrush 全部不存在,也不可能安装。所以「真导出」这一步,需真实环境,命令行不可复现。站内那篇 游戏美术风格指南 用提纲把「导出要规范、命名要统一」讲成了方向;本节不重复那套口号,而是回答一个更具体的问题:当一件事本机根本做不了时,规范里该怎么写它——留白、还是编数字?我们选第三条路:把不可复现的部分降级成可静态校验的产物。
2.3.1 三档可复现性,与一条不许越界的线
先把「可复现」分档,这是本节所有取舍的前提:
| 档 | 含义 | 本套的处理 |
|---|---|---|
| 可命令行复现 | 给定输入与代码,本机跑出的数字/文件完全一致 | S / P / W 层,正文给实跑输出与 digest |
| 需真实环境 | 要 DCC、渲染器、画师才能产生 | X 层,正文每处显式标注「需真实环境,命令行不可复现」 |
| 不可复现 | 画师审美、笔触质量 | 只做公开可观察机制的引用,不写成「实测」 |
X 层的纪律不是「不写」,而是换一种写:真值(真导出的字节、顶点数)由真实环境产生,本机只能做三件事——导出清单承载声明值、schema 静态校验管结构与一致性、数值对齐账把声明值与可预测的不变量对上。三条都不需要 DCC,却能在导出之前就拦住大部分事故。
2.3.2 导出清单(X 层)
下面这个块是 X 层:清单的字段结构可以静态校验,但清单里的 declaredVertexCount / declaredBytes 来自真实导出环境,命令行不可复现:
{
"specVersion": 1,
"tier": "X",
"note": "DCC 导出清单(X 层):字段结构、枚举与跨字段一致性可以静态校验;「真打开 DCC 并导出」这一步在本机不可复现。declaredVertexCount / declaredBytes 是导出环境的声明值,命令行不可复现;本节只校验结构与不变量,不校验真实文件字节。",
"requiredFields": ["id", "sourceApp", "exportFormat", "unit", "upAxis", "handedness", "declaredVertexCount", "declaredBytes", "reproducible"],
"enums": {
"sourceApp": ["blender", "max", "maya"],
"exportFormat": ["gltf", "fbx", "obj"],
"unit": ["cm", "m"],
"upAxis": ["Y", "Z"],
"handedness": ["right", "left"],
"reproducible": ["cli", "requires-real-env"]
},
"idPattern": "^[a-z]+_[a-z][a-z0-9-]*$",
"items": [
{"id": "furn_lamp-base", "sourceApp": "blender", "exportFormat": "gltf", "unit": "cm", "upAxis": "Z", "handedness": "right", "declaredVertexCount": 214, "declaredBytes": 8720, "reproducible": "requires-real-env"},
{"id": "furn_chair-base", "sourceApp": "max", "exportFormat": "fbx", "unit": "cm", "upAxis": "Z", "handedness": "right", "declaredVertexCount": 340, "declaredBytes": 13808, "reproducible": "requires-real-env"},
{"id": "char_innkeeper-idle", "sourceApp": "maya", "exportFormat": "gltf", "unit": "cm", "upAxis": "Y", "handedness": "right", "declaredVertexCount": 1280, "declaredBytes": 52000, "reproducible": "cli"},
{"id": "tile_floor-wood", "sourceApp": "blender", "exportFormat": "gltf", "unit": "cm", "upAxis": "Z", "handedness": "right", "declaredVertexCount": 24, "declaredBytes": 944, "reproducible": "requires-real-env"}
]
}
注意 reproducible 这个字段本身也被校验:enums 里它是 ["cli", "requires-real-env"],但审计脚本会把声明 cli 的条目直接判违规——因为无 DCC 环境下,真导出不可能由命令行复现。「不许过度承诺」在这里是一条会失败的门禁,不是一句提醒。
2.3.3 S 层:三类静态审计
下面这段是 S 层脚本,读三份配置(清单、管线账、约定表),对每条清单项跑三类检查:结构(字段/枚举/命名/轴向自洽)、不变量(声明值与管线账预测值是否相等)、诚实性(是否声明了 cli):
#!/usr/bin/env python3
# file: sim/ta02_export_audit.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta02_export_audit.py
"""导出清单静态审计(S 层):把「真 DCC 导出不可复现」编码成可静态校验的清单。
读 config/ta02_export_manifest.json(导出清单,X 层)、config/ta02_pipeline.json(顶点/字节
整数模型)、config/ta02_axis_align.json(约定表),对每条清单项跑三类检查:
* **结构**:必填字段、枚举取值、id 文法、sourceApp 的轴向/手性自洽;
* **不变量**:declaredVertexCount / declaredBytes 必须等于管线账预测值(差值即对齐账);
* **诚实性**:无 DCC 环境下 reproducible 不得声明 "cli"——声明了就是过度承诺。
落 out/ta02_export_audit.json。真导出本身(字节级)不在本脚本能力内,属 X 层。
"""
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
FAMILY = {"E_FIELD": "structure", "E_ENUM": "structure", "E_ID": "structure", "E_AXIS": "structure",
"E_REF": "structure", "E_VERTS": "invariant", "E_BYTES": "invariant", "E_OVERCLAIM": "honesty"}
def predicted(asset, model):
# 管线账预测的导出顶点数与导出字节数(与 2.1 同一条公式链)。
verts = asset["verts"] + asset["seamVerts"] + asset["hardEdgeVerts"]
return verts, verts * model["exportStride"] + asset["tris"] * 3 * model["indexBytes"]
def audit_item(item, man, model, assets, conv):
"""三类检查。返回 (违例列表, 预测值或 None)。"""
errs = []
for f in man["requiredFields"]:
if f not in item:
errs.append({"code": "E_FIELD", "token": f})
for key, allowed in man["enums"].items():
if key in item and item[key] not in allowed:
errs.append({"code": "E_ENUM", "token": f"{key}={item[key]}"})
if not re.match(man["idPattern"], item.get("id", "")):
errs.append({"code": "E_ID", "token": item.get("id", "")})
app = conv.get(item.get("sourceApp"))
if app and (item.get("upAxis") != app["upAxis"] or item.get("handedness") != app["handedness"]):
errs.append({"code": "E_AXIS", "token": f"{item.get('upAxis')}/{item.get('handedness')}"})
if item.get("reproducible") == "cli":
errs.append({"code": "E_OVERCLAIM", "token": "cli"})
asset = assets.get(item.get("id"))
if asset is None:
errs.append({"code": "E_REF", "token": item.get("id", "")})
return errs, None
pv, pb = predicted(asset, model)
if item.get("declaredVertexCount") != pv:
errs.append({"code": "E_VERTS", "token": f"{item.get('declaredVertexCount')}!={pv}"})
if item.get("declaredBytes") != pb:
errs.append({"code": "E_BYTES", "token":
f"{item.get('declaredBytes')}!={pb} delta={item.get('declaredBytes', 0) - pb}"})
return errs, {"predictedVertexCount": pv, "predictedBytes": pb}
def main():
man = json.load(open(os.path.join(ROOT, "config/ta02_export_manifest.json"), encoding="utf-8"))
pipe = json.load(open(os.path.join(ROOT, "config/ta02_pipeline.json"), encoding="utf-8"))
align = json.load(open(os.path.join(ROOT, "config/ta02_axis_align.json"), encoding="utf-8"))
model = pipe["byteModel"]
assets = {a["id"]: a for a in pipe["assets"]}
conv = {c["id"]: c for c in align["conventions"]}
violations, rows = [], []
for it in man["items"]:
errs, pred = audit_item(it, man, model, assets, conv)
violations += [{"id": it.get("id"), **e} for e in errs]
rows.append({"id": it.get("id"), "sourceApp": it.get("sourceApp"),
"format": it.get("exportFormat"), "declared": it.get("reproducible"),
**(pred or {})})
violations.sort(key=lambda v: (v["id"], v["code"]))
counts, fams = {}, {}
for v in violations:
counts[v["code"]] = counts.get(v["code"], 0) + 1
fams[FAMILY[v["code"]]] = fams.get(FAMILY[v["code"]], 0) + 1
os.makedirs(OUT, exist_ok=True)
summary = {"project": "stardust-inn", "tier": "X", "itemCount": len(man["items"]),
"structureViolations": fams.get("structure", 0),
"invariantViolations": fams.get("invariant", 0),
"honestyViolations": fams.get("honesty", 0),
"cliCount": sum(1 for it in man["items"] if it.get("reproducible") == "cli"),
"cliPermille": 0, "counts": counts, "violations": violations, "rows": rows}
with open(os.path.join(OUT, "ta02_export_audit.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"itemCount={summary['itemCount']} structure={summary['structureViolations']} "
f"invariant={summary['invariantViolations']} honesty={summary['honestyViolations']} "
f"cliPermille={summary['cliPermille']}")
for v in violations:
print(f" {v['id']:22s} {v['code']:12s} {v['token']}")
print("wrote out/ta02_export_audit.json")
return 0
if __name__ == "__main__":
sys.exit(main())
三类检查的分工要说清楚:结构检查不需要真值,只看清单自己是否自洽(例如 sourceApp 是 blender,upAxis 就必须是 Z、手性必须是 right——这正是 2.2 那张表的第二次消费);不变量检查把清单里的声明值与 2.1 那条公式链算出的预测值相减,差值就是对齐账;诚实性检查把「本机做不了的事」编码成一条硬约束。
2.3.4 P 层:同一套判据的 TS 侧与测试
同一套判据,TypeScript 侧独立实现一遍。它只读同一份配置,不 import Python 的任何东西:
// file: core/src/ta02/audit.ts
// tier: P
// verify: node --test 'core/test/ta02_*.test.ts'
// 导出清单静态审计的 TS 侧实现:与 sim/ta02_export_audit.py 共用三份 config。
// 判据全是字符串/整数(字段名、枚举、正则、整数差值),故两侧违例集合逐位一致。
export type Violation = { id: string; code: string; token: string };
export type Item = Record<string, unknown>;
export type Manifest = { requiredFields: string[]; enums: Record<string, string[]>; idPattern: string; items: Item[] };
export type Asset = { id: string; tris: number; verts: number; seamVerts: number; hardEdgeVerts: number };
export type ByteModel = { exportStride: number; indexBytes: number };
export type Convention = { id: string; upAxis: string; handedness: string };
/** 管线账预测的导出顶点数与导出字节数。 */
export function predicted(a: Asset, m: ByteModel): number[] {
const v = a.verts + a.seamVerts + a.hardEdgeVerts;
return [v, v * m.exportStride + a.tris * 3 * m.indexBytes];
}
export function auditItem(it: Item, man: Manifest, model: ByteModel,
assets: Map<string, Asset>, conv: Map<string, Convention>): Violation[] {
const id = String(it.id ?? "");
const out: Violation[] = [];
const push = (code: string, token: string): void => void out.push({ id, code, token });
for (const f of man.requiredFields) if (!(f in it)) push("E_FIELD", f);
for (const [key, allowed] of Object.entries(man.enums)) {
if (it[key] !== undefined && !allowed.includes(String(it[key]))) push("E_ENUM", `${key}=${it[key]}`);
}
if (!new RegExp(man.idPattern).test(id)) push("E_ID", id);
const app = conv.get(String(it.sourceApp ?? ""));
if (app && (it.upAxis !== app.upAxis || it.handedness !== app.handedness)) {
push("E_AXIS", `${it.upAxis}/${it.handedness}`);
}
if (it.reproducible === "cli") push("E_OVERCLAIM", "cli");
const asset = assets.get(id);
if (!asset) {
push("E_REF", id);
return out;
}
const [pv, pb] = predicted(asset, model);
if (it.declaredVertexCount !== pv) push("E_VERTS", `${it.declaredVertexCount}!=${pv}`);
if (it.declaredBytes !== pb) {
push("E_BYTES", `${it.declaredBytes}!=${pb} delta=${Number(it.declaredBytes ?? 0) - pb}`);
}
return out;
}
export function auditAll(man: Manifest, model: ByteModel, assets: Map<string, Asset>,
conv: Map<string, Convention>): Violation[] {
const all = man.items.flatMap((it) => auditItem(it, man, model, assets, conv));
return all.sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : x.code < y.code ? -1 : x.code > y.code ? 1 : 0));
}
/** 对齐账每行:声明值 vs 管线账预测值。 */
export function auditRows(man: Manifest, model: ByteModel, assets: Map<string, Asset>) {
return man.items.map((it) => {
const a = assets.get(String(it.id));
const [pv, pb] = a ? predicted(a, model) : [0, 0];
return { id: String(it.id), sourceApp: String(it.sourceApp), format: String(it.exportFormat),
declared: String(it.reproducible), predictedVertexCount: pv, predictedBytes: pb };
});
}
测试读 Python 落盘的 JSON 做跨语言对照,并逐类触发负例:
// file: core/test/ta02_audit.test.ts
// tier: P
// verify: node --test 'core/test/ta02_*.test.ts'
// 跨语言对照:读 sim/ta02_export_audit.py 落盘的 out/ta02_export_audit.json,断言 TS 侧
// 复算的违例集合与对齐账一致(字符串/整数类,逐位一致),并带负例断言:过度承诺、
// 字节不符、轴向不自洽、id 不合文法,四类都必须被判出来。
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 { auditAll, auditRows, predicted } from "../src/ta02/audit.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const load = (p: string) => JSON.parse(readFileSync(resolve(ROOT, p), "utf8"));
const man = load("config/ta02_export_manifest.json");
const pipe = load("config/ta02_pipeline.json");
const align = load("config/ta02_axis_align.json");
const py = load("out/ta02_export_audit.json");
const assets = new Map(pipe.assets.map((a: { id: string }) => [a.id, a]));
const conv = new Map(align.conventions.map((c: { id: string }) => [c.id, c]));
const mine = auditAll(man, pipe.byteModel, assets, conv);
const key = (v: { id: string; code: string; token: string }): string => `${v.id}:${v.code}:${v.token}`;
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS 违例集合 == Python 违例集合(逐位一致)", () => {
assert.deepEqual(mine.map(key), py.violations.map(key));
assert.equal(digest(mine.map(key)), digest(py.violations.map(key)));
console.log(`export audit digest = ${digest(mine.map(key))}`);
});
test("对齐账逐行可对照;诚实性:无 DCC 环境可命令行复现千分比为 0", () => {
const rows = auditRows(man, pipe.byteModel, assets);
assert.equal(rows.length, py.itemCount);
assert.deepEqual(rows.map((r) => [r.predictedVertexCount, r.predictedBytes]),
py.rows.map((r: { predictedVertexCount: number; predictedBytes: number }) =>
[r.predictedVertexCount, r.predictedBytes]));
assert.equal(rows.filter((r, i) => r.predictedBytes !== man.items[i].declaredBytes).length, 1);
assert.equal(py.cliPermille, 0);
assert.equal(py.cliCount, 1);
});
test("负例断言:五类违例各自可被单独触发", () => {
const base = { id: "furn_lamp-base", sourceApp: "blender", exportFormat: "gltf", unit: "cm",
upAxis: "Z", handedness: "right", declaredVertexCount: 214, declaredBytes: 8720,
reproducible: "requires-real-env" };
const one = (patch: object) => auditAll({ ...man, items: [{ ...base, ...patch }] },
pipe.byteModel, assets, conv).map((v) => v.code);
assert.deepEqual(one({}), []);
assert.deepEqual(one({ reproducible: "cli" }), ["E_OVERCLAIM"]);
assert.deepEqual(one({ declaredBytes: 944 }), ["E_BYTES"]);
assert.deepEqual(one({ upAxis: "Y", handedness: "left" }), ["E_AXIS"]);
assert.deepEqual(one({ id: "furn_lamp_base" }), ["E_ID", "E_REF"]);
assert.deepEqual(one({ exportFormat: "usdz" }), ["E_ENUM"]);
assert.deepEqual(predicted(assets.get("tile_floor-wood"), pipe.byteModel), [24, 912]);
});
2.3.5 实跑输出与对齐账
S 层 /opt/homebrew/bin/python3 sim/ta02_export_audit.py:
itemCount=4 structure=0 invariant=1 honesty=1 cliPermille=0
char_innkeeper-idle E_OVERCLAIM cli
tile_floor-wood E_BYTES 944!=912 delta=32
wrote out/ta02_export_audit.json
P 层 node --test 'core/test/ta02_*.test.ts':pass 11 / fail 0。把每条违例按 id:code:token 排序拼接后取 sha256 前 16 位,两侧算出的 digest 都是 e30899a5ca9bea2e——违例集合是字符串/整数类,逐位一致。
对齐账逐条摊开(declared 是清单声明值,predicted 是管线账预测值):
| 资产 | 来源 | 格式 | 声明可复现 | 声明顶点 | 预测顶点 | 声明字节 | 预测字节 |
|---|---|---|---|---|---|---|---|
furn_lamp-base | blender | gltf | requires-real-env | 214 | 214 | 8720 | 8720 |
furn_chair-base | max | fbx | requires-real-env | 340 | 340 | 13808 | 13808 |
char_innkeeper-idle | maya | gltf | cli | 1280 | 1280 | 52000 | 52000 |
tile_floor-wood | blender | gltf | requires-real-env | 24 | 24 | 944 | 912 |
两条违例都是故意留的,它们证明这套审计真的会拦东西:
char_innkeeper-idle 声明 cli(E_OVERCLAIM)。它是本机唯一无法复现的条目——真实环境里这条声明在语义上就是错的,清单写下的那一刻就该被拦下。tile_floor-wood 的声明字节则比预测多 32(E_BYTES,delta=32);32 字节正好是一份导出顶点(exportStride 32),所以对齐账给出的定位线索是「导出时多带了一个顶点份的字节」——可能是一个多余的孤立顶点,也可能是顶点色通道没关掉。这就是对齐账的用法:它不是判决,是线索。
cliPermille=0 是本节的结论性数字:本机没有任何一条 DCC 导出是命令行可复现的,千分比 0‰。这个 0 不是缺陷,是诚实——它精确地划出了这套方法的能力边界。
2.3.6 这套方法不能做什么
必须把边界说清楚,否则「静态校验」会被误当成「真校验」:
它不能证明清单与真实文件一致。 审计保证的是「清单自洽 + 声明值符合可预测的不变量」,真实导出的字节级差异仍然需真实环境才能发现。清单可以骗过审计(声明值与预测值都填对,但真导出的文件不是那样),所以清单必须由导出流程自动生成,而不是人手填。它不能覆盖不可预测的量。 顶点拆点率、UV 岛数量这些能被公式预测;但法线朝向、材质槽的实际合并结果、导出器的版本行为,本机无法预测——这些只能靠真实环境回归。它的价值在时间点。 这套检查跑在导出之后、入库之前,把「等美术下次打开引擎才发现模型躺在地上」提前到「CI 里一条失败」。早发现才是它全部的价值,而不是「比人眼更准」。
阅读导航:上一节:2.2 导出设置:单位、轴向与坐标系对齐 · 下一节:3.1 导入设置与 .meta 语义 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。