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_room | E_TOKEN_DRIFT | bg 从 [245,240,230] 偏成 [245,240,229],最大通道差 1 |
garden | E_TOKEN_DRIFT | accent 从 [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 霓虹调色板与色彩分级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。