1.2 风格向量与风格距离矩阵

1.1 给了六个主题的五维向量,本节把它铺成一张 6×6 的风格距离矩阵:用声明权重 [2,2,1,3,3] 的加权 L1 整数均值再 ×10,判据是下限 800。15 对全部高于下限,最近一对 lowpoly/cozy-clean 是 1002;摘要指纹与卷二 16.2 独立复算逐位相同,这就是「同源但可区分」。

游戏美术方向与风格圣经短文 把「六套主题要一眼能分开」写成一句口号;本节把它变成一个可以断言的整数。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可区分下限 800sp16_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,越大越不像):

pixellowpolyhanddrawnvoxelretro-cybercozy-clean
pixel030521929163913373480
lowpoly305202669216833641002
handdrawn192926690146822421671
voxel163921681468017562038
retro-cyber133733642242175603794
cozy-clean348010021671203837940

最接近的一对是 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 风格选择与项目约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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