「细节太碎」和「画面太脏」是评审桌上最常出现、也最难反驳的两句评语。它们和 11.1 的「剪影不够清晰」是同一类问题:听起来专业,但不可验收。分工先划清:纹理压缩、mipmap、各向异性过滤的算法原理是 纹理与显存 那一篇的地盘,本节不重推原理;本节只做美术侧的细节密度规范——把「碎」和「脏」各自翻译成一个能被门禁拦下来的千分比。
11.2.1 「太碎」到底碎在哪
一块 128×128 的家具贴片,画面上「看起来碎」通常来自两个不同的原因,必须分开度量,否则永远吵不出结论:
- 高频细节太多:木纹、划痕、钉眼、布纹全都画上了,相邻像素的亮度跳变遍地都是。这会让贴片在缩到游戏尺寸时糊成一团灰,还会和 UI 抢注意力。
- 规律性噪声:1px 交替的棋盘格、等间距的麻点、重复的噪点纹理。它的平均亮度可能和周围完全一样(远看是一块纯色),但在像素级制造出大量「拐点」,是最典型的视觉噪声。
前者用细节密度度量,后者用棋盘拐点率度量。两个数都是千分比、都是整数,判据都是「超过上限即 fail」。
11.2.2 两个整数口径
先把亮度统一到一个整数口径上:luma(c) = (2126*r + 7152*g + 722*b) // 10000,即 Rec.709 亮度乘 10000 再取整。有了整数 luma,两个度量都能纯整数算完:
| 口径 | 定义 | 上限 |
|---|---|---|
| 细节密度 | 相邻梯度超阈的像素数 × 1000 // 内区像素数 | 200‰ |
| 视觉噪声 | 2×2 棋盘拐点数 × 1000 // 内区像素数 | 80‰ |
其中「内区像素数」是 (n-1) * (n-1)——每个像素只看右邻与下邻,所以边界一行一列自然出局。边缘判据是 |luma(x,y)-luma(x+1,y)| + |luma(x,y)-luma(x,y+1)| > 12;拐点判据更严:luma(a) == luma(d) 且 luma(b) == luma(c) 且 |luma(a)-luma(b)| > 12,也就是「对角相等、反对角相等、两值差超阈」——这正是 1px 棋盘的定义。
11.2.3 S 层:四档贴片的账
#!/usr/bin/env python3
# file: sim/sp11_detail.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp11_detail.py
"""11.2 细节密度与视觉噪声的 S 层整数账。
读 config/sp11_detail.json,程序化生成 4 个 128×128 家具贴片(三档木纹 + 一档 1px 棋盘噪声),
用整数 luma(Rec.709:2126/7152/722)算相邻像素梯度:
edge(x,y) = |luma(x,y)-luma(x+1,y)| + |luma(x,y)-luma(x,y+1)|
detailPermille = 边缘像素数 * 1000 // ((n-1)*(n-1))
checkerPermille= 2×2 棋盘拐点数 * 1000 // ((n-1)*(n-1))
拐点判据:luma(a)==luma(d) 且 luma(b)==luma(c) 且 |luma(a)-luma(b)| > 阈值
再按 detailPermilleCap / checkerPermilleCap 判违例(> 即 fail)。
产出 out/sp11_detail.json + out/gen/sp11_detail.png。全部整数类,逐位一致。
"""
import json
import os
import sys
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")
def luma(c):
return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000
def build_tile(tile, n, pal):
base, plank, grain = pal["base"], pal["plank"], pal["grain"]
px = [[list(base) for _ in range(n)] for _ in range(n)]
if tile["kind"] == "checker":
for y in range(n):
for x in range(n):
px[y][x] = list(plank) if (x + y) % 2 == 0 else list(base)
return px
for i in range(tile["planks"]): # 木板:横线
y0 = (i + 1) * n // (tile["planks"] + 1)
for y in range(y0, min(y0 + tile["plankThickness"], n)):
for x in range(n):
px[y][x] = list(plank)
for g in range(tile["grain"]): # 木纹:确定性散布的竖短线(无随机)
x, y0 = (g * 37 + 11) % n, (g * 53 + 7) % n
for k in range(tile["grainLen"]):
px[(y0 + k) % n][x] = list(grain)
return px
def edge_account(px, n, thr):
total, e = (n - 1) * (n - 1), 0
for y in range(n - 1):
for x in range(n - 1):
lc = luma(px[y][x])
if abs(lc - luma(px[y][x + 1])) + abs(lc - luma(px[y + 1][x])) > thr:
e += 1
return e, total, (e * 1000) // total
def checker_account(px, n, thr):
total, cnt = (n - 1) * (n - 1), 0
for y in range(n - 1):
for x in range(n - 1):
la, lb = luma(px[y][x]), luma(px[y][x + 1])
lc, ld = luma(px[y + 1][x]), luma(px[y + 1][x + 1])
if la == ld and lb == lc and abs(la - lb) > thr:
cnt += 1
return cnt, (cnt * 1000) // total
def gen_sheet(tiles_px, path, n, pal):
cell, gap, cols = 96, 8, len(tiles_px)
im = Image.new("RGB", (cols * cell + (cols + 1) * gap, cell + 2 * gap), tuple(pal["base"]))
for c, px in enumerate(tiles_px):
for i in range(cell):
for j in range(cell):
im.putpixel((gap + c * (cell + gap) + i, gap + j),
tuple(px[(j * n) // cell][(i * n) // cell]))
im.save(path)
def main():
cfg = json.load(open(os.path.join(ROOT, "config/sp11_detail.json"), encoding="utf-8"))
n, thr, pal = cfg["canvas"], cfg["edgeThreshold"], cfg["palette"]
rows, tiles_px = [], []
for t in cfg["tiles"]:
px = build_tile(t, n, pal)
tiles_px.append(px)
e, total, dp = edge_account(px, n, thr)
ck, cp = checker_account(px, n, thr)
v = [k for k, val, cap in (("detailPermille", dp, cfg["detailPermilleCap"]),
("checkerPermille", cp, cfg["checkerPermilleCap"])) if val > cap]
rows.append({"id": t["id"], "cn": t["cn"], "kind": t["kind"], "edgePixels": e,
"interiorPixels": total, "detailPermille": dp, "checkerBlocks": ck,
"checkerPermille": cp, "violations": v})
summary = {"project": cfg["project"], "specVersion": cfg["specVersion"], "canvas": n,
"edgeThreshold": thr, "gameScalePx": cfg["gameScalePx"],
"detailPermilleCap": cfg["detailPermilleCap"], "checkerPermilleCap": cfg["checkerPermilleCap"],
"tileCount": len(rows), "tiles": rows,
"violatingTiles": [r["id"] for r in rows if r["violations"]]}
os.makedirs(GEN, exist_ok=True)
with open(os.path.join(OUT, "sp11_detail.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
gen_sheet(tiles_px, os.path.join(GEN, "sp11_detail.png"), n, pal)
print(f"canvas={n} thr={thr} detailCap={cfg['detailPermilleCap']}‰ checkerCap={cfg['checkerPermilleCap']}‰")
for r in rows:
print(f" {r['id']:<9} edges={r['edgePixels']:>5} detail={r['detailPermille']:>4}‰ "
f"checker={r['checkerPermille']:>4}‰ viol={r['violations']}")
print(f"violatingTiles={summary['violatingTiles']}")
print("wrote out/sp11_detail.json out/gen/sp11_detail.png")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp11_detail.py:
canvas=128 thr=12 detailCap=200‰ checkerCap=80‰
sparse edges= 1116 detail= 69‰ checker= 0‰ viol=[]
medium edges= 2251 detail= 139‰ checker= 0‰ viol=[]
dense edges= 4410 detail= 273‰ checker= 0‰ viol=['detailPermille']
overload edges=16129 detail=1000‰ checker=1000‰ viol=['detailPermille', 'checkerPermille']
violatingTiles=['dense', 'overload']
wrote out/sp11_detail.json out/gen/sp11_detail.png
11.2.4 P 层:同一套规则的 TS 内核
// file: core/src/sp11/detail.ts
// tier: P
// verify: node --test 'core/test/sp11_*.test.ts'
// 11.2 细节密度与视觉噪声的 TS 侧校验器。与 sim/sp11_detail.py 同一套整数规则:
// 整数 luma(Rec.709:2126/7152/722)、相邻梯度超阈记一个边缘像素、
// 2×2 棋盘拐点判据、千分比一律整数地板除。整数类逐位一致。
export type RGB = number[];
export function luma709(c: RGB): number {
return Math.floor((2126 * c[0] + 7152 * c[1] + 722 * c[2]) / 10000);
}
export function buildTile(tile: any, n: number, pal: any): RGB[][] {
const base: RGB = pal.base;
const plank: RGB = pal.plank;
const grain: RGB = pal.grain;
const px: RGB[][] = [];
for (let y = 0; y < n; y += 1) {
const row: RGB[] = [];
for (let x = 0; x < n; x += 1) row.push(base.slice());
px.push(row);
}
if (tile.kind === "checker") {
for (let y = 0; y < n; y += 1) {
for (let x = 0; x < n; x += 1) px[y][x] = (x + y) % 2 === 0 ? plank.slice() : base.slice();
}
return px;
}
const planks: number = tile.planks;
const th: number = tile.plankThickness;
for (let i = 0; i < planks; i += 1) {
const y0 = Math.floor(((i + 1) * n) / (planks + 1));
for (let y = y0; y < Math.min(y0 + th, n); y += 1) {
for (let x = 0; x < n; x += 1) px[y][x] = plank.slice();
}
}
for (let g = 0; g < tile.grain; g += 1) {
const x = (g * 37 + 11) % n;
const y0 = (g * 53 + 7) % n;
for (let k = 0; k < tile.grainLen; k += 1) px[(y0 + k) % n][x] = grain.slice();
}
return px;
}
export function edgeAccount(px: RGB[][], n: number, thr: number): any {
const total = (n - 1) * (n - 1);
let e = 0;
for (let y = 0; y < n - 1; y += 1) {
for (let x = 0; x < n - 1; x += 1) {
const lc = luma709(px[y][x]);
const d = Math.abs(lc - luma709(px[y][x + 1])) + Math.abs(lc - luma709(px[y + 1][x]));
if (d > thr) e += 1;
}
}
return { edgePixels: e, interiorPixels: total, detailPermille: Math.floor((e * 1000) / total) };
}
export function checkerAccount(px: RGB[][], n: number, thr: number): any {
const total = (n - 1) * (n - 1);
let cnt = 0;
for (let y = 0; y < n - 1; y += 1) {
for (let x = 0; x < n - 1; x += 1) {
const la = luma709(px[y][x]);
const lb = luma709(px[y][x + 1]);
const lc = luma709(px[y + 1][x]);
const ld = luma709(px[y + 1][x + 1]);
if (la === ld && lb === lc && Math.abs(la - lb) > thr) cnt += 1;
}
}
return { checkerBlocks: cnt, checkerPermille: Math.floor((cnt * 1000) / total) };
}
export function violations(row: any, caps: any): string[] {
const v: string[] = [];
if (row.detailPermille > caps.detailPermilleCap) v.push("detailPermille");
if (row.checkerPermille > caps.checkerPermilleCap) v.push("checkerPermille");
return v;
}
P 层测试 core/test/sp11_detail.test.ts 读 S 层落盘的 out/sp11_detail.json,逐项断言边缘像素数、细节密度、棋盘拐点数、噪声千分比与违例集合,并固定两个 luma 基准(luma709([92,64,51]) = 69、luma709([138,101,78]) = 107)。实跑 node --test 'core/test/sp11_*.test.ts' 全绿,本节摘要为 detail digest = cb9b5304d9b236ed。
11.2.5 读表:三档合格、一档必须 fail
| 档位 | 板缝 / 木纹 | 边缘像素 | 细节密度 | 噪声 | 判定 |
|---|---|---|---|---|---|
| 留白 sparse | 4 板 4 纹 | 1116 | 69‰ | 0‰ | 通过 |
| 适中 medium | 8 板 12 纹 | 2251 | 139‰ | 0‰ | 通过 |
| 偏密 dense | 16 板 28 纹 | 4410 | 273‰ | 0‰ | fail(细节密度) |
| 过载 overload | 1px 棋盘 | 16129 | 1000‰ | 1000‰ | fail(两项) |
三个结论值得记下来:
- 细节密度是单调的。板缝从 4 条加到 16 条,边缘像素 1116 → 2251 → 4410 稳步上升,
sparse < medium < dense。这说明这个数确实在度量「细节多少」,而不是在噪声里乱跳。 - 偏密只踩一条线。
dense的细节密度 273‰ 越过 200‰ 上限,但噪声仍是 0‰——它是「画得太多」,不是「画得太规律」。门禁给出的violations=['detailPermille']精确指出该减什么。 - 过载踩两条线。1px 棋盘把
(n-1)*(n-1) = 16129个内区像素全部变成边缘,细节密度直接顶到 1000‰;同时每一个 2×2 窗口都是拐点,噪声也是 1000‰。两项同时 fail,violatingTiles=['dense', 'overload']一个不漏。
对《星屑旅店》的直接结论:家具木纹按 medium 档(8 板 12 纹) 定基线,给到 dense 就已经超预算;棋盘噪声类纹理(比如某些「复古抖动」滤镜)一律禁止直接铺在贴片上。
11.2.6 边界与取舍
- 阈值的量纲要和贴片尺寸绑定。细节密度是千分比,与贴片像素数无关,所以 64×64 与 512×512 的贴片能用同一条上限;但「边缘判据的阈值 12」是 luma 单位,换调色板要重测。
- 拐点判据只抓 1px 规律噪声。2px 以上的条纹不会被判成噪声,要靠人眼或另加口径;本节刻意不把口径堆得太复杂。
- 本节不含真渲染截图与画师主观判断(标 X)。四张贴片是程序化生成的位图,
out/gen/sp11_detail.png仅供对照;「这块木纹好不好看」仍需人工评审。 - 整数口径的代价:
//会丢尾数,所以两个千分比在极小的贴片上会粗一些;本套接受这个代价,换取 S 层与 P 层的逐位一致。
11.1 的剪影口径与本节共享同一套「像素邻域 + 千分比 + 上限判违例」骨架;11.3 会把它从单张贴片搬到整张场景,用等距投影把 tile 摆成地图。
阅读导航:上一节:11.1 剪影与可读性度量 · 下一节:11.3 场景构图与模块化 kit 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。