《数据可视化中的视觉编码与感知》 从图表口径回答「人眼怎么区分两个视觉编码」;本节换游戏美术口径问另一件事:一套美术风格能不能被量成几个数,让「像不像」变成可算的距离。站内 游戏美术方向与风格圣经短文 是口号式的道理,本卷把它落成可校验的参数与账。第一卷 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 风格向量与风格距离矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。