7.1 与 7.2 把厚涂里能写成数据的部分写完了:笔刷参数、色域、图层模板、复用率。剩下的那一半——笔触本身好不好——本机根本做不了。这一节整节标 X:正文里每一处涉及笔触质量的判断,都必须显式标注「需真实环境(真实画师 / 真实绘图板 / 真实 DCC),命令行不可复现」。站内 2D 手绘与矢量 那一节已经把「画质与笔触」归到 X,本节接着它往下走:不只承认不可复现,而是把不可复现本身编码成一条会失败的门禁。
笔触质量为什么不可复现
先把「不可复现」说清楚,它不是一句偷懒的话。本机没有绘图板、没有 Photoshop / Krita / Clip Studio、没有任何 DCC,也不可能安装。即使有,笔触质量也不是「跑一遍就得到同一个数」的东西:
| 笔触质量的维度 | 为什么不可复现 | 本机的处理 |
|---|---|---|
| 起笔与收笔的粗细过渡 | 依赖手的压力曲线与运笔速度 | X,需真实环境,命令行不可复现 |
| 笔触方向跟随形体 | 依赖画师对体积的理解 | X,需真实环境,命令行不可复现 |
| 暗部 / 亮部 / 纹理的堆叠秩序 | 依赖逐层叠加的手感 | X,需真实环境,命令行不可复现 |
| 边缘处理是否与厚涂一致 | 依赖笔刷与笔压的配合 | X,需真实环境,命令行不可复现 |
| 整幅贴图的色域统一 | 依赖画师的眼睛(色域参数只是边界) | X,需真实环境,命令行不可复现 |
| 缩到游戏尺寸后是否可读 | 依赖真实分辨率下的观看 | X,需真实环境,命令行不可复现 |
右列每一行都写着同一句话,这不是重复,是纪律:任何一行漏标,就会被误读成「本机能算」。而这套书稿最想拦住的事,恰恰是有人给「不可复现」的东西编一个看起来很像真的数字——比如「笔触自然度 0.87」「堆叠秩序得分 92」。
这类数字的危险不在它错,而在它无从证伪。一个对比度 31 低于下限 45,任何人重跑一遍都能复现这个结论;但「自然度 0.87」没有任何人能复现,它只会让评审会陷入「我觉得是 0.9」的扯皮。不可复现的量一旦被赋予小数,就失去了被检验的可能。
X 层:笔触质量验收声明表
下面这段是 X 层声明表 config/st07_stroke_claims.json。它的字段结构可以静态校验,但每一条的 criterion 真值来自真实画师,命令行不可复现。关键在于 forbiddenFields:它明确列出「测量值」类字段名,任何人往里加一个 measuredValue 都会被下一节的门禁拦下:
{
"version": 1,
"project": "stardust-inn",
"tier": "X",
"note": "笔触质量验收声明表(第 7 章 7.3,X 层)。笔触质量(起收笔过渡、笔触方向、堆叠秩序、边缘处理、色域统一、缩尺可读性)只能由真实画师在真实环境里判断,命令行不可复现。本表把这些验收项写成可静态校验的声明:每条只允许 evidence=\"requires-real-env\",且**禁止携带任何测量值字段**(measuredValue / metricValue / score / confidence)——一旦出现,就说明有人给不可复现的东西编了一个数字。",
"allowedEvidence": ["requires-real-env"],
"forbiddenFields": ["measuredValue", "metricValue", "score", "confidence"],
"claims": [
{ "id": "cl_taper", "criterion": "起笔与收笔的粗细过渡是否自然", "judge": "画师主观评审", "evidence": "requires-real-env" },
{ "id": "cl_direction", "criterion": "笔触方向是否跟随形体转折", "judge": "画师主观评审", "evidence": "requires-real-env" },
{ "id": "cl_stack-order", "criterion": "暗部 / 亮部 / 纹理的堆叠秩序是否成立", "judge": "主美评审", "evidence": "requires-real-env" },
{ "id": "cl_edge", "criterion": "边缘处理是否与厚涂笔触一致", "judge": "画师主观评审", "evidence": "requires-real-env" },
{ "id": "cl_palette-unity", "criterion": "整幅手绘贴图的色域是否统一", "judge": "美术总监评审", "evidence": "requires-real-env" },
{ "id": "cl_readability", "criterion": "缩到游戏尺寸后剪影与明暗是否仍可读", "judge": "主美评审", "evidence": "requires-real-env" }
]
}
evidence 这个字段本身就是被校验的对象:它只允许 requires-real-env。「本机做不了」在这里不是注释,是枚举值。
P 层:把「不许编数字」写成门禁
下面这段是 P 层内核 core/src/st07/stroke_claim.ts。它一行都不评价笔触——它只做一件事:扫描整张声明表,任何一条若声称可命令行复现,或携带任何被禁的「测量值」字段,即判违规:
// file: core/src/st07/stroke_claim.ts
// tier: P
// verify: node --test 'core/test/st07_*.test.ts'
// 笔触质量声明表的静态校验内核(P 层)。它**不评价笔触**,只做一件事:把「不可复现的
// 验收项」写成会失败的门禁——任何一条声明若声称可命令行复现(evidence 不是
// requires-real-env),或携带任何「测量值」字段(measuredValue / score…),即判违规。
// 这正是本套「不假装能真画」的纪律:不是留白,而是把留白本身编码成断言。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Claim = { id: string; criterion: string; judge: string; evidence: string };
export type ClaimSpec = {
allowedEvidence: string[];
forbiddenFields: string[];
claims: (Claim & Record<string, unknown>)[];
};
export type ClaimReport = {
claimCount: number;
violations: string[];
inventedCount: number;
realEnvCount: number;
pass: boolean;
};
const ID_PATTERN = /^cl_[a-z][a-z0-9-]*$/;
/** 校验整张声明表:结构 + 诚实性。 */
export function validateClaims(spec: ClaimSpec): ClaimReport {
const violations: string[] = [];
let inventedCount = 0;
let realEnvCount = 0;
for (const c of spec.claims) {
if (!ID_PATTERN.test(String(c.id))) violations.push(`${c.id}: id 不合文法`);
if (!c.criterion) violations.push(`${c.id}: 缺验收描述`);
if (!c.judge) violations.push(`${c.id}: 缺评审角色`);
if (!spec.allowedEvidence.includes(String(c.evidence))) {
violations.push(`${c.id}: evidence=${c.evidence} 越界,仅允许 ${spec.allowedEvidence.join("/")}`);
} else {
realEnvCount += 1;
}
for (const f of spec.forbiddenFields) {
if (f in c) {
violations.push(`${c.id}: 出现被禁字段 ${f}`);
inventedCount += 1;
}
}
}
return { claimCount: spec.claims.length, violations, inventedCount, realEnvCount,
pass: violations.length === 0 && inventedCount === 0 };
}
注意 pass 的判据:violations.length === 0 && inventedCount === 0。只要有一条声明带了测量值,pass 就是假——这不是「警告」,是硬失败。
负例:编出来的数字必须被拦下
这个门禁最怕的失败模式是放行:明明有人编了一个数,却没人报。所以测试的力气全在负例上:
// file: core/test/st07_stroke_claim.test.ts
// tier: P
// verify: node --test 'core/test/st07_*.test.ts'
// 笔触质量声明表的断言:基线 6 条声明全部标 requires-real-env、零违例、零编造;
// 负例:声称可命令行复现、携带测量值字段、id 不合文法,都必须被判出来。
// 这层校验不产生任何笔触数字——它只保证「没有人给不可复现的东西编数字」。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validateClaims } from "../src/st07/stroke_claim.ts";
import type { ClaimSpec } from "../src/st07/stroke_claim.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/st07_stroke_claims.json"), "utf8")) as ClaimSpec;
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
test("基线:6 条声明全部标 requires-real-env,零违例、零编造", () => {
const r = validateClaims(spec);
assert.equal(r.claimCount, 6);
assert.deepEqual(r.violations, []);
assert.equal(r.inventedCount, 0);
assert.equal(r.realEnvCount, 6);
assert.equal(r.pass, true);
});
test("负例:声称可命令行复现(evidence=cli)必须被判违规", () => {
const bad: ClaimSpec = { ...spec,
claims: [{ ...spec.claims[0], evidence: "cli" }] };
const r = validateClaims(bad);
assert.equal(r.pass, false);
assert.ok(r.violations.some((e) => /evidence=cli/.test(e)));
assert.equal(r.realEnvCount, 0);
});
test("负例:给不可复现的项编一个测量值,必须被拦下", () => {
const bad: ClaimSpec = { ...spec,
claims: [{ ...spec.claims[0], measuredValue: 0.87 } as ClaimSpec["claims"][number]] };
const r = validateClaims(bad);
assert.equal(r.inventedCount, 1);
assert.ok(r.violations.some((e) => /被禁字段 measuredValue/.test(e)));
assert.equal(r.pass, false);
});
test("负例:id 不合文法、缺评审角色都要被抓", () => {
const bad: ClaimSpec = { ...spec,
claims: [{ ...spec.claims[0], id: "claim_bad", judge: "" }] };
const r = validateClaims(bad);
assert.ok(r.violations.some((e) => /id 不合文法/.test(e)));
assert.ok(r.violations.some((e) => /缺评审角色/.test(e)));
});
test("P 层摘要(digest):本卷无 S 层,故只有诚实性自洽摘要", () => {
const r = validateClaims(spec);
const canon = JSON.stringify({ claimCount: r.claimCount, realEnvCount: r.realEnvCount,
inventedCount: r.inventedCount, pass: r.pass });
assert.equal(sha16(canon), "0432e4ba8f99b1a6");
console.log(`st07 stroke/claim digest = ${sha16(canon)}`);
});
在本机跑 node --test 'core/test/st07_*.test.ts',本节 5 条(三节合计 17 条),真实输出:
st07 stroke/claim digest = 0432e4ba8f99b1a6
✔ 基线:6 条声明全部标 requires-real-env,零违例、零编造 (0.852417ms)
✔ 负例:声称可命令行复现(evidence=cli)必须被判违规 (0.132292ms)
✔ 负例:给不可复现的项编一个测量值,必须被拦下 (0.083083ms)
✔ 负例:id 不合文法、缺评审角色都要被抓 (0.07875ms)
✔ P 层摘要(digest):本卷无 S 层,故只有诚实性自洽摘要 (0.663833ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
那条 measuredValue: 0.87 的负例是整个第 7 章最想讲的一行:它不是「测出来的 0.87」,是有人凭空写的 0.87。门禁拦的不是这个数字的大小,而是「这个字段不该存在」这件事本身。
这一章的三层分工
回到全章。厚涂是这套书稿里唯一没有 S 层的一章,它的三层是这样分的:
| 层 | 管什么 | 产物 | 可复现性 |
|---|---|---|---|
| D | 笔刷 / 色域 / 分层 / 复用的规范数据 | 三份 JSON | 静态可校验 |
| P | 把规范变成会失败的断言 | 三个 TS 内核 + 测试 | node --test,17 条 |
| X | 笔触质量本身 | 声明表 | 需真实环境,命令行不可复现 |
这套分法的要点是:X 不是留白,是被显式编码的边界。一个团队如果只写 D 与 P,规范会给人「什么都验过了」的错觉;把 X 显式列出来,评审会上就不会有人问「笔触质量那个指标呢」——因为答案是明确的:它没有指标,它只有画师的眼睛。
真实环境里怎么验收笔触
既然命令行不可复现,验收就只能在真实环境里做。下面这套流程本机一步都跑不了,写出来是为了让「X」这个标签有具体所指,而不是一句免责声明:
- 真实绘图板与真实笔刷:在 Photoshop / Krita / Clip Studio 里按 7.1 的笔刷预设建笔刷,实际画一遍起稿、暗部、亮部、纹理、细节五个图层。需真实环境,命令行不可复现。
- 真实分辨率观看:把画好的贴图缩到游戏实际显示的尺寸(如 64×96)再看一遍剪影与明暗。需真实环境,命令行不可复现。
- 真实画师评审:由画师与主美按
cl_taper…cl_readability六条逐项过一遍,给出「过 / 打回」。需真实环境,命令行不可复现。 - 真实 DCC 内嵌:把贴图挂到真实材质球上,在真实光照下看一遍。需真实环境,命令行不可复现。
四步的共同点是:没有一步能由 CI 自动判定。这也是本节把 evidence 锁成 requires-real-env 的原因——不是不想自动化,是这些判断本质上依赖人的感知,自动化只会得到一个编出来的数。能给自动化的是前三节的 D 与 P:笔刷参数有没有越界、色域有没有越界、图层序列有没有漂移、有没有孤儿模板。把能自动化的全部自动化,把不能自动化的显式标出来,这才是一份诚实的规范。
自检表
| 检查项 | 合格标准 |
|---|---|
| X 显式 | 每处涉及笔触质量的判断都标注「需真实环境,命令行不可复现」 |
| 声明可判 | 每条声明都有 id、验收描述、评审角色、evidence |
| 诚实性硬失败 | 出现任一测量值字段,pass 即为假 |
| 不编数字 | 全文没有任何笔触质量的「测量值」 |
| 边界清晰 | 笔触质量只作主观评审,不写成「实测」 |
厚涂收尾了。下一章换一种完全不同的风格:体素。它和厚涂正好相反——厚涂几乎全靠手感,体素则几乎全靠整数账:网格尺寸、体素数、调色板上限,全都能精确算出来。
阅读导航:上一节:7.2 手绘贴图的分层与复用 · 下一节:8.1 体素网格与体素账 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。