11.2 细节密度与视觉噪声

「细节太碎」是美术评审的高频评语,但没人说得清碎到什么程度算碎。本节把《星屑旅店》的家具贴片拆成细节密度(边缘像素千分比)与视觉噪声(2×2 棋盘拐点千分比)两个整数口径,四档木纹从留白 69‰ 一路排到过载 1000‰,给出过密必须 fail 的负例,附 S 层脚本与 P 层 TS 内核的逐位一致复算。

「细节太碎」和「画面太脏」是评审桌上最常出现、也最难反驳的两句评语。它们和 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

档位板缝 / 木纹边缘像素细节密度噪声判定
留白 sparse4 板 4 纹111669‰0‰通过
适中 medium8 板 12 纹2251139‰0‰通过
偏密 dense16 板 28 纹4410273‰0‰fail(细节密度)
过载 overload1px 棋盘161291000‰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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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