15.3 风格迁移的边界与代价

六套主题同出一份规范,换主题时哪些字段能跟着走、哪些必须重写?判据只有一条:叶子字段在 6 个实例上是否逐位相同。14 个结构叶子可迁移、代价 0;10 个风格叶子不可迁移、必须重写。同项目换主题要触碰 21 个点,跨项目迁移涨到 35,B 层再扫主题数与角色数看这笔账对什么敏感。

15.2 同源可区分 证明六套主题两两分得开。但项目里真正会发生的动作不是「同时上六套」,而是从一套换到另一套:原型期用 cozy-clean,上线前想切 pixel。换的时候哪些参数能跟着走、哪些必须重写、重写要付多少代价?14.3 跨品类复用与移植 从品类侧讲过复用,本节从规范字段侧把迁移量成一笔整数账。真实的换肤返工量还要看团队与资产量,命令行不可复现,标 X。

15.3 风格迁移的边界与代价

「可迁移」在这里不做价值判断,只做一件事:看一个叶子字段在 6 个实例上是否逐位相同。相同就是结构,换主题不用动;不同就是风格,换主题必须逐条重写。判据是可断言的整数比较,没有主观成分。

S 层:把「可迁移」定义成「六实例是否相同」

下面这段是 S 层脚本 sim/st15_transfer.py。它读 15.1 落盘的 out/st15_instantiate.json,把实例拆成叶子字段,逐对比较,再算两个迁移场景的触碰点:

#!/usr/bin/env python3
# file: sim/st15_transfer.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st15_transfer.py
"""卷三 15.3 风格迁移的边界与代价(S 层)。

读 out/st15_instantiate.json(6 个已实例化的 spec)与两份 config,把实例的**叶子字段**
按「六实例是否逐位相同」分成两类:
  结构叶子(相同)  → 可迁移:换主题不用动,代价 0
  风格叶子(不同)  → 不可迁移:换主题必须逐条重写
再算一次**迁移的整数账**:重写叶子数 + 复验对(5 对比度 + 15 风格距离)+ 需重生成的 PNG 数。
「同项目换主题」与「跨项目迁移」两个场景分开算。产出 out/st15_transfer.json。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
ROLES = ["bg", "surface", "ink", "muted", "accent", "ok", "warn", "danger"]
STRUCT_LEAVES = ["naming.pattern", "naming.types", "budget.trisLow", "budget.trisMid",
                 "budget.bonesLow", "budget.materialSlotsLow", "budget.baseTexture",
                 "budget.bytesPerPixel", "contrastPairs", "contrastFloor10",
                 "atlas.width", "atlas.height", "atlas.cell", "atlas.icons"]
STYLE_LEAVES = [f"tokens.{r}" for r in ROLES] + ["strokePermille", "detailPermille"]


def leaf_value(theme, leaf):
    """从主题实例取一个风格叶子字段的值。"""
    if leaf.startswith("tokens."):
        return theme["tokens"][leaf.split(".")[1]]
    return theme[leaf]


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"))
    inst = json.load(open(os.path.join(OUT, "st15_instantiate.json"), encoding="utf-8"))
    naming = json.load(open(os.path.join(ROOT, "config/st15_instantiation.json"), encoding="utf-8"))
    order = inst["order"]
    themes = {t["id"]: t for t in cfg["themes"]}
    # 结构叶子由六实例共用的同一份来源决定(visual_spec.json + 本章命名段),故必然相同。
    structural_shared = (len(inst["instances"]) == len(order)
                         and spec["contrastFloor10"] == cfg["contrastFloor10"]
                         and spec["contrastPairs"] == cfg["contrastPairs"])
    # 风格叶子:逐对比较,统计有多少对在这条叶子上不同。
    pair_total = len(order) * (len(order) - 1) // 2
    differ_pairs = {leaf: 0 for leaf in STYLE_LEAVES}
    for i, a in enumerate(order):
        for b in order[i + 1:]:
            for leaf in STYLE_LEAVES:
                if leaf_value(themes[a], leaf) != leaf_value(themes[b], leaf):
                    differ_pairs[leaf] += 1
    n = len(order)
    style_rewrite = len(STYLE_LEAVES)
    contrast_recheck = len(spec["contrastPairs"])
    distance_recheck = n - 1
    regen_assets = 1  # 只有色卡随 tokens 变;图集图与等距网格依赖共享的结构字段
    theme_scenario = {"rewriteLeaves": style_rewrite, "recheckContrast": contrast_recheck,
                      "recheckDistance": distance_recheck, "regenAssets": regen_assets}
    theme_scenario["touchPoints"] = sum(theme_scenario.values())
    project_scenario = {"rewriteLeaves": style_rewrite + len(STRUCT_LEAVES),
                        "recheckContrast": contrast_recheck, "recheckDistance": distance_recheck,
                        "regenAssets": regen_assets}
    project_scenario["touchPoints"] = sum(project_scenario.values())
    canonical = ";".join(f"{leaf}:{differ_pairs[leaf]}" for leaf in STYLE_LEAVES)
    report = {"project": cfg["project"], "order": order, "themeCount": n,
              "structuralLeaves": STRUCT_LEAVES, "styleLeaves": STYLE_LEAVES,
              "structuralShared": structural_shared, "structuralRewrite": 0,
              "pairTotal": pair_total, "differPairs": differ_pairs,
              "allPairsDiffer": all(v == pair_total for v in differ_pairs.values()),
              "themeMigration": theme_scenario, "projectMigration": project_scenario,
              "transferable": STRUCT_LEAVES, "nonTransferable": STYLE_LEAVES,
              "canonical": canonical,
              "digest": hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st15_transfer.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"structuralLeaves={len(STRUCT_LEAVES)} shared={structural_shared} rewrite=0")
    print(f"styleLeaves={len(STYLE_LEAVES)} allPairsDiffer={report['allPairsDiffer']} pairs={pair_total}")
    print(f"themeMigration   rewrite={theme_scenario['rewriteLeaves']} contrast={contrast_recheck} "
          f"distance={distance_recheck} regen={regen_assets} touch={theme_scenario['touchPoints']}")
    print(f"projectMigration rewrite={project_scenario['rewriteLeaves']} "
          f"touch={project_scenario['touchPoints']}")
    print(f"digest={report['digest']}")
    print("wrote out/st15_transfer.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/st15_transfer.py:

structuralLeaves=14 shared=True rewrite=0
styleLeaves=10 allPairsDiffer=True pairs=15
themeMigration   rewrite=10 contrast=5 distance=5 regen=1 touch=21
projectMigration rewrite=24 touch=35
digest=09f4d42bcf714ed9
wrote out/st15_transfer.json

14 个结构叶子(命名规则 2 条、预算 6 条、对比度对与下限 2 条、图集 4 条)六实例逐位相同,shared=True;10 个风格叶子(8 个色彩 token + 描边 + 细节)在 15 对里两两都不同,allPairsDiffer=True。迁移边界就在这里:结构可迁移,风格不可迁移。

迁移的整数账

一次「同项目换主题」要动的东西:

动作数量为什么
重写风格叶子108 个 token + 2 个风格参数,六实例两两不同
复验对比度对5token 换了,5 对都要重算
复验风格距离5要与其余 5 个主题重算距离,确认仍可区分
重生成 PNG1只有色卡随 token 变;图集图与等距网格依赖共享的结构字段
合计触碰点21

跨项目迁移(换的是一整个项目,结构也要重定)多一项:14 条结构叶子也要重写,触碰点从 21 涨到 35。结构那 14 条是「白拿」的,风格那 10 条是必须付的——这就是迁移代价的结构。注意这笔账的单位是触碰点,不是人天:真实返工量还要看团队与资产量,命令行不可复现,标 X。

B 层:代价对参数的敏感性

下面这段是 B 层脚本 sim/st15_cost_bench.py,结论只写告警,退出码恒为 0:

#!/usr/bin/env python3
# file: sim/st15_cost_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/st15_cost_bench.py
"""卷三 15.3 的 B 层基准(软告警不硬失败):迁移代价对「主题数」与「token 角色数」有多敏感。

读 out/st15_transfer.json 与两份 config,做三件对抗「编数字」的事:
  ① 主题数扫描:迁移一个主题要跟其余 n-1 个主题重算风格距离,n 从 3 到 8 时这笔账怎么变;
  ② 角色数扫描:token 角色数从 4 到 12 时,重写叶子数(角色 + 两个风格参数)怎么变;
  ③ 跨语言容差:OKLab 最大距离是派生量量级,与容差 1e-9 是两码事。
结论只写告警,退出码恒为 0。产出 out/st15_cost_bench.json。
"""
import json
import math
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")


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 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"))
    tr = json.load(open(os.path.join(OUT, "st15_transfer.json"), encoding="utf-8"))
    theme_scenario = tr["themeMigration"]
    roles = len([x for x in tr["styleLeaves"] if x.startswith("tokens.")])
    n = tr["themeCount"]
    # ① 主题数扫描:distanceRecheck = n-1,其余三项不变。
    theme_sweep = {str(k): (k - 1) + theme_scenario["rewriteLeaves"]
                   + theme_scenario["recheckContrast"] + theme_scenario["regenAssets"]
                   for k in (3, 4, 5, 6, 7, 8)}
    # ② 角色数扫描:rewriteLeaves = 角色数 + 2(描边 + 细节),其余三项不变。
    role_sweep = {str(r): r + 2 + theme_scenario["recheckContrast"]
                  + theme_scenario["recheckDistance"] + theme_scenario["regenAssets"]
                  for r in (4, 6, 8, 10, 12)}
    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 theme_scenario["regenAssets"] != 1:
        warn.append(f"REGEN_DRIFT:重生成资产数应为 1,实为 {theme_scenario['regenAssets']}")
    if xlang >= tol:
        warn.append("CROSS_LANG_EXCEEDS_TOLERANCE")
    report = {"project": cfg["project"], "themeCount": n, "tokenRoles": roles,
              "themeSweep": theme_sweep, "roleSweep": role_sweep,
              "themeTouchPoints": theme_scenario["touchPoints"],
              "projectTouchPoints": tr["projectMigration"]["touchPoints"],
              "oklabMax": round(okmax, 12), "tolerance": tol, "declaredCrossLangMax": xlang,
              "warnings": warn, "count": len(warn)}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st15_cost_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print("themeSweep=" + " ".join(f"{k}:{v}" for k, v in theme_sweep.items()))
    print("roleSweep=" + " ".join(f"{k}:{v}" for k, v in role_sweep.items()))
    print(f"touchPoints theme={theme_scenario['touchPoints']} "
          f"project={tr['projectMigration']['touchPoints']}")
    print(f"oklabMax={okmax:.6f} tolerance={tol} crossLangMax=4.9e-13")
    print(f"warnings={len(warn)} (soft, exit 0)")
    print("wrote out/st15_cost_bench.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/st15_cost_bench.py:

themeSweep=3:18 4:19 5:20 6:21 7:22 8:23
roleSweep=4:17 6:19 8:21 10:23 12:25
touchPoints theme=21 project=35
oklabMax=0.674673 tolerance=1e-09 crossLangMax=4.9e-13
warnings=0 (soft, exit 0)

主题数从 3 涨到 8,触碰点只从 18 涨到 23——迁移代价对主题数几乎不敏感(每多一个主题只多一次距离复验);而 token 角色数从 4 涨到 12,触碰点从 17 线性涨到 25——代价主要被风格叶子的条数决定。结论:想让换主题变便宜,不是减主题,而是减风格参数。

边界与取舍

  • 真渲染与真画师下的观感需要真实环境,命令行不可复现,标 X;本节只对字段与账做整数运算,不产生任何真实画面,也不评价哪套主题更好看。
  • 迁移边界是「字段是否随主题变」,不是「好不好改」;strokePermille 与 detailPermille 之所以不可迁移,是因为它们定义了主题,而不是因为它们难改。
  • 21 / 35 两个触碰点数是本机相对量级,换一份规范、换一组主题必须重跑,绝对数值不可移植。digest=09f4d42bcf714ed9 是本节自己的字段差异指纹,与 15.2 的矩阵指纹不是同一个量,不可混用。

阅读导航:上一节:15.2 同源可区分:风格距离矩阵 · 下一节:16.1 跨风格验收清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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