3.3 X:画质与笔触

「画得好不好」没有可跑的数字,硬凑一个笔触指标比不写更危险。本节整节标 X:把 2D 手绘的画质与笔触显式划出可命令行复现的范围,只保留能静态校验的东西——笔刷参数声明表的结构、范围与诚实性。真实笔触质量需真实画师与真实环境,命令行不可复现;本节给出的是「不许过度承诺」的门禁,不是画质评分。

3.3 X:画质与笔触

站内 独立游戏像素美术风格指南 讲的是 Aseprite 技法与素材资源——那是「怎么画」;本节讲的是另一件事:「画得好不好」为什么不能变成命令行里的一行数字。前面两节都在算能算的账:线宽、圆角、色块数、切分复用率。这一节整节标 X——真实画质与笔触需真实画师与真实环境,命令行不可复现。卷二 2.3 X 范式:真实 DCC 不可复现 已经把「不可复现」写成了方法;本节把同一套方法用到画质与笔触上。卷一 11.2 细节密度与视觉噪声 算的是细节的量,卷三 7.3 X:笔触质量 是同一主题的姊妹节;本节只做划界与参数描述。

三档可复现性:画质落在哪一档

档含义本节的处理
可命令行复现给定输入与代码,本机跑出的数字/文件一致S / P 层:只校验声明表的结构与范围
需真实环境要真实画师、真实笔刷引擎、真实数位板X 层:每处显式标注「需真实环境,命令行不可复现」
不可复现笔触的「手感」、画质的「高级感」只作公开可观察机制引用,不写成「实测」

画质与笔触落在第二、三档:本机没有画师、没有笔刷引擎、没有数位板,一笔「有呼吸感的排线」既无法生成,也无法度量。所以本节不产出任何画质指标——不得为凑一个可跑数字而编造笔触/画质指标。

为什么笔触不能变成数字

笔触是连续的手部运动加笔刷引擎的物理响应。要把「笔触好」变成数字,得先定义「好」,而定义「好」需要审美判断——审美判断需真实画师与真实环境,命令行不可复现。任何试图用像素统计替代审美判断的指标,都会在真实作品上失效:它能测「边缘数量」,测不出「边缘是否服务于形体」。所以本节的做法不是度量笔触,而是把「不可度量」这件事编码成一条会失败的门禁。

能静态校验的只有「声明」

下面这个块是 X 层:笔刷参数声明表的结构可以静态校验,但表里的参数(尺寸、不透明度、流量、硬度)来自真实画师在真实环境里的设定,命令行不可复现。这张表只声明管线允许的参数范围,不声明任何画质或笔触质量指标:

{
  "version": 1,
  "tier": "X",
  "project": "stardust-inn",
  "note": "笔刷参数声明表(第 3 章 3.3,X 层)。这张表只声明「管线允许的笔刷参数范围」(尺寸/不透明度/流量/硬度),**不声明任何画质或笔触质量指标**——真实笔触质量需真实画师与真实环境,命令行不可复现(X)。可静态校验的只有结构、枚举、id 文法、参数范围与诚实性(不得把需真实环境的事声明成命令行可复现)。",
  "requiredFields": ["id", "brush", "sizePx", "opacityPercent", "flowPercent", "hardnessPercent", "reproducible"],
  "enums": {
    "brush": ["ink-pen", "round-soft", "flat-bristle", "chalk"],
    "reproducible": ["cli", "requires-real-env"]
  },
  "idPattern": "^brush_[a-z][a-z0-9-]*$",
  "range": {
    "sizePx": [1, 400],
    "opacityPercent": [1, 100],
    "flowPercent": [1, 100],
    "hardnessPercent": [0, 100]
  },
  "items": [
    { "id": "brush_ink-outline", "brush": "ink-pen", "sizePx": 4, "opacityPercent": 100, "flowPercent": 100, "hardnessPercent": 90, "reproducible": "cli" },
    { "id": "brush_fill-flat", "brush": "round-soft", "sizePx": 24, "opacityPercent": 100, "flowPercent": 80, "hardnessPercent": 100, "reproducible": "requires-real-env" },
    { "id": "brush_shade-soft", "brush": "round-soft", "sizePx": 40, "opacityPercent": 35, "flowPercent": 40, "hardnessPercent": 0, "reproducible": "requires-real-env" },
    { "id": "brush_texture-chalk", "brush": "chalk", "sizePx": 500, "opacityPercent": 60, "flowPercent": 55, "hardnessPercent": 20, "reproducible": "requires-real-env" },
    { "id": "brush_bristle-flat", "brush": "flat-bristle", "sizePx": 30, "opacityPercent": 70, "flowPercent": 65, "hardnessPercent": 70, "reproducible": "requires-real-env" }
  ]
}

注意 reproducible 字段本身也被校验:enums 里它是 ["cli", "requires-real-env"],但审计脚本会把声明 cli 的条目直接判违规——因为无真实画师与真实环境时,笔触与画质不可能由命令行复现。「不许过度承诺」在这里是一条会失败的门禁,不是一句提醒。

S 层:笔刷参数声明表的静态审计

下面这段是 S 层脚本。它不产出任何画质或笔触指标(那需真实环境),只对声明表做三类静态检查——结构(字段/枚举/id 文法)、范围(四个参数落在声明区间内)、诚实性(reproducible 不得声明 cli):

#!/usr/bin/env python3
# file: sim/st03_brush_audit.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st03_brush_audit.py
"""笔刷参数声明表的静态审计(第 3 章 3.3,S 层,服务于 X 层)。

3.3 整节标 X:**真实画质与笔触需真实画师与真实环境,命令行不可复现**。本脚本**不产出任何
画质或笔触指标**(那需要真实环境),只对「笔刷参数声明表」做三类**静态**检查:

* **结构**:必填字段、枚举取值、id 文法;
* **范围**:sizePx / opacityPercent / flowPercent / hardnessPercent 必须落在声明区间内;
* **诚实性**:`reproducible` 声明 `cli` 即为**过度承诺**——无真实画师与真实环境时,笔触与画质
  不可能由命令行复现。

落 out/st03_brush_audit.json 供 P 层对照。三类检查全不需要画笔,也就不会编造笔触数字。
"""
import hashlib
import json
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FAMILY = {"E_FIELD": "structure", "E_ENUM": "structure", "E_ID": "structure",
          "E_RANGE": "range", "E_OVERCLAIM": "honesty"}


def audit_item(item, cfg):
    errs = []
    for f in cfg["requiredFields"]:
        if f not in item:
            errs.append({"code": "E_FIELD", "token": f})
    for key, allowed in cfg["enums"].items():
        if key in item and item[key] not in allowed:
            errs.append({"code": "E_ENUM", "token": f"{key}={item[key]}"})
    if not re.match(cfg["idPattern"], str(item.get("id", ""))):
        errs.append({"code": "E_ID", "token": str(item.get("id", ""))})
    for key, (lo, hi) in cfg["range"].items():
        v = item.get(key)
        if isinstance(v, int) and not (lo <= v <= hi):
            errs.append({"code": "E_RANGE", "token": f"{key}={v}∉[{lo},{hi}]"})
    if item.get("reproducible") == "cli":
        errs.append({"code": "E_OVERCLAIM", "token": "cli"})
    return errs


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/st03_brush.json"), encoding="utf-8"))
    violations = []
    for it in cfg["items"]:
        violations += [{"id": it.get("id"), **e} for e in audit_item(it, cfg)]
    violations.sort(key=lambda v: (v["id"], v["code"]))
    counts, fams = {}, {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
        fams[FAMILY[v["code"]]] = fams.get(FAMILY[v["code"]], 0) + 1
    n = len(cfg["items"])
    cli = sum(1 for it in cfg["items"] if it.get("reproducible") == "cli")
    keys = [f"{v['id']}:{v['code']}:{v['token']}" for v in violations]
    canon = json.dumps({"itemCount": n, "structure": fams.get("structure", 0),
                        "range": fams.get("range", 0), "honesty": fams.get("honesty", 0),
                        "cliCount": cli}, separators=(",", ":"), ensure_ascii=False)
    summary = {"project": cfg["project"], "tier": cfg["tier"], "itemCount": n,
               "structureViolations": fams.get("structure", 0), "rangeViolations": fams.get("range", 0),
               "honestyViolations": fams.get("honesty", 0), "violationCount": len(violations),
               "cliCount": cli, "cliPermille": cli * 1000 // n, "counts": counts,
               "violations": violations, "canonical": canon,
               "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
               "violationDigest": hashlib.sha256("\n".join(keys).encode("utf-8")).hexdigest()[:16]}
    json.dump(summary, open(os.path.join(ROOT, "out/st03_brush_audit.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, sort_keys=True)
    print(f"itemCount={n} structure={summary['structureViolations']} "
          f"range={summary['rangeViolations']} honesty={summary['honestyViolations']} "
          f"cliPermille={summary['cliPermille']}")
    for v in violations:
        print(f"  {v['id']:22s} {v['code']:12s} {v['token']}")
    print(f"digest={summary['digest']} violationDigest={summary['violationDigest']}")
    print("wrote out/st03_brush_audit.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/st03_brush_audit.py:

itemCount=5 structure=0 range=1 honesty=1 cliPermille=200
  brush_ink-outline      E_OVERCLAIM  cli
  brush_texture-chalk    E_RANGE      sizePx=500∉[1,400]
digest=8756f2b4121cf544 violationDigest=bf67e7c82283e7ed
wrote out/st03_brush_audit.json

两条违例都是故意留的:brush_ink-outline 声明 cli(E_OVERCLAIM)——它把「需真实环境」的笔触谎称成命令行可复现;brush_texture-chalk 的 sizePx=500 超出声明区间 [1,400](E_RANGE)。cliPermille=200 说明 5 条里 1 条过度承诺——这个数字不是画质评分,是诚实性比例。

P 层:诚实性门禁的 TS 侧

同一套判据,TypeScript 侧独立实现一遍 core/src/st03/brush.ts。它只读同一份声明表,不 import Python 的任何东西:

// file: core/src/st03/brush.ts
// tier: P
// verify: node --test 'core/test/st03_*.test.ts'
// 笔刷参数声明表的 TS 侧静态审计(第 3 章 3.3)。与 sim/st03_brush_audit.py 共用
// config/st03_brush.json;判据全是字符串/整数(字段名、枚举、正则、整数区间),故两侧
// 违例集合逐位一致。**不产出任何画质或笔触指标**——那需要真实环境,命令行不可复现(X)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type BrushItem = Record<string, unknown>;
export type BrushCfg = {
  requiredFields: string[]; enums: Record<string, string[]>; idPattern: string;
  range: Record<string, number[]>; items: BrushItem[];
};
export type Violation = { id: string; code: string; token: string };

const RANGE_KEYS = ["sizePx", "opacityPercent", "flowPercent", "hardnessPercent"];

export function auditItem(it: BrushItem, cfg: BrushCfg): Violation[] {
  const id = String(it.id ?? "");
  const out: Violation[] = [];
  const push = (code: string, token: string): void => void out.push({ id, code, token });
  for (const f of cfg.requiredFields) if (!(f in it)) push("E_FIELD", f);
  for (const [key, allowed] of Object.entries(cfg.enums)) {
    if (it[key] !== undefined && !allowed.includes(String(it[key]))) push("E_ENUM", `${key}=${it[key]}`);
  }
  if (!new RegExp(cfg.idPattern).test(id)) push("E_ID", id);
  for (const key of RANGE_KEYS) {
    const [lo, hi] = cfg.range[key];
    const v = it[key];
    if (typeof v === "number" && Number.isInteger(v) && !(lo <= v && v <= hi)) {
      push("E_RANGE", `${key}=${v}∉[${lo},${hi}]`);
    }
  }
  if (it.reproducible === "cli") push("E_OVERCLAIM", "cli");
  return out;
}

export function auditAll(cfg: BrushCfg): Violation[] {
  const all = cfg.items.flatMap((it) => auditItem(it, cfg));
  return all.sort((x, y) =>
    x.id < y.id ? -1 : x.id > y.id ? 1 : x.code < y.code ? -1 : x.code > y.code ? 1 : 0);
}

/** 诚实性千分比:把需真实环境的事声明成 cli 的比例,整数地板除。 */
export function cliPermille(cfg: BrushCfg): number {
  const cli = cfg.items.filter((it) => it.reproducible === "cli").length;
  return Math.floor((cli * 1000) / cfg.items.length);
}

/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(cfg: BrushCfg): string {
  const vs = auditAll(cfg);
  const fam = (c: string): number => vs.filter((v) => v.code === c).length;
  const structure = vs.length - fam("E_RANGE") - fam("E_OVERCLAIM");
  return JSON.stringify({
    itemCount: cfg.items.length, structure, range: fam("E_RANGE"), honesty: fam("E_OVERCLAIM"),
    cliCount: cfg.items.filter((it) => it.reproducible === "cli").length,
  });
}

node --test 'core/test/st03_*.test.ts'(本节 3 条,三节合计 11 条)真实输出:

brush digest = 8756f2b4121cf544
✔ 跨语言:违例集合与两类摘要逐位一致(字符串/整数类) (1.718083ms)
✔ 诚实性:无真实环境时把笔触声明成命令行可复现即为过度承诺 (0.099ms)
✔ 负例:过度承诺、越界、id 不合文法、枚举非法,四类各自可被单独触发 (0.13375ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

跨语言那两条把 TS 复算的违例集合与 canonical 摘要逐位对上 Python 落盘的 out/st03_brush_audit.json,两个 sha256 分别是 8756f2b4121cf544 与 bf67e7c82283e7ed——字段名、枚举、正则、整数区间全是字符串/整数类,逐位一致。第三条是负例:过度承诺、越界、id 不合文法、枚举非法,四类各自可被单独触发。

这套方法不能做什么

必须把边界说清楚,否则「静态校验」会被误当成「画质校验」:

它不能评价画质。 审计保证的是「声明表自洽 + 没有过度承诺」,一笔线画得有没有呼吸感,需真实画师与真实环境,命令行不可复现。它不能替代真实评审。 表里的参数区间可以由人填对,但参数对不等于成品对;真实笔触质量只能靠真实环境里的人工评审。它的价值在时间点。 这套检查跑在交付之前,把「等评审才发现笔触声明根本没法复现」提前到「CI 里一条失败」。早发现才是它全部的价值。

自检表

检查项合格标准
划界显式画质与笔触每处标注「需真实环境,命令行不可复现」
不编指标本节不产出任何笔触/画质数字,只校验声明
声明可查结构、范围、诚实性三类静态检查可复算
不许过度承诺声明 cli 即判 E_OVERCLAIM,是一条会失败的门禁
账可复算违例集合与摘要跨语言逐位一致

画质与笔触是本章唯一整节标 X 的边界:算不了的就不算,只把「不许假装能算」写成门禁。下一章换到低多边形 Low-poly,重新回到可算的整数账——面数与顶点预算。

阅读导航:上一节:3.2 手绘资产的切分与复用 · 下一节:4.1 面数与顶点预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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