附录 A:风格参数表、量化规则与数据集索引

《游戏美术与技术美术》卷三《风格与品类美术实战》附录:全卷风格参数与公式索引、st<章号>_ 工具链命名约定、六层可运行性口径与逐层复现命令,并给出可跑的 S 层清点脚本与 P 层索引校验内核,两者对同一份工作区算出的清单与摘要逐位一致,并附一键复现与排错顺序。

一、这份索引解决什么

卷三 48 节各自给了风格参数、量化规则与一份可跑脚本,但散在 16 章里。这份附录把风格参数与量化公式汇成一张表、把工具链文件汇成一套命名约定、把六层复现命令汇成一段可照敲的清单,最后给出两段真跑的脚本——S 层清点器与 P 层索引内核——它们对同一份工作区算出的清单与摘要逐位一致。卷三与前两卷共用工作区 prism、只读同一份真源 config/visual_spec.json;本卷的净增量在「跨风格」:同一套结构实例化 6 次、跑同一套校验器 6 次全过,并用 6×6 距离矩阵证明「同源但可区分」。

二、卷三工具链命名约定

本卷与卷一、卷二共用工作区 prism,但所有文件一律用 st<章号>_ 前缀:

目录命名例
config/st<章号>_<用途>.jsonconfig/st02_pixel.json
sim/st<章号>_<用途>.pysim/st02_quantize.py
core/src/st<章号>/<用途>.tscore/src/st02/quantize.ts
core/test/st<章号>_<用途>.test.tscore/test/st02_quantize.test.ts
web/build_st<章号>_<用途>.mjs / verify_st<章号>_<用途>.mjsweb/build_st09_board.mjs

唯一真源仍是 config/visual_spec.json(卷一落盘,本卷只读),6 个主题包在 config/sp16_themes.json(同样只读)。前缀严格不串卷:sp 是卷一、ta 是卷二、st 是本卷;清点脚本按路径前缀过滤,所以三卷的数字互不干扰。注意 web/ 层的章号不在文件名首位(build_st09_…),清点脚本要同时认这两套命名。

# snippet: node --test 接收 glob 而非目录,必须写全章号并加引号让 node 自己展开
node --test 'core/test/st02_*.test.ts'   # 只跑第 2 章;跑本卷全部则写 'core/test/st*.test.ts'

三、环境速查

项本机实测值说明
Python / 库/opt/homebrew/bin/python3 = 3.14.6(numpy 2.5.1 / pandas 3.0.3 / Pillow 12.3.0;/usr/bin/python3 = 3.9.6 无这些库,不要用);matplotlib / scipy / colour 均未装S 层一律用全路径;图表纯 xml.etree 生成 SVG,图像用 PIL 生成 PNG
Node / tsc / Playwrightv26.5.0(strip-only:禁 enum / namespace / 参数属性 / 装饰器);tsc 未装;Playwright 1.59.1(全局)P 层靠原生类型擦除;W 层需 NODE_PATH=/opt/homebrew/lib/node_modules + createRequire
DCC一个都没有3.3 / 5.3 / 7.3 / 10.3 四节整节标 X

卷三比前两卷多一条硬约束:色彩真源是 sRGB 8-bit 整数三元组,感知量是派生量。凡派生量一律按声明容差断言,正文不写「逐位相等」,只写「容差内一致,实测最大偏差 N」。

四、六层可运行性口径与复现命令

层复现命令
S 仿真层/opt/homebrew/bin/python3 sim/st<章号>_*.py(逐个跑,产出 JSON / CSV / SVG / PNG)
P 内核层node --test 'core/test/st<章号>_*.test.ts'(必须写全 glob)
W 网页层node web/build_st<章号>_*.mjs 产出 HTML,再 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_st<章号>_*.mjs
D 静态层 / X 不可复现均无独立命令:D 的 schema 由 sim/ 与 core/ 同时读入并校验;X 在正文每处显式标注「需真实环境,命令行不可复现」
B 基准/opt/homebrew/bin/python3 sim/st<章号>_bench.py(软告警,不硬失败)
# snippet: 逐章逐个跑 S 层,避免 shell 把通配符展开成多个文件名
for f in sim/st*.py; do /opt/homebrew/bin/python3 "$f" || exit 1; done

W 层有一条必须遵守的写法:无头 Chromium 冷启动时第一个 page 读 canvas 像素恒为空白,所以每个验证脚本要先插一行预热页,canvas 断言放宽为「已绘制(≥ 1 px)」,禁写 === 0。

五、全卷规则表与公式索引

整数类(跨语言逐位一致)

量公式出处
屏高缩放 / 颜色差scale = 屏高 // 逻辑高(只取整数倍,除不尽留黑边);`colorDistInt = (3Δr
色彩算子gray = (r*299 + g*587 + b*114) // 1000;sat = (max − min) * 1000 // max(max = 0 记 0);luma255 = (2126r + 7152g + 722b) // 1000011.1 / 12.1 / 13.1
整数分级去饱和 c' = gray + ((c − gray) * level) // 100;霓虹 c' = clamp(luma + (c − luma) * gain // 1000 + lift)(gain = 1300 / lift = 8)11.1 / 13.1
对比度与亮度分档contrast10 = (Lmax + 500) * 10 // (Lmin + 500)(下限 45);bands(edges = [0,50,150,300,500,1000]) 各档计数之和 == token 数11.2 / 12.x / 13.x / 16.x
占比类(千分比)噪声 = 笔画数 × 最小笔画宽 × 笔画均长 * 1000 // 图标面积(预算 300‰);体素占用 = filledVoxels * 1000 // gridVoxels;装箱率 = placedArea * 1000 // atlasArea;发光溢出 ≤ ceil = 50011.3 / 8.1 / 13.2
等距投影 / 对齐 / 遮挡sx = (tx − ty) * (tileW // 2)、sy = (tx + ty) * (tileH // 2)(32 × 16);heightPx % (tileH // 2) == 0;visible = tiles − occluded9.1 / 9.2 / 9.3
品类产能与移植(×100)eff100 = 出勤‰ * 专注‰ // 1000(850 * 700 // 1000 = 595‰);elemBudgetPx = screenPx // elems;savePermille = save100 * 1000 // new10014.1 / 14.2 / 14.3
风格距离(×10)与触碰点`dist10 = Σ wᵢ ·aᵢ − bᵢ

浮点类(跨语言容差内一致)

量公式声明容差
色彩空间换算sRGB ↔ 线性 c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4(逆函数同一组常数);OKLab / OKLCH 用 Ottosson 同一组矩阵相对误差 ≤ 1e-9
色盲模拟Brettel / Viénot–Mollon 1999 同一组矩阵,输出 8-bit 整数通道差 ≤ 1
WCAG 相对亮度分段线性化 + 0.2126 / 0.7152 / 0.0722 加权相对误差 ≤ 1e-9

正文一律不写「三处颜色完全相同」,只写「容差内一致,实测最大偏差 N」。本卷 B 层四次实跑(13.2 / 15.2 / 15.3 / 16.2)给出的最大偏差都是 4.9e-13,比声明容差 1e-9 小三个数量级——同一组常数在两套语言里各写一遍,差异只来自末位浮点表示;但那是实测结果,不是承诺。

这张表也是本卷「跨风格」主张的落点:上半张(整数类)跨主题全部成立,所以同一套校验器能把 6 个主题包各跑一遍都给出确定结论;下半张(浮点类)只在色彩派生量上出现且各有声明容差,所以「换一个主题」不会把跨语言断言变成 flaky。

六、S 层清点脚本:把这份索引重新跑出来

下面这段只登记本卷 st 前缀的四个清单(config/ / sim/ / core/src/ / core/test/),按路径排序后逐个登记,算总数并给一个跨语言可复算的摘要。只登记静态文件路径,不登记产物(out/ 随时会被清空重建),所以同一份工作区在任何机器上跑出的清单与摘要都逐位一致:

#!/usr/bin/env python3
# file: sim/st17_index.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st17_index.py
"""附录 A 的 S 层索引清点器(卷三):只登记**本卷 `st` 前缀**的四个清单(config / sim /
core/src / core/test),按路径排序后逐个登记,算总数并给一个跨语言可复算的摘要(digest)。

只登记**静态文件路径**,不登记产物(out/ 下随时会被清空重建),所以同一份工作区在任何
机器上跑出的清单与摘要都逐位一致;前缀过滤让三卷的数字互不干扰。digest 用两个不同
偏移量的 FNV-1a 32 位拼成 16 位十六进制,TS 侧用 Math.imul 复算,故两侧必然相同。
"""
from __future__ import annotations

import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

GROUPS = (
    ("config", "config", lambda rp: rp.startswith("config/st")),
    ("sim", "sim", lambda rp: rp.startswith("sim/st")),
    ("coreSrc", "core/src", lambda rp: rp.startswith("core/src/st")),
    ("coreTest", "core/test", lambda rp: rp.startswith("core/test/st")),
)


def listing(rel: str, pred) -> list[str]:
    d = os.path.join(ROOT, rel)
    out: list[str] = []
    for dirpath, _dirs, files in os.walk(d):
        for n in files:
            rp = os.path.relpath(os.path.join(dirpath, n), ROOT)
            if pred(rp):
                out.append(rp)
    return sorted(out)


def fnv1a(text: str, basis: int) -> int:
    h = basis
    for ch in text.encode("utf-8"):
        h = ((h ^ ch) * 0x01000193) & 0xFFFFFFFF
    return h


def digest_of(lines: list[str]) -> str:
    joined = "\n".join(lines)
    return f"{fnv1a(joined, 0x811C9DC5):08x}{fnv1a(joined, 0x7FFFFFFF):08x}"


def main() -> int:
    groups: dict[str, list[str]] = {}
    all_lines: list[str] = []
    for key, rel, pred in GROUPS:
        files = listing(rel, pred)
        groups[key] = files
        all_lines.extend(files)

    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    report = {
        "project": spec["project"],
        "volume": "art-style",
        "counts": {k: len(v) for k, v in groups.items()},
        "total": len(all_lines),
        "digest": digest_of(all_lines),
        "groups": groups,
    }
    with open(os.path.join(ROOT, "out/st17_index.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"project={report['project']} volume={report['volume']} "
          f"total={report['total']} digest={report['digest']}")
    for k, _rel, _pred in GROUPS:
        print(f"  {k:<9} {report['counts'][k]:>3}")
    print("wrote out/st17_index.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st17_index.py(前缀过滤只认本卷 st 文件,所以卷一、卷二的文件不会被算进来):

project=stardust-inn volume=art-style total=170 digest=b27d4120a6616862
  config     35
  sim        47
  coreSrc    44
  coreTest   44
wrote out/st17_index.json

七、P 层索引校验内核:与 Python 侧逐位对拍

同一个摘要用 TypeScript 复算一遍。关键在两处:os.walk 的顺序不可依赖,所以两侧都先排序;JS 的 32 位乘法会丢精度,所以必须用 Math.imul:

// file: core/src/st17/index.ts
// tier: P
// verify: node --test 'core/test/st17_*.test.ts'
// 附录 A 的 P 层索引内核(卷三)。与 sim/st17_index.py 同一套规则:只登记本卷 `st` 前缀的
// 四个清单,按路径排序后拼接,用两个不同偏移量的 FNV-1a 32 位拼成 16 位十六进制摘要。
// 只登记静态文件路径,不登记产物,故两侧清单与摘要必然逐位一致。只允许可擦除 TS 语法。

import { readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";

const GROUPS: { key: string; rel: string; pred: (rp: string) => boolean }[] = [
  { key: "config", rel: "config", pred: (rp) => rp.startsWith("config/st") },
  { key: "sim", rel: "sim", pred: (rp) => rp.startsWith("sim/st") },
  { key: "coreSrc", rel: "core/src", pred: (rp) => rp.startsWith("core/src/st") },
  { key: "coreTest", rel: "core/test", pred: (rp) => rp.startsWith("core/test/st") },
];

export function listing(root: string, rel: string, pred: (rp: string) => boolean): string[] {
  const out: string[] = [];
  const walk = (abs: string) => {
    for (const name of readdirSync(abs)) {
      const full = join(abs, name);
      const rp = relative(root, full).split(sep).join("/");
      if (statSync(full).isDirectory()) walk(full);
      else if (pred(rp)) out.push(rp);
    }
  };
  walk(join(root, rel));
  return out.sort();
}

export function fnv1a(text: string, basis: number): number {
  let h = basis >>> 0;
  const bytes = Buffer.from(text, "utf8");
  for (const b of bytes) h = Math.imul(h ^ b, 0x01000193) >>> 0;
  return h;
}

export function digestOf(lines: string[]): string {
  const joined = lines.join("\n");
  const a = fnv1a(joined, 0x811c9dc5).toString(16).padStart(8, "0");
  const b = fnv1a(joined, 0x7fffffff).toString(16).padStart(8, "0");
  return a + b;
}

export function indexWorkspace(root: string): { counts: Record<string, number>; total: number; digest: string; groups: Record<string, string[]> } {
  const groups: Record<string, string[]> = {};
  const counts: Record<string, number> = {};
  const all: string[] = [];
  for (const g of GROUPS) {
    const files = listing(root, g.rel, g.pred);
    groups[g.key] = files;
    counts[g.key] = files.length;
    all.push(...files);
  }
  return { counts, total: all.length, digest: digestOf(all), groups };
}

配套测试 core/test/st17_index.test.ts 一边读 Python 落盘的 out/st17_index.json 做跨语言对照,一边用内存清单构造两条负例(摘要必须内容敏感、空清单必须有确定值)。实跑 node --test 'core/test/st17_*.test.ts':

✔ 索引清点:TS 复算与 Python 产物逐位一致
✔ 正例:四个清单都非空,且 total 等于四者之和
✔ 负例:摘要内容敏感,改一个字符必须换一个摘要
✔ 负例:空清单有确定摘要,且与单元素清单不同
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 150.982375

两侧的 total=170 与 digest=b27d4120a6616862 一致——这就是本卷反复强调的那句话的一次最小演示:同一份输入,两套语言,一个结论。

更硬的一条证据在 15.2 与 16.2:同一份 config/sp16_themes.json 被卷一 16.2、卷二 16.2、卷三 1.2 / 15.2 / 16.2 五处各自独立复算,风格向量与 6×6 距离矩阵的摘要都是 fa04e9418d9f3c2c,一位不差。矩阵本身也是诚实的:15 对全部严格高于下限 distinguishableFloor10 = 800(below = 0),最近一对 lowpoly / cozy-clean = 1002、余量只有 202;B 层把权重削成「只看色彩三维」后,最近一对立刻掉到 138——远低于下限。这就是本卷「三个可测维度一个都不能少」的实测依据,而不是一句口号。

八、一键复现与排错顺序(四步,不要跳步)

  1. 先跑环境自检:/opt/homebrew/bin/python3 sim/st00_env_check.py。它不通过,后面全是噪音——它会顺带确认 config/visual_spec.json 与 config/sp16_themes.json 都在,缺一个就说明卷一没落盘。
  2. 再跑 S 层:for f in sim/st*.py; do /opt/homebrew/bin/python3 "$f" || exit 1; done。S 层失败通常是配置被改坏了,先看是哪一章的 config/st<章号>_*.json。
  3. 然后跑 P 层:node --test 'core/test/st*.test.ts'。P 层的跨语言对照失败几乎总是因为第 2 步没跑——它读的是 S 层落盘的 JSON。
  4. 最后跑 W 层与 B 层:node web/build_st09_board.mjs 配 verify_st09_board.mjs 跑浏览器断言;再跑 B 层(15.2 / 15.3 / 16.2)的风格距离阈值敏感性与跨语言容差,看软告警。

三个最常见的坑:用了系统 python3(无 numpy,报 ModuleNotFoundError);没写全 glob(node --test core/test/ 找不到任何 .ts,静默 0 测试);只跑 P 层没跑 S 层(对照文件不存在,报 ENOENT)。卷三还多一个坑:W 层忘了插预热页,canvas 断言会在冷启动时随机失败。

阅读导航:本附录是卷三的收尾索引,回看请从《风格与品类美术实战》目录 进入。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 9.3 等距资产的制作约束
  2. 9.2 等距 tile 与遮挡规则
  3. 9.1 等距投影的整数变换