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 | 角色 | sRGB | sat‰ | luma‰ |
|---|---|---|---|---|
| bg | ground | [12,14,24] | 500 | 54 |
| surface | ground | [28,24,46] | 478 | 101 |
| ink | emissive | [120,255,240] | 529 | 882 |
| muted | — | [150,160,200] | 250 | 627 |
| accent | emissive | [255,60,140] | 764 | 419 |
| ok | emissive | [110,240,170] | 541 | 811 |
| warn | emissive | [255,170,60] | 764 | 705 |
| danger | emissive | [255,80,120] | 686 | 470 |
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 发光与溢出规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。