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 面数与顶点预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。