游戏美术方向与风格圣经短文 把「六套主题要一眼能分开」写成一句口号;本节把它变成一个可以断言的整数。1.1 已经把每个主题量成五维整数向量,但向量只能回答「它是什么」,回答不了「这两套到底有多像」。本节把向量铺成一张 6×6 的风格距离矩阵:任意两主题的距离必须严格大于下限 800,「同源但可区分」才成立。卷二 16.2 双案例回放 从管线侧算过同一张矩阵,卷一 16.2 六主题规范装配 从规范侧算过;本节从风格侧再算一次,三处必须逐位相同。
1.2 风格向量与风格距离矩阵
距离的定义只有一行:五维加权 L1 的整数均值,再 ×10 取整。权重 [2,2,1,3,3](明度、饱和各 2,色相跨度 1,描边、细节各 3)与下限 distinguishableFloor10 = 800 都取自只读真源 config/sp16_themes.json,本节不另造——换一套权重或阈值,矩阵就换了个人,跨卷对拍也就失去意义。全部整数运算、地板除,所以距离是整数类,跨语言逐位一致,没有任何浮点。
| 符号 | 含义 | 来源 |
|---|---|---|
weights | [2,2,1,3,3] 五维权重 | sp16_themes.json,逐字引用 |
distinguishableFloor10 | 可区分下限 800 | sp16_themes.json,逐字引用 |
dist10(a,b) | 加权 L1 整数均值再 ×10 | 本节定义,纯整数 |
S 层:6×6 距离矩阵与热力图
下面这段是 S 层脚本 sim/st01_distance.py。它读只读真源,算 6×6 矩阵与 15 对距离,判 belowFloor 是否为空,并落一张 out/st01_distance.svg 热力图(色越暖越不像)。摘要串与卷二 16.2 同式,两卷 digest 因此可对拍。
#!/usr/bin/env python3
# file: sim/st01_distance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st01_distance.py
"""卷三 1.2 风格向量与风格距离矩阵(S 层)。
读只读真源 config/sp16_themes.json:五维风格向量(与 1.1 同式),再用**声明权重**
[2,2,1,3,3] 算 6×6 加权 L1 距离矩阵(整数 ×10,逐位一致)。判据是
distinguishableFloor10:任意两主题距离必须严格大于它,「同源但可区分」才成立。
摘要串(canonical)与卷二 16.2 同式:主题顺序 + 逐主题向量,再接 15 对距离;
故两卷各自独立复算,digest 应逐位相同。产出 out/st01_distance.json + .svg 热力图。
"""
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")
ROLES = ["accent", "bg", "ink", "surface"]
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):
mx, mn = max(c), min(c)
d = mx - mn
if d == 0:
return None
h = (60 * (c[1] - c[2]) / d if mx == c[0]
else 60 * ((c[2] - c[0]) / d + 2) if mx == c[1]
else 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, w):
"""五维加权 L1 的整数均值再 ×10 取整 → 整数类,逐位一致。"""
return (sum(wi * abs(x - y) for wi, x, y in zip(w, a, b)) * 10) // sum(w)
def main():
cfg = json.load(open(THEMES, encoding="utf-8"))
w, floor10 = cfg["weights"], cfg["distinguishableFloor10"]
names = [t["id"] for t in cfg["themes"]]
vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
matrix = [[0 if a == b else dist10(vecs[a], vecs[b], w) for b in names] for a in names]
flat = [(names[i], names[j], dist10(vecs[names[i]], vecs[names[j]], w))
for i in range(len(names)) for j in range(i + 1, len(names))]
lo = min(flat, key=lambda r: r[2])
hi = max(flat, key=lambda r: r[2])
below = [f"{a}/{b}:{d}" for a, b, d in flat if d <= floor10]
parts = [f"{n}:{','.join(map(str, vecs[n]))}" for n in names]
parts += [f"{a}>{b}:{d}" for a, b, d in flat]
canon = ";".join(parts)
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
report = {"project": cfg["project"], "order": names, "dims": cfg["dims"], "weights": w,
"distinguishableFloor10": floor10, "vectors": {n: vecs[n] for n in names},
"matrix": matrix, "minPair": {"a": lo[0], "b": lo[1], "d": lo[2]},
"maxPair": {"a": hi[0], "b": hi[1], "d": hi[2]}, "belowFloor": below,
"headroom": lo[2] - floor10, "pairCount": len(flat),
"canonical": canon, "digest": digest}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "st01_distance.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, matrix, os.path.join(OUT, "st01_distance.svg"))
print(f"themes={len(names)} pairs={len(flat)} floor10={floor10} below={len(below)}")
print(f"min={lo[0]}/{lo[1]}={lo[2]} max={hi[0]}/{hi[1]}={hi[2]} headroom={lo[2] - floor10}")
print(f"digest={digest}")
print("wrote out/st01_distance.json out/st01_distance.svg")
return 0
def write_svg(names, matrix, path):
"""6×6 热力图:色越暖(红)越不像,越冷(蓝)越像;对角为 0。"""
n, cell = len(names), 58
W = H = 110 + n * cell
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": "#111827"})
mx = max(max(r) for r in matrix) or 1
for i, a in enumerate(names):
ET.SubElement(svg, "text", {"x": "8", "y": str(96 + i * cell), "fill": "#cbd5e1",
"font-family": "monospace", "font-size": "10"}).text = a
for j, b in enumerate(names):
d = matrix[i][j]
t = d / mx # 0 冷 → 1 暖
r = int(59 + t * 180)
g = int(130 - t * 80)
bl = int(246 - t * 170)
x, y = 90 + j * cell, 74 + i * cell
ET.SubElement(svg, "rect", {"x": str(x), "y": str(y), "width": str(cell - 3),
"height": str(cell - 3), "rx": "3",
"fill": f"#{r:02x}{g:02x}{bl:02x}"})
ET.SubElement(svg, "text", {"x": str(x + 6), "y": str(y + 32), "fill": "#0b1220",
"font-family": "monospace", "font-size": "11"}).text = str(d)
for j, b in enumerate(names):
ET.SubElement(svg, "text", {"x": str(96 + j * cell), "y": "62", "fill": "#cbd5e1",
"font-family": "monospace", "font-size": "10"}).text = b[:9]
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_distance.py:
themes=6 pairs=15 floor10=800 below=0
min=lowpoly/cozy-clean=1002 max=retro-cyber/cozy-clean=3794 headroom=202
digest=fa04e9418d9f3c2c
wrote out/st01_distance.json out/st01_distance.svg
把 15 对铺成 6×6 矩阵(对角为 0,单位 ×10,越大越不像):
| pixel | lowpoly | handdrawn | voxel | retro-cyber | cozy-clean | |
|---|---|---|---|---|---|---|
| pixel | 0 | 3052 | 1929 | 1639 | 1337 | 3480 |
| lowpoly | 3052 | 0 | 2669 | 2168 | 3364 | 1002 |
| handdrawn | 1929 | 2669 | 0 | 1468 | 2242 | 1671 |
| voxel | 1639 | 2168 | 1468 | 0 | 1756 | 2038 |
| retro-cyber | 1337 | 3364 | 2242 | 1756 | 0 | 3794 |
| cozy-clean | 3480 | 1002 | 1671 | 2038 | 3794 | 0 |
最接近的一对是 lowpoly/cozy-clean = 1002(都是浅色底),最远的是 retro-cyber/cozy-clean = 3794(一个霓虹暗色、一个奶白暖色)。下限 800,below=0 说明 15 对全部严格高于它,余量 headroom=202。这正是「同源但可区分」的量化口径:六套主题同出一份规范,却两两拉开。digest=fa04e9418d9f3c2c 与卷二 16.2 独立算出的矩阵摘要逐位相同——同一份主题包,两卷各自复算,五维向量与 15 对距离一位不差。
P 层:矩阵与摘要串
下面这段是 P 层 TypeScript 内核 core/src/st01/distance.ts。它复用 1.1 的 style.ts,把矩阵、15 对与 canonical 摘要串搬到 TS 侧,与 Python 同式,供跨语言对拍。
// file: core/src/st01/distance.ts
// tier: P
// verify: node --test 'core/test/st01_*.test.ts'
// 1.2 风格距离矩阵的 TS 内核:五维加权 L1 的整数均值再 ×10,逐位一致;6×6 矩阵与
// canonical 摘要串(主题顺序 + 逐主题向量 + 15 对距离)与 Python 侧同式,故两卷 digest
// 可对拍。距离是整数类,跨语言「逐位一致」;这里没有任何浮点。
import { FULL_DIMS, dist10 } from "./style.ts";
export type Vec = number[];
/** 五维加权 L1 距离 ×10(整数类,逐位一致)。 */
export function styleDistance(a: Vec, b: Vec, weights: number[]): number {
return dist10(a, b, weights, FULL_DIMS);
}
/** 6×6 对称矩阵:对角为 0,其余为 styleDistance ×10。 */
export function distanceMatrix(names: string[], vecs: Record<string, Vec>, weights: number[]): number[][] {
return names.map((a) => names.map((b) => (a === b ? 0 : styleDistance(vecs[a], vecs[b], weights))));
}
/** 15 对无序对(i<j,按 names 顺序)。 */
export function pairs(names: string[], vecs: Record<string, Vec>, weights: number[]): { a: string; b: string; d: number }[] {
const out: { a: string; b: string; d: number }[] = [];
for (let i = 0; i < names.length; i += 1) {
for (let j = i + 1; j < names.length; j += 1) {
out.push({ a: names[i], b: names[j], d: styleDistance(vecs[names[i]], vecs[names[j]], weights) });
}
}
return out;
}
/** canonical 摘要串:与 sim/st01_distance.py 逐字同式(`;` 连接,无空白)。 */
export function canonical(order: string[], vecs: Record<string, Vec>, flat: { a: string; b: string; d: number }[]): string {
const parts = order.map((n) => `${n}:${vecs[n].join(",")}`);
for (const r of flat) parts.push(`${r.a}>${r.b}:${r.d}`);
return parts.join(";");
}
在本机跑 node --test 'core/test/st01_*.test.ts',本节相关的 5 条(全章 glob 共 16 条)真实输出:
✔ 6×6 距离矩阵:整数 ×10 与 Python 逐位一致 (0.931875ms)
✔ 最近/最远一对与下限余量:与 Python 一致 (0.171875ms)
✔ 同源可区分:15 对全部严格高于下限,越界集合为空 (0.082958ms)
✔ canonical 摘要 + sha256 指纹:跨语言逐位一致,并锚定跨卷指纹 (0.365084ms)
✔ 负例:把两个主题调成同一向量,距离为 0 必被下限拦下 (0.089958ms)
ℹ tests 16
ℹ pass 16
ℹ fail 0
自检表
| 检查项 | 合格标准 |
|---|---|
| 权重不另造 | weights 与下限逐字取自只读真源,正文可指认出处 |
| 距离是整数 | 加权 L1 地板除,跨语言逐位一致,无浮点 |
| 越界可断言 | 任一对距离 ≤ 下限必须能被测出来,below 为空 |
| 跨卷可对拍 | digest 与卷二 16.2 独立复算逐位相同 |
| 有负例 | 把两主题调成同一向量,距离 0 必被下限拦下 |
矩阵回答「哪两套最像」,但项目要选的不是「最不像的那套」,而是「最合项目约束的那套」。下一节把约束写成硬门槛与目标向量,让风格选择也变成一笔可算的账。
阅读导航:上一节:1.1 三个可测维度:色彩 / 形状 / 细节 · 下一节:1.3 风格选择与项目约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。