16.1 Art Bible 成文方法与模板

Art Bible 不该是一本写给人读的口号集,而是一份每章都能对上一份可跑产物的模板。本节把《星屑旅店》前十五章的规范收成 12 个必填章,用一份真跑的 S 层脚本与 P 层 TS 内核做「模板章节 ↔ 规范产物」双向覆盖校验:缺章、缺产物、孤儿产物、文件不存在四类违例都会 fail。

16.1.1 从散落的规范到一本 Art Bible

前十五章各写了各的规范:色彩、命名、预算、图集、UI、评审。到收口这一步,读者手里的其实是一堆互不相连的配置与脚本——新人第一天不知道该先看哪一份,评审时也说不清「这条规则出自哪一章」。Art Bible 要解决的就是这件事:把散落的规范收成一本有固定章序、每章都挂着一份可跑产物的书。

站内已有 《美术风格指南与 Art Direction 2026》 讲了 Art Bible 的「道理」——它该有什么气质、怎么写得好读;本节只做一件事:给出一个可校验的模板,让「这本 Art Bible 收全了吗」变成一个能跑的判据,而不是一次口头评审。分工延续 《渲染引擎》的资产管线入口 :引擎侧怎么消费这些规范属于实现,外链;本节只做模板 ↔ 产物这一层的对账。

16.1.2 D 层:模板章节与产物注册表

模板是一份静态结构 config/sp16_art_bible.json:requiredSections 列出必填章 id,chapters 逐章声明它收口的 artifact(那份可跑产物),registry 是规范产物的注册表。本套取 12 个必填章,正好覆盖前十二章各自的可跑产物:

{
  "project": "stardust-inn",
  "artBibleVersion": 1,
  "specRef": "config/visual_spec.json",
  "note": "Art Bible 模板:12 个必填章,每章声明它收口的那份可跑产物(artifact)。coverage 校验双向:requiredSections 的每一章都必须有对应章节(缺章即 fail);registry 里每一份产物都必须被恰好一章引用(孤儿即 fail);章节声明的 artifact 还必须在磁盘上真实存在。",
  "requiredSections": [
    "production-pipeline", "style-positioning", "color-tokens", "color-perception",
    "naming-directory", "manifest-reference", "geometry-budget", "texture-budget",
    "ui-nine-slice", "icon-atlas", "character-scene", "vfx-animation"
  ],
  "chapters": [
    { "no": 1, "id": "production-pipeline", "title": "美术生产分工", "artifact": "sim/sp01_pipeline_flow.py", "tier": "S" },
    { "no": 2, "id": "style-positioning", "title": "视觉目标与风格向量", "artifact": "sim/sp02_style_distance.py", "tier": "S" },
    { "no": 3, "id": "color-tokens", "title": "色彩 token 系统", "artifact": "sim/sp03_tokens.py", "tier": "S" },
    { "no": 4, "id": "color-perception", "title": "色彩空间与感知一致性", "artifact": "sim/sp04_oklab.py", "tier": "S" },
    { "no": 5, "id": "naming-directory", "title": "资产命名与目录规范", "artifact": "sim/sp05_naming.py", "tier": "P" },
    { "no": 6, "id": "manifest-reference", "title": "资产清单与引用一致性", "artifact": "sim/sp06_dir.py", "tier": "P" },
    { "no": 7, "id": "geometry-budget", "title": "面数骨骼材质槽预算", "artifact": "sim/sp07_budget.py", "tier": "S" },
    { "no": 8, "id": "texture-budget", "title": "显存与纹理预算", "artifact": "sim/sp08_mip.py", "tier": "S" },
    { "no": 9, "id": "ui-nine-slice", "title": "UI 栅格与九宫格", "artifact": "sim/sp09_nineslice.py", "tier": "S" },
    { "no": 10, "id": "icon-atlas", "title": "图标网格与图集", "artifact": "sim/sp10_atlas.py", "tier": "S" },
    { "no": 11, "id": "character-scene", "title": "剪影可读性与场景构图", "artifact": "sim/sp11_silhouette.py", "tier": "S" },
    { "no": 12, "id": "vfx-animation", "title": "特效与动画规范", "artifact": "sim/sp12_vfx.py", "tier": "S" }
  ],
  "registry": [
    "sim/sp01_pipeline_flow.py", "sim/sp02_style_distance.py", "sim/sp03_tokens.py",
    "sim/sp04_oklab.py", "sim/sp05_naming.py", "sim/sp06_dir.py",
    "sim/sp07_budget.py", "sim/sp08_mip.py", "sim/sp09_nineslice.py",
    "sim/sp10_atlas.py", "sim/sp11_silhouette.py", "sim/sp12_vfx.py"
  ]
}

16.1.3 S 层:双向覆盖度校验

下面这段是 S 层脚本。它做的是双向对账——章节侧与注册表侧各查一遍,缺章、缺产物、孤儿、重复、文件不存在五类都产出违例字符串:

#!/usr/bin/env python3
# file: sim/sp16_bible.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp16_bible.py
"""16.1 Art Bible 覆盖度的 S 层校验器。

把 config/sp16_art_bible.json 的模板章节与规范产物注册表做**双向覆盖**校验:
  - requiredSections 里的每一章都必须有对应章节(缺章 → MISSING_SECTION)
  - 每章声明的 artifact 必须出现在 registry(缺产物 → MISSING_ARTIFACT)
  - registry 里每一份产物都必须被恰好一章引用(孤儿 → ORPHAN_ARTIFACT)
  - 同一章节 id 不得重复(DUPLICATE_SECTION)
  - artifact 必须在磁盘上真实存在(FILE_ABSENT)
落 out/sp16_bible.json 供 P 层跨语言对照。
"""
import hashlib
import json
import os
import sys

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


def check(bible: dict, exists) -> list:
    """exists(path) -> bool,注入以便测试构造负例。返回排序后的违例集合。"""
    chapters = bible["chapters"]
    registry = set(bible["registry"])
    required = list(bible["requiredSections"])
    ids = [c["id"] for c in chapters]
    viol = []

    for sid in required:
        if sid not in ids:
            viol.append(f"MISSING_SECTION:{sid}")
    for c in chapters:
        if ids.count(c["id"]) > 1:
            viol.append(f"DUPLICATE_SECTION:{c['id']}")
        if c["artifact"] not in registry:
            viol.append(f"MISSING_ARTIFACT:{c['id']}:{c['artifact']}")
        if not exists(c["artifact"]):
            viol.append(f"FILE_ABSENT:{c['id']}:{c['artifact']}")
    used = {c["artifact"] for c in chapters}
    for a in sorted(registry):
        if a not in used:
            viol.append(f"ORPHAN_ARTIFACT:{a}")
    return sorted(set(viol))


def digest(bible: dict, violations: list) -> str:
    parts = [f"{c['no']}:{c['id']}:{c['artifact']}" for c in bible["chapters"]]
    parts += sorted(bible["registry"]) + violations
    return hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]


def main() -> int:
    bible = json.load(open(os.path.join(ROOT, "config/sp16_art_bible.json"), encoding="utf-8"))
    viol = check(bible, lambda p: os.path.exists(os.path.join(ROOT, p)))
    sections = [c["id"] for c in bible["chapters"]]
    covered = [s for s in bible["requiredSections"] if s in sections]
    report = {"project": bible["project"], "artBibleVersion": bible["artBibleVersion"],
              "chapterCount": len(bible["chapters"]), "requiredCount": len(bible["requiredSections"]),
              "coveredCount": len(covered), "registryCount": len(set(bible["registry"])),
              "sections": sections, "violations": viol, "count": len(viol)}
    report["digest"] = digest(bible, viol)
    with open(os.path.join(ROOT, "out/sp16_bible.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"chapters={report['chapterCount']} required={report['requiredCount']} "
          f"covered={report['coveredCount']} registry={report['registryCount']} "
          f"violations={report['count']}")
    for c in bible["chapters"]:
        print(f"  {c['no']:>2} {c['id']:<18} -> {c['artifact']}")
    for v in viol:
        print(f"  VIOLATION {v}")
    print(f"digest={report['digest']}")
    print("wrote out/sp16_bible.json")
    return 0


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

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

chapters=12 required=12 covered=12 registry=12 violations=0
   1 production-pipeline  -> sim/sp01_pipeline_flow.py
   2 style-positioning    -> sim/sp02_style_distance.py
   3 color-tokens         -> sim/sp03_tokens.py
   4 color-perception     -> sim/sp04_oklab.py
   5 naming-directory     -> sim/sp05_naming.py
   6 manifest-reference   -> sim/sp06_dir.py
   7 geometry-budget      -> sim/sp07_budget.py
   8 texture-budget       -> sim/sp08_mip.py
   9 ui-nine-slice        -> sim/sp09_nineslice.py
  10 icon-atlas           -> sim/sp10_atlas.py
  11 character-scene      -> sim/sp11_silhouette.py
  12 vfx-animation        -> sim/sp12_vfx.py
digest=3e90574bb7d54854
wrote out/sp16_bible.json

16.1.4 P 层:同一套覆盖判据的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp16/bible.ts,与 Python 逐条对应:双向覆盖、四类违例、exists 注入,全部一致:

// file: core/src/sp16/bible.ts
// tier: P
// verify: node --test 'core/test/sp16_*.test.ts'
// 16.1 Art Bible 覆盖度的 P 层内核。与 sim/sp16_bible.py 同一套规则:模板章节与规范产物
// 注册表双向覆盖,缺章 / 缺产物 / 孤儿 / 重复 / 文件不存在都产出违例。exists 注入以便
// 测试构造负例。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export function checkCoverage(bible: any, exists: (p: string) => boolean): string[] {
  const chapters = bible.chapters;
  const registry = new Set<string>(bible.registry);
  const ids = chapters.map((c: any) => c.id);
  const viol: string[] = [];

  for (const sid of bible.requiredSections) {
    if (!ids.includes(sid)) viol.push(`MISSING_SECTION:${sid}`);
  }
  for (const c of chapters) {
    if (ids.filter((x: string) => x === c.id).length > 1) viol.push(`DUPLICATE_SECTION:${c.id}`);
    if (!registry.has(c.artifact)) viol.push(`MISSING_ARTIFACT:${c.id}:${c.artifact}`);
    if (!exists(c.artifact)) viol.push(`FILE_ABSENT:${c.id}:${c.artifact}`);
  }
  const used = new Set(chapters.map((c: any) => c.artifact));
  for (const a of [...registry].sort()) {
    if (!used.has(a)) viol.push(`ORPHAN_ARTIFACT:${a}`);
  }
  return [...new Set(viol)].sort();
}

core/test/sp16_bible.test.ts 一边读 Python 产物做跨语言对照,一边用内存对象构造四条负例——缺章、孤儿产物、重复章节、文件不存在都必须 fail:

// file: core/test/sp16_bible.test.ts
// tier: P
// verify: node --test 'core/test/sp16_*.test.ts'
// 跨语言对照:读 sim/sp16_bible.py 落盘的 out/sp16_bible.json,断言 TS 侧的覆盖度校验
// 与 Python 逐位一致,并比对 sha256 指纹。负例覆盖四条判据:缺章、孤儿产物、重复章节、
// 文件不存在——每条都必须真的 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 { checkCoverage } from "../src/sp16/bible.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const bible = JSON.parse(readFileSync(resolve(ROOT, "config/sp16_art_bible.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp16_bible.json"), "utf8"));

const alwaysExists = (): boolean => true;
const clone = (): any => JSON.parse(JSON.stringify(bible));

test("Art Bible 覆盖度:TS 与 Python 违例集合逐位一致", () => {
  assert.deepEqual(checkCoverage(bible, alwaysExists), py.violations);
  assert.equal(py.coveredCount, py.requiredCount);
  assert.deepEqual(py.violations, []);
});

test("sha256 指纹跨语言一致", () => {
  const parts = bible.chapters.map((c: any) => `${c.no}:${c.id}:${c.artifact}`);
  const all = [...parts, ...[...bible.registry].sort(), ...checkCoverage(bible, alwaysExists)];
  const digest = createHash("sha256").update(all.join(";"), "utf8").digest("hex").slice(0, 16);
  assert.equal(digest, py.digest);
});

test("负例:删掉一章 → 必须报 MISSING_SECTION", () => {
  const b = clone();
  b.chapters = b.chapters.filter((c: any) => c.id !== "color-tokens");
  assert.ok(checkCoverage(b, alwaysExists).includes("MISSING_SECTION:color-tokens"));
});

test("负例:注册表多出一份没人引用的产物 → 必须报 ORPHAN_ARTIFACT", () => {
  const b = clone();
  b.registry = [...b.registry, "sim/sp99_orphan.py"];
  assert.ok(checkCoverage(b, alwaysExists).includes("ORPHAN_ARTIFACT:sim/sp99_orphan.py"));
});

test("负例:章节重复与产物文件不存在都必须 fail", () => {
  const dup = clone();
  dup.chapters.push({ ...dup.chapters[0] });
  assert.ok(checkCoverage(dup, alwaysExists).includes("DUPLICATE_SECTION:production-pipeline"));
  const gone = checkCoverage(bible, (p) => p !== "sim/sp05_naming.py");
  assert.ok(gone.includes("FILE_ABSENT:naming-directory:sim/sp05_naming.py"), gone.join(","));
});

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

✔ Art Bible 覆盖度:TS 与 Python 违例集合逐位一致 (0.891417ms)
✔ sha256 指纹跨语言一致 (0.409166ms)
✔ 负例:删掉一章 → 必须报 MISSING_SECTION (0.158292ms)
✔ 负例:注册表多出一份没人引用的产物 → 必须报 ORPHAN_ARTIFACT (0.101417ms)
✔ 负例:章节重复与产物文件不存在都必须 fail (0.115917ms)
ℹ tests 14
ℹ pass 14
ℹ fail 0

16.1.5 判读:缺一章为什么必须 fail

样本里 violations=0,因为模板是完整的。真正值钱的是四条负例:它们证明这个门禁不是「打印一行 OK」的摆设。

  • **缺章(MISSING_SECTION)**最危险:一本少了「色彩感知」章的 Art Bible,读起来照样通顺,只有对照 requiredSections 才发现整整一章没写。这就是「必填章清单」存在的意义——它把「写没写全」从审美判断变成了集合运算。
  • **孤儿产物(ORPHAN_ARTIFACT)**是反向漏网:某章被删了,它的产物却还挂在注册表里,没人引用、没人维护,慢慢腐烂。双向对账才能抓到。
  • **文件不存在(FILE_ABSENT)**是本章相对纯文本 Art Bible 的净增量:章节写的产物路径必须真在磁盘上,否则这本「可校验的规范」就退化成了一张目录页。
  • **重复章节(DUPLICATE_SECTION)**在合并两个分支的 Art Bible 时最常见。

16.1.6 边界与取舍

  • 真 Art Bible 的审美质量 / 可读性 / 画师接受度需要真实团队与真实评审,命令行不可复现,标 X;本节只校验结构与覆盖度,不评价文笔。
  • 必填章取前十二章的可跑产物;13–15 章(外包、评审、版本)以文本清单形式收在附录,不参与本节的产物覆盖校验。
  • 本节全是字符串集合与计数(章节数、覆盖数、违例集合),逐位一致;不涉及色彩派生量。

阅读导航:上一节:15.3 资产 diff 与回滚 · 下一节:16.2 双案例回放:《星屑旅店》6 主题的规范装配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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