7.3 X:笔触质量

前两节把厚涂里能写成数据的部分写完了:笔刷参数、色域、分层与复用。剩下的那一半——笔触质量——本机根本做不了。本节整节标 X,逐条说明它为什么不可复现,并把「不许给不可复现的东西编一个数字」写成一条会失败的门禁:声明表里只要出现一个测量值字段,测试立刻报错。

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」这个标签有具体所指,而不是一句免责声明:

  1. 真实绘图板与真实笔刷:在 Photoshop / Krita / Clip Studio 里按 7.1 的笔刷预设建笔刷,实际画一遍起稿、暗部、亮部、纹理、细节五个图层。需真实环境,命令行不可复现。
  2. 真实分辨率观看:把画好的贴图缩到游戏实际显示的尺寸(如 64×96)再看一遍剪影与明暗。需真实环境,命令行不可复现。
  3. 真实画师评审:由画师与主美按 cl_taper … cl_readability 六条逐项过一遍,给出「过 / 打回」。需真实环境,命令行不可复现。
  4. 真实 DCC 内嵌:把贴图挂到真实材质球上,在真实光照下看一遍。需真实环境,命令行不可复现。

四步的共同点是:没有一步能由 CI 自动判定。这也是本节把 evidence 锁成 requires-real-env 的原因——不是不想自动化,是这些判断本质上依赖人的感知,自动化只会得到一个编出来的数。能给自动化的是前三节的 D 与 P:笔刷参数有没有越界、色域有没有越界、图层序列有没有漂移、有没有孤儿模板。把能自动化的全部自动化,把不能自动化的显式标出来,这才是一份诚实的规范。

自检表

检查项合格标准
X 显式每处涉及笔触质量的判断都标注「需真实环境,命令行不可复现」
声明可判每条声明都有 id、验收描述、评审角色、evidence
诚实性硬失败出现任一测量值字段,pass 即为假
不编数字全文没有任何笔触质量的「测量值」
边界清晰笔触质量只作主观评审,不写成「实测」

厚涂收尾了。下一章换一种完全不同的风格:体素。它和厚涂正好相反——厚涂几乎全靠手感,体素则几乎全靠整数账:网格尺寸、体素数、调色板上限,全都能精确算出来。

阅读导航:上一节:7.2 手绘贴图的分层与复用 · 下一节:8.1 体素网格与体素账 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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