12.1 高明度低饱和调色板

清新治愈的「高明度低饱和」不是形容词,而是可校验的整数:背景族明度有下限、中性角色饱和度有上限、强调色有数量上限。本节把三条约束写进 cozy-clean 主题,用 Python 真跑候选色审计并生成色卡 PNG,再用 TypeScript 校验器与 Python 落盘的 JSON 对拍,证明两侧的整数类违例集合与摘要逐位一致。

12.1 高明度低饱和调色板

站内 游戏美术风格指南 把「清新治愈要亮、要淡」讲成了方向。本节不重复那套口号,而是把它压成三条可校验的整数:背景族明度有下限、中性角色饱和度有上限、强调色有数量上限。本章对应主题包 cozy-clean,它同时是旗舰案例《星屑旅店》的默认主题——8 个 token 逐字取自只读真源 15.1 规范实例化:6 个主题包 ,本节不另造。上一章 11.3 视觉噪声与可读性平衡 处理暗色侧的噪声与可读性;色彩 token 的写法在卷一 3.2 色彩 token 系统 ,对比度下限的来历在卷一 4.3 色盲安全与对比度下限 。本节只做调色板的整数约束与账,不评价「这套配色好不好看」——那需要真实画师与真实环境,命令行不可复现(X)。

高明度低饱和:三条整数约束

「亮」和「淡」看着主观,其实各有一个整数落点。cozy-clean 的 8 个 token 里,bg/surface/ink/muted 是中性族,accent/ok/warn/danger 是强调族。约束只压中性族:

约束形态为什么必须整数
明度下限luma709:bg ≥ 230、surface ≥ 200、ink ≥ 60、muted ≥ 90明度是 ×10000 地板除的整数,跨语言逐位一致
饱和度上限中性角色 ≤ 300‰饱和度 = (max-min)*1000//max,整数千分比
强调色数量强调角色 ≤ 4计数是整数,多一个就多一份「跳」

强调族不设明度与饱和度上限:accent 的饱和度 690‰ 远超中性上限 300‰,这正是它「跳」出来的原因。约束的对象是「底色要淡」,不是「整张图要淡」。把 8 个 token 逐个量出来:

tokenRGB角色luma709饱和度‰
bg245,240,230中性24061
surface228,220,206中性22096
ink92,80,66中性81282
muted110,98,84中性99236
accent168,84,52强调99690
ok64,116,74强调101448
warn150,84,32强调94786
danger170,52,48强调76717

四条中性全部达标:bg 明度 240 ≥ 230、surface 220 ≥ 200、ink 81 ≥ 60、muted 99 ≥ 90;四条中性饱和度 61/96/282/236 全部 ≤ 300,均值 168‰。这就是「高明度低饱和」的整数口径。

S 层:调色板约束与候选色审计

下面这段是 S 层脚本。它读本章的 config/st12_palette.json(约束与候选色表)、config/sp16_themes.json(取 cozy-clean 实例)与 config/visual_spec.json(对比度下限),对每个候选色跑明度/饱和度检查,并依同一份 spec 用 PIL 真生成色卡 PNG:

{
  "themeId": "cozy-clean",
  "constraints": {
    "neutralRoles": ["bg", "surface", "ink", "muted"],
    "accentRoles": ["accent", "ok", "warn", "danger"],
    "lumaFloor": { "bg": 230, "surface": 200, "ink": 60, "muted": 90 },
    "satCapNeutralPermille": 300, "accentMax": 4
  }
}
#!/usr/bin/env python3
# file: sim/st12_palette.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st12_palette.py
"""清新/治愈:高明度低饱和调色板的整数账(第 12 章 12.1,S 层)。

读 config/st12_palette.json(本章整数约束 + 候选色表)+ config/sp16_themes.json(取 cozy-clean
实例,也是《星屑旅店》默认主题)+ config/visual_spec.json(对比度下限)。三类整数检查:
**明度下限**(bg/surface/ink/muted 的 luma709 必须达各自下限)、**饱和度上限**(中性角色饱和度不超
satCapNeutralPermille)、**强调色数量**(accent 角色数不超 accentMax)。并依同一份 spec 用 PIL
真生成色卡 PNG。落 out/st12_palette.json 供 P 层对照。luma709 与饱和度都是整数类,逐位一致。
"""
import csv
import hashlib
import json
import os
import sys

from PIL import Image, ImageDraw

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEN = os.path.join(ROOT, "out", "gen")
SRGB = (0.04045, 12.92, 0.055, 1.055, 2.4)
LUMA = (0.2126, 0.7152, 0.0722)


def srgb_to_linear(c: int) -> float:
    s = c / 255
    return s / SRGB[1] if s <= SRGB[0] else ((s + SRGB[2]) / SRGB[3]) ** SRGB[4]


def contrast10(a, b) -> int:
    f = lambda c: sum(LUMA[i] * srgb_to_linear(c[i]) for i in range(3))
    hi, lo = sorted((f(a), f(b)), reverse=True)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def luma709(c) -> int:
    """整数明度(×10000 地板除),整数类,逐位一致。"""
    return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000


def sat_permille(c) -> int:
    mx = max(c)
    return 0 if mx == 0 else (mx - min(c)) * 1000 // mx


def audit_proposal(p, cons):
    errs = []
    if p["role"] in cons["neutralRoles"]:
        if luma709(p["rgb"]) < cons["lumaFloor"][p["role"]]:
            errs.append("E_LUMA_FLOOR")
        if sat_permille(p["rgb"]) > cons["satCapNeutralPermille"]:
            errs.append("E_SAT_CAP")
    return errs


def gen_palette_card(tokens, path):
    cols, cell = list(tokens.values()), 24
    im = Image.new("RGB", (cell * len(cols), 48))
    d = ImageDraw.Draw(im)
    for i, c in enumerate(cols):
        d.rectangle([i * cell, 0, (i + 1) * cell - 1, 47], fill=tuple(c))
    im.save(path)


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/st12_palette.json"), encoding="utf-8"))
    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"))
    th = next(t for t in themes["themes"] if t["id"] == cfg["themeId"])
    tokens, cons = th["tokens"], cfg["constraints"]
    min_c10 = min(contrast10(tokens[a], tokens[b]) for a, b in spec["contrastPairs"])
    accent_count = sum(1 for r in tokens if r in cons["accentRoles"])
    violations, rows = [], []
    for p in cfg["proposals"]:
        errs = audit_proposal(p, cons)
        violations += [{"id": p["id"], "code": c} for c in errs]
        rows.append({"id": p["id"], "role": p["role"], "luma": luma709(p["rgb"]),
                     "sat": sat_permille(p["rgb"]), "ok": not errs})
    if accent_count > cons["accentMax"]:
        violations.append({"id": "palette", "code": "E_ACCENT_COUNT"})
    neutrals = cons["neutralRoles"]
    neutral_sat = sum(sat_permille(tokens[r]) for r in neutrals) // len(neutrals)
    counts = {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
    canon = json.dumps({"tokenCount": len(tokens), "minContrast10": min_c10,
                        "proposalCount": len(cfg["proposals"]), "violationCount": len(violations),
                        "neutralSatMeanPermille": neutral_sat},
                       separators=(",", ":"), ensure_ascii=False)
    summary = {"project": cfg["project"], "themeId": cfg["themeId"], "tokenMatch": tokens == cfg["tokens"],
               "tokenCount": len(tokens), "minContrast10": min_c10, "contrastFloor10": spec["contrastFloor10"],
               "contrastPass": min_c10 >= spec["contrastFloor10"], "accentCount": accent_count,
               "accentMax": cons["accentMax"], "neutralSatMeanPermille": neutral_sat,
               "lumaFloor": cons["lumaFloor"], "satCapNeutralPermille": cons["satCapNeutralPermille"],
               "tokenLuma": {r: luma709(tokens[r]) for r in tokens},
               "tokenSat": {r: sat_permille(tokens[r]) for r in tokens},
               "proposalCount": len(cfg["proposals"]), "violationCount": len(violations),
               "counts": counts, "violations": violations, "rows": rows,
               "canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
    os.makedirs(GEN, exist_ok=True)
    json.dump(summary, open(os.path.join(ROOT, "out/st12_palette.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, sort_keys=True)
    with open(os.path.join(ROOT, "out/st12_palette.csv"), "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["id", "role", "luma", "sat", "ok"])
        for r in rows:
            wr.writerow([r["id"], r["role"], r["luma"], r["sat"], str(r["ok"]).lower()])
    gen_palette_card(tokens, os.path.join(GEN, "st12_palette_card.png"))
    print(f"tokenCount={len(tokens)} minContrast10={min_c10} contrastPass={summary['contrastPass']}")
    print(f"bgLuma={luma709(tokens['bg'])} surfaceLuma={luma709(tokens['surface'])} "
          f"inkLuma={luma709(tokens['ink'])} mutedLuma={luma709(tokens['muted'])}")
    print(f"bgSat={sat_permille(tokens['bg'])} surfaceSat={sat_permille(tokens['surface'])} "
          f"neutralSatMeanPermille={neutral_sat}")
    print(f"accentCount={accent_count}/{cons['accentMax']} proposals={len(cfg['proposals'])} "
          f"violations={len(violations)} counts={counts}")
    for v in violations:
        print(f"  {v['id']:20s} {v['code']}")
    print(f"digest={summary['digest']}")
    print("wrote out/st12_palette.json out/st12_palette.csv out/gen/st12_palette_card.png")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st12_palette.py:

tokenCount=8 minContrast10=46 contrastPass=True
bgLuma=240 surfaceLuma=220 inkLuma=81 mutedLuma=99
bgSat=61 surfaceSat=96 neutralSatMeanPermille=168
accentCount=4/4 proposals=5 violations=4 counts={'E_LUMA_FLOOR': 2, 'E_SAT_CAP': 2}
  alt-bg-cool          E_LUMA_FLOOR
  alt-surface-tan      E_LUMA_FLOOR
  alt-surface-tan      E_SAT_CAP
  alt-muted-warm       E_SAT_CAP
digest=24653930f4f8368c
wrote out/st12_palette.json out/st12_palette.csv out/gen/st12_palette_card.png

读出来:cozy-clean 的 8 个 token 与主题包逐位相同(tokenMatch=True),5 对对比度最小 minContrast10=46 ≥ 45。4 条违例是故意留的,它们证明这道闸门真的会拦东西:

候选色违例含义
alt-bg-coolE_LUMA_FLOOR偏冷的浅底明度 222 < 230,撑不起「高明度」
alt-surface-tanE_LUMA_FLOOR + E_SAT_CAP偏暖卡面明度 188 < 200 且饱和度 355 > 300,既暗又艳
alt-muted-warmE_SAT_CAP暖灰饱和度 466 > 300,不再是「低饱和」的中性色

alt-ink-soft([70,64,58])与 alt-accent-clay([180,96,60])放行:前者明度 64 ≥ 60 且饱和度 171 ≤ 300,后者是强调色、不设上限。同一条约束,中性色被拦、强调色放行,这正是「底色要淡」而非「整张图要淡」的体现。

P 层:同一套判据的 TS 侧

同一套判据,TypeScript 侧独立实现一遍 core/src/st12/palette.ts。它只读同一份配置,对比度复用 sp00/spec.ts 的同源常数,不 import Python 的任何东西:

// file: core/src/st12/palette.ts
// tier: P
// verify: node --test 'core/test/st12_*.test.ts'
// 高明度低饱和调色板的 TS 侧校验器(第 12 章 12.1)。与 sim/st12_palette.py 共用
// config/st12_palette.json、config/visual_spec.json、config/sp16_themes.json;判据全是字符串与
// 整数(明度 luma709、饱和度千分比、强调色数、对比度 ×10),故两侧违例集合与摘要逐位一致。
// 对比度复用 sp00/spec.ts 的同源常数。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

import { contrast10 } from "../sp00/spec.ts";

export type RGB = number[];
export type Tokens = Record<string, RGB>;
export type Proposal = { id: string; role: string; rgb: RGB; note: string };
export type Constraints = { neutralRoles: string[]; accentRoles: string[];
  lumaFloor: Record<string, number>; satCapNeutralPermille: number; accentMax: number };
export type PaletteCfg = { themeId: string; tokens: Tokens; constraints: Constraints; proposals: Proposal[] };
export type Theme = { id: string; tokens: Tokens; strokePermille: number; detailPermille: number };
export type Spec = { contrastPairs: string[][]; contrastFloor10: number };
export type Violation = { id: string; code: string };
export type PaletteReport = { tokenMatch: boolean; tokenCount: number; minContrast10: number;
  contrastFloor10: number; contrastPass: boolean; accentCount: number; accentMax: number;
  neutralSatMeanPermille: number; proposalCount: number; violationCount: number;
  counts: Record<string, number>; violations: Violation[]; canonical: string; digest: string };

/** 整数明度 ×10000 地板除:整数类,逐位一致。 */
export function luma709(c: RGB): number {
  return Math.floor((2126 * c[0] + 7152 * c[1] + 722 * c[2]) / 10000);
}

/** 饱和度千分比:整数地板除,禁用浮点。 */
export function satPermille(c: RGB): number {
  const mx = Math.max(...c);
  return mx === 0 ? 0 : Math.floor(((mx - Math.min(...c)) * 1000) / mx);
}

export function tokensEqual(a: Tokens, b: Tokens): boolean {
  const ka = Object.keys(a);
  if (ka.length !== Object.keys(b).length) return false;
  return ka.every((k) => k in b && a[k].length === b[k].length && a[k].every((v, i) => v === b[k][i]));
}

export function auditProposal(p: Proposal, cons: Constraints): string[] {
  const errs: string[] = [];
  if (cons.neutralRoles.includes(p.role)) {
    if (luma709(p.rgb) < cons.lumaFloor[p.role]) errs.push("E_LUMA_FLOOR");
    if (satPermille(p.rgb) > cons.satCapNeutralPermille) errs.push("E_SAT_CAP");
  }
  return errs;
}

export function auditPalette(cfg: PaletteCfg, spec: Spec, themes: { themes: Theme[] }): PaletteReport {
  const th = themes.themes.find((t) => t.id === cfg.themeId);
  if (!th) throw new Error("themes 缺 " + cfg.themeId + " 实例");
  const tokens = th.tokens;
  const cons = cfg.constraints;
  const minContrast10 = Math.min(...spec.contrastPairs.map(([a, b]) => contrast10(tokens[a], tokens[b])));
  const accentCount = Object.keys(tokens).filter((k) => cons.accentRoles.includes(k)).length;
  const violations: Violation[] = [];
  for (const p of cfg.proposals) for (const c of auditProposal(p, cons)) violations.push({ id: p.id, code: c });
  if (accentCount > cons.accentMax) violations.push({ id: "palette", code: "E_ACCENT_COUNT" });
  const neutrals = cons.neutralRoles;
  const neutralSatMeanPermille = Math.floor(
    neutrals.reduce((s, r) => s + satPermille(tokens[r]), 0) / neutrals.length);
  const counts: Record<string, number> = {};
  for (const v of violations) counts[v.code] = (counts[v.code] ?? 0) + 1;
  const report: PaletteReport = {
    tokenMatch: tokensEqual(tokens, cfg.tokens), tokenCount: Object.keys(tokens).length, minContrast10,
    contrastFloor10: spec.contrastFloor10, contrastPass: minContrast10 >= spec.contrastFloor10,
    accentCount, accentMax: cons.accentMax, neutralSatMeanPermille, proposalCount: cfg.proposals.length,
    violationCount: violations.length, counts, violations, canonical: "", digest: "",
  };
  report.canonical = canonical(report);
  return report;
}

/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(r: PaletteReport): string {
  return JSON.stringify({ tokenCount: r.tokenCount, minContrast10: r.minContrast10,
    proposalCount: r.proposalCount, violationCount: r.violationCount,
    neutralSatMeanPermille: r.neutralSatMeanPermille });
}

node --test 'core/test/st12_*.test.ts'(本节 4 条,三节合计 12 条)真实输出:

palette digest = 24653930f4f8368c
✔ 正例:token 与 cozy-clean 主题一致、对比度达下限、强调色不超上限 (0.477125ms)
✔ 跨语言:违例集合、计数与摘要逐位一致(整数类) (1.042042ms)
✔ 负例:明度不足、饱和度越界各自可被单独触发 (0.109125ms)
✔ 地板除锁死:明度与饱和度不得用浮点,token 相等判据逐通道比较 (0.091667ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

两条跨语言断言把 TS 复算的违例集合(id:code)、计数与 canonical 摘要逐位对上 Python 落盘的 out/st12_palette.json,sha256 前 16 位都是 24653930f4f8368c——明度、饱和度、强调色数、千分比全是整数类,逐位一致。色彩派生量(sRGB→linear、OKLab、ΔE)不在本节范围,若引入则只能写「容差内一致,实测最大偏差 4.9e-13」。第三条是负例:明度不足、饱和度越界各自可被单独触发;任何一类从「拦下」变成「放行」,这套规范就已经不可信。

自检表

检查项合格标准
高明度中性族 luma709 全部达各自下限,bg 240 ≥ 230
低饱和中性角色饱和度 ≤ 300‰,均值 168‰
强调色受控强调角色数 ≤ 4,强调色不设饱和度上限
对比度不破5 对对比度最小值 ≥ 下限 45,实测 46
账可复算违例集合与摘要跨语言逐位一致
边界清晰「好不好看」标 X,不写成「实测」

高明度低饱和定的是「底色」的整数边界,但一套清新风格还有一半在形状上:圆角多圆、柔边多宽、过渡几个带。下一节把这三样也写成整数阶梯与整除约束。

阅读导航:上一节:11.3 视觉噪声与可读性平衡 · 下一节:12.2 圆角与柔和过渡规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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