美术评审里最常听到的两句评语是「剪影不够清晰」和「细节太碎」。这两句话听起来很专业,但没法验收:什么叫清晰?碎到什么程度算碎?《星屑旅店》的美术方向文档在 游戏美术方向与风格圣经(2026) 里已经把基调定成「低多边形 + 硬边剪影」,那一页负责口号与风格意图;本节负责把口号翻译成可计算的度量。分工先划清:三维网格的建模原理——拓扑、法线、LOD——不在本套书范围;本节只做二维剪影层的可读性规范,把「清晰」落到面积、周长、紧凑度、占空比与降采样交并比这五个能跑的整数上。
11.1.1 为什么剪影要单独立规范
游戏里道具在屏幕上的实际像素高度往往只有几十像素。玩家辨认一个水桶、一口箱子、一盏提灯,靠的不是贴图细节,而是外轮廓的形状。所以剪影规范要回答一个很具体的问题:把这张剪影缩到实际游戏尺寸之后,它和「另一个容易混淆的物件」还分得开吗?
要回答它,先把剪影拆成四个纯整数的口径,避开一切浮点:
| 口径 | 定义 | 单位 | 用途 |
|---|---|---|---|
| 面积 A | 前景像素个数 | 像素 | 基准量 |
| 周长 P | 前景像素中「四邻域不全为前景」的个数 | 像素 | 边界复杂度 |
| 紧凑度 C | 12566 * A // (P * P) | 千分比 | 形状是否「抱团」 |
| 占空比 F | A * 1000 // (外接框宽 * 外接框高) | 千分比 | 剪影是否「撑满框」 |
紧凑度里的 12566 是 4π 乘 1000 再取整,也就是把连续域的 4πA/P² 搬到离散像素上。这里必须诚实:离散圆的周长按像素边界数算会偏大,所以圆的紧凑度会算出 1265‰ > 1000‰——这不是错误,而是「像素周长」这个口径的固有偏差。因此 C 只用于同类形状之间比大小,绝不当绝对阈值用。
11.1.2 五个程序化剪影
本节不依赖任何画师素材,剪影全部用整数公式程序化生成,这样才可复现、可门禁。配置 config/sp11_silhouette.json 定义五个形状,画布 128×128,iouMaxPermille 定在 978‰:
circle圆(水桶身)、square方(箱体)、octagon八边形(切角水桶)——三个基准形;notch带缺口(开口箱):方形挖掉顶部一条 48×64 的缺口;circle_handle圆带把手(提灯):圆右侧伸出一根细把手,把外接框从 96 撑到 112。
前四个是「主体形状不同」,第五个是「主体相同、只多一个小附件」——这正是可读性最容易翻车的场景。配置里的 generated 声明了本节产出的 PNG,prism_rungen.py 会按 width/height/mode 逐项核对。
11.1.3 S 层:四个整数口径的账
#!/usr/bin/env python3
# file: sim/sp11_silhouette.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp11_silhouette.py
"""11.1 剪影可读性度量的 S 层整数账。
128×128 程序化剪影 → 面积 / 轮廓像素周长 / 紧凑度 12566*A//(P*P) / 外接框占空比;
再降采样到 32×32、16×16,用交并比千分比判「缩到该尺寸后仍可区分」。读
config/visual_spec.json(只读)与 config/sp11_silhouette.json,产出
out/sp11_silhouette.json + out/gen/sp11_silhouette.png。整数类逐位一致。
"""
import json
import os
import sys
from itertools import combinations
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT, GEN = os.path.join(ROOT, "out"), os.path.join(ROOT, "out/gen")
NB = ((1, 0), (-1, 0), (0, 1), (0, -1))
def build_mask(s, n):
"""dx = 2x-(n-1)、dy = 2y-(n-1),对称且纯整数。"""
r, k = s["r"], s["kind"]
def inside(dx, dy):
ax, ay = abs(dx), abs(dy)
if k == "circle":
return dx * dx + dy * dy <= r * r
if k == "square":
return ax <= r and ay <= r
if k == "octagon":
return ax <= r and ay <= r and ax + ay <= r + 2 * s["cut"]
if k == "notch":
return ax <= r and ay <= r and not (ax <= s["notchHalf"] and dy <= -s["notchDepth"])
return dx * dx + dy * dy <= r * r or (
s["handleMin"] <= dx < s["handleMin"] + s["handleSpan"] and ay <= s["handleHalf"])
return [[1 if inside(2 * x - (n - 1), 2 * y - (n - 1)) else 0 for x in range(n)] for y in range(n)]
def metrics(m):
"""面积、轮廓像素周长、外接框(同一趟收集前景点)。"""
n = len(m)
fg = [(x, y) for y in range(n) for x in range(n) if m[y][x]]
per = sum(1 for x, y in fg
if any(not (0 <= x + a < n and 0 <= y + b < n and m[y + b][x + a]) for a, b in NB))
xs, ys = [x for x, _ in fg], [y for _, y in fg]
bb = {"x": min(xs), "y": min(ys), "w": max(xs) - min(xs) + 1, "h": max(ys) - min(ys) + 1}
return len(fg), per, bb
def flat(m):
return [v for row in m for v in row]
def downsample(m, n, scale):
b = n // scale
return [[1 if sum(m[by * b + j][bx * b + i] for j in range(b) for i in range(b)) * 2 >= b * b else 0
for bx in range(scale)] for by in range(scale)]
def iou_permille(a, b):
pa, pb = flat(a), flat(b)
inter = sum(1 for x, y in zip(pa, pb) if x and y)
union = sum(1 for x, y in zip(pa, pb) if x or y)
return inter * 1000 // union if union else 1000
def gen_sheet(masks, ids, last, tokens, path):
cell, gap = 96, 8
im = Image.new("RGB", (len(ids) * cell + (len(ids) + 1) * gap, 2 * cell + 3 * gap), tuple(tokens["bg"]))
for c, sid in enumerate(ids):
for r, src in enumerate((masks[sid], downsample(masks[sid], len(masks[sid]), last))):
ox, oy, k = gap + c * (cell + gap), gap + r * (cell + gap), len(src)
for i in range(cell):
for j in range(cell):
if src[(j * k) // cell][(i * k) // cell]:
im.putpixel((ox + i, oy + j), tuple(tokens["ink"]))
im.save(path)
def main():
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
cfg = json.load(open(os.path.join(ROOT, "config/sp11_silhouette.json"), encoding="utf-8"))
n, scales, cap = cfg["canvas"], cfg["scales"], cfg["iouMaxPermille"]
ids = [s["id"] for s in cfg["shapes"]]
masks = {s["id"]: build_mask(s, n) for s in cfg["shapes"]}
rows = []
for s in cfg["shapes"]:
a, p, bb = metrics(masks[s["id"]])
rows.append({"id": s["id"], "cn": s["cn"], "kind": s["kind"], "area": a, "perimeter": p,
"compactnessPermille": (12566 * a) // (p * p), "bbox": bb,
"fillPermille": (a * 1000) // (bb["w"] * bb["h"])})
pairs, collapsed = {}, {}
for scale in scales:
sm = {sid: downsample(masks[sid], n, scale) for sid in ids}
pairs[str(scale)], collapsed[str(scale)] = [], []
for a, b in combinations(ids, 2):
iou = iou_permille(sm[a], sm[b])
ham = sum(1 for x, y in zip(flat(sm[a]), flat(sm[b])) if x != y)
pairs[str(scale)].append({"a": a, "b": b, "iouPermille": iou, "hamming": ham,
"distinguishable": iou <= cap})
if iou > cap:
collapsed[str(scale)].append(f"{a}|{b}")
summary = {"project": spec["project"], "specVersion": spec["specVersion"], "canvas": n,
"scales": scales, "iouMaxPermille": cap, "gameScalePx": cfg["gameScalePx"],
"shapeCount": len(rows), "pairCount": len(rows) * (len(rows) - 1) // 2,
"shapes": rows, "pairs": pairs, "collapsed": collapsed}
os.makedirs(GEN, exist_ok=True)
with open(os.path.join(OUT, "sp11_silhouette.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
gen_sheet(masks, ids, scales[-1], spec["tokens"], os.path.join(GEN, "sp11_silhouette.png"))
print(f"canvas={n} shapes={len(rows)} pairs={summary['pairCount']} iouMax={cap}‰")
for r in rows:
print(f" {r['id']:<14} area={r['area']:>5} perim={r['perimeter']:>4} "
f"compact={r['compactnessPermille']:>4}‰ fill={r['fillPermille']:>4}‰ bbox={r['bbox']['w']}x{r['bbox']['h']}")
for scale in scales:
w = max(pairs[str(scale)], key=lambda r: r["iouPermille"])
print(f" @{scale}: collapsed={collapsed[str(scale)]} most_similar={w['a']}|{w['b']} iou={w['iouPermille']}‰")
print("wrote out/sp11_silhouette.json out/gen/sp11_silhouette.png")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp11_silhouette.py:
canvas=128 shapes=5 pairs=10 iouMax=978‰
circle area= 7232 perim= 268 compact=1265‰ fill= 784‰ bbox=96x96
octagon area= 8736 perim= 320 compact=1072‰ fill= 947‰ bbox=96x96
square area= 9216 perim= 380 compact= 801‰ fill=1000‰ bbox=96x96
notch area= 8832 perim= 410 compact= 660‰ fill= 958‰ bbox=96x96
circle_handle area= 7392 perim= 320 compact= 907‰ fill= 687‰ bbox=112x96
@32: collapsed=[] most_similar=circle|circle_handle iou=973‰
@16: collapsed=['circle|circle_handle'] most_similar=circle|circle_handle iou=982‰
wrote out/sp11_silhouette.json out/gen/sp11_silhouette.png
11.1.4 P 层:同一套规则的 TS 内核
// file: core/src/sp11/silhouette.ts
// tier: P
// verify: node --test 'core/test/sp11_*.test.ts'
// 11.1 剪影可读性度量的 TS 侧校验器。与 sim/sp11_silhouette.py 同一套整数规则:
// 程序化剪影(dx=2x-(n-1))、面积、轮廓像素周长、紧凑度 12566*A//(P*P)、
// 外接框占空比、块内过半降采样、交并比千分比。整数类逐位一致。
export function buildMask(s: any, n: number): number[][] {
const r: number = s.r;
const m: number[][] = [];
for (let y = 0; y < n; y += 1) {
const dy = 2 * y - (n - 1);
const row: number[] = [];
for (let x = 0; x < n; x += 1) {
const dx = 2 * x - (n - 1);
const ax = Math.abs(dx);
const ay = Math.abs(dy);
let ins = false;
if (s.kind === "circle") ins = dx * dx + dy * dy <= r * r;
else if (s.kind === "square") ins = ax <= r && ay <= r;
else if (s.kind === "octagon") ins = ax <= r && ay <= r && ax + ay <= r + 2 * s.cut;
else if (s.kind === "notch") ins = ax <= r && ay <= r && !(ax <= s.notchHalf && dy <= -s.notchDepth);
else ins = dx * dx + dy * dy <= r * r || (s.handleMin <= dx && dx < s.handleMin + s.handleSpan && ay <= s.handleHalf);
row.push(ins ? 1 : 0);
}
m.push(row);
}
return m;
}
export function areaOf(m: number[][]): number {
let a = 0;
for (const row of m) for (const v of row) a += v;
return a;
}
export function perimeterOf(m: number[][]): number {
const n = m.length;
const nb = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let p = 0;
for (let y = 0; y < n; y += 1) for (let x = 0; x < n; x += 1) {
if (!m[y][x]) continue;
for (const [a, b] of nb) {
if (!(x + a >= 0 && x + a < n && y + b >= 0 && y + b < n && m[y + b][x + a])) { p += 1; break; }
}
}
return p;
}
export function bboxOf(m: number[][]): any {
const n = m.length;
const xs: number[] = [];
const ys: number[] = [];
for (let y = 0; y < n; y += 1) for (let x = 0; x < n; x += 1) if (m[y][x]) { xs.push(x); ys.push(y); }
const x0 = Math.min(...xs);
const y0 = Math.min(...ys);
return { x: x0, y: y0, w: Math.max(...xs) - x0 + 1, h: Math.max(...ys) - y0 + 1 };
}
export function compactnessPermille(area: number, perimeter: number): number {
return Math.floor((12566 * area) / (perimeter * perimeter));
}
export function fillPermille(area: number, w: number, h: number): number {
return Math.floor((area * 1000) / (w * h));
}
export function downsample(m: number[][], n: number, scale: number): number[][] {
const b = Math.floor(n / scale);
const out: number[][] = [];
for (let by = 0; by < scale; by += 1) {
const row: number[] = [];
for (let bx = 0; bx < scale; bx += 1) {
let s = 0;
for (let j = 0; j < b; j += 1) for (let i = 0; i < b; i += 1) s += m[by * b + j][bx * b + i];
row.push(s * 2 >= b * b ? 1 : 0);
}
out.push(row);
}
return out;
}
export function flat(m: number[][]): number[] {
const out: number[] = [];
for (const row of m) for (const v of row) out.push(v);
return out;
}
export function iouPermille(a: number[][], b: number[][]): number {
const pa = flat(a);
const pb = flat(b);
let inter = 0;
let union = 0;
for (let i = 0; i < pa.length; i += 1) {
if (pa[i] === 1 && pb[i] === 1) inter += 1;
if (pa[i] === 1 || pb[i] === 1) union += 1;
}
return union ? Math.floor((inter * 1000) / union) : 1000;
}
export function hamming(a: number[][], b: number[][]): number {
const pa = flat(a);
const pb = flat(b);
let d = 0;
for (let i = 0; i < pa.length; i += 1) if (pa[i] !== pb[i]) d += 1;
return d;
}
export function distinguishable(iou: number, cap: number): boolean {
return iou <= cap;
}
P 层测试 core/test/sp11_silhouette.test.ts 读 S 层落盘的 out/sp11_silhouette.json,把面积、周长、紧凑度、外接框、占空比、降采样后的汉明距离与交并比逐项断言,再复现「16px 塌成圆」这一负例。实跑 node --test 'core/test/sp11_*.test.ts' 全绿,本节剪影用例摘要为 silhouette digest = 93fd0d7fcbf36b0c。
11.1.5 缩到 16px 就塌:可读性的分辨率下限
把五张剪影按块内过半的规则降采样到 32×32 与 16×16,两两算交并比(IoU)千分比,判据是 IoU ≤ 978‰ 才算「仍可区分」。结果非常干净:
| 缩放 | 塌掉的配对 | 最像的一对 | IoU |
|---|---|---|---|
| 32×32 | 无 | circle 与 circle_handle | 973‰ |
| 16×16 | `circle | circle_handle` | circle 与 circle_handle |
读这张表:主体形状不同的四张剪影(圆、方、八边形、带缺口)在 16px 都还分得开,因为差异是「整块面积级别」的。真正出事的是 circle_handle——它和 circle 的差别只有右侧那根细把手,面积占比很小,缩到 32px 时 IoU 已顶到 973‰(离 978‰ 上限只差 5‰),缩到 16px 直接越过阈值变成 982‰,被 collapsed['16'] 精确抓住。这不是脚本 bug,而是真实的可读性结论:
- 小附件撑不起可读性。把手的像素面积按比例缩小,缩到 16px 后它只剩一两个像素,对 IoU 的贡献趋近于零。
- 分辨率有下限。同一个形状在 32px 合格、16px 不合格,说明「剪影合格」必须绑定「在多大的游戏尺寸下合格」。
- 占空比是旁证。把手把外接框从 96 撑到 112,占空比从
circle的 784‰ 掉到circle_handle的 687‰——外接框被撑宽但前景没跟上,这个数就是「剪影没抱团」的信号。
对《星屑旅店》的直接结论:提灯的把手在 16px 下不可依赖,需要靠颜色对比或轮廓上的第二处特征(比如把手加粗到 ≥3px)来补,而不是靠剪影本身。
11.1.6 边界与取舍
- 紧凑度只做相对比较。离散像素周长偏大,圆的 C 会超过 1000‰;把它当绝对阈值(「C 必须 ≥ 900」)是错的。正确用法是在同批形状里找离群值。
- 降采样用块内过半,不是平均。平均会引入浮点与插值,破坏逐位一致;过半是纯整数计数,跨语言可复算。
- 本节不含真渲染截图与画师主观判断(标 X)。剪影表是程序化生成的位图,
out/gen/sp11_silhouette.png仅供对照;真实项目里的剪影评审仍需人工确认风格是否对味。 - 一次只比两个形状。IoU 是成对度量,五个形状得到 10 个配对;要做「整组互不混淆」的判定,取配对里的最大值与上限比较即可。
本节的四个整数口径与降采样判据会被 11.2 复用:细节密度统计的就是同一套像素邻域,只是从「轮廓」换到「内部高频」。
阅读导航:上一节:10.3 图集与批次的耦合 · 下一节:11.2 细节密度与视觉噪声 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。