13.1 霓虹调色板与色彩分级

复古 / 赛博的霓虹感是可校验的整数约束:发光色饱和度下限 500‰、地色亮度上限 250‰,把「暗底高饱和」与「亮底低饱和」分开;再算 5 对 WCAG ×10 对比度与整数色彩分级。实测 retro-cyber 零违例,cozy-clean 有 4 项违例,分级改色却改不动地色亮度上限。

13.1 霓虹调色板与色彩分级

上一节 12.3 一致性维护 给清新 / 治愈主题(cozy-clean)定了「高明度低饱和」的维护口径;本节把镜头拉到另一端——复古 / 赛博主题 retro-cyber。霓虹感不是「画得亮」,而是暗底 + 高饱和发光色这一对约束的整数版:地色亮度压得下、发光色饱和度推得上。

色彩管理的原理(sRGB / 线性空间 / HDR 与色调映射)是 色彩管理与 HDR 管线 的主场,本书不重推;风格方向的口号式写法见 游戏美术方向与风格圣经 ,那一篇讲道理,本节讲可校验的整数账。数据全部来自 config/sp16_themes.json 的 retro-cyber 与 cozy-clean 两组 token,8 个角色色各一组。

霓虹下限:把「暗底高饱和」写成整数

先把「霓虹」翻译成两个可测的整数。饱和度用 HSV 口径 sat = (max − min) × 1000 // max(千分比,max 为 0 时记 0);亮度用 Rec.709 luma 近似 luma255 = (2126r + 7152g + 722b) // 10000,再取 luma × 1000 // 255。两个量都是整数地板除,跨语言逐位一致。约束是:emissive(发光色)饱和度 ≥ 500‰,ground(地色)亮度 ≤ 250‰。

token角色sRGBsat‰luma‰
bgground[12,14,24]50054
surfaceground[28,24,46]478101
inkemissive[120,255,240]529882
muted—[150,160,200]250627
accentemissive[255,60,140]764419
okemissive[110,240,170]541811
warnemissive[255,170,60]764705
dangeremissive[255,80,120]686470

muted 是中性次要文字色,既不参与发光也不参与地色判定(表里标「—」)——这是规格的显式划界,不是漏判。下面这段是 S 层脚本,读 config/st13_neon.json 与 sp16_themes.json,算 8 个 token 的饱和度 / 亮度、5 对对比度,以及整数分级前后的对照。

#!/usr/bin/env python3
# file: sim/st13_neon.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st13_neon.py
"""13.1 霓虹调色板与色彩分级(S 层,复古 / 赛博)。

读 config/st13_neon.json 与 sp16_themes.json 的 retro-cyber / cozy-clean 两组 token,算三笔整数账:

① 霓虹下限账:饱和度 `sat = (max − min) × 1000 // max`,亮度 `luma = luma255 × 1000 // 255`。
   发光色(emissive)饱和度 ≥ 500‰、地色(ground)亮度 ≤ 250‰ —— 把「暗底 + 高饱和发光」
   与「亮底低饱和」分开。
② 对比度账:5 对 WCAG ×10 整数口径(floor10 = 45,与卷一 ch4 同源)。
③ 分级账:整数分级 `c' = clamp(luma + (c − luma) × gain // 1000 + lift)`。分级只改色、
   不改地色亮度上限,故对亮底主题分级也变不出霓虹。

全部整数地板除,跨语言逐位一致。落 out/st13_neon.json。
"""
import hashlib
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)


def luma255(rgb):
    return (2126 * rgb[0] + 7152 * rgb[1] + 722 * rgb[2]) // 10000


def luma_permille(rgb):
    return luma255(rgb) * 1000 // 255


def sat_permille(rgb):
    """饱和度千分比(HSV 口径):max 为 0 时记 0。"""
    mx, mn = max(rgb), min(rgb)
    return 0 if mx == 0 else (mx - mn) * 1000 // mx


def grade(rgb, lift, gain):
    """整数分级:抬黑 lift + 绕 luma 的饱和度增益,逐通道 clamp 到 0–255。"""
    l = luma255(rgb)
    return [max(0, min(255, l + (c - l) * gain // 1000 + lift)) for c in rgb]


def srgb_to_linear(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 取整 → 整数类,逐位一致(与卷一 ch4 同源)。"""
    la = 0.2126 * srgb_to_linear(a[0]) + 0.7152 * srgb_to_linear(a[1]) + 0.0722 * srgb_to_linear(a[2])
    lb = 0.2126 * srgb_to_linear(b[0]) + 0.7152 * srgb_to_linear(b[1]) + 0.0722 * srgb_to_linear(b[2])
    hi, lo = max(la, lb), min(la, lb)
    return round((hi + 0.05) / (lo + 0.05) * 10)


def audit(tokens, cfg):
    """下限账 + 对比度账 → (rows, sat_fails, luma_fails, contrast, contrast_fails)。"""
    rows, sat_fails, luma_fails = [], [], []
    for name in cfg["order"]:
        rgb = tokens[name]
        s, l = sat_permille(rgb), luma_permille(rgb)
        rows.append({"token": name, "rgb": rgb, "satPermille": s, "lumaPermille": l})
        if name in cfg["emissive"] and s < cfg["floors"]["emissiveSatPermille"]:
            sat_fails.append(name)
        if name in cfg["ground"] and l > cfg["floors"]["groundLumaPermille"]:
            luma_fails.append(name)
    contrast, con_f = [], []
    for a, b in cfg["contrastPairs"]:
        c = contrast10(tokens[a], tokens[b])
        contrast.append({"pair": f"{a}/{b}", "contrast10": c, "ok": c >= cfg["contrastFloor10"]})
        if c < cfg["contrastFloor10"]:
            con_f.append(f"{a}/{b}")
    return rows, sat_fails, luma_fails, contrast, con_f


def fails_of(tokens, cfg, order):
    """对一组(可能已分级的)token 取 emissive 饱和度 / ground 亮度违例。"""
    return ([n for n in cfg["emissive"] if sat_permille(tokens[n]) < cfg["floors"]["emissiveSatPermille"]],
            [n for n in cfg["ground"] if luma_permille(tokens[n]) > cfg["floors"]["groundLumaPermille"]])


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/st13_neon.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    by_id = {t["id"]: t for t in themes["themes"]}
    tt, ct = by_id[cfg["theme"]], by_id[cfg["compareTheme"]]
    g = cfg["grading"]

    rows, sat_f, luma_f, contrast, con_f = audit(tt["tokens"], cfg)
    c_rows, c_sat_f, c_luma_f, c_contrast, c_con_f = audit(ct["tokens"], cfg)
    graded = {n: grade(tt["tokens"][n], g["lift"], g["satGainPermille"]) for n in cfg["order"]}
    c_graded = {n: grade(ct["tokens"][n], g["lift"], g["satGainPermille"]) for n in cfg["order"]}
    graded_sat = [sat_permille(graded[n]) for n in cfg["order"]]
    graded_luma = [luma_permille(graded[n]) for n in cfg["order"]]
    gs_f, gl_f = fails_of(graded, cfg, cfg["order"])
    cgs_f, cgl_f = fails_of(c_graded, cfg, cfg["order"])

    canon = json.dumps({
        "satFails": sat_f, "lumaFails": luma_f, "contrastFails": con_f,
        "cmpSatFails": c_sat_f, "cmpLumaFails": c_luma_f, "cmpContrastFails": c_con_f,
        "gradedSat": graded_sat, "gradedLuma": graded_luma,
        "gradedSatFails": gs_f, "gradedLumaFails": gl_f,
        "cmpGradedSatFails": cgs_f, "cmpGradedLumaFails": cgl_f,
    }, separators=(",", ":"))
    report = {
        "project": cfg["project"], "theme": cfg["theme"], "compareTheme": cfg["compareTheme"],
        "floors": cfg["floors"], "contrastFloor10": cfg["contrastFloor10"], "grading": g,
        "tokens": rows, "satFails": sat_f, "lumaFails": luma_f,
        "contrast": contrast, "contrastFails": con_f,
        "compare": {"tokens": c_rows, "satFails": c_sat_f, "lumaFails": c_luma_f,
                    "contrast": c_contrast, "contrastFails": c_con_f},
        "graded": [{"token": n, "rgb": graded[n], "satPermille": graded_sat[i], "lumaPermille": graded_luma[i]}
                   for i, n in enumerate(cfg["order"])],
        "gradedSatFails": gs_f, "gradedLumaFails": gl_f,
        "compareGraded": {"satFails": cgs_f, "lumaFails": cgl_f},
        "canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
    }
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "st13_neon.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"theme={cfg['theme']} compare={cfg['compareTheme']}")
    print(f"satFloorPermille={cfg['floors']['emissiveSatPermille']} groundCeilPermille={cfg['floors']['groundLumaPermille']}")
    print(f"emissive sat fails={sat_f} ground luma fails={luma_f} contrast fails={con_f}")
    print(f"compare sat fails={c_sat_f} ground luma fails={c_luma_f} contrast fails={c_con_f}")
    print(f"graded sat={graded_sat}")
    print(f"graded satFails={gs_f} lumaFails={gl_f}")
    print(f"compare graded satFails={cgs_f} lumaFails={cgl_f}")
    print(f"digest={report['digest']}")
    print("wrote out/st13_neon.json")
    return 0


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

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

theme=retro-cyber compare=cozy-clean
satFloorPermille=500 groundCeilPermille=250
emissive sat fails=[] ground luma fails=[] contrast fails=[]
compare sat fails=['ink', 'ok'] ground luma fails=['bg', 'surface'] contrast fails=[]
graded sat=[457, 483, 623, 295, 792, 654, 874, 701]
graded satFails=[] lumaFails=[]
compare graded satFails=['ink'] lumaFails=['bg', 'surface']
digest=156a04b26195071e
wrote out/st13_neon.json

读出来:retro-cyber 三项账全空(0 违例),5 对对比度是 159 / 75 / 142 / 58 / 55,全部 ≥ 45。换成 cozy-clean 跑同一套下限:ink、ok 饱和度不达标(282‰、448‰ < 500‰),bg、surface 亮度超标(941‰、862‰ > 250‰)——4 项违例,而它的对比度 5 对全过(69 / 52 / 58 / 46 / 48)。这就把「对比度合格」与「配色是霓虹」彻底分开了:cozy-clean 的可读性没问题,但它不是霓虹。

下面这段是 P 层 TypeScript 内核 core/src/st13/neon.ts,与 S 脚本逐条对应:整数饱和度 / 亮度、整数分级、下限账与对比度账(对比度复用 sp00/spec.ts 的 contrast10,转换常数两语言同源)。

// file: core/src/st13/neon.ts
// tier: P
// verify: node --test 'core/test/st13_*.test.ts'
// 13.1 霓虹调色板与色彩分级的 TS 内核:整数饱和度 / 亮度、整数分级、霓虹下限与对比度判定。
// 对比度复用 sp00/spec.ts 的 contrast10(转换常数两语言同源)。只允许可擦除 TS 语法
// (禁 enum / namespace / 参数属性 / 装饰器)。

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

export type RGB = number[];
export type TokenMap = Record<string, RGB>;

/** 整数亮度(0–255):权重 2126/7152/722 逼近 Rec.709 luma。 */
export function luma255(rgb: RGB): number {
  return Math.floor((2126 * rgb[0] + 7152 * rgb[1] + 722 * rgb[2]) / 10000);
}

/** 亮度千分比,整数地板除。 */
export function lumaPermille(rgb: RGB): number {
  return Math.floor((luma255(rgb) * 1000) / 255);
}

/** 饱和度千分比(HSV 口径):(max − min) × 1000 // max;max 为 0 时记 0。 */
export function satPermille(rgb: RGB): number {
  const mx = Math.max(rgb[0], rgb[1], rgb[2]);
  const mn = Math.min(rgb[0], rgb[1], rgb[2]);
  return mx === 0 ? 0 : Math.floor(((mx - mn) * 1000) / mx);
}

/** 整数分级:抬黑 lift + 绕 luma 的饱和度增益,逐通道 clamp 到 0–255。 */
export function grade(rgb: RGB, lift: number, gainPermille: number): RGB {
  const l = luma255(rgb);
  return rgb.map((c) => Math.max(0, Math.min(255, l + Math.floor(((c - l) * gainPermille) / 1000) + lift)));
}

/** 对一组 token 跑下限账 + 对比度账。 */
export function auditTokens(
  tokens: TokenMap,
  order: string[],
  emissive: string[],
  ground: string[],
  floors: { emissiveSatPermille: number; groundLumaPermille: number },
  contrastPairs: string[][],
  contrastFloor10: number,
): {
  rows: { token: string; rgb: RGB; satPermille: number; lumaPermille: number }[];
  satFails: string[];
  lumaFails: string[];
  contrast: { pair: string; contrast10: number; ok: boolean }[];
  contrastFails: string[];
} {
  const rows = order.map((name) => ({
    token: name, rgb: tokens[name], satPermille: satPermille(tokens[name]), lumaPermille: lumaPermille(tokens[name]),
  }));
  const satFails = emissive.filter((n) => satPermille(tokens[n]) < floors.emissiveSatPermille);
  const lumaFails = ground.filter((n) => lumaPermille(tokens[n]) > floors.groundLumaPermille);
  const contrast = contrastPairs.map(([a, b]) => {
    const c = contrast10(tokens[a], tokens[b]);
    return { pair: `${a}/${b}`, contrast10: c, ok: c >= contrastFloor10 };
  });
  const contrastFails = contrast.filter((r) => !r.ok).map((r) => r.pair);
  return { rows, satFails, lumaFails, contrast, contrastFails };
}

配套测试 core/test/st13_neon.test.ts 读 Python 落盘的 out/st13_neon.json,跑 node --test 'core/test/st13_*.test.ts':

✔ 整数饱和度/亮度:与 Python 逐位一致 (0.488916ms)
✔ 主题霓虹下限账:emissive 饱和度 / ground 亮度 / 对比度,与 Python 逐位一致 (0.501542ms)
✔ 对照主题(亮底低饱和)下限账:与 Python 逐位一致 (0.144375ms)
✔ 整数分级:分级后的 sat/luma 列表与 Python 逐位一致 (0.136333ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.400083ms)
✔ 负例:分级改色不改地色亮度上限,亮底分级后 ground 仍不达标 (0.100667ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0

六条全绿。跨语言 digest 为 156a04b26195071e:Python 与 TS 各自独立复算的整数违例集合、分级后的 sat/luma 列表逐位一致(这些都是整数类)。muted 的 627‰ 亮度说明一件事——中性色可以很亮,只要它不参与发光判定。

整数分级:只改色,不改亮度上限

分级(grading)是调色板的整数变换:c' = clamp(luma + (c − luma) × gain // 1000 + lift)。lift = 8 抬黑(霓虹招牌底噪),gain = 1300 把饱和度绕 luma 拉开 30%。把它套在 retro-cyber 上,8 个 token 的饱和度变成 457 / 483 / 623 / 295 / 792 / 654 / 874 / 701,仍然 0 违例。

这里有一个反直觉的实测结果:分级后 bg 的饱和度从 500‰ 掉到 457‰。原因不是 bug——bg = [12,14,24] 的最暗通道已经贴近 0,抬黑后 clamp 把通道差压小了。分级不是单向提饱和,它在暗部会丢饱和。同一套分级套在 cozy-clean 上,ink 的饱和度从 282‰ 抬到过了 500‰,但 bg、surface 的亮度上限照样超标——分级改色,改不动「亮底」这个事实。要真变霓虹,必须换地色取值,不是加一层滤镜。真实观感(霓虹招牌在屏幕上的实际辉光)需真实渲染环境,命令行不可复现。

自检表

检查项合格标准
下限是整数sat‰ / luma‰ 均为整数地板除,无浮点
违例集合retro-cyber 0 违例;cozy-clean 4 违例(2 饱和 + 2 亮度)
分级可逆性分级只改色值,不改 ground 亮度上限的判定
边界清晰真实辉光观感标 X,不写成「实测」

霓虹的取值定下后,下一节处理「发光」本身:光晕画多大、溢出多少、图集怎么留 padding。

阅读导航:上一节:12.3 一致性维护 · 下一节:13.2 发光与溢出规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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