16.1 跨风格验收清单

把跨风格验收做成一张可跑的清单:8 项判据分 static 与 per-theme 两类,命名、token 数、对比度、图集装箱率、mip 字节、最近邻距离全写成整数比较。同一张清单在 6 个主题包上各跑一遍,48 项判定全过;TS 内核与 Python 逐位一致,四条负例都能真的报错。

15.3 风格迁移的边界与代价 讲清了哪些字段能跟着走、哪些必须重写。收尾这一章要回答的是:一套视觉规范做完了,怎么验收?本节把验收做成一张可跑的清单——8 项判据,命名、token 数、对比度、图集装箱率、mip 字节、最近邻距离,全部写成整数比较。判据一律取自只读真源 config/visual_spec.json 与 config/sp16_themes.json,本节不另造阈值。

16.1.1 一张清单,两类项

清单里的每一项分两类:static(与主题无关,如资产命名、图集装箱率、mip 字节)和 per-theme(随主题变,如 token 数、对比度、可区分度)。每项还带一个 blocking 标记——false 的项只告警不阻断,适合「应该达到但不必卡死」的软指标。判定值全部是整数:对比度与风格距离是 ×10 的整数,装箱率是千分比整数,字节数也是整数。没有浮点,所以清单在 Python 与 TypeScript 两侧能逐位一致。清单本身也落成静态文件:

{
  "project": "stardust-inn",
  "specVersion": 1,
  "note": "跨风格验收清单(卷三 16.1)。同一张清单在 6 个主题包上各跑一遍:清单项分 static(与主题无关,如命名与预算)与 per-theme(随主题变,如对比度与可区分度)两类。每项只写整数判据,故跨语言逐位一致;blocking=false 的项只告警不阻断。",
  "contrastFloor10": 45,
  "distinguishableFloor10": 800,
  "atlasPermille": 144,
  "mipChainBytes": 5592404,
  "pairCount": 15,
  "assetIds": ["ui_icon_coin_v2", "char_innkeeper_idle", "tile_floor_wood_v1", "fx_steam_puff", "prop_lamp_round_v3"],
  "idPattern": "^[a-z][a-z0-9]*_[a-z0-9_]+$",
  "reservedWords": ["new", "class", "default", "null", "delete", "void"],
  "items": [
    { "id": "name.prefix",    "category": "naming", "kind": "static",    "tier": "P", "blocking": true,  "criterion": "所有资产 id 匹配前缀模式" },
    { "id": "name.reserved",  "category": "naming", "kind": "static",    "tier": "P", "blocking": true,  "criterion": "资产 id 不含引擎保留字" },
    { "id": "tokens.count",   "category": "color",  "kind": "per-theme", "tier": "P", "blocking": true,  "criterion": "token 数 == 8" },
    { "id": "contrast.floor", "category": "color",  "kind": "per-theme", "tier": "S", "blocking": true,  "criterion": "min contrast10 >= 45" },
    { "id": "atlas.permille", "category": "atlas",  "kind": "static",    "tier": "S", "blocking": false, "criterion": "atlas 装箱率千分比 == 144" },
    { "id": "budget.mip",     "category": "budget", "kind": "static",    "tier": "S", "blocking": true,  "criterion": "mipChainBytes == 5592404" },
    { "id": "distinct.floor","category": "style",  "kind": "per-theme", "tier": "S", "blocking": true,  "criterion": "最近邻风格距离 > 800" },
    { "id": "distinct.pairs","category": "style",  "kind": "static",    "tier": "S", "blocking": false, "criterion": "两两距离对数 == 15" }
  ]
}

16.1.2 S 层:清单在 6 个主题上各跑一遍

sim/st16_acceptance.py 把这张清单在 6 个主题包上各跑一遍。它只做整数运算:对比度、风格向量、装箱率、mip 链字节各有一个纯函数,evaluate() 按 id 分派到对应判据,main() 逐主题逐项求值并累计违例。落盘 out/st16_acceptance.json 供 P 层对照:

#!/usr/bin/env python3
# file: sim/st16_acceptance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st16_acceptance.py
"""卷三 16.1 跨风格验收清单(S 层)。把 config/st16_acceptance.json 的同一张清单在 6 个
主题包上各跑一遍:清单项分 static(与主题无关)与 per-theme(随主题变)两类。判据只写
整数比较(对比度 ×10、风格距离 ×10、千分比、字节),故跨语言逐位一致。落
out/st16_acceptance.json 供 P 层对照。"""
import hashlib
import json
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLES = ["accent", "bg", "ink", "surface"]


def srgb_to_linear(c):
    s = c / 255
    return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4


def contrast10(a, b):
    def f(c):
        return 0.2126 * srgb_to_linear(c[0]) + 0.7152 * srgb_to_linear(c[1]) + 0.0722 * srgb_to_linear(c[2])
    hi, lo = sorted((f(a), f(b)), reverse=True)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def luma709(c):
    return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000


def median_int(xs):
    s = sorted(xs)
    n = len(s)
    return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) // 2


def sat_permille(c):
    mx = max(c)
    return 0 if mx == 0 else (mx - min(c)) * 1000 // mx


def hue_deg(c):
    mx, mn = max(c), min(c)
    d = mx - mn
    if d == 0:
        return None
    h = (60 * (c[1] - c[2]) / d if mx == c[0]
         else 60 * ((c[2] - c[0]) / d + 2) if mx == c[1]
         else 60 * ((c[0] - c[1]) / d + 4))
    return int(h % 360)


def style_vector(t):
    cols = [t["tokens"][k] for k in ROLES]
    hues = [h for h in (hue_deg(c) for c in cols) if h is not None]
    span = (max(hues) - min(hues)) if len(hues) >= 2 else 0
    return [median_int([luma709(c) for c in cols]) * 1000 // 255,
            sum(sat_permille(c) for c in cols) // len(cols),
            span * 1000 // 360, int(t["strokePermille"]), int(t["detailPermille"])]


def dist10(a, b, w):
    return (sum(wi * abs(x - y) for wi, x, y in zip(w, a, b)) * 10) // sum(w)


def atlas_permille(at):
    cols, rows = at["width"] // at["cell"], at["height"] // at["cell"]
    placed = min(at["icons"], cols * rows)
    return (placed * at["cell"] * at["cell"] * 1000) // (at["width"] * at["height"])


def mip_bytes(base, bpp):
    total, n = 0, base
    while True:
        total += (n * n * bpp + 3) // 4 * 4
        if n == 1:
            return total
        n //= 2


def evaluate(item_id, theme, ctx):
    """返回 (value_str, pass)。ctx 里带 cfg/spec/nearest/atlas/mip/pairs。"""
    cfg, spec, near = ctx["cfg"], ctx["spec"], ctx["nearest"]
    if item_id == "name.prefix":
        pat = re.compile(cfg["idPattern"])
        ok = all(pat.match(a) for a in cfg["assetIds"])
        return f"matched={sum(1 for a in cfg['assetIds'] if pat.match(a))}/{len(cfg['assetIds'])}", ok
    if item_id == "name.reserved":
        hits = [a for a in cfg["assetIds"] for r in cfg["reservedWords"] if r in a.split("_")]
        return f"hits={len(hits)}", len(hits) == 0
    if item_id == "tokens.count":
        return f"count={len(theme['tokens'])}", len(theme["tokens"]) == 8
    if item_id == "contrast.floor":
        mn = min(contrast10(theme["tokens"][a], theme["tokens"][b]) for a, b in spec["contrastPairs"])
        return f"min={mn}", mn >= spec["contrastFloor10"]
    if item_id == "atlas.permille":
        return f"pm={ctx['atlas']}", ctx["atlas"] == cfg["atlasPermille"]
    if item_id == "budget.mip":
        return f"bytes={ctx['mip']}", ctx["mip"] == cfg["mipChainBytes"]
    if item_id == "distinct.floor":
        return f"nearest={near}", near > cfg["distinguishableFloor10"]
    if item_id == "distinct.pairs":
        return f"pairs={ctx['pairs']}", ctx["pairs"] == cfg["pairCount"]
    return "unknown", False


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/st16_acceptance.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    w = themes["weights"]
    names = [t["id"] for t in themes["themes"]]
    vecs = {t["id"]: style_vector(t) for t in themes["themes"]}
    pairs = [(names[i], names[j]) for i in range(len(names)) for j in range(i + 1, len(names))]
    ctx_base = {"cfg": cfg, "spec": spec, "atlas": atlas_permille(themes["atlas"]),
                "mip": mip_bytes(spec["budget"]["baseTexture"], spec["budget"]["bytesPerPixel"]),
                "pairs": len(pairs)}
    records, violations = [], []
    for t in themes["themes"]:
        near = min(dist10(vecs[t["id"]], vecs[o], w) for o in vecs if o != t["id"])
        ctx = dict(ctx_base, nearest=near)
        checks = []
        for it in cfg["items"]:
            val, ok = evaluate(it["id"], t, ctx)
            blocking = bool(it["blocking"])
            checks.append({"id": it["id"], "value": val, "pass": ok, "blocking": blocking})
            if not ok and blocking:
                violations.append(f"{t['id']}:{it['id']}:{val}")
        mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
        records.append({"id": t["id"], "nearest": near, "minContrast10": mn, "checks": checks,
                        "pass": all(c["pass"] for c in checks),
                        "passCount": sum(1 for c in checks if c["pass"])})
    total = len(records) * len(cfg["items"])
    passed = sum(r["passCount"] for r in records)
    parts = [f"{r['id']}:{','.join(str(c['value']) for c in r['checks'])}" for r in records]
    report = {"project": cfg["project"], "order": names, "themeCount": len(names),
              "itemCount": len(cfg["items"]), "checkCount": total, "passCount": passed,
              "categories": sorted({it["category"] for it in cfg["items"]}),
              "themes": records, "violations": sorted(violations),
              "digest": hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]}
    with open(os.path.join(ROOT, "out/st16_acceptance.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"themes={report['themeCount']} items={report['itemCount']} "
          f"checks={total} pass={passed} violations={len(report['violations'])}")
    for r in records:
        print(f"  {r['id']:<12} pass={r['passCount']}/{len(cfg['items'])} "
              f"minContrast10={r['minContrast10']:<4} nearest={r['nearest']}")
    print(f"digest={report['digest']}")
    print("wrote out/st16_acceptance.json")
    return 0


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

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

themes=6 items=8 checks=48 pass=48 violations=0
  pixel        pass=8/8 minContrast10=45   nearest=1337
  lowpoly      pass=8/8 minContrast10=51   nearest=1002
  handdrawn    pass=8/8 minContrast10=46   nearest=1468
  voxel        pass=8/8 minContrast10=48   nearest=1468
  retro-cyber  pass=8/8 minContrast10=55   nearest=1337
  cozy-clean   pass=8/8 minContrast10=46   nearest=1002
digest=facdc68d1a8417a5
wrote out/st16_acceptance.json

16.1.3 P 层:同一张清单的 TS 内核

同一张清单在 TypeScript 侧由 core/src/st16/acceptance.ts 实现。它不重写对比度与风格向量,而是从 sp00/spec.ts、st01/style.ts 复用同源常数,所以两侧的 value 串逐字相同:

// file: core/src/st16/acceptance.ts
// tier: P
// verify: node --test 'core/test/st16_*.test.ts'
// 卷三 16.1 跨风格验收清单的 P 层内核。与 sim/st16_acceptance.py 同一套规则:同一张清单在
// 6 个主题包上各跑一遍,清单项分 static / per-theme 两类,判据只写整数比较。对比度复用
// sp00/spec.ts 的同源常数,风格向量复用 st01/style.ts,故与 Python 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { contrast10, mipChainBytes } from "../sp00/spec.ts";
import { styleVector, dist10, FULL_DIMS, ROLES } from "../st01/style.ts";

export type Palette = Record<string, number[]>;

/** 图集装箱率千分比(整数地板除)。 */
export function atlasPermille(at: any): number {
  const cols = Math.floor(at.width / at.cell);
  const rows = Math.floor(at.height / at.cell);
  const placed = Math.min(at.icons, cols * rows);
  return Math.floor((placed * at.cell * at.cell * 1000) / (at.width * at.height));
}

/** 最近邻风格距离(×10)。 */
export function nearestDistance(id: string, vecs: Record<string, number[]>, weights: number[]): number {
  let best = Number.POSITIVE_INFINITY;
  for (const other of Object.keys(vecs)) {
    if (other === id) continue;
    best = Math.min(best, dist10(vecs[id], vecs[other], weights, FULL_DIMS));
  }
  return best;
}

/** 单条清单项的求值,与 Python 的 evaluate() 逐条对应,返回 [value, pass]。 */
export function evaluate(itemId: string, theme: any, ctx: any): [string, boolean] {
  const cfg = ctx.cfg;
  const spec = ctx.spec;
  switch (itemId) {
    case "name.prefix": {
      const pat = new RegExp(cfg.idPattern);
      const n = cfg.assetIds.filter((a: string) => pat.test(a)).length;
      return [`matched=${n}/${cfg.assetIds.length}`, n === cfg.assetIds.length];
    }
    case "name.reserved": {
      const hits = cfg.assetIds.filter((a: string) =>
        a.split("_").some((seg: string) => cfg.reservedWords.includes(seg)));
      return [`hits=${hits.length}`, hits.length === 0];
    }
    case "tokens.count":
      return [`count=${Object.keys(theme.tokens).length}`, Object.keys(theme.tokens).length === 8];
    case "contrast.floor": {
      const mn = Math.min(...spec.contrastPairs.map(([a, b]: string[]) => contrast10(theme.tokens[a], theme.tokens[b])));
      return [`min=${mn}`, mn >= spec.contrastFloor10];
    }
    case "atlas.permille":
      return [`pm=${ctx.atlas}`, ctx.atlas === cfg.atlasPermille];
    case "budget.mip":
      return [`bytes=${ctx.mip}`, ctx.mip === cfg.mipChainBytes];
    case "distinct.floor":
      return [`nearest=${ctx.nearest}`, ctx.nearest > cfg.distinguishableFloor10];
    case "distinct.pairs":
      return [`pairs=${ctx.pairs}`, ctx.pairs === cfg.pairCount];
    default:
      return ["unknown", false];
  }
}

/** 同一张清单在单个主题上的完整结果。 */
export function runChecklist(theme: any, cfg: any, spec: any, vecs: Record<string, number[]>, weights: number[], pairCount: number): any {
  const ctx = {
    cfg, spec, vecs,
    atlas: atlasPermille(spec.atlas),
    mip: mipChainBytes(spec.budget.baseTexture, spec.budget.bytesPerPixel),
    pairs: pairCount,
    nearest: nearestDistance(theme.id, vecs, weights),
  };
  const checks = cfg.items.map((it: any) => {
    const [value, pass] = evaluate(it.id, theme, ctx);
    return { id: it.id, value, pass, blocking: !!it.blocking };
  });
  return { id: theme.id, nearest: ctx.nearest, checks, pass: checks.every((c: any) => c.pass) };
}

/** 与 Python 同式的摘要串:逐主题按清单顺序拼 value。 */
export function canonical(records: any[]): string {
  return records.map((r) => `${r.id}:${r.checks.map((c: any) => c.value).join(",")}`).join(";");
}

export { styleVector, ROLES };

core/test/st16_acceptance.test.ts 读 Python 落盘做跨语言对照,并构造四条负例(对比度塌陷、与 pixel 同向量、非法 id 前缀、含保留字)。实跑 node --test 'core/test/st16_*.test.ts'(此处只摘本节 3 条;三节合计 10 条):

✔ 跨风格验收清单:6 主题 × 8 项逐项与 Python 一致且全过 (1.575459ms)
✔ 摘要串 + sha256 指纹跨语言逐位一致 (0.513625ms)
✔ 负例:对比度塌陷、与 pixel 同向量、非法 id、保留字都必须 fail (0.239959ms)
ℹ tests 10
ℹ pass 10
ℹ fail 0

16.1.4 边界与取舍

  • 「好不好看」不进清单。清单只判整数账(命名、token 数、对比度、装箱率、字节、距离),审美判断仍需人工评审,标 X。
  • 软指标只告警,阈值不可移植。blocking=false 的 atlas.permille 与 distinct.pairs 越界只提示不阻断;45、800、144 都是本机 6 主题的相对量级,换一批主题必须重跑本节脚本重定基线。

阅读导航:上一节:15.3 风格迁移的边界与代价 · 下一节:16.2 三卷总收官:一份规范的完整回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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