1.3 风格选择与项目约束

距离矩阵回答「哪两套最像」,但项目要选的是「最合约束的那套」。本节把风格选择写成一笔可算的账:四道硬门槛(对比度 / 可区分 / 细节上限 / 明度下限)先过滤,过关者再按与项目目标向量的加权 L1 距离排序,预算内取最近者。结果是 cozy-clean,拟合度 969,比第二名近 136。

站内 移动端与 PC 的美术性能预算短文 讲的是「预算是多少」;本节讲的是「在预算与可读性的夹缝里,风格怎么选」。1.2 的距离矩阵回答「哪两套主题最像」,但项目要选的是最合约束的那套——两者不是同一个问题。约束由三部分组成:硬门槛(不过就淘汰)、目标向量(项目美术方向给出的理想风格)、成本预算(人天 × 100)。把这三样写成数据,风格选择就从评审会上的争论变成一笔可复算的账。人天口径与卷二 14.1 产能度量 一致;明度下限与第一卷 3.1 主辅强调色与明度梯度 同源。

1.3 风格选择与项目约束

《星屑旅店》是等距经营的治愈系休闲游戏,项目侧给出一份约束表 config/st01_constraints.json。其中 weights、contrastFloor10、distinguishableFloor10 逐字取自只读真源(sp16_themes.json / visual_spec.json),只有 targetVector、detailMaxPermille、lumaMinPermille、成本与预算是本项目的建模输入——真实美术方向与产能需真实生产环境,命令行不可复现(X)。

{
  "project": "stardust-inn",
  "note": "卷三 1.3 的项目约束与风格选择输入。targetVector 是《星屑旅店》美术方向给出的**目标风格向量**(五维整数千分比,建模输入);weights 与 sp16_themes.json 逐字相同;gates 里 contrastFloor10 与 distinguishableFloor10 也取自 sp16_themes.json(同一份判据,不另造阈值),detailMaxPermille 与 lumaMinPermille 是本项目自定的硬门槛。costX100 是六个风格包的**建模成本**(人天 × 100),budgetX100 是项目给基础主题包的预算。所有成本与目标向量都是建模输入,真实产能与美术方向需真实生产环境,命令行不可复现(X)。全整数,无浮点。",
  "targetVector": [560, 320, 300, 260, 360],
  "weights": [2, 2, 1, 3, 3],
  "budgetX100": 9000,
  "gates": {
    "contrastFloor10": 45,
    "distinguishableFloor10": 800,
    "detailMaxPermille": 700,
    "lumaMinPermille": 400
  },
  "costX100": {
    "pixel": 7200,
    "lowpoly": 4100,
    "handdrawn": 9600,
    "voxel": 5400,
    "retro-cyber": 6800,
    "cozy-clean": 3600
  }
}

四道硬门槛各守一个方向:对比度守住可读性下限,可区分守住与其它主题的区分度(复用 1.2 的下限 800),细节上限守住产能(细节越多,画得越慢),明度下限守住小屏上的可读性。注意门槛与「拟合度」是两回事:拟合度再好,只要撞门槛就出局。

S 层:硬门槛过滤 + 目标最近

下面这段是 S 层脚本 sim/st01_constraint.py。它对六个主题逐条判门槛,过关者再算与 targetVector 的加权 L1 距离 ×10(越小越贴近项目方向),最后在预算内取最近者。全整数、地板除;对比度 ×10 取整也是整数类,跨语言逐位一致。

#!/usr/bin/env python3
# file: sim/st01_constraint.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st01_constraint.py
"""卷三 1.3 风格选择与项目约束(S 层)。

读 config/st01_constraints.json(项目目标向量 + 硬门槛 + 建模成本)与只读真源
config/visual_spec.json(对比度对)与 config/sp16_themes.json(6 主题包与权重):
① 四道硬门槛逐主题判定(对比度 / 可区分 / 细节上限 / 明度下限);
② 过关主题按「与目标向量的加权 L1 距离 ×10」排序(越小越贴近项目美术方向);
③ 在预算内取最近者,输出选择、拟合度、成本与淘汰理由。全整数、地板除。
真实产能与美术方向需真实生产环境,命令行不可复现(X)。产出 out/st01_constraint.json。
"""
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
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):
    """WCAG 对比度 ×10 取整(四舍五入用 +0.5 地板除,与 TS 的 Math.round 同义)→ 整数类。"""
    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 int(((hi + 0.05) / (lo + 0.05)) * 10 + 0.5)


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
    luma = median_int([luma709(c) for c in cols])
    return [luma * 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 main():
    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"))
    proj = json.load(open(os.path.join(ROOT, "config/st01_constraints.json"), encoding="utf-8"))
    w, g = proj["weights"], proj["gates"]
    names = [t["id"] for t in themes["themes"]]
    vecs = {t["id"]: style_vector(t) for t in themes["themes"]}
    target = proj["targetVector"]
    rows = []
    for t in themes["themes"]:
        tid = t["id"]
        mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
        near = min(dist10(vecs[tid], vecs[o], w) for o in names if o != tid)
        v = vecs[tid]
        checks = [("contrast", mn >= g["contrastFloor10"], f"minContrast10={mn}"),
                  ("distinct", near > g["distinguishableFloor10"], f"nearest={near}"),
                  ("detail", v[4] <= g["detailMaxPermille"], f"detail={v[4]}"),
                  ("luma", v[0] >= g["lumaMinPermille"], f"luma={v[0]}")]
        passed = all(ok for _, ok, _ in checks)
        rows.append({"id": tid, "vector": v, "checks": [{"name": n, "pass": p, "detail": d}
                                                         for n, p, d in checks],
                     "pass": passed, "fit10": dist10(v, target, w),
                     "costX100": proj["costX100"][tid],
                     "failReasons": [n for n, p, _ in checks if not p]})
    survivors = [r for r in rows if r["pass"]]
    affordable = [r for r in survivors if r["costX100"] <= proj["budgetX100"]]
    pool = affordable or survivors
    ranked = sorted(pool, key=lambda r: (r["fit10"], r["costX100"]))
    chosen = ranked[0] if ranked else None
    runner = ranked[1] if len(ranked) > 1 else None
    report = {"project": proj["project"], "targetVector": target, "weights": w,
              "budgetX100": proj["budgetX100"], "gates": g, "order": names,
              "candidates": rows, "survivorCount": len(survivors),
              "affordableCount": len(affordable),
              "chosen": chosen["id"] if chosen else None,
              "chosenFit10": chosen["fit10"] if chosen else None,
              "chosenCostX100": chosen["costX100"] if chosen else None,
              "runnerUp": runner["id"] if runner else None,
              "runnerUpFit10": runner["fit10"] if runner else None,
              "fitMargin10": (runner["fit10"] - chosen["fit10"]) if (chosen and runner) else None}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st01_constraint.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for r in rows:
        tag = "PASS" if r["pass"] else "FAIL:" + ",".join(r["failReasons"])
        print(f"{r['id']:<12} fit10={r['fit10']:<5} cost={r['costX100']:<5} {tag}")
    print(f"survivors={len(survivors)} affordable={len(affordable)} "
          f"chosen={report['chosen']} fit10={report['chosenFit10']} cost={report['chosenCostX100']}")
    print(f"runnerUp={report['runnerUp']} fit10={report['runnerUpFit10']} margin={report['fitMargin10']}")
    print("wrote out/st01_constraint.json")
    return 0


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

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

pixel        fit10=2511  cost=7200  FAIL:detail
lowpoly      fit10=1179  cost=4100  PASS
handdrawn    fit10=1490  cost=9600  PASS
voxel        fit10=1105  cost=5400  PASS
retro-cyber  fit10=2825  cost=6800  FAIL:luma
cozy-clean   fit10=969   cost=3600  PASS
survivors=4 affordable=3 chosen=cozy-clean fit10=969 cost=3600
runnerUp=voxel fit10=1105 margin=136
wrote out/st01_constraint.json

读出来:pixel 细节 780 超上限 700 出局,retro-cyber 明度 258 低于下限 400 出局;handdrawn 过了门槛,但成本 9600 超预算 9000,进不了候选池。剩下三个里 cozy-clean 拟合度最低(969),第二名的 voxel 是 1105,两者差 136。所以《星屑旅店》的基础主题包选 cozy-clean——不是因为它「最好看」(本卷不评价画得好不好),而是因为它在硬门槛内离项目目标最近、且预算扛得住。

P 层:选择器内核

下面这段是 P 层 TypeScript 内核 core/src/st01/constraint.ts。它复用 1.1 的 style.ts 与 sp00/spec.ts 的对比度常数,把门槛判定与选择逻辑搬到 TS 侧。

// file: core/src/st01/constraint.ts
// tier: P
// verify: node --test 'core/test/st01_*.test.ts'
// 1.3 风格选择与项目约束的 TS 内核:四道硬门槛逐主题判定,过关者按「与目标向量的加权
// L1 距离 ×10」排序,预算内取最近者。对比度复用 sp00/spec.ts 的同源常数;其余全整数
// 地板除,与 Python 侧逐位一致。
import { contrast10 } from "../sp00/spec.ts";
import { FULL_DIMS, dist10 } from "./style.ts";
import type { Palette } from "./style.ts";

export type Gates = {
  contrastFloor10: number;
  distinguishableFloor10: number;
  detailMaxPermille: number;
  lumaMinPermille: number;
};

export type Candidate = {
  id: string;
  vector: number[];
  pass: boolean;
  fit10: number;
  costX100: number;
  failReasons: string[];
};

/** 一组 token 在所有对比度对上的最小值(×10,整数)。 */
export function minContrast10(tokens: Palette, contrastPairs: string[][]): number {
  return Math.min(...contrastPairs.map(([a, b]) => contrast10(tokens[a], tokens[b])));
}

/** 四道硬门槛:对比度 / 可区分 / 细节上限 / 明度下限。 */
export function gateChecks(
  vector: number[], minContrast: number, nearest10: number, gates: Gates,
): { name: string; pass: boolean; detail: string }[] {
  return [
    { name: "contrast", pass: minContrast >= gates.contrastFloor10, detail: `minContrast10=${minContrast}` },
    { name: "distinct", pass: nearest10 > gates.distinguishableFloor10, detail: `nearest=${nearest10}` },
    { name: "detail", pass: vector[4] <= gates.detailMaxPermille, detail: `detail=${vector[4]}` },
    { name: "luma", pass: vector[0] >= gates.lumaMinPermille, detail: `luma=${vector[0]}` },
  ];
}

/** 评价一个候选主题:四道门槛 + 与目标向量的拟合度 + 建模成本。 */
export function evaluate(
  id: string, vector: number[], minContrast: number, nearest10: number,
  weights: number[], gates: Gates, costX100: number, target: number[],
): Candidate {
  const checks = gateChecks(vector, minContrast, nearest10, gates);
  return {
    id,
    vector,
    pass: checks.every((c) => c.pass),
    fit10: dist10(vector, target, weights, FULL_DIMS),
    costX100,
    failReasons: checks.filter((c) => !c.pass).map((c) => c.name),
  };
}

/** 在预算内取拟合度最小者(同分取成本低者);无预算内候选则退回到全部过关候选。 */
export function choose(candidates: Candidate[], budgetX100: number): {
  chosen: Candidate | null; runner: Candidate | null; survivors: number; affordable: number;
} {
  const survivors = candidates.filter((c) => c.pass);
  const affordable = survivors.filter((c) => c.costX100 <= budgetX100);
  const pool = affordable.length ? affordable : survivors;
  const ranked = [...pool].sort((a, b) => a.fit10 - b.fit10 || a.costX100 - b.costX100);
  return { chosen: ranked[0] ?? null, runner: ranked[1] ?? null, survivors: survivors.length, affordable: affordable.length };
}

在本机跑 node --test 'core/test/st01_*.test.ts',本节相关的 5 条(全章 glob 共 16 条)真实输出:

✔ 四道硬门槛逐主题判定:与 Python 结论一致 (1.099625ms)
✔ 拟合度与成本:整数与 Python 逐位一致 (0.16625ms)
✔ 选择结果:预算内取拟合度最小者,与 Python 一致 (0.159167ms)
✔ 负例:细节超上限的主题必被淘汰,不得因拟合度好而入选 (0.151208ms)
✔ 负例:预算外的主题即使拟合度更好也不得入选 (0.133292ms)
ℹ tests 16
ℹ pass 16
ℹ fail 0

两条负例各守一个方向:pixel 拟合度不算最差,但细节超上限必须出局;handdrawn 门槛全过,但成本超预算不得入选。任何一条从「拦下」变成「放行」,选择结果就已经不可信了。

自检表

检查项合格标准
阈值不另造权重、对比度下限、可区分下限逐字取自只读真源
门槛与拟合分离门槛是布尔淘汰,拟合度是排序,两者不混用
预算可复算成本与预算全整数(人天 × 100),选择结果可断言
边界诚实目标向量与成本标 X(建模输入),不写成「实测」
有负例撞门槛者、超预算者都必须被拦下

到这里,风格的可测化就闭环了:1.1 定义三个维度,1.2 把它们算成距离,1.3 用约束把距离变成选择。下一章进入第一个品类——像素风,从「分辨率、像素密度与调色板上限」的整数账开始。

阅读导航:上一节:1.2 风格向量与风格距离矩阵 · 下一节:2.1 分辨率、像素密度与调色板上限 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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