11.3 视觉噪声与可读性平衡
《移动端与 PC 游戏性能预算 2026》 讲的是面数、骨骼、材质槽这类性能预算——它管的是「跑得动」;本章不讲性能。本章算的是可读性预算:暗黑/恐怖的画面细节一多,玩家就分不清哪个是家具、哪个是背景。分工一句话:那篇管画面跑得动,本章管画面看得清。上一节 11.2 亮度分布与对比度 把亮度分布定了下来,本节收最后一笔账——细节。卷一 11.2 细节密度与视觉噪声 已经给出细节密度的口径,本节把它落成暗色底上可跑的整数账。
暗底上的高细节有两个相反的失败模式,它们互相独立,不能互相抵消:
| 失败模式 | 判据 | 救它的办法 |
|---|---|---|
| 视觉噪声 | 噪声千分比 > 预算 | 裁笔画数 |
| 细节看不见 | 细节对比度 < 下限 | 调调色板(改对比度) |
第一笔账是噪声:噪声千分比 = 笔画数 × 最小笔画宽 × 笔画均长 × 1000 // 图标面积。笔画均长取图标边长的一半,最小笔画宽取 2px——比这更细的笔画在显示尺寸下会糊成噪点。第二笔账是细节对比度:细节色与底色之间的 WCAG 对比度 ×10(与 11.2 同一口径)必须 ≥ 下限,否则细节画了也看不见。两条判据都取整数,逐位可复算。
下面这段是 S 层脚本,读 config/st11_dark.json 的 noise 段,对每个图标算噪声千分比、预算内的最大笔画数与细节可见性,判定 ok / noise / invisible / noise+invisible,并 PIL 生成噪声对照 PNG 回读尺寸/模式。
#!/usr/bin/env python3
# file: sim/st11_noise.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st11_noise.py
"""11.3 视觉噪声与可读性平衡(S 层,暗黑/恐怖)。
暗色底上的高细节有两个相反的失败模式:细节太密 → 视觉噪声(糊成一团);细节对比度太低 →
细节根本看不见(白画)。脚本读 config/st11_dark.json 的 noise 段,对每个图标算三笔整数账:
① 噪声千分比 = 笔画数 × 最小笔画宽 × 笔画均长 × 1000 // 图标面积;② 预算内的最大笔画数
(把噪声压回 budgetPermille 需要裁到几笔);③ 细节对比度是否达到 minDetailContrast10。
PIL 生成噪声对照 PNG 并回读尺寸/模式。落 out/st11_noise.json 供 P 层对照。
"""
import hashlib
import json
import os
import sys
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def account(icon, nz):
"""单个图标的噪声账,全整数。"""
d = icon["displayPx"]
stroke_len = d // 2
stroke_area = nz["minStrokePx"] * stroke_len
area = d * d
noise_pm = icon["detailStrokes"] * stroke_area * 1000 // area
max_strokes = (nz["budgetPermille"] * area // 1000) // stroke_area
trimmed = min(icon["detailStrokes"], max_strokes)
trimmed_pm = trimmed * stroke_area * 1000 // area
over = noise_pm > nz["budgetPermille"]
visible = icon["detailContrast10"] >= nz["minDetailContrast10"]
if over and not visible:
verdict = "noise+invisible"
elif over:
verdict = "noise"
elif not visible:
verdict = "invisible"
else:
verdict = "ok"
return {"id": icon["id"], "displayPx": d, "strokes": icon["detailStrokes"],
"detailContrast10": icon["detailContrast10"], "noisePermille": noise_pm,
"overBudget": over, "detailVisible": visible, "verdict": verdict,
"maxStrokes": max_strokes, "trimmedStrokes": trimmed, "trimmedNoisePermille": trimmed_pm}
def gen_grid(rows, tokens, path, size):
"""噪声柱状图:柱高 = min(噪声千分比, 1000) × 高 // 1000,预算线画在 300。"""
w, h = size
im = Image.new("RGB", (w, h), tuple(tokens["bg"]))
d = ImageDraw.Draw(im)
n = len(rows)
for i, r in enumerate(rows):
x0, x1 = i * w // n, (i + 1) * w // n - 1
col = max(2, min(r["noisePermille"], 1000) * (h - 8) // 1000)
fill = tuple(tokens["danger"]) if r["overBudget"] else tuple(tokens["ok"])
d.rectangle([x0 + 2, h - 4 - col, x1 - 2, h - 5], fill=fill)
yb = h - 4 - 300 * (h - 8) // 1000
for x in range(0, w, 8):
d.point((x, yb), fill=tuple(tokens["warn"]))
im.save(path)
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st11_dark.json"), encoding="utf-8"))
nz = cfg["noise"]
rows = [account(i, nz) for i in nz["icons"]]
noisy = [r["id"] for r in rows if r["overBudget"]]
invisible = [r["id"] for r in rows if not r["detailVisible"]]
ok = [r["id"] for r in rows if r["verdict"] == "ok"]
gen = next(g for g in cfg["generated"] if g["id"] == "dark_noise")
png = os.path.join(os.path.dirname(ROOT), gen["file"])
os.makedirs(os.path.dirname(png), exist_ok=True)
gen_grid(rows, cfg["tokens"], png, (gen["width"], gen["height"]))
with Image.open(png) as im:
info = {"size": list(im.size), "mode": im.mode}
canon = json.dumps({"noisePermille": [r["noisePermille"] for r in rows],
"maxStrokes": [r["maxStrokes"] for r in rows],
"noisyCount": len(noisy), "invisibleCount": len(invisible),
"okCount": len(ok)},
separators=(",", ":"), ensure_ascii=False)
report = {"project": cfg["project"], "theme": cfg["theme"],
"budgetPermille": nz["budgetPermille"], "minDetailContrast10": nz["minDetailContrast10"],
"icons": rows, "noisy": noisy, "invisible": invisible, "ok": ok, "png": info,
"canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "st11_noise.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"budget={nz['budgetPermille']} minDetailContrast10={nz['minDetailContrast10']}")
for r in rows:
print(f" {r['id']:<6} d={r['displayPx']:<3} strokes={r['strokes']:<3} "
f"noise={r['noisePermille']:>4} over={str(r['overBudget']):<5} "
f"detailC10={r['detailContrast10']:<3} visible={str(r['detailVisible']):<5} "
f"trimTo={r['trimmedStrokes']:<3}->{r['trimmedNoisePermille']:>4} {r['verdict']}")
print(f"noisy={len(noisy)}{noisy} invisible={len(invisible)}{invisible} ok={len(ok)}{ok}")
print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']}")
print(f"digest={report['digest']}")
print("wrote out/st11_noise.json out/gen/st11_noise_grid.png")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/st11_noise.py(同机相对量级):
budget=300 minDetailContrast10=45
bed d=32 strokes=14 noise= 437 over=True detailC10=58 visible=True trimTo=9 -> 281 noise
lamp d=32 strokes=6 noise= 187 over=False detailC10=41 visible=False trimTo=6 -> 187 invisible
rug d=48 strokes=22 noise= 458 over=True detailC10=72 visible=True trimTo=14 -> 291 noise
clock d=24 strokes=4 noise= 166 over=False detailC10=63 visible=True trimTo=4 -> 166 ok
chair d=32 strokes=8 noise= 250 over=False detailC10=52 visible=True trimTo=8 -> 250 ok
chest d=48 strokes=18 noise= 375 over=True detailC10=38 visible=False trimTo=14 -> 291 noise+invisible
noisy=3['bed', 'rug', 'chest'] invisible=2['lamp', 'chest'] ok=2['clock', 'chair']
png 192x96 RGB
digest=2065131d8367b47f
wrote out/st11_noise.json out/gen/st11_noise_grid.png
6 个图标里只有 clock 与 chair 两条账都过(ok=2)。3 个噪声超预算(bed rug chest),2 个细节看不见(lamp chest)。把噪声超标的图标裁到 maxStrokes——bed 14→9(437→281)、rug 22→14(458→291)、chest 18→14(375→291)——噪声全部回到预算内。但 lamp(41)与 chest(38)的细节对比度低于下限 45,裁剪笔画救不了它们:笔画裁到再少,看不见的细节还是看不见,只能回去调调色板。这正是两条账不能互相抵消的证据——噪声是「画多了」,可见性是「画淡了」,一个加法一个减法,各有各的解。
下面这段是 P 层 TypeScript 内核 core/src/st11/noise.ts。noisePermille 与 maxStrokes 用整数地板除,account 判定 ok / noise / invisible / noise+invisible——与 Python 侧同一套规则。
// file: core/src/st11/noise.ts
// tier: P
// verify: node --test 'core/test/st11_*.test.ts'
// 11.3 视觉噪声与可读性平衡的 TS 内核:噪声千分比、预算内最大笔画数、细节可见性判定。
// 全部整数地板除,与 sim/st11_noise.py 同口径,逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Icon = { id: string; displayPx: number; detailStrokes: number; detailContrast10: number };
export type NoiseCfg = { minStrokePx: number; budgetPermille: number; minDetailContrast10: number };
export type NoiseRow = {
id: string; displayPx: number; strokes: number; detailContrast10: number;
noisePermille: number; overBudget: boolean; detailVisible: boolean; verdict: string;
maxStrokes: number; trimmedStrokes: number; trimmedNoisePermille: number;
};
/** 噪声千分比 = 笔画数 × 最小笔画宽 × 笔画均长 × 1000 // 图标面积。 */
export function noisePermille(icon: Icon, cfg: NoiseCfg): number {
const strokeArea = cfg.minStrokePx * Math.floor(icon.displayPx / 2);
const area = icon.displayPx * icon.displayPx;
return Math.floor((icon.detailStrokes * strokeArea * 1000) / area);
}
/** 把噪声压回 budgetPermille 需要裁到几笔。 */
export function maxStrokes(icon: Icon, cfg: NoiseCfg): number {
const strokeArea = cfg.minStrokePx * Math.floor(icon.displayPx / 2);
const area = icon.displayPx * icon.displayPx;
return Math.floor(Math.floor((cfg.budgetPermille * area) / 1000) / strokeArea);
}
export function account(icon: Icon, cfg: NoiseCfg): NoiseRow {
const strokeArea = cfg.minStrokePx * Math.floor(icon.displayPx / 2);
const area = icon.displayPx * icon.displayPx;
const noise = noisePermille(icon, cfg);
const cap = maxStrokes(icon, cfg);
const trimmed = Math.min(icon.detailStrokes, cap);
const over = noise > cfg.budgetPermille;
const visible = icon.detailContrast10 >= cfg.minDetailContrast10;
let verdict = "ok";
if (over && !visible) verdict = "noise+invisible";
else if (over) verdict = "noise";
else if (!visible) verdict = "invisible";
return {
id: icon.id, displayPx: icon.displayPx, strokes: icon.detailStrokes,
detailContrast10: icon.detailContrast10, noisePermille: noise, overBudget: over,
detailVisible: visible, verdict, maxStrokes: cap, trimmedStrokes: trimmed,
trimmedNoisePermille: Math.floor((trimmed * strokeArea * 1000) / area),
};
}
配套测试 core/test/st11_noise.test.ts 读 out/st11_noise.json,跑 node --test 'core/test/st11_noise.test.ts':
✔ 噪声千分比与预算内最大笔画数:与 Python 逐位一致 (0.578375ms)
✔ 裁剪后噪声不超预算:裁到 maxStrokes 即可回到 budgetPermille 内 (0.138167ms)
✔ 判定分类:noisy / invisible / ok 计数与 Python 逐位一致 (0.440208ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.370542ms)
✔ 负例:噪声与可见性是两条独立的账,不能互相抵消 (0.116916ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
最后一条负例是本章最重要的一条对抗:bed 细节对比度达标(58)但笔画超预算,必须判 noise;lamp 笔画在预算内但对比度不足(41),必须判 invisible;把预算从 300 收紧到 120,原本合格的 chair 也必须被判越界——预算是真闸门,不是装饰。跨语言 digest 2065131d8367b47f 与 Python 侧逐位相同(整数类)。测试全文见工作区 core/test/st11_noise.test.ts。
自检表
| 检查项 | 合格标准 |
|---|---|
| 噪声可复算 | 笔画数 × 笔画宽 × 笔画均长 × 1000 // 面积 |
| 可见性独立 | 对比度不足必须单列,不能被噪声账掩盖 |
| 裁剪有效 | 裁到 maxStrokes 后噪声回到预算内 |
| 闸门会拦 | 收紧预算后原合格图标必须被判越界 |
暗黑/恐怖的三笔账到这里就齐了:饱和度分级定基调,亮度分布定可读性,噪声账定细节的边界。下一章转向它的反面——清新/治愈,用高明度低饱和把同一套规范往另一个方向实例化。
阅读导航:上一节:11.2 亮度分布与对比度 · 下一节:12.1 高明度低饱和调色板 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。