2.1 从「治愈」「硬核」到可测参数

「治愈」「硬核」是形容词,不是参数。这一节把它们落成四个可测的整数区间——明度中位数、饱和度均值、对比度、色温代理——再用同一套区间判定《星屑旅店》的六个主题包,并把判定逻辑在 Python 与 TypeScript 两侧逐位对齐,给出可复算的风格分类。

站内《2026 游戏美术方向与风格圣经》 用 73 行口号式提纲把「治愈」「硬核」这类词铺开,读起来提气,但它不回答一个工程问题:评审会上,凭什么判定一个交付物到底是不是「治愈」? 本节与 2.2、2.3 做的是另一件事——把形容词变成可测参数与可跑的风格距离:区间写进配置、判定用整数算、Python 与 TypeScript 两侧结果逐位一致。第 1 章已经把生产链路与 TA 的位置铺开了(1.3 技术美术的位置:桥接美术与程序(含 ART_SCOPE 分工表) ),从这一节起,我们开始把「视觉目标」落成数字。

形容词不是参数,是区间的名字

「治愈」「硬核」是形容词。它们进不了配置、写不进验收标准、也没法在 CI 里断言。要把它们变成工程语言,只有一条路:给每个形容词划一个可测参数的区间,形容词就成了区间的名字。

三类词要分开处理:

词能否落成区间处理方式
「治愈」「冷峻」能拆成明度、饱和度、色温、对比度四个可测维度
「高级感」「有氛围」不能退回更具体的词,否则只是把问题往后推
「像某某游戏」部分能先取色、量特征,再退化成上面的可测维度

一个反直觉但重要的事实:形容词不是互斥标签。一个调色板可以同时是「硬核」且「冷峻」——本文的 retro-cyber 就是。区间是可以嵌套的,不是分类器的互斥类别。把它当分类器用,评审会就会陷入「这套到底是治愈还是清新」的无意义争论。

四个可测维度

本节只取四个维度,全部是整数:

维度定义取值类别
lumaMedian四色 Rec.709 整数亮度 (2126R+7152G+722B)//10000 的中位数0–255整数类,逐位一致
satMeanPermille四色 HSV 饱和度 (max-min)*1000//max 的整数均值0–1000整数类,逐位一致
contrast10最亮/最暗两色的 WCAG 对比度 ×1010–210整数类,逐位一致
warmthPermille四色 (R-B)*1000//255 的整数均值−1000–1000整数类,逐位一致

四个都是整数,所以跨语言是逐位一致,没有容差。这是本节与 2.2 的关键差别:2.2 会引入 OKLab 浮点派生量,那里才需要容差口径。contrast10 直接复用第 3、4 章的同一套公式,不另起炉灶。

S 层:把六个主题包算成向量

下面这段是 S 层脚本 sim/sp02_adjective_params.py。它读 config/sp02_adjectives.json,把六个主题包的四色 sRGB 真源算成四维整数向量,再用形容词区间判定命中集合,落盘 out/sp02_adjectives.json 供 P 层对照。

#!/usr/bin/env python3
# file: sim/sp02_adjective_params.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp02_adjective_params.py
"""形容词 → 可测参数(S 层,全整数,跨语言逐位一致)。

四维:lumaMedian=Rec.709 整数亮度中位数(0-255);satMeanPermille=HSV 饱和度均值千分比;
contrast10=最亮/最暗 WCAG 对比度×10;warmthPermille=(R-B)*1000//255 均值(可负)。
产出 out/sp02_adjectives.json(P 层读它对照)+ out/sp02_adjectives.csv。
"""
import csv
import hashlib
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/sp02_adjectives.json")
OUT = os.path.join(ROOT, "out")
DIMS = ("lumaMedian", "satMeanPermille", "contrast10", "warmthPermille")


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 取整 → 整数类。与 sp00 同口径。"""
    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 luma709(c):
    return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000


def median_int(xs):
    s = sorted(xs)
    n = len(s)
    return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) // 2


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


def mean_int(xs):
    return sum(xs) // len(xs)


def warmth_permille(c):
    return (c[0] - c[2]) * 1000 // 255


def vector(pal):
    cols = [pal[k] for k in sorted(pal)]
    by = sorted(cols, key=luma709)
    return {
        "lumaMedian": median_int([luma709(c) for c in cols]),
        "satMeanPermille": mean_int([sat_permille(c) for c in cols]),
        "contrast10": contrast10(by[0], by[-1]),
        "warmthPermille": mean_int([warmth_permille(c) for c in cols]),
    }


def classify(v, adjectives):
    """闭区间;形容词只约束它写出的维度,未写出的维度不参与判定。"""
    return [name for name in sorted(adjectives)
            if all(lo <= v[d] <= hi for d, (lo, hi) in adjectives[name].items())]


def digest(rows):
    parts = [f"{n}:{','.join(str(rows[n]['vector'][d]) for d in DIMS)}:{'+'.join(rows[n]['adjectives'])}"
             for n in sorted(rows)]
    return hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]


def main():
    spec = json.load(open(SPEC, encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    rows = {}
    for name, pal in spec["themes"].items():
        v = vector(pal)
        rows[name] = {"vector": v, "adjectives": classify(v, spec["adjectives"])}
    coverage = {a: sorted(n for n in rows if a in rows[n]["adjectives"]) for a in spec["adjectives"]}
    summary = {"game": spec["game"], "specVersion": spec["version"], "dims": spec["dims"],
               "adjectives": spec["adjectives"], "vectors": {n: rows[n]["vector"] for n in sorted(rows)},
               "classification": {n: rows[n]["adjectives"] for n in sorted(rows)}, "coverage": coverage,
               "uncovered": sorted(n for n in rows if not rows[n]["adjectives"]), "digest": digest(rows)}
    with open(os.path.join(OUT, "sp02_adjectives.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    with open(os.path.join(OUT, "sp02_adjectives.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["theme", *DIMS, "adjectives"])
        for n in sorted(rows):
            w.writerow([n, *[rows[n]["vector"][d] for d in DIMS], "+".join(rows[n]["adjectives"]) or "-"])
    for n in sorted(rows):
        v = rows[n]["vector"]
        print(f"{n:12s} lumaMed={v['lumaMedian']:3d} satMean={v['satMeanPermille']:4d} "
              f"cr10={v['contrast10']:3d} warmth={v['warmthPermille']:5d}  -> {'+'.join(rows[n]['adjectives']) or '-'}")
    print(f"coverage={json.dumps(coverage, ensure_ascii=False, sort_keys=True)}")
    print(f"uncovered={summary['uncovered']} digest={summary['digest']}")
    print("wrote out/sp02_adjectives.json out/sp02_adjectives.csv")


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

参数放在 config/sp02_adjectives.json:themes 是六套四色真源,adjectives 是四个形容词的闭区间——只约束它写出的维度,没写出的维度不参与判定。

{
  "version": 1,
  "game": "stardust-inn",
  "observedAt": "2026-10",
  "note": "形容词→可测参数。四个维度全是整数:lumaMedian=四色 Rec.709 整数亮度 (2126R+7152G+722B)//10000 的中位数(0-255);satMeanPermille=四色 HSV 饱和度 (max-min)*1000//max 的整数均值(0-1000);contrast10=最亮/最暗两色的 WCAG 对比度×10;warmthPermille=四色 (R-B)*1000//255 的整数均值(可负)。themes=6 个主题包的四色 sRGB 8-bit 真源;adjectives=形容词的参数区间(闭区间、整数、只约束写出的维度)。区间是设计决策,向量是实测。",
  "dims": ["lumaMedian", "satMeanPermille", "contrast10", "warmthPermille"],
  "themes": {
    "pixel": { "bg": [26, 28, 44], "mid": [64, 80, 120], "ink": [240, 240, 232], "accent": [255, 209, 102] },
    "lowpoly": { "bg": [140, 180, 210], "mid": [90, 140, 110], "ink": [245, 245, 240], "accent": [230, 140, 90] },
    "handdrawn": { "bg": [250, 246, 238], "mid": [232, 220, 200], "ink": [70, 60, 52], "accent": [220, 140, 110] },
    "voxel": { "bg": [40, 34, 52], "mid": [88, 72, 110], "ink": [230, 220, 240], "accent": [90, 200, 180] },
    "retro-cyber": { "bg": [12, 14, 24], "mid": [40, 30, 70], "ink": [120, 255, 240], "accent": [255, 60, 140] },
    "cozy-clean": { "bg": [245, 240, 230], "mid": [224, 214, 199], "ink": [92, 80, 66], "accent": [240, 168, 120] }
  },
  "adjectives": {
    "治愈": { "lumaMedian": [180, 255], "satMeanPermille": [0, 300] },
    "明快": { "lumaMedian": [140, 255], "satMeanPermille": [320, 1000] },
    "硬核": { "lumaMedian": [0, 140], "contrast10": [110, 210] },
    "冷峻": { "lumaMedian": [0, 100], "warmthPermille": [-1000, 0] }
  }
}

实测输出

/opt/homebrew/bin/python3 sim/sp02_adjective_params.py 在本机的输出(同机相对量级,绝对数值不可移植):

cozy-clean   lumaMed=197 satMean= 238 cr10= 69 warmth=  181  -> 治愈
handdrawn    lumaMed=187 satMean= 235 cr10=100 warmth=  168  -> 治愈
lowpoly      lumaMed=164 satMean= 329 cr10= 35 warmth=   53  -> 明快
pixel        lumaMed=145 satMean= 377 cr10=147 warmth=   85  -> 明快
retro-cyber  lumaMed= 71 satMean= 591 cr10=159 warmth=  -47  -> 冷峻+硬核
voxel        lumaMed=126 satMean= 331 cr10=116 warmth= -132  -> 硬核
coverage={"冷峻": ["retro-cyber"], "明快": ["lowpoly", "pixel"], "治愈": ["cozy-clean", "handdrawn"], "硬核": ["retro-cyber", "voxel"]}
uncovered=[] digest=512bc2e8b0a8c999

三点值得看:

  • 覆盖完整:uncovered=[],六个主题包每个都至少命中一个形容词,四个形容词每个都至少有两个主题包。这说明区间不是为某一套主题量身定做的,否则换一套主题就会大面积落空。
  • 嵌套真实存在:retro-cyber 同时命中「冷峻」与「硬核」,因为「冷峻」(明度 ≤ 100 且色温 ≤ 0)是「硬核」(明度 ≤ 140 且对比度 ≥ 110)的真子集。这就是上一节说「形容词不是互斥标签」的可复算证据。
  • 区间是设计决策,向量是实测:197/238/69/181 这串数字是从 [245,240,230] 这些 sRGB 整数算出来的,不是我写死的;而 [180,255] 这样的区间才是我定的。两者在正文里必须分清,否则「量化」就退化成了「拍脑袋填数」。

P 层:同一套区间在 TS 里复算

P 层内核 core/src/sp02/params.ts 用同一套整数公式复算,并从 sp00/spec.ts 复用 contrast10,保证对比度口径与全书同源:

// file: core/src/sp02/params.ts
// tier: P
// verify: node --test 'core/test/sp02_params.test.ts'
// 形容词 → 可测参数的 TS 侧内核,与 sim/sp02_adjective_params.py 逐位对齐:四维全是整数,
// 没有容差。contrast10 从 sp00 复用,保证对比度口径与全书同源。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
import { contrast10 } from "../sp00/spec.ts";

export type RGB = number[];
export type Palette = Record<string, RGB>;
export type Vector = Record<string, number>;
export type Ranges = Record<string, Record<string, number[]>>;
export const DIMS = ["lumaMedian", "satMeanPermille", "contrast10", "warmthPermille"];

/** Rec.709 整数亮度,地板除 —— 不经过浮点。 */
export function luma709(c: RGB): number {
  return Math.floor((2126 * c[0] + 7152 * c[1] + 722 * c[2]) / 10000);
}

/** 整数中位数:偶数个取中间两个的地板平均。 */
export function medianInt(xs: number[]): number {
  const s = [...xs].sort((a, b) => a - b);
  const n = s.length;
  return n % 2 === 1 ? s[(n - 1) / 2] : Math.floor((s[n / 2 - 1] + s[n / 2]) / 2);
}

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

export function meanInt(xs: number[]): number {
  return Math.floor(xs.reduce((a, b) => a + b, 0) / xs.length);
}

/** 色温代理:(R - B) / 255 × 1000,整数地板除,可负。 */
export function warmthPermille(c: RGB): number {
  return Math.floor(((c[0] - c[2]) * 1000) / 255);
}

/** 调色板(任意色数)→ 四维整数向量。 */
export function vector(pal: Palette): Vector {
  const cols = Object.keys(pal).sort().map((k) => pal[k]);
  const by = [...cols].sort((a, b) => luma709(a) - luma709(b));
  return {
    lumaMedian: medianInt(cols.map(luma709)),
    satMeanPermille: meanInt(cols.map(satPermille)),
    contrast10: contrast10(by[0], by[by.length - 1]),
    warmthPermille: meanInt(cols.map(warmthPermille)),
  };
}

/** 闭区间判定:形容词只约束它写出的维度,未写出的维度不参与。 */
export function classify(v: Vector, adjectives: Ranges): string[] {
  return Object.keys(adjectives).sort().filter((name) =>
    Object.keys(adjectives[name]).every((d) => {
      const [lo, hi] = adjectives[name][d];
      return lo <= v[d] && v[d] <= hi;
    }));
}

/** 跨语言指纹:与 Python 侧同格式,sha256 前 16 位。 */
export function digest(rows: Record<string, { vector: Vector; adjectives: string[] }>): string {
  const parts = Object.keys(rows).sort().map((n) =>
    `${n}:${DIMS.map((d) => String(rows[n].vector[d])).join(",")}:${rows[n].adjectives.join("+")}`);
  return createHash("sha256").update(parts.join(";"), "utf8").digest("hex").slice(0, 16);
}

测试读的是 Python 落盘的 out/sp02_adjectives.json(不是同一份源码),做跨语言对照:

$ node --test 'core/test/sp02_params.test.ts'
✔ 向量:四维整数与 Python 逐位一致
✔ 分类:命中集合与 Python 逐项一致
✔ 覆盖:每个形容词都有主题、每个主题都被覆盖
✔ sha256 指纹跨语言一致
✔ 负例:区间必须真的能拒绝越界调色板
✔ 整数性:三个原始度量都是整数(无浮点泄漏)
ℹ tests 6   ℹ pass 6   ℹ fail 0

digest=512bc2e8b0a8c999 是两侧各自对「主题名 + 四维整数 + 命中形容词」算的 sha256 前 16 位。它相等,说明六个向量与六条命中集合逐位一致。本节的量全是整数,所以这里可以放心写「逐位一致」;2.2 引入 OKLab 之后就只能写「容差内一致,实测最大偏差 4.99e-13」。

边界与反例

  • 区间不能凭空定。上面的区间是按六个主题包的设计意图划的;换一批主题包,区间要重划。区间是团队共识的载体,不是客观真理,更不是从数据里「学」出来的。
  • 四个维度不够。它们只覆盖色彩。剪影、细节密度、描边要到 2.2 才进来;等距投影的几何约束要到第 9 章。
  • X 层:「高级感」「有氛围」这类词,以及画师对具体笔触的审美判断,需真实画师与真实评审环境,命令行不可复现,本节不做任何数值化,只把它们退回更具体的词。

自检表

检查项合格标准
形容词有区间每个进评审表的形容词在 config 里都有闭区间
区间可复算向量四维全部整数,无浮点、无 Math.random
跨语言一致Python 与 TS 的向量、命中集合、digest 逐位一致
有负例越界调色板必须被判「无命中」,不能静默通过
边界诚实不可测形容词明确标 X,不硬凑数字

阅读导航:上一节:1.3 技术美术的位置:桥接美术与程序(含 ART_SCOPE 分工表) · 下一节:2.2 风格向量与风格距离 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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