16.2 双案例回放:6 主题走同一条管线

16.1 把管线串成一条链,但那条链只在默认主题上验过。本节做回放:同一份 visual_spec.json 换 6 个主题包,跑同一套四道闸门的管线 6 遍,六次全过;再算 6×6 风格距离矩阵(整数 ×10)证明「同源但可区分」,并复述跨语言纪律:整数逐位一致、色彩派生量容差内一致。

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,越大越不像):

pixellowpolyhanddrawnvoxelretro-cybercozy-clean
pixel030521929163913373480
lowpoly305202669216833641002
handdrawn192926690146822421671
voxel163921681468017562038
retro-cyber133733642242175603794
cozy-clean348010021671203837940

最接近的一对是 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 交付物与卷三衔接 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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