10.1 与 10.2 把二次元里能写成数据的部分写完了:角色色、线色约束、色阶带、头身比、面部特征线。剩下的那一半——画质本身好不好——本机根本做不了。这一节整节标 X:正文里每一处涉及画质的判断,都必须显式标注「需真实环境(真实画师 / 真实绘图板 / 真实环境),命令行不可复现」。站内 2D 手绘与矢量 那一节已经把「画质与笔触」归到 X,本节接着它往下走:不只承认不可复现,而是把不可复现本身编码成一条会失败的门禁。
画质为什么不可复现
先把「不可复现」说清楚,它不是一句偷懒的话。本机没有绘图板、没有 Photoshop / Krita / Clip Studio,也不可能安装。即使有,画质也不是「跑一遍就得到同一个数」的东西:
| 画质的维度 | 为什么不可复现 | 本机的处理 |
|---|---|---|
| 线稿的粗细变化(起收笔、转角加粗) | 依赖手的压力曲线与运笔速度 | X,需真实环境,命令行不可复现 |
| 眼睛的高光与瞳孔层次 | 依赖画师对「眼神」的判断 | X,需真实环境,命令行不可复现 |
| 发丝的分组与前后穿插 | 依赖画师对体积与剪影的理解 | X,需真实环境,命令行不可复现 |
| 赛璐璐阴影的边界是否干净 | 依赖逐块填色的手感与边缘处理 | X,需真实环境,命令行不可复现 |
| 上色是否发脏(中间调是否被压成灰) | 依赖画师的眼睛(色域参数只是边界) | X,需真实环境,命令行不可复现 |
| 缩到游戏尺寸后是否仍可辨识 | 依赖真实分辨率下的观看 | X,需真实环境,命令行不可复现 |
右列每一行都写着同一句话,这不是重复,是纪律:任何一行漏标,就会被误读成「本机能算」。而这套书稿最想拦住的事,恰恰是有人给「不可复现」的东西编一个看起来很像真的数字——比如「画风相似度 0.92」「可爱度 8.7/10」「线稿自然度 87%」。
这类数字的危险不在它错,而在它无从证伪。一个面部特征线间距 30 < 下限 40,任何人重跑一遍都能复现这个结论;但「画风相似度 0.92」没有任何人能复现,它只会让评审会陷入「我觉得是 0.95」的扯皮。不可复现的量一旦被赋予小数,就失去了被检验的可能。
X 层:画质验收声明表
下面这段是 X 层声明表 config/st10_quality_claims.json。它的字段结构可以静态校验,但每一条的 criterion 真值来自真实画师,命令行不可复现。关键在于 forbiddenFields:它明确列出「测量值」类字段名,任何人往里加一个 measuredValue 都会被下一节的门禁拦下:
{
"version": 1,
"project": "stardust-inn",
"tier": "X",
"note": "二次元 / 日式的画质验收声明表(第 10 章 10.3,X 层)。画质(线稿的粗细变化、眼睛高光与瞳孔层次、发丝分组与穿插、赛璐璐阴影边界、上色是否发脏、缩到游戏尺寸后是否可辨识)只能由真实画师在真实环境里判断,命令行不可复现。本表把这些验收项写成可静态校验的声明:每条只允许 evidence=\"requires-real-env\",且**禁止携带任何测量值字段**(measuredValue / metricValue / score / confidence)——一旦出现,就说明有人给不可复现的东西编了一个数字。",
"allowedEvidence": ["requires-real-env"],
"forbiddenFields": ["measuredValue", "metricValue", "score", "confidence"],
"claims": [
{ "id": "cq_line-taper", "criterion": "线稿的粗细变化是否自然(起收笔、转角加粗)", "judge": "画师主观评审", "evidence": "requires-real-env" },
{ "id": "cq_eye-highlight", "criterion": "眼睛的高光与瞳孔层次是否成立", "judge": "画师主观评审", "evidence": "requires-real-env" },
{ "id": "cq_hair-grouping", "criterion": "发丝的分组与前后穿插是否读得出来", "judge": "主美评审", "evidence": "requires-real-env" },
{ "id": "cq_cel-edge", "criterion": "赛璐璐阴影的边界是否干净、无锯齿与脏边", "judge": "画师主观评审", "evidence": "requires-real-env" },
{ "id": "cq_color-dirty", "criterion": "上色是否发脏(中间调是否被压成灰)", "judge": "美术总监评审", "evidence": "requires-real-env" },
{ "id": "cq_small-size-read", "criterion": "缩到游戏实际尺寸后角色剪影与表情是否仍可辨识", "judge": "主美评审", "evidence": "requires-real-env" }
]
}
evidence 这个字段本身就是被校验的对象:它只允许 requires-real-env。「本机做不了」在这里不是注释,是枚举值。
P 层:把「不许编数字」写成门禁
下面这段是 P 层内核 core/src/st10/quality_claim.ts。它一行都不评价画质——它只做一件事:扫描整张声明表,任何一条若声称可命令行复现,或携带任何被禁的「测量值」字段,即判违规:
// file: core/src/st10/quality_claim.ts
// tier: P
// verify: node --test 'core/test/st10_*.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 = /^cq_[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/st10_quality_claim.test.ts
// tier: P
// verify: node --test 'core/test/st10_*.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/st10/quality_claim.ts";
import type { ClaimSpec } from "../src/st10/quality_claim.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/st10_quality_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, ids: spec.claims.map((c) => c.id),
});
assert.equal(sha16(canon), "bdd3af306b5006d9");
console.log(`st10 quality/claim digest = ${sha16(canon)}`);
});
在本机跑 node --test 'core/test/st10_*.test.ts',本节 5 条(三节合计 15 条),真实输出:
st10 quality/claim digest = bdd3af306b5006d9
✔ 基线:6 条声明全部标 requires-real-env,零违例、零编造 (0.936542ms)
✔ 负例:声称可命令行复现(evidence=cli)必须被判违规 (0.133042ms)
✔ 负例:给不可复现的项编一个测量值,必须被拦下 (0.082125ms)
✔ 负例:id 不合文法、缺评审角色都要被抓 (0.08325ms)
✔ P 层摘要(digest):本卷无 S 层,故只有诚实性自洽摘要 (0.674917ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
那条 measuredValue: 0.87 的负例是整个第 10 章最想讲的一行:它不是「测出来的 0.87」,是有人凭空写的 0.87。门禁拦的不是这个数字的大小,而是「这个字段不该存在」这件事本身。
这一章的三层分工
回到全章。二次元是这套书稿里第二个没有 S 层的一章(第一个是 7.3 X:笔触质量 所在的厚涂章),它的三层是这样分的:
| 层 | 管什么 | 产物 | 可复现性 |
|---|---|---|---|
| D | 色彩 / 线色 / 色阶带 / 头身比 / 面部比例 | 两份 JSON | 静态可校验 |
| P | 把规范变成会失败的断言 | 三个 TS 内核 + 测试 | node --test,15 条 |
| X | 画质本身 | 声明表 | 需真实环境,命令行不可复现 |
这套分法的要点是:X 不是留白,是被显式编码的边界。一个团队如果只写 D 与 P,规范会给人「什么都验过了」的错觉;把 X 显式列出来,评审会上就不会有人问「画质那个指标呢」——因为答案是明确的:它没有指标,它只有画师的眼睛。
真实环境里怎么验收画质
既然命令行不可复现,验收就只能在真实环境里做。下面这套流程本机一步都跑不了,写出来是为了让「X」这个标签有具体所指,而不是一句免责声明:
- 真实绘图板与真实笔刷:在 Photoshop / Krita / Clip Studio 里按 10.1 的线色约束与色阶带画一遍线稿与赛璐璐上色。需真实环境,命令行不可复现。
- 真实分辨率观看:把画好的角色缩到游戏实际显示的尺寸(如 96×128)再看一遍剪影与表情。需真实环境,命令行不可复现。
- 真实画师评审:由画师与主美按
cq_line-taper…cq_small-size-read六条逐项过一遍,给出「过 / 打回」。需真实环境,命令行不可复现。 - 真实引擎内嵌:把角色挂进真实 UI 或场景里,在真实光照与真实 UI 底色下看一遍。需真实环境,命令行不可复现。
四步的共同点是:没有一步能由 CI 自动判定。这也是本节把 evidence 锁成 requires-real-env 的原因——不是不想自动化,是这些判断本质上依赖人的感知,自动化只会得到一个编出来的数。能给自动化的是前两节的 D 与 P:角色色有没有越界、色阶带有没有塌成一档、头身比自不自洽、面部顺序有没有错。把能自动化的全部自动化,把不能自动化的显式标出来,这才是一份诚实的规范。
自检表
| 检查项 | 合格标准 |
|---|---|
| X 显式 | 每处涉及画质的判断都标注「需真实环境,命令行不可复现」 |
| 声明可判 | 每条声明都有 id、验收描述、评审角色、evidence |
| 诚实性硬失败 | 出现任一测量值字段,pass 即为假 |
| 不编数字 | 全文没有任何画质的「测量值」 |
| 边界清晰 | 画质只作主观评审,不写成「实测」 |
二次元收尾了。下一章换一种完全不同的情绪:暗黑 / 恐怖。它和二次元正好相反——二次元靠高明度与干净线条,暗黑则几乎全靠低饱和与亮度分布,而这两样恰恰都能算成整数账。
阅读导航:上一节:10.2 角色比例与面部规范 · 下一节:11.1 低饱和色彩分级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。