16.2 双案例回放:6 主题走同一条管线
16.1 管线装配与检查表
把前十五章串成了一条有向链,但那条链只在默认主题上验过一次。本节做回放:同一份 config/visual_spec.json 换 6 个主题包(pixel / lowpoly / handdrawn / voxel / retro-cyber / cozy-clean),跑同一套四道闸门的管线 6 遍,六次全过;再算 6×6 风格距离矩阵证明「同源但可区分」。像素画法本身另见 《独立游戏像素美术风格指南》
;卷三《风格与品类美术实战》的逐风格展开尚未落盘,此处只作书名提及。
跨语言纪律在这里再复述一遍:预算与阈值一律整数(对比度 ×10、风格距离 ×10、千分比、字节),除法一律整数地板除 → Python 与 TypeScript 逐位一致;色彩真源是 sRGB 8-bit 整数三元组,感知量(linear / OKLab / ΔE)是派生量,跨语言按声明容差 1e-9 断言,实测最大偏差 4.9e-13。正文不写「三处颜色完全相同」。
16.2.1 S 层:同一套闸门,六次
S 层脚本读 visual_spec.json 与 6 个主题包,对每个主题跑同一套四道闸门——tokens(8 个 token 齐备)、contrast(5 对对比度达 contrastFloor10)、atlas(图集装箱率与规范一致)、distinct(与其它任一主题的距离高于 distinguishableFloor10),再算 6×6 风格距离矩阵:
#!/usr/bin/env python3
# file: sim/ta16_six.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta16_six.py
"""卷二 16.2 六主题走同一条管线(S 层):同一份 visual_spec.json + 6 个主题包,跑同一套四道
闸门(tokens/contrast/atlas/distinct)6 遍,六次全过;再算 6×6 风格距离矩阵(整数 ×10)。
落 out/ta16_six.json 供 P 层对照。"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLES = ["accent", "bg", "ink", "surface"]
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):
f = lambda c: 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 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_pm(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
luma = median_int([(2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000 for c in cols])
return [luma * 1000 // 255, sum(sat_pm(c) for c in cols) // len(cols),
span * 1000 // 360, int(t["strokePermille"]), int(t["detailPermille"])]
def dist10(a, b, w):
"""五维加权 L1 的整数均值再 ×10 取整 → 整数类,逐位一致。"""
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 run_pipeline(t, spec, expected, vecs, w, floor10):
"""同一套四道闸门,返回 (name, pass, detail) 与最小对比度。"""
mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
near = min(dist10(vecs[t["id"]], vecs[o], w) for o in vecs if o != t["id"])
pm = atlas_pm(spec["atlas"])
gates = [("atlas", pm == expected, f"permille={pm}"),
("contrast", mn >= spec["contrastFloor10"], f"minContrast10={mn}"),
("distinct", near > floor10, f"nearest={near}"),
("tokens", len(t["tokens"]) == 8, f"count={len(t['tokens'])}")]
return gates, mn
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"))
w, floor10 = cfg["weights"], cfg["distinguishableFloor10"]
expected = atlas_pm(cfg["atlas"])
names = [t["id"] for t in cfg["themes"]]
vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
records = []
for t in cfg["themes"]:
gates, mn = run_pipeline(t, spec, expected, vecs, w, floor10)
records.append({"id": t["id"], "vector": vecs[t["id"]], "minContrast10": mn,
"gates": [{"name": n, "pass": p, "detail": d} for n, p, d in gates],
"pass": all(p for _, p, _ in gates)})
matrix = [[0 if a == b else dist10(vecs[a], vecs[b], w) for b in names] for a in names]
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))]
lo, hi = min(flat, key=lambda r: r[2]), max(flat, key=lambda r: r[2])
parts = [f"{r['id']}:{','.join(map(str, r['vector']))}" for r in records]
parts += [f"{a}>{b}:{d}" for a, b, d in flat]
report = {"project": cfg["project"], "order": names, "themeCount": len(names), "gateCount": 4,
"themes": records, "matrix": matrix, "minPair": {"a": lo[0], "b": lo[1], "d": lo[2]},
"maxPair": {"a": hi[0], "b": hi[1], "d": hi[2]}, "contrastFloor10": spec["contrastFloor10"],
"distinguishableFloor10": floor10, "belowFloor": [f"{a}/{b}:{d}" for a, b, d in flat if d <= floor10],
"passCount": sum(1 for r in records if r["pass"]),
"digest": hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]}
with open(os.path.join(ROOT, "out/ta16_six.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={len(names)} gates/theme=4 pass={report['passCount']}/{len(names)}")
for r in records:
gp = "".join("1" if g["pass"] else "0" for g in r["gates"])
print(f" {r['id']:<12} minContrast10={r['minContrast10']:<4} gates={gp} pass={r['pass']}")
print(f"pairs={len(flat)} min={lo[0]}/{lo[1]}={lo[2]} max={hi[0]}/{hi[1]}={hi[2]} floor10={floor10} below={len(report['belowFloor'])}")
print(f"digest={report['digest']}")
print("wrote out/ta16_six.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta16_six.py:
themes=6 gates/theme=4 pass=6/6
pixel minContrast10=45 gates=1111 pass=True
lowpoly minContrast10=51 gates=1111 pass=True
handdrawn minContrast10=46 gates=1111 pass=True
voxel minContrast10=48 gates=1111 pass=True
retro-cyber minContrast10=55 gates=1111 pass=True
cozy-clean minContrast10=46 gates=1111 pass=True
pairs=15 min=lowpoly/cozy-clean=1002 max=retro-cyber/cozy-clean=3794 floor10=800 below=0
digest=fa04e9418d9f3c2c
wrote out/ta16_six.json
gates=1111 是四道闸门全过,6 个主题 24 次判定全绿。注意 digest=fa04e9418d9f3c2c 与卷一 16.2 独立算出的矩阵摘要逐位相同——同一份主题包,两卷各自复算,风格向量与距离一位不差,这正是「同一份规范、同一套判据」的跨卷证据。
16.2.2 6×6 风格距离矩阵:同源但可区分
把 15 对距离铺成 6×6 矩阵(对角为 0,单位 ×10,越大越不像):
| pixel | lowpoly | handdrawn | voxel | retro-cyber | cozy-clean | |
|---|---|---|---|---|---|---|
| pixel | 0 | 3052 | 1929 | 1639 | 1337 | 3480 |
| lowpoly | 3052 | 0 | 2669 | 2168 | 3364 | 1002 |
| handdrawn | 1929 | 2669 | 0 | 1468 | 2242 | 1671 |
| voxel | 1639 | 2168 | 1468 | 0 | 1756 | 2038 |
| retro-cyber | 1337 | 3364 | 2242 | 1756 | 0 | 3794 |
| cozy-clean | 3480 | 1002 | 1671 | 2038 | 3794 | 0 |
最接近的一对是 lowpoly/cozy-clean = 1002(都是浅色),最远的是 retro-cyber/cozy-clean = 3794(一个霓虹暗色、一个奶白暖色)。下限 distinguishableFloor10 = 800,15 对全部高于它,belowFloor 为空——这就是「同源但可区分」的量化口径。
16.2.3 B 层:预算漂移与阈值敏感性
B 层脚本读 S 层落盘的矩阵与两份 config,做三件对抗「编数字」的事——主题切换后的预算漂移、风格距离阈值敏感性、跨语言色彩容差。结论只写告警,退出码恒为 0:
#!/usr/bin/env python3
# file: sim/ta16_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ta16_bench.py
"""卷二 16.2 的 B 层基准(软告警不硬失败):读 out/ta16_six.json 与两份 config,算主题切换后
的预算漂移、风格距离阈值敏感性、跨语言色彩容差。结论只写告警,退出码恒为 0。"""
import json
import math
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def lin(c):
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
def oklab(rgb):
r, g, b = (lin(c) for c in rgb)
l, m, s = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
l_, m_, s_ = (math.copysign(abs(v) ** (1 / 3), v) for v in (l, m, s))
return (0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_)
def okdist(p, q):
a, b = oklab(p), oklab(q)
return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
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"))
six = json.load(open(os.path.join(ROOT, "out/ta16_six.json"), encoding="utf-8"))
order, matrix = six["order"], six["matrix"]
flat = [matrix[i][j] for i in range(len(order)) for j in range(i + 1, len(order))]
floor10, nearest = cfg["distinguishableFloor10"], min(flat)
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 (1000, 1200, 1500, 2000, 2500)}
toks = list(spec["tokens"].values())
okmax = max(okdist(toks[i], toks[j]) for i in range(len(toks)) for j in range(i + 1, len(toks)))
tol, xlang = 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,
"oklabMax": round(okmax, 12), "tolerance": tol, "declaredCrossLangMax": xlang,
"warnings": warn, "count": len(warn)}
with open(os.path.join(ROOT, "out/ta16_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"oklabMax={okmax:.6f} tolerance={tol} crossLangMax=4.9e-13")
print(f"warnings={len(warn)} (soft, exit 0)")
print("wrote out/ta16_bench.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta16_bench.py:
atlasPermille=144 mipChainBytes=5592404 strokeSpan=500 detailSpan=570
nearestPair=1002 floor10=800 headroom=202
thresholdSweep=1000:0 1200:1 1500:3 2000:7 2500:10
oklabMax=0.674673 tolerance=1e-09 crossLangMax=4.9e-13
warnings=0 (soft, exit 0)
wrote out/ta16_bench.json
读出来:预算漂移为 0——6 个主题共用同一张图集与同一套纹理预算(atlasPermille=144、mipChainBytes=5592404 六实例相同),只有风格参数在动(strokeSpan=500、detailSpan=570);headroom=202 是最近一对距下限的余量,够用。阈值敏感性一目了然:下限 1000 时 0 对越界,抬到 1200 就有 1 对(lowpoly/cozy-clean=1002)被判「太像」——800 这个下限不是随手取的,它和最近一对 1002 之间留了 202 的余量。oklabMax=0.674673 是 OKLab 距离的量级(派生量),与跨语言容差 1e-9 是两码事:容差比的是「两语言各自算出的同一派生量之差」,实测 4.9e-13 远小于 1e-9。
16.2.4 P 层:同一套判据的跨语言对照
P 层内核 core/src/ta16/six.ts 与测试 core/test/ta16_six.test.ts 全文见工作区,与上面 S 脚本逐条对应:对比度复用 sp00/spec.ts 的同源常数,风格向量与矩阵全是整数地板除。这里只摘跨语言对照的实跑输出——node --test 'core/test/ta16_*.test.ts'(本节 4 条,三节合计 14 条):
✔ 四道闸门:TS 与 Python 结论一致且六主题全过 (1.211166ms)
✔ 五维风格向量与 6×6 矩阵:整数 ×10 逐位一致 (0.195083ms)
✔ 同源可区分 + sha256 指纹跨语言一致 (0.420583ms)
✔ 负例:对比度塌陷、两主题太像、token 缺失都必须 fail (0.211125ms)
ℹ tests 14
ℹ pass 14
ℹ fail 0
前三条断言 TS 与 Python 的闸门结论、五维向量、6×6 矩阵与 sha256 指纹逐位相同;第四条是负例——把 ink 压成近背景色(对比度塌陷)、造一个与 pixel 同向量的 twin(太像)、删掉一个 token,三者都必须被判 fail。
16.2.5 边界与取舍
- 真渲染下的六主题观感需要真实渲染器与画师,命令行不可复现,标 X;本节只对色彩与风格参数做整数账,不产生真实画面。
- 风格向量取四个角色色的整数千分比,风格距离与对比度都是整数类,逐位一致;色彩派生量按容差一致。下限 800、余量 202 都是本机相对量级,换一批主题必须重跑,绝对数值不可移植。
- 管线的「同一套」指闸门顺序、判据、下限完全一致,不是「六主题产出同一张图」——恰恰相反,
distinct闸门要求它们足够不一样。
阅读导航:上一节:16.1 管线装配与检查表 · 下一节:16.3 交付物与卷三衔接 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。