11.1 低饱和色彩分级

暗黑与恐怖的第一步不是换色相,而是把整块调色板压饱和度。本节从 retro-cyber 的暗色档出发,用整数地板除做分级:灰值加权和加按等级往灰值收,算出每个 token 分级前后的饱和度千分比与四个等级的平均饱和度,并让 Python 与 TypeScript 各自复算、逐位对齐。

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 亮度分布与对比度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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