前三卷各自收官:卷一在 16.2 双案例回放:《星屑旅店》6 主题的规范装配
里装配了规范,卷二在 16.2 双案例回放:6 主题走同一条管线
里把规范灌进管线,卷三在 16.1 跨风格验收清单
里给出了验收判据。本节是三卷总收官:把这三段串成一条链,在 6 个主题包上各跑一遍——不是各说各话,而是同一份 config/visual_spec.json 依次过三关。全部公式索引见卷一 附录 A:视觉规范公式、参数与数据集索引
与卷二 附录 A:管线 schema、规则表与公式索引
。
16.2.1 一条链,三段判定
链分三段,每段判定的位数不同:规范装配(卷一 ch16)判 2 位——token 是否齐 8 个、5 对对比度是否都达下限;管线回放(卷二 ch16)判 4 位——前 2 位加上图集装箱率与可区分度;跨风格验收(卷三 ch16)判 8 位——16.1 那张清单。6 个主题 × (2+4+8) = 84 项判定,全过才算这条链成立。本节的 S 层脚本与 TS 内核同步实现,摘要指纹必须逐位相同。
16.2.2 S 层:三卷收官链在 6 主题上各跑一遍
sim/st16_replay.py 是这条链的 S 层。它把三段判定写成三个 bits_* 函数,main() 逐主题串起来;顺带独立复算 6×6 风格距离矩阵,其 canonical 摘要应为跨卷锚点 fa04e9418d9f3c2c(卷一 16.2、卷二 16.2 与卷三 1.2 共享):
#!/usr/bin/env python3
# file: sim/st16_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st16_replay.py
"""卷三 16.2 三卷总收官:一份规范的完整回放(S 层)。把三卷各自的收官口径串成一条链,在 6
个主题包上各跑一遍:卷一 ch16(tokens+对比度)、卷二 ch16(tokens/contrast/atlas/distinct
四道闸门)、卷三 ch16(16.1 的 8 项验收清单),6×(2+4+8)=84 项判定必须全过。再独立复算
6×6 风格距离矩阵,其 canonical 摘要应为跨卷锚点 fa04e9418d9f3c2c。落 out/st16_replay.json。"""
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"]
ANCHOR = "fa04e9418d9f3c2c"
def lin(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 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(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_pm(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 bits_spec(t, spec):
mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
return [len(t["tokens"]) == 8, mn >= spec["contrastFloor10"]]
def bits_pipeline(t, spec, near, cfg, pm):
return bits_spec(t, spec) + [pm == atlas_pm(cfg["atlas"]), near > cfg["distinguishableFloor10"]]
def bits_accept(t, acc, spec, near, pm, mipb, pairs):
pat = re.compile(acc["idPattern"])
mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
return [all(pat.match(a) for a in acc["assetIds"]),
not any(r in a.split("_") for a in acc["assetIds"] for r in acc["reservedWords"]),
len(t["tokens"]) == 8, mn >= spec["contrastFloor10"], pm == acc["atlasPermille"],
mipb == acc["mipChainBytes"], near > acc["distinguishableFloor10"], pairs == acc["pairCount"]]
def main():
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
cfg = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
acc = json.load(open(os.path.join(ROOT, "config/st16_acceptance.json"), encoding="utf-8"))
w = cfg["weights"]
names = [t["id"] for t in cfg["themes"]]
vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
flat = [(names[i], names[j], dist10(vecs[names[i]], vecs[names[j]], w))
for i in range(len(names)) for j in range(i + 1, len(names))]
pm = atlas_pm(cfg["atlas"])
mipb = mip_bytes(spec["budget"]["baseTexture"], spec["budget"]["bytesPerPixel"])
records, violations = [], []
for t in cfg["themes"]:
near = min(dist10(vecs[t["id"]], vecs[o], w) for o in vecs if o != t["id"])
stages = [("spec-assembly", bits_spec(t, spec)), ("pipeline", bits_pipeline(t, spec, near, cfg, pm)),
("acceptance", bits_accept(t, acc, spec, near, pm, mipb, len(flat)))]
for name, bits in stages:
if not all(bits):
violations.append(f"{t['id']}:{name}")
records.append({"id": t["id"], "near": near,
"stages": [{"name": n, "bits": b} for n, b in stages],
"pass": all(all(b) for _, b in stages),
"checkCount": sum(len(b) for _, b in stages),
"passCount": sum(sum(b) for _, b in stages)})
canon = ";".join([f"{n}:{','.join(map(str, vecs[n]))}" for n in names]
+ [f"{a}>{b}:{d}" for a, b, d in flat])
matrix_digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
report = {"project": cfg["project"], "order": names, "themeCount": len(names), "stageCount": 3,
"checkCount": sum(r["checkCount"] for r in records),
"passCount": sum(r["passCount"] for r in records),
"matrixDigest": matrix_digest, "anchorDigest": ANCHOR, "anchorOk": matrix_digest == ANCHOR,
"minPair": min(flat, key=lambda r: r[2])[2], "maxPair": max(flat, key=lambda r: r[2])[2],
"themes": records, "violations": sorted(violations),
"digest": hashlib.sha256("|".join(
f"{r['id']}:{''.join(str(int(b)) for st in r['stages'] for b in st['bits'])}"
for r in records).encode("utf-8")).hexdigest()[:16]}
with open(os.path.join(ROOT, "out/st16_replay.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']} stages={report['stageCount']} checks={report['checkCount']} "
f"pass={report['passCount']} violations={len(report['violations'])}")
for r in records:
bits = " ".join(f"{st['name']}=" + "".join(str(int(b)) for b in st["bits"]) for st in r["stages"])
print(f" {r['id']:<12} {bits} pass={r['pass']}")
print(f"matrixDigest={matrix_digest} anchor={ANCHOR} ok={report['anchorOk']}")
print(f"digest={report['digest']}")
print("wrote out/st16_replay.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st16_replay.py:
themes=6 stages=3 checks=84 pass=84 violations=0
pixel spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
lowpoly spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
handdrawn spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
voxel spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
retro-cyber spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
cozy-clean spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
matrixDigest=fa04e9418d9f3c2c anchor=fa04e9418d9f3c2c ok=True
digest=3e4d7308130787ad
wrote out/st16_replay.json
84 项判定全过。关键是最后一行:矩阵摘要 fa04e9418d9f3c2c 命中跨卷锚点——它与卷一 16.2、卷二 16.2、卷三 1.2 各自独立复算的结果逐位相同,说明三卷用的确实是同一份规范。
16.2.3 B 层:阈值与容差的敏感性
sim/st16_bench.py 是软告警的 B 层,做三件对抗「编数字」的事:预算漂移(6 主题的 stroke/detail 跨度)、阈值扫描(把可区分下限从 600 扫到 2500,看有几对被判太像)、跨语言容差(声明的 4.9e-13 必须小于配置容差 1e-9):
#!/usr/bin/env python3
# file: sim/st16_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/st16_bench.py
"""卷三 16.2 的 B 层基准(软告警不硬失败):读 out/st16_replay.json 与两份 config,算三件对抗
「编数字」的事——主题切换后的预算漂移、可区分阈值的敏感性、跨语言色彩容差。退出码恒为 0。"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLES = ["accent", "bg", "ink", "surface"]
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 atlas_pm(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 main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
cfg = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
replay = json.load(open(os.path.join(ROOT, "out/st16_replay.json"), encoding="utf-8"))
w, floor10 = cfg["weights"], cfg["distinguishableFloor10"]
names = [t["id"] for t in cfg["themes"]]
vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
flat = [(sum(wi * abs(x - y) for wi, x, y in zip(w, vecs[names[i]], vecs[names[j]])) * 10) // sum(w)
for i in range(len(names)) for j in range(i + 1, len(names))]
stroke = [t["strokePermille"] for t in cfg["themes"]]
detail = [t["detailPermille"] for t in cfg["themes"]]
budget = {"atlasPermille": atlas_pm(cfg["atlas"]), "atlasDrift": 0,
"mipChainBytes": mip_bytes(spec["budget"]["baseTexture"], spec["budget"]["bytesPerPixel"]),
"strokeSpan": max(stroke) - min(stroke), "detailSpan": max(detail) - min(detail)}
sweep = {str(f): sum(1 for d in flat if d <= f) for f in (600, 800, 1000, 1200, 1500, 2000, 2500)}
nearest, tol, xlang = min(flat), spec["tolerance"]["oklab"], 4.9e-13
warn = []
if nearest - floor10 < 100:
warn.append(f"THRESHOLD_TIGHT:最近一对 {nearest} 距下限仅 {nearest - floor10}")
if xlang >= tol:
warn.append("CROSS_LANG_EXCEEDS_TOLERANCE")
report = {"project": cfg["project"], "budget": budget, "thresholdSweep": sweep,
"nearestPair": nearest, "floor10": floor10, "headroom": nearest - floor10,
"tolerance": tol, "declaredCrossLangMax": xlang, "anchorOk": replay["anchorOk"],
"warnings": warn, "count": len(warn)}
with open(os.path.join(ROOT, "out/st16_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"atlasPermille={budget['atlasPermille']} mipChainBytes={budget['mipChainBytes']} "
f"strokeSpan={budget['strokeSpan']} detailSpan={budget['detailSpan']}")
print(f"nearestPair={nearest} floor10={floor10} headroom={nearest - floor10}")
print("thresholdSweep=" + " ".join(f"{k}:{v}" for k, v in sweep.items()))
print(f"tolerance={tol} crossLangMax={xlang} warnings={len(warn)} (soft, exit 0)")
print("wrote out/st16_bench.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st16_bench.py:
atlasPermille=144 mipChainBytes=5592404 strokeSpan=500 detailSpan=570
nearestPair=1002 floor10=800 headroom=202
thresholdSweep=600:0 800:0 1000:0 1200:1 1500:3 2000:7 2500:10
tolerance=1e-09 crossLangMax=4.9e-13 warnings=0 (soft, exit 0)
wrote out/st16_bench.json
扫描说明下限 800 不能松:扫到 1000 时还没有一对被判太像,但一到 1200 就冒出 1 对、1500 有 3 对——最近的一对 1002 只比 800 高 202,余量并不宽裕。容差方面,色彩是 sRGB 8-bit 整数三元组(事实来源),线性化/OKLab/ΔE 都是派生量,跨语言最大偏差实测 4.9e-13,落在 1e-9 容差之内,故颜色写「容差内一致」而非逐位相等;整数类判据(对比度、距离、千分比、字节)才是逐位一致。
16.2.4 边界与取舍
- 这条链判的是参数与结构,不是像素。真渲染下六主题各自的观感需真实渲染器与画师,命令行不可复现,标 X。
- 跨卷锚点
fa04e9418d9f3c2c只证「三卷同源」,不证「三卷都好」——同源是可复现的前提,不是质量结论。完整文件索引与逐层复现命令见本卷 附录 A:风格参数表、量化规则与数据集索引 ,三卷目录入口见《游戏美术与技术美术》卷三《风格与品类美术实战》目录 。
阅读导航:上一节:16.1 跨风格验收清单 · 下一节:16.3 交付物与后续 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。