11.1 低饱和色彩分级
《游戏美术风格指南 2026》 是一篇 73 行的口号式提纲——它讲的是「暗黑风要压抑、要克制、要让玩家不安」这类方向;本章不讲方向。本章只把「压抑」落成可跑的整数账:低饱和到底要压掉多少饱和度、压完以后对比度还剩多少。分工一句话:那篇讲为什么压,本章算压多少。上一节 10.3 X:画质 把二次元的画质划到 X,本节开始进入暗黑/恐怖的第一笔账。色彩的底层空间与 token 体系见卷一 4.1 sRGB 与线性空间 ,本章不重推。
暗黑与恐怖的通用手法是压饱和度:不换色相,只把每个通道往灰值收。这样做的道理是——高饱和度在暗底上会「跳」出来,破坏压抑感;收掉饱和度后,颜色之间只剩明度差,画面自然沉下去。问题是「压多少」不能靠眼睛调,得有一条可复算的规则:
| 约束 | 为什么是整数 | 本节落在哪 |
|---|---|---|
| 灰值 | 加权和地板除,不是浮点亮度 | 分级函数 |
| 分级通道 | 往灰值收的百分比是整数档位 | 四个等级 |
| 饱和度千分比 | 饱和度是计数的,不是「差不多」 | 分级前后对照 |
分级函数只有两行:先把 token 折成灰值 gray = (r*299 + g*587 + b*114) // 1000,再把每个通道往灰值收 level 个百分点 gray + ((c - gray) * level) // 100。level = 100 就是原色,level = 0 就是纯灰。除法全是整数地板除,Python 的 // 与 JS 的 Math.floor 对负数同向取整,所以这条规则跨语言逐位一致。
下面这段是 D 层输入,把 retro-cyber 的原 token、分级参数与分级后的暗色 token 记成一份数据。baseTokens 取自 config/sp16_themes.json 的 retro-cyber(第 13 章取它的霓虹侧,本章只取暗侧),tokens 是分级结果。数值是建模输入,真实屏幕观感需真实显示设备,命令行不可复现(X)。
{
"version": 1,
"project": "stardust-inn",
"theme": "retro-cyber",
"note": "暗黑/恐怖章输入。baseTokens 是 config/sp16_themes.json 的 retro-cyber 原 token(只读引用,第 13 章取它的霓虹侧),本章只取暗侧:用整数地板除把饱和度按等级压下来得到 tokens(分级后的暗色档)。tokens 段与分级结果逐位一致,由 sim/st11_grading.py 断言。亮度带、噪声预算与图集口径均取整数。",
"grading": {
"levels": [100, 60, 35, 20],
"target": 35,
"weights": [299, 587, 114],
"note": "灰值 gray = (r*299 + g*587 + b*114) // 1000;分级通道 = gray + ((c - gray) * level) // 100。全整数地板除,Python 与 TS 逐位一致。level=100 即原 token。"
},
"baseTokens": {
"bg": [12, 14, 24], "surface": [28, 24, 46], "ink": [120, 255, 240], "muted": [150, 160, 200],
"accent": [255, 60, 140], "ok": [110, 240, 170], "warn": [255, 170, 60], "danger": [255, 80, 120]
},
"tokens": {
"bg": [13, 14, 17], "surface": [27, 25, 33], "ink": [179, 227, 221], "muted": [157, 160, 174],
"accent": [171, 103, 131], "ok": [163, 209, 184], "warn": [207, 177, 139], "danger": [177, 116, 130]
},
"luminance": {
"bandEdges": [0, 50, 150, 300, 500, 1000],
"minLadderGapPermille": 5,
"note": "bandEdges 是相对亮度千分比的整数分带边界;minLadderGapPermille 是排序后相邻 token 亮度千分比的最小可接受间隔。"
},
"noise": {
"minStrokePx": 2,
"budgetPermille": 300,
"minDetailContrast10": 45,
"note": "噪声千分比 = 细节笔画数 × 最小笔画宽 × 笔画均长 × 1000 // 图标面积;细节对比度低于 minDetailContrast10 即细节在暗色底上看不见。",
"icons": [
{ "id": "bed", "displayPx": 32, "detailStrokes": 14, "detailContrast10": 58 },
{ "id": "lamp", "displayPx": 32, "detailStrokes": 6, "detailContrast10": 41 },
{ "id": "rug", "displayPx": 48, "detailStrokes": 22, "detailContrast10": 72 },
{ "id": "clock", "displayPx": 24, "detailStrokes": 4, "detailContrast10": 63 },
{ "id": "chair", "displayPx": 32, "detailStrokes": 8, "detailContrast10": 52 },
{ "id": "chest", "displayPx": 48, "detailStrokes": 18, "detailContrast10": 38 }
]
},
"atlas": { "width": 1024, "height": 1024, "cell": 64, "icons": 37 },
"budget": { "baseTexture": 1024, "bytesPerPixel": 4 },
"generated": [
{ "id": "dark_grading", "file": "prism/out/gen/st11_grading_strip.png", "width": 192, "height": 48, "mode": "RGB", "tokenCount": 8, "distinctColors": 8 },
{ "id": "dark_luminance", "file": "prism/out/gen/st11_luminance_ladder.png", "width": 256, "height": 64, "mode": "RGB" },
{ "id": "dark_noise", "file": "prism/out/gen/st11_noise_grid.png", "width": 192, "height": 96, "mode": "RGB" }
]
}
下面这段是 S 层脚本,读 config/st11_dark.json 与 sp16_themes.json 的 retro-cyber 主题,断言 target 级分级结果与 tokens 一致,算每个 token 分级前后的饱和度千分比与四个等级的平均饱和度,并 PIL 生成分级色卡 PNG 回读尺寸/模式/颜色数。
#!/usr/bin/env python3
# file: sim/st11_grading.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st11_grading.py
"""11.1 低饱和色彩分级(S 层,暗黑/恐怖)。
暗黑与恐怖的通用做法是把调色板整体「压饱和度」:不是换色相,而是把每个通道往灰值收。
脚本读 config/st11_dark.json,从 sp16_themes.json 取 retro-cyber 原 token(第 13 章取它的
霓虹侧,本章只取暗侧),用整数地板除做分级:gray = (r*299+g*587+b*114)//1000,
通道 = gray + ((c-gray)*level)//100。算三笔整数账:① 断言 target 级分级结果 == config.tokens;
② 每个 token 分级前后的饱和度千分比;③ 四个等级下的平均饱和度千分比。PIL 生成分级色卡 PNG
并回读尺寸/模式/颜色数。落 out/st11_grading.json 供 P 层对照。
"""
import hashlib
import json
import os
import sys
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def gray_of(c, w):
"""整数灰值:加权和地板除。"""
return (c[0] * w[0] + c[1] * w[1] + c[2] * w[2]) // 1000
def grade(c, level, w):
"""整数分级:每个通道往灰值收 level 个百分点,地板除。level=100 即原色。"""
g = gray_of(c, w)
return [g + ((ch - g) * level) // 100 for ch in c]
def sat_permille(c):
"""HSV 式饱和度千分比:(max-min)*1000 // max。全整数。"""
hi, lo = max(c), min(c)
return 0 if hi == 0 else (hi - lo) * 1000 // hi
def gen_strip(tokens, path, size):
"""分级色卡:每个 token 占等宽一列,颜色数 == token 数。"""
w, h = size
im = Image.new("RGB", (w, h), tuple(next(iter(tokens.values()))))
d = ImageDraw.Draw(im)
n = len(tokens)
for i, c in enumerate(tokens.values()):
d.rectangle([i * w // n, 0, (i + 1) * w // n - 1, h - 1], fill=tuple(c))
im.save(path)
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st11_dark.json"), encoding="utf-8"))
themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
theme = next(t for t in themes["themes"] if t["id"] == cfg["theme"])
base, w = cfg["baseTokens"], cfg["grading"]["weights"]
assert base == theme["tokens"], "config.baseTokens 与 retro-cyber 主题不一致"
target = cfg["grading"]["target"]
graded = {k: grade(v, target, w) for k, v in base.items()}
assert graded == cfg["tokens"], "target 级分级结果与 config.tokens 不一致"
by_level = {}
for lv in cfg["grading"]["levels"]:
sats = [sat_permille(grade(v, lv, w)) for v in base.values()]
by_level[lv] = sum(sats) // len(sats)
per_token = {k: {"baseSatPermille": sat_permille(base[k]),
"gradedSatPermille": sat_permille(graded[k]),
"deltaPermille": sat_permille(base[k]) - sat_permille(graded[k])}
for k in base}
base_mean = sum(sat_permille(v) for v in base.values()) // len(base)
graded_mean = sum(sat_permille(v) for v in graded.values()) // len(graded)
desaturated = sum(1 for k in base if per_token[k]["deltaPermille"] > 0)
gen = next(g for g in cfg["generated"] if g["id"] == "dark_grading")
png = os.path.join(os.path.dirname(ROOT), gen["file"])
os.makedirs(os.path.dirname(png), exist_ok=True)
gen_strip(graded, png, (gen["width"], gen["height"]))
with Image.open(png) as im:
info = {"size": list(im.size), "mode": im.mode,
"colors": len(set(im.convert("RGB").get_flattened_data()))}
canon = json.dumps({"graded": {k: graded[k] for k in sorted(graded)},
"gradedMeanSatPermille": graded_mean,
"levels": {str(lv): by_level[lv] for lv in sorted(by_level)}},
separators=(",", ":"), ensure_ascii=False)
report = {"project": cfg["project"], "theme": cfg["theme"], "target": target,
"tokens": graded, "perToken": per_token,
"baseMeanSatPermille": base_mean, "gradedMeanSatPermille": graded_mean,
"desaturated": desaturated, "levelMeanSatPermille": {str(k): by_level[k] for k in sorted(by_level)},
"png": info, "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, "st11_grading.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']} target={target} desaturated={desaturated}/{len(base)}")
print(f"baseMeanSatPermille={base_mean} gradedMeanSatPermille={graded_mean}")
for lv in sorted(by_level):
print(f" level={lv:<4} meanSatPermille={by_level[lv]}")
for k in sorted(per_token):
p = per_token[k]
print(f" {k:<8} {base[k]} sat{p['baseSatPermille']:>4} -> {graded[k]} sat{p['gradedSatPermille']:>4}")
print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']} colors={info['colors']}")
print(f"digest={report['digest']}")
print("wrote out/st11_grading.json out/gen/st11_grading_strip.png")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/st11_grading.py(同机相对量级,绝对数值不可移植):
theme=retro-cyber target=35 desaturated=8/8
baseMeanSatPermille=564 gradedMeanSatPermille=259
level=20 meanSatPermille=162
level=35 meanSatPermille=259
level=60 meanSatPermille=400
level=100 meanSatPermille=564
accent [255, 60, 140] sat 764 -> [171, 103, 131] sat 397
bg [12, 14, 24] sat 500 -> [13, 14, 17] sat 235
danger [255, 80, 120] sat 686 -> [177, 116, 130] sat 344
ink [120, 255, 240] sat 529 -> [179, 227, 221] sat 211
muted [150, 160, 200] sat 250 -> [157, 160, 174] sat 97
ok [110, 240, 170] sat 541 -> [163, 209, 184] sat 220
surface [28, 24, 46] sat 478 -> [27, 25, 33] sat 242
warn [255, 170, 60] sat 764 -> [207, 177, 139] sat 328
png 192x48 RGB colors=8
digest=d3649b75d9447500
wrote out/st11_grading.json out/gen/st11_grading_strip.png
平均饱和度千分比随等级单调下降:level=100 是 564,level=60 是 400,level=35 是 259,level=20 是 162。本章取 target=35,8 个 token 全部被压过饱和度(desaturated=8/8)。最猛的是 accent:764 → 397,掉了 367 个千分比;muted 本来就低(250 → 97),压完几乎变灰。这正是暗黑风要的效果——强调色不再抢眼,只剩一个「暗红」的印象。
下面这段是 P 层 TypeScript 内核 core/src/st11/grading.ts。grade 用整数地板除把通道往灰值收,satPermille 求饱和度千分比,meanSatPermille 求平均——与 Python 侧同一套规则。
// file: core/src/st11/grading.ts
// tier: P
// verify: node --test 'core/test/st11_*.test.ts'
// 11.1 低饱和色彩分级的 TS 内核:灰值加权和与按等级往灰值收的整数分级,以及饱和度千分比。
// 分级是整数地板除(Python // 与 JS Math.floor 对负数同向取整),逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type RGB = number[];
/** 整数灰值:加权和地板除。 */
export function grayOf(c: RGB, w: number[]): number {
return Math.floor((c[0] * w[0] + c[1] * w[1] + c[2] * w[2]) / 1000);
}
/** 整数分级:每个通道往灰值收 level 个百分点,地板除。level=100 即原色。 */
export function grade(c: RGB, level: number, w: number[]): RGB {
const g = grayOf(c, w);
return c.map((ch) => g + Math.floor(((ch - g) * level) / 100));
}
/** HSV 式饱和度千分比:(max-min)*1000 // max。全整数。 */
export function satPermille(c: RGB): number {
const hi = Math.max(...c);
const lo = Math.min(...c);
return hi === 0 ? 0 : Math.floor(((hi - lo) * 1000) / hi);
}
/** 一组 token 的平均饱和度千分比(整数地板除)。 */
export function meanSatPermille(tokens: Record<string, RGB>): number {
const keys = Object.keys(tokens).sort();
const sum = keys.reduce((acc, k) => acc + satPermille(tokens[k]), 0);
return Math.floor(sum / keys.length);
}
配套测试 core/test/st11_grading.test.ts 读 Python 落盘的 out/st11_grading.json(不是同一份源码),跑 node --test 'core/test/st11_grading.test.ts':
✔ 整数分级:通道往灰值收,负数通道与 Python 同向取整 (0.781709ms)
✔ target 级分级结果与 config.tokens 逐位一致 (0.11575ms)
✔ 饱和度千分比与平均饱和度:与 Python 逐位一致 (0.11225ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.385875ms)
✔ 负例:分级单调——等级越低,平均饱和度不升 (0.139417ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
五条全绿,其中「跨语言」一条比的是 canonical 摘要与 sha256 指纹 d3649b75d9447500——整数类逐位一致。分级规则里没有需要容差断言的浮点量:灰值是整数地板除,分级通道是整数地板除,饱和度千分比也是整数地板除。测试全文见工作区 core/test/st11_grading.test.ts。
自检表
| 检查项 | 合格标准 |
|---|---|
| 分级可复算 | 通道 = 灰值 + (通道 − 灰值) × 等级 // 100,全整数 |
| 等级单调 | 等级越低,平均饱和度千分比不升 |
| target 可对账 | target 级结果与 tokens 逐位一致(脚本断言) |
| 边界清晰 | 真实屏幕观感标 X,不写成「实测」 |
饱和度压下来以后,颜色之间只剩明度差——下一节算亮度分布这笔账,看暗色调色板还剩多少对比度可用。
阅读导航:上一节:10.3 X:画质 · 下一节:11.2 亮度分布与对比度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。