1.1 三个可测维度:色彩 / 形状 / 细节

风格能不能被量成几个数?本节把第一卷的五维风格向量拆回人话——色彩(明度中位数 / 饱和度均值 / 色相跨度)、形状(描边占比)、细节(细节密度)三个可测维度,并做一次消融对照:只用色彩三维时最近一对会掉到可区分下限以下,补上形状与细节才恢复,这正是「三个维度一个都不能少」的整数证据。

《数据可视化中的视觉编码与感知》 从图表口径回答「人眼怎么区分两个视觉编码」;本节换游戏美术口径问另一件事:一套美术风格能不能被量成几个数,让「像不像」变成可算的距离。站内 游戏美术方向与风格圣经短文 是口号式的道理,本卷把它落成可校验的参数与账。第一卷 2.1 从「治愈」「硬核」到可测参数 与 2.2 风格向量与风格距离 已经立起五维向量,卷二 16.2 双案例回放 用它跑过六主题的距离矩阵;本卷第一节先把那五维拆回人话:色彩、形状、细节三个可测维度。

1.1 三个可测维度:色彩 / 形状 / 细节

「治愈」「硬核」「复古」这些词没法直接比较,但色彩、形状、细节可以。三者不是三个形容词,而是同一份风格向量的三个归组——五个整数维度按「测的是什么」分成三堆,这份归组就写在只读真源 config/sp16_themes.json 的 dims 里:

可测维度包含的整数维度从哪来
色彩lumaMedianPermille / satMeanPermille / hueSpanPermille四角色色的 sRGB 8-bit 真源算出
形状strokePermille描边像素占 tile 边长的千分比,设计声明
细节detailPermille有效细节像素占图块面积的千分比,设计声明

色彩之所以占三个维度,是因为一个维度不够用:明度中位数回答「整体偏亮还是偏暗」,饱和度均值回答「艳还是素」,色相跨度回答「同色系还是撞色」。这里有一条常被忽略的实测事实:WCAG 对比度只由相对亮度决定,与色相无关——把强调色从琥珀换到天蓝,相对亮度只差 0.0012,五对对比度一个数都没动。所以「对比度合格」既不保证配色好看,也不保证色相有区分度;色相必须单独占一维。前三维从真源算出来,后两维参考板给不出,只能由美术声明——把它们放进同一个向量,前提是五维都在同一个 0–1000 千分比刻度上。

S 层:六主题的三维向量与消融对照

下面这段是 S 层脚本 sim/st01_dimensions.py。它读只读真源 config/sp16_themes.json,算六主题的五维整数风格向量,再按三个可测维度归组,并做一次消融对照:把形状与细节两维去掉、只用色彩三维,看最近一对是否还站得住。全整数、地板除。

#!/usr/bin/env python3
# file: sim/st01_dimensions.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st01_dimensions.py
"""卷三 1.1 三个可测维度(S 层):色彩 / 形状 / 细节。

读只读真源 config/sp16_themes.json(6 个主题包 + dims/weights/distinguishableFloor10),
把五维风格向量按**三个可测维度**归组:色彩 = lumaMedian/satMean/hueSpan,形状 = stroke,
细节 = detail。全整数、地板除,逐位一致。

再做一次**消融对照**:只用色彩三维(权重子集 [2,2,1])时,最近一对会掉到可区分下限以下;
补上形状与细节(权重 [2,2,1,3,3])后,15 对全部回到下限之上——这就是「三个维度一个都不能少」
的整数证据。产出 out/st01_dimensions.json + .svg,供 P 层 TS 侧对照。
"""
import hashlib
import itertools
import json
import os
import sys
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
THEMES = os.path.join(ROOT, "config/sp16_themes.json")
OUT = os.path.join(ROOT, "out")

# 四角色色的取用顺序与卷二 16.2 一致,两卷摘要才能对拍。
ROLES = ["accent", "bg", "ink", "surface"]
# 五维按三个可测维度归组:色彩占 0-2,形状占 3,细节占 4。
AXES = {"color": [0, 1, 2], "shape": [3], "detail": [4]}


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 = max(c)
    return 0 if mx == 0 else (mx - min(c)) * 1000 // mx


def hue_deg(c):
    """整数度色相(地板除,与全书整数纪律一致);灰返回 None。"""
    mx, mn = max(c), min(c)
    d = mx - mn
    if d == 0:
        return None
    if mx == c[0]:
        h = 60 * (c[1] - c[2]) / d
    elif mx == c[1]:
        h = 60 * ((c[2] - c[0]) / d + 2)
    else:
        h = 60 * ((c[0] - c[1]) / d + 4)
    return int(h % 360)


def style_vector(t):
    """五维整数风格向量:色彩三维 + 形状一维 + 细节一维。"""
    cols = [t["tokens"][k] for k in ROLES]
    hues = [h for h in (hue_deg(c) for c in cols) if h is not None]
    span = (max(hues) - min(hues)) if len(hues) >= 2 else 0
    luma = median_int([luma709(c) for c in cols])
    return [luma * 1000 // 255, sum(sat_permille(c) for c in cols) // len(cols),
            span * 1000 // 360, int(t["strokePermille"]), int(t["detailPermille"])]


def dist10(a, b, weights, dims):
    """选定维度上的加权 L1 整数均值再 ×10 取整 → 整数类,逐位一致。"""
    acc = sum(weights[i] * abs(a[i] - b[i]) for i in dims)
    return (acc * 10) // sum(weights[i] for i in dims)


def main():
    cfg = json.load(open(THEMES, encoding="utf-8"))
    weights, floor10 = cfg["weights"], cfg["distinguishableFloor10"]
    names = [t["id"] for t in cfg["themes"]]
    vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
    color_dims = AXES["color"]
    full_dims = [0, 1, 2, 3, 4]
    pairs = list(itertools.combinations(names, 2))
    color = {f"{a}/{b}": dist10(vecs[a], vecs[b], weights, color_dims) for a, b in pairs}
    full = {f"{a}/{b}": dist10(vecs[a], vecs[b], weights, full_dims) for a, b in pairs}
    color_min = min(color.values())
    full_min = min(full.values())
    # 消融:只用色彩三维时,有多少对掉到可区分下限以下。
    color_below = sorted(k for k, d in color.items() if d <= floor10)
    full_below = sorted(k for k, d in full.items() if d <= floor10)
    parts = [f"{n}:{','.join(map(str, vecs[n]))}" for n in names]
    digest = hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]
    report = {"project": cfg["project"], "order": names, "dims": cfg["dims"],
              "axes": AXES, "weights": weights, "distinguishableFloor10": floor10,
              "vectors": {n: vecs[n] for n in names},
              "colorOnlyMin10": color_min, "fullMin10": full_min,
              "colorOnlyBelowFloor": color_below, "fullBelowFloor": full_below,
              "digest": digest}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st01_dimensions.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    write_svg(names, vecs, os.path.join(OUT, "st01_dimensions.svg"))
    for n in names:
        print(f"{n:<12} vec={vecs[n]}  color={vecs[n][:3]} shape={vecs[n][3]} detail={vecs[n][4]}")
    print(f"colorOnly: min10={color_min} belowFloor={len(color_below)} {color_below}")
    print(f"full5dim : min10={full_min} belowFloor={len(full_below)} {full_below}")
    print(f"floor10={floor10} digest={digest}")
    print("wrote out/st01_dimensions.json out/st01_dimensions.svg")
    return 0


def write_svg(names, vecs, path):
    """6 主题 × 5 维的分组条形图:色彩三维(蓝)与形状/细节(橙/绿)同框。"""
    W, H, row, bar = 560, 30 + len(names) * 34, 34, 84
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}", "role": "img"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#1f2933"})
    cols = ["#93c5fd", "#60a5fa", "#3b82f6", "#f59e0b", "#22c55e"]
    for i, n in enumerate(names):
        y = 22 + i * row
        ET.SubElement(svg, "text", {"x": "8", "y": str(y + 12), "fill": "#eceff1",
                                    "font-family": "monospace", "font-size": "11"}).text = n
        for k in range(5):
            x = 110 + k * bar
            ln = vecs[n][k] * (bar - 6) // 1000
            ET.SubElement(svg, "rect", {"x": str(x), "y": str(y + 2), "width": str(max(ln, 1)),
                                        "height": "14", "rx": "3", "fill": cols[k]})
    tree = ET.ElementTree(svg)
    ET.indent(tree, space="  ")
    tree.write(path, encoding="utf-8", xml_declaration=True)


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

本机实跑 /opt/homebrew/bin/python3 sim/st01_dimensions.py(同机相对量级,绝对数值不可移植):

pixel        vec=[501, 356, 533, 620, 780]  color=[501, 356, 533] shape=620 detail=780
lowpoly      vec=[533, 394, 525, 120, 210]  color=[533, 394, 525] shape=120 detail=210
handdrawn    vec=[643, 269, 69, 480, 520]  color=[643, 269, 69] shape=480 detail=520
voxel        vec=[447, 325, 280, 300, 640]  color=[447, 325, 280] shape=300 detail=640
retro-cyber  vec=[258, 567, 450, 540, 700]  color=[258, 567, 450] shape=540 detail=700
cozy-clean   vec=[623, 282, 66, 150, 260]  color=[623, 282, 66] shape=150 detail=260
colorOnly: min10=138 belowFloor=2 ['handdrawn/cozy-clean', 'pixel/lowpoly']
full5dim : min10=1002 belowFloor=0 []
floor10=800 digest=635053b1f79529c9
wrote out/st01_dimensions.json out/st01_dimensions.svg

P 层:TS 侧风格内核

下面这段是 P 层 TypeScript 内核 core/src/st01/style.ts。它把上面 S 脚本的向量与距离逻辑搬到 TS 侧:styleVector 算五维整数向量,AXES 是三个可测维度的归组,dist10 是选定维度上的加权 L1 整数均值再 ×10。全整数地板除,与 Python 侧逐位一致;转换常数与 sp00/spec.ts 同源。

// file: core/src/st01/style.ts
// tier: P
// verify: node --test 'core/test/st01_*.test.ts'
// 卷三第 1 章的共享风格内核:五维整数风格向量 + 按三个可测维度归组 + 加权 L1 距离 ×10。
// 全部整数地板除,故与 Python 侧(sim/st01_*.py)**逐位一致**;转换常数与 sp00/spec.ts 同源。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

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

/** 四角色色的取用顺序与卷二 16.2 一致,两卷摘要才能对拍。 */
export const ROLES: string[] = ["accent", "bg", "ink", "surface"];

/** 五维按三个可测维度归组:色彩占 0-2,形状占 3,细节占 4。 */
export const AXES: Record<string, number[]> = {
  color: [0, 1, 2],
  shape: [3],
  detail: [4],
};

export const FULL_DIMS: number[] = [0, 1, 2, 3, 4];

/** Rec.709 整数亮度(0-255 输入 → 0-255 输出)。 */
export function luma709(rgb: RGB): number {
  return Math.floor((2126 * rgb[0] + 7152 * rgb[1] + 722 * rgb[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 饱和度(本身就是千分比)。 */
export function satPermille(rgb: RGB): number {
  const mx = Math.max(...rgb);
  return mx === 0 ? 0 : Math.floor(((mx - Math.min(...rgb)) * 1000) / mx);
}

/** 整数度色相;灰(max==min)返回 null。负数先归一到 [0,360) 再地板除,与 Python 一致。 */
export function hueDeg(rgb: RGB): number | null {
  const mx = Math.max(...rgb);
  const mn = Math.min(...rgb);
  const d = mx - mn;
  if (d === 0) return null;
  let h = 0;
  if (mx === rgb[0]) h = (60 * (rgb[1] - rgb[2])) / d;
  else if (mx === rgb[1]) h = 60 * ((rgb[2] - rgb[0]) / d + 2);
  else h = 60 * ((rgb[0] - rgb[1]) / d + 4);
  return Math.floor(((h % 360) + 360) % 360);
}

/** 四角色色 + 两项设计声明 → 五维整数风格向量。 */
export function styleVector(palette: Palette, strokePermille: number, detailPermille: number): number[] {
  const cols = ROLES.map((k) => palette[k]);
  const hues = cols.map(hueDeg).filter((h): h is number => h !== null);
  const span = hues.length >= 2 ? Math.max(...hues) - Math.min(...hues) : 0;
  return [
    Math.floor((medianInt(cols.map(luma709)) * 1000) / 255),
    Math.floor(cols.map(satPermille).reduce((a, b) => a + b, 0) / cols.length),
    Math.floor((span * 1000) / 360),
    strokePermille,
    detailPermille,
  ];
}

/** 选定维度上的加权 L1 整数均值再 ×10 取整 → 整数类,逐位一致。 */
export function dist10(a: number[], b: number[], weights: number[], dims: number[]): number {
  let acc = 0;
  let wsum = 0;
  for (const i of dims) {
    acc += weights[i] * Math.abs(a[i] - b[i]);
    wsum += weights[i];
  }
  return Math.floor((acc * 10) / wsum);
}

在本机跑 node --test 'core/test/st01_*.test.ts',本节相关的 6 条(全章 glob 共 16 条,本节 6 / 1.2 五 / 1.3 五)真实输出:

✔ 五维风格向量:整数与 Python 逐位一致 (0.834208ms)
✔ 三个可测维度归组:色彩 0-2、形状 3、细节 4 覆盖全部五维且不重叠 (0.104083ms)
✔ 消融对照:只用色彩三维时最近一对掉到下限以下,补上形状/细节后恢复 (0.191375ms)
✔ 负例:色彩三维的越界对正是 handdrawn/cozy-clean 与 pixel/lowpoly (0.097708ms)
✔ 摘要指纹:五维向量串的 sha256 前 16 位跨语言一致 (0.326417ms)
✔ ROLES 顺序固定为 accent,bg,ink,surface(与卷二 16.2 同源) (0.052375ms)
ℹ tests 16
ℹ pass 16
ℹ fail 0

消融说明:一个维度都不能少

colorOnly: min10=138 与 full5dim: min10=1002 的对比就是本节的核心证据。只用色彩三维时,最近的一对只有 138,远低于下限 800,而且有两对越界:handdrawn/cozy-clean(都是高明度暖调,均值色几乎重合)与 pixel/lowpoly(都偏暗蓝底)。一旦补上形状与细节,最近一对升到 1002,越界集合清空——两对都是靠描边(480/150、620/120)与细节(520/260、780/210)被拉开的。

这说明「三个可测维度」不是把五维换个说法:色彩单独看会骗人,形状与细节才是兜底。也正因如此,风格向量的权重里描边与细节各占 3,比色彩三维的权重之和还高——权重本身就在说「别只看颜色」。向量与距离都是整数类,跨语言逐位一致;唯一浮点派生量是 1.3 会用到的对比度,它 ×10 取整后仍是整数,跨语言也逐位一致。

自检表

检查项合格标准
三维可归组五个整数维度无重叠、无遗漏地分到色彩 / 形状 / 细节
刻度统一五维都归一化到 0–1000 千分比,声明值与算出值同框
消融有负例去掉形状与细节后,必须真有对越界,否则归组无意义
说法诚实向量与距离写「逐位一致」;不写「颜色完全相同」

三个维度里,色彩最容易被高估。下一节把五维向量铺成一张 6×6 的距离矩阵,用整数距离把「同源但可区分」算成一个可以断言的事实。

阅读导航:上一节:目录 · 下一节:1.2 风格向量与风格距离矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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