站内 移动端与 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 分辨率、像素密度与调色板上限 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。