11.3 视觉噪声与可读性平衡

暗色底上的高细节有两个相反的失败模式:太密就成了视觉噪声,太淡又会看不见。本节对《星屑旅店》的每个图标算两笔独立的整数账——噪声千分比(笔画数乘笔画宽乘笔画均长除以图标面积)与细节对比度是否达到下限,并证明裁剪笔画救不了对比度不足,两条账不能互相抵消。

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 高明度低饱和调色板 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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