2.3 X 范式:真实 DCC 不可复现

本机没有任何 DCC,真打开、真导出、真渲染都不可能复现——但「不可复现」不该是留白。本节把这件事写成方法:用一份导出清单承载真环境的声明值,用 schema 静态校验管结构与跨字段一致性,用数值对齐账把声明值与管线账预测值逐条对上,并把「无 DCC 环境不得声明可命令行复现」编码成一条会失败的门禁。

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-baseblendergltfrequires-real-env21421487208720
furn_chair-basemaxfbxrequires-real-env3403401380813808
char_innkeeper-idlemayagltfcli128012805200052000
tile_floor-woodblendergltfrequires-real-env2424944912

两条违例都是故意留的,它们证明这套审计真的会拦东西:

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 语义 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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