12.3 一致性维护

规范一旦铺到多个场景,一致性就不再靠人记:每个场景的 8 个 token 都要与主题逐通道比较,任何一通道偏移都判漂移;同时复算每场景 5 对对比度的最小值,低于下限即判违例。本节用 Python 真跑四个场景的一致性审计,再用 TypeScript 校验器与 Python 落盘的 JSON 对拍,证明两侧的整数类违例集合与摘要逐位一致。

12.3 一致性维护

站内 游戏美术风格指南 把「跨场景要统一」讲成了纪律。本节不重复那套口号,而是把它压成一笔可断言的整数账:每个场景的 8 个 token 与主题逐通道比较,任何一通道偏移都判漂移。上一节 12.2 圆角与柔和过渡规范 定完形状,本节收口「时间维度」——同一份规范铺到多个场景后会不会走样。跨场景色彩一致性的通用口径见卷一 3.3 跨场景色彩一致性 ,主题之间「同源但可区分」的距离口径见本卷 15.2 同源可区分:风格距离矩阵 。本节只做一致性校验的整数账,不评价「这几个场景放在一起好不好看」——那需要真实环境,命令行不可复现(X)。

一致性维护:两类整数检查

一致性维护的敌人不是「画错」,而是「悄悄偏一点」。cozy-clean 主题有 8 个 token,四场景各持一份实例。检查只有两类,全部整数:

检查判据为什么是整数
逐通道漂移任一通道 abs(场景 - 主题) > driftTolerance 判 E_TOKEN_DRIFT通道差是整数;driftTolerance = 0 表示零容忍
对比度不破场景 5 对对比度最小值 < 下限判 E_CONTRAST_FLOOR对比度 ×10 是整数(与卷一 ch4 同源)

driftTolerance = 0 不是苛刻,而是清新治愈风格最怕的就是「差不多」:bg 从 [245,240,230] 偏到 [245,240,229] 肉眼几乎看不出,但四个场景各偏一点,整包就会「花」掉。漂移量用最大逐通道绝对差度量,一个整数就够。

S 层:跨场景一致性账

下面这段是 S 层脚本。它读本章的 config/st12_consistency.json(四场景 token 实例)、config/sp16_themes.json(取 cozy-clean 主题)与 config/visual_spec.json(对比度下限),逐场景跑两类检查:

{
  "themeId": "cozy-clean",
  "driftTolerance": 0,
  "scenes": [
    { "id": "lobby", "label": "旅店大堂", "tokens": { "bg": [245, 240, 230], "accent": [168, 84, 52] } },
    { "id": "garden", "label": "后院花园", "tokens": { "bg": [245, 240, 230], "accent": [170, 86, 52] } }
  ]
}
#!/usr/bin/env python3
# file: sim/st12_consistency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st12_consistency.py
"""清新/治愈:跨场景一致性维护的整数账(第 12 章 12.3,S 层)。

读 config/st12_consistency.json(场景实例表)+ config/sp16_themes.json(取 cozy-clean 主题 token,
也是《星屑旅店》默认主题)+ config/visual_spec.json(对比度下限)。逐场景与主题 token 做
**逐通道比较**:任何一通道偏移 > driftTolerance 即判 `E_TOKEN_DRIFT`;同时复算每场景 5 对对比度的
最小值,低于下限判 `E_CONTRAST_FLOOR`。全部整数类(通道差、明度、对比度 ×10),逐位一致。
落 out/st12_consistency.json + .csv 供 P 层对照。
"""
import csv
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRGB = (0.04045, 12.92, 0.055, 1.055, 2.4)
LUMA = (0.2126, 0.7152, 0.0722)


def srgb_to_linear(c: int) -> float:
    s = c / 255
    return s / SRGB[1] if s <= SRGB[0] else ((s + SRGB[2]) / SRGB[3]) ** SRGB[4]


def contrast10(a, b) -> int:
    f = lambda c: sum(LUMA[i] * srgb_to_linear(c[i]) for i in range(3))
    hi, lo = sorted((f(a), f(b)), reverse=True)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def max_channel_diff(a, b) -> int:
    """两套 token 的最大逐通道绝对差(整数类,逐位一致)。"""
    return max(abs(a[r][i] - b[r][i]) for r in a for i in range(len(a[r])))


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/st12_consistency.json"), encoding="utf-8"))
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    th = next(t for t in themes["themes"] if t["id"] == cfg["themeId"])
    tokens, tol = th["tokens"], cfg["driftTolerance"]
    violations, rows = [], []
    for s in cfg["scenes"]:
        diff = max_channel_diff(s["tokens"], tokens)
        min_c10 = min(contrast10(s["tokens"][a], s["tokens"][b]) for a, b in spec["contrastPairs"])
        if diff > tol:
            violations.append({"id": s["id"], "code": "E_TOKEN_DRIFT"})
        if min_c10 < spec["contrastFloor10"]:
            violations.append({"id": s["id"], "code": "E_CONTRAST_FLOOR"})
        rows.append({"id": s["id"], "label": s["label"], "maxChannelDiff": diff,
                     "minContrast10": min_c10, "ok": diff <= tol and min_c10 >= spec["contrastFloor10"]})
    drift = [r for r in rows if r["maxChannelDiff"] > tol]
    counts = {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
    theme_min_c10 = min(contrast10(tokens[a], tokens[b]) for a, b in spec["contrastPairs"])
    canon = json.dumps({"sceneCount": len(cfg["scenes"]), "tokenCount": len(tokens),
                        "driftCount": len(drift), "violationCount": len(violations),
                        "themeMinContrast10": theme_min_c10},
                       separators=(",", ":"), ensure_ascii=False)
    summary = {"project": cfg["project"], "themeId": cfg["themeId"], "driftTolerance": tol,
               "tokenCount": len(tokens), "sceneCount": len(cfg["scenes"]),
               "themeMinContrast10": theme_min_c10, "contrastFloor10": spec["contrastFloor10"],
               "driftCount": len(drift), "maxDrift": max(r["maxChannelDiff"] for r in rows),
               "violationCount": len(violations), "counts": counts, "violations": violations,
               "rows": rows, "canonical": canon,
               "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
    json.dump(summary, open(os.path.join(ROOT, "out/st12_consistency.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, sort_keys=True)
    with open(os.path.join(ROOT, "out/st12_consistency.csv"), "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["id", "label", "maxChannelDiff", "minContrast10", "ok"])
        for r in rows:
            wr.writerow([r["id"], r["label"], r["maxChannelDiff"], r["minContrast10"], str(r["ok"]).lower()])
    print(f"tokenCount={len(tokens)} themeMinContrast10={theme_min_c10} floor10={spec['contrastFloor10']}")
    print(f"scenes={len(cfg['scenes'])} driftTolerance={tol} driftCount={len(drift)} "
          f"maxDrift={summary['maxDrift']}")
    for r in rows:
        print(f"  {r['id']:12s} {r['label']:6s} maxDiff={r['maxChannelDiff']} minContrast10={r['minContrast10']} ok={r['ok']}")
    print(f"violations={len(violations)} counts={counts}")
    print(f"digest={summary['digest']}")
    print("wrote out/st12_consistency.json out/st12_consistency.csv")
    return 0


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

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

tokenCount=8 themeMinContrast10=46 floor10=45
scenes=4 driftTolerance=0 driftCount=2 maxDrift=2
  lobby        旅店大堂   maxDiff=0 minContrast10=46 ok=True
  guest_room   客房     maxDiff=1 minContrast10=46 ok=False
  shop         杂货铺    maxDiff=0 minContrast10=46 ok=True
  garden       后院花园   maxDiff=2 minContrast10=45 ok=False
violations=2 counts={'E_TOKEN_DRIFT': 2}
digest=268897668263b20e
wrote out/st12_consistency.json out/st12_consistency.csv

读出来:主题本身 5 对对比度最小 46 ≥ 45;四个场景里两个干净、两个漂移。2 条违例是故意留的:

场景违例含义
guest_roomE_TOKEN_DRIFTbg 从 [245,240,230] 偏成 [245,240,229],最大通道差 1
gardenE_TOKEN_DRIFTaccent 从 [168,84,52] 偏成 [170,86,52],最大通道差 2

garden 的 accent 漂移还把该场景 5 对对比度的最小值从 46 压到了 45——刚好贴住下限。这说明漂移不只是「颜色变了」,它会连带吃掉对比度余量:accent 与 bg 的相对亮度一旦移动,对比度就跟着动。maxDrift=2 在整数账里是「两条通道各偏 2」,肉眼几乎看不出,但规范判它违例——一致性维护要的正是这种「零容忍」。

P 层:同一套判据的 TS 侧

同一套判据,TypeScript 侧独立实现一遍 core/src/st12/consistency.ts。它只读同一份配置,对比度复用 sp00/spec.ts 的同源常数,不 import Python 的任何东西:

// file: core/src/st12/consistency.ts
// tier: P
// verify: node --test 'core/test/st12_*.test.ts'
// 跨场景一致性维护的 TS 侧校验器(第 12 章 12.3)。与 sim/st12_consistency.py 共用
// config/st12_consistency.json、config/visual_spec.json、config/sp16_themes.json;判据全是整数
// (逐通道差、对比度 ×10、场景数),故两侧违例集合与摘要逐位一致。对比度复用 sp00/spec.ts 的同源常数。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

import { contrast10 } from "../sp00/spec.ts";

export type RGB = number[];
export type Tokens = Record<string, RGB>;
export type Scene = { id: string; label: string; tokens: Tokens };
export type ConsistencyCfg = { themeId: string; driftTolerance: number; scenes: Scene[] };
export type Theme = { id: string; tokens: Tokens };
export type Spec = { contrastPairs: string[][]; contrastFloor10: number };
export type Violation = { id: string; code: string };
export type SceneRow = { id: string; label: string; maxChannelDiff: number; minContrast10: number; ok: boolean };
export type ConsistencyReport = { themeId: string; driftTolerance: number; tokenCount: number;
  sceneCount: number; themeMinContrast10: number; contrastFloor10: number; driftCount: number;
  maxDrift: number; violationCount: number; counts: Record<string, number>; violations: Violation[];
  rows: SceneRow[]; canonical: string; digest: string };

/** 两套 token 的最大逐通道绝对差(整数类,逐位一致)。 */
export function maxChannelDiff(a: Tokens, b: Tokens): number {
  let mx = 0;
  for (const r of Object.keys(a)) {
    for (let i = 0; i < a[r].length; i += 1) mx = Math.max(mx, Math.abs(a[r][i] - b[r][i]));
  }
  return mx;
}

export function auditConsistency(cfg: ConsistencyCfg, spec: Spec, themes: { themes: Theme[] }): ConsistencyReport {
  const th = themes.themes.find((t) => t.id === cfg.themeId);
  if (!th) throw new Error("themes 缺 " + cfg.themeId + " 实例");
  const tokens = th.tokens;
  const violations: Violation[] = [];
  const rows: SceneRow[] = [];
  for (const s of cfg.scenes) {
    const diff = maxChannelDiff(s.tokens, tokens);
    const minContrast10 = Math.min(...spec.contrastPairs.map(([a, b]) => contrast10(s.tokens[a], s.tokens[b])));
    if (diff > cfg.driftTolerance) violations.push({ id: s.id, code: "E_TOKEN_DRIFT" });
    if (minContrast10 < spec.contrastFloor10) violations.push({ id: s.id, code: "E_CONTRAST_FLOOR" });
    rows.push({ id: s.id, label: s.label, maxChannelDiff: diff, minContrast10,
      ok: diff <= cfg.driftTolerance && minContrast10 >= spec.contrastFloor10 });
  }
  const driftCount = rows.filter((r) => r.maxChannelDiff > cfg.driftTolerance).length;
  const counts: Record<string, number> = {};
  for (const v of violations) counts[v.code] = (counts[v.code] ?? 0) + 1;
  const themeMinContrast10 = Math.min(...spec.contrastPairs.map(([a, b]) => contrast10(tokens[a], tokens[b])));
  const report: ConsistencyReport = {
    themeId: cfg.themeId, driftTolerance: cfg.driftTolerance, tokenCount: Object.keys(tokens).length,
    sceneCount: cfg.scenes.length, themeMinContrast10, contrastFloor10: spec.contrastFloor10, driftCount,
    maxDrift: Math.max(...rows.map((r) => r.maxChannelDiff)), violationCount: violations.length, counts,
    violations, rows, canonical: "", digest: "",
  };
  report.canonical = canonical(report);
  return report;
}

/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(r: ConsistencyReport): string {
  return JSON.stringify({ sceneCount: r.sceneCount, tokenCount: r.tokenCount, driftCount: r.driftCount,
    violationCount: r.violationCount, themeMinContrast10: r.themeMinContrast10 });
}

node --test 'core/test/st12_*.test.ts'(本节 4 条,三节合计 12 条)真实输出:

consistency digest = 268897668263b20e
✔ 正例:主题对比度达下限、场景数与漂移数与 Python 一致 (0.478708ms)
✔ 跨语言:违例集合、计数与摘要逐位一致(整数类) (1.131792ms)
✔ 负例:单通道偏移即判漂移,容差 0 不放行 (0.094875ms)
✔ 地板除锁死:逐通道差与对比度 ×10 均为整数类 (0.068166ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

两条跨语言断言把 TS 复算的违例集合、逐场景行(id/maxChannelDiff/minContrast10/ok)与 canonical 摘要逐位对上 Python 落盘的 out/st12_consistency.json,sha256 前 16 位都是 268897668263b20e——通道差、对比度 ×10、场景数全是整数类,逐位一致。第三条是负例:单通道偏 1 即被 driftTolerance=0 拦下;任何一通道从「拦下」变成「放行」,一致性维护就已经失效。

自检表

检查项合格标准
逐通道零容忍任一通道偏移 > 0 判 E_TOKEN_DRIFT,漂移集合可断言
对比度不破每场景 5 对对比度最小值 ≥ 下限 45
主题基线正确主题 8 个 token 与 cozy-clean 逐位一致,themeMinContrast10=46
漂移可归因maxDrift 给出最大通道差,能指认哪个场景偏了多少
账可复算违例集合、逐场景行与摘要跨语言逐位一致
边界清晰「放一起好不好看」标 X,不写成「实测」

本章三节收口了清新/治愈的三个整数面:颜色(明度下限、饱和度上限)、形状(圆角与柔边阶梯)、时间(跨场景漂移)。三者共用同一份 cozy-clean 规范,也共用同一套「整数类逐位一致」的跨语言纪律。下一章换到光谱的另一端——复古/赛博的霓虹调色板,那里「亮」的定义与这里几乎相反。

阅读导航:上一节:12.2 圆角与柔和过渡规范 · 下一节:13.1 霓虹调色板与色彩分级 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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