7.1 笔刷与色域参数规范

风格化厚涂既没有可导出的模型,也没有可渲染的着色器,能写进规范的只有两样:笔刷参数与色域。本节把两者落成一份 D 层 JSON 规范与一段可跑的 TypeScript 校验器——笔刷的尺寸、间距与图层不透明度带,色域的饱和度上限、明度下限与色相带,全部是整数,并说清为什么这一章只走 D+P+X 而不硬凑一个 S 层。

站内那篇 游戏美术风格指南 用提纲把「厚涂要有笔触、色要统一」讲成了方向;本节不重复那套口号,而是回答一个更硬的问题:当一种风格几乎全靠手感时,规范里到底还剩下什么能被写成数据。前一节 6.3 写实的性能代价 处理的是可量化的贴图通道与显存账;厚涂反过来,它的「好看」几乎全在真实画师的手上。

厚涂能写进规范的是什么

写实 PBR 有一整条贴图通道与字节账可算,扁平手绘有 3.1 扁平色块与线宽规范 那样的整数阶梯。风格化厚涂夹在中间,最容易被写成一句没法验收的话:「笔触要自然、颜色要统一」。这句话的问题不是它错,而是它不可判定——两个人对「自然」的理解不同,评审会上就只能吵。

把它拆开看,厚涂其实有两类东西可以写成整数参数:

可写进规范的不可写进规范的
笔刷的尺寸、间距、不透明度、流量、硬度一笔下去「像不像」
每个图层角色对应的不透明度带笔触方向「对不对」
色域的饱和度上限、明度下限、色相带颜色「好不好看」
贴图页与图层模板的复用结构(下一节)整体「有没有味道」

左列是声明值——规范写下的整数,不是从任何一幅画里量出来的;右列是真实画师在真实环境里的判断,命令行不可复现(X)。本节只做左列,右列留给 7.3。把右列硬塞成数字,就是这套书稿最想拦住的事。

D 层:笔刷预设与色域规范

下面这段是 D 层规范 config/st07_brush_gamut.json。它把笔刷参数区间、图层角色不透明度带与色域三条约束落成数据。注意所有数字都是规范声明的,不是测量值:

{
  "version": 1,
  "project": "stardust-inn",
  "themeId": "handdrawn",
  "note": "风格化厚涂(手绘贴图)的笔刷预设与色域参数规范(第 7 章 7.1,D 层)。全部是**声明值**:笔刷的尺寸/间距/不透明度/流量/硬度、图层角色对应的不透明度带,以及色域的饱和度上限/明度下限/色相带,都是规范写下的整数,不是真实绘画的测量值。真实笔触观感需真实画师与真实环境,命令行不可复现(X)。色域只约束 bg/surface/ink/accent 四个角色色;ok/warn/danger 是信号色,不参与色域。",
  "brushRules": {
    "idPattern": "^br_[a-z][a-z0-9-]*$",
    "sizePx": [1, 512],
    "spacingPermille": [50, 400],
    "opacityPermille": [50, 1000],
    "flowPermille": [50, 1000],
    "hardnessPermille": [0, 1000],
    "blendModes": ["normal", "multiply", "screen", "overlay"],
    "layerRoles": ["base", "shadow", "light", "texture", "detail"],
    "layerOpacityBand": {
      "base": [900, 1000],
      "shadow": [300, 700],
      "light": [300, 700],
      "texture": [100, 500],
      "detail": [100, 500]
    }
  },
  "brushes": [
    { "id": "br_base-blockin", "name": "起稿大块", "sizePx": 256, "spacingPermille": 120, "opacityPermille": 1000, "flowPermille": 900, "hardnessPermille": 800, "blendMode": "normal", "layerRole": "base" },
    { "id": "br_shadow-soft", "name": "柔和暗部", "sizePx": 180, "spacingPermille": 80, "opacityPermille": 600, "flowPermille": 400, "hardnessPermille": 200, "blendMode": "multiply", "layerRole": "shadow" },
    { "id": "br_light-rim", "name": "亮部提亮", "sizePx": 120, "spacingPermille": 90, "opacityPermille": 550, "flowPermille": 350, "hardnessPermille": 300, "blendMode": "screen", "layerRole": "light" },
    { "id": "br_texture-canvas", "name": "画布纹理", "sizePx": 320, "spacingPermille": 200, "opacityPermille": 300, "flowPermille": 200, "hardnessPermille": 100, "blendMode": "overlay", "layerRole": "texture" },
    { "id": "br_detail-edge", "name": "边缘细节", "sizePx": 24, "spacingPermille": 60, "opacityPermille": 400, "flowPermille": 800, "hardnessPermille": 900, "blendMode": "normal", "layerRole": "detail" }
  ],
  "gamut": {
    "roleColors": ["bg", "surface", "ink", "accent"],
    "satCeilPermille": 750,
    "lumaFloorPermille": 50,
    "hueBandDeg": [0, 60]
  }
}

这份规范里最值得解释的是 layerOpacityBand。它不是「画得好不好」的标准,而是一条结构性约束:base 图层是铺底,必须接近不透明([900,1000]);shadow / light 是叠加层,要能透出底色([300,700]);texture / detail 是纹理与细节,必须更轻([100,500])。一支笔刷自称是 base 却只有 600 的不透明度,多半是复制了暗部笔刷忘了改角色——这条带子会当场把它拦下。

色域那三条同样是声明值:手绘贴图用暖色域(色相带 [0,60]),饱和度上限 750‰,明度下限 50‰。它不是普适真理,是这一套主题下的选择——retro-cyber 那套霓虹主题会声明完全不同的带子。

P 层:把规范变成可断言的校验器

下面这段是 P 层 TypeScript 内核 core/src/st07/brush_gamut.ts。它把规范变成四个纯函数:三个整数色彩度量(satPermille / lumaPermille / hueDeg)与两个校验器(validateBrush / validateGamut)。它一行都不评价笔触,只做区间与枚举判定。

// file: core/src/st07/brush_gamut.ts
// tier: P
// verify: node --test 'core/test/st07_*.test.ts'
// 风格化厚涂的笔刷预设与色域参数规范(P 层校验内核)。
// 只做整数账与枚举校验:笔刷参数区间、图层角色不透明度带、色域饱和度上限/明度下限/色相带。
// **不评价笔触好坏**——那是真实画师与真实环境的事,命令行不可复现(X)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type RGB = [number, number, number];
export type Range = [number, number];
export type BrushRules = {
  idPattern: string; sizePx: Range; spacingPermille: Range; opacityPermille: Range;
  flowPermille: Range; hardnessPermille: Range; blendModes: string[]; layerRoles: string[];
  layerOpacityBand: Record<string, Range>;
};
export type Gamut = {
  roleColors: string[]; satCeilPermille: number; lumaFloorPermille: number; hueBandDeg: Range;
};
export type Brush = {
  id: string; name: string; sizePx: number; spacingPermille: number; opacityPermille: number;
  flowPermille: number; hardnessPermille: number; blendMode: string; layerRole: string;
};

// 与 config/st07_brush_gamut.json 的 brushRules / gamut 同源;测试断言二者逐字一致(防漂移)。
export const BRUSH_RULES: BrushRules = {
  idPattern: "^br_[a-z][a-z0-9-]*$",
  sizePx: [1, 512], spacingPermille: [50, 400], opacityPermille: [50, 1000],
  flowPermille: [50, 1000], hardnessPermille: [0, 1000],
  blendModes: ["normal", "multiply", "screen", "overlay"],
  layerRoles: ["base", "shadow", "light", "texture", "detail"],
  layerOpacityBand: {
    base: [900, 1000], shadow: [300, 700], light: [300, 700], texture: [100, 500], detail: [100, 500],
  },
};
export const GAMUT: Gamut = {
  roleColors: ["bg", "surface", "ink", "accent"],
  satCeilPermille: 750, lumaFloorPermille: 50, hueBandDeg: [0, 60],
};

/** 饱和度千分比(整数地板除,灰度返回 0)。 */
export function satPermille(c: RGB): number {
  const mx = Math.max(c[0], c[1], c[2]), mn = Math.min(c[0], c[1], c[2]);
  return mx === 0 ? 0 : Math.floor(((mx - mn) * 1000) / mx);
}
/** 感知明度千分比(Rec.709 整数权重,地板除)。 */
export function lumaPermille(c: RGB): number {
  return Math.floor((2126 * c[0] + 7152 * c[1] + 722 * c[2]) / 10000);
}
/** 色相角(度,整数 0–359);灰阶返回 null。 */
export function hueDeg(c: RGB): number | null {
  const mx = Math.max(c[0], c[1], c[2]), mn = Math.min(c[0], c[1], c[2]), d = mx - mn;
  if (d === 0) return null;
  const h = mx === c[0] ? 60 * ((c[1] - c[2]) / d)
    : mx === c[1] ? 60 * ((c[2] - c[0]) / d + 2) : 60 * ((c[0] - c[1]) / d + 4);
  return Math.floor(((h % 360) + 360) % 360);
}
/** 单支笔刷校验:返回违例(空数组即合格)。 */
export function validateBrush(b: Brush, r: BrushRules): string[] {
  const out: string[] = [];
  if (!new RegExp(r.idPattern).test(b.id)) out.push(`${b.id}: id 不合文法`);
  const band = (name: string, v: number): void => {
    const g = r[name as keyof BrushRules] as Range;
    if (v < g[0] || v > g[1]) out.push(`${b.id}: ${name}=${v} 越界 [${g[0]},${g[1]}]`);
  };
  band("sizePx", b.sizePx); band("spacingPermille", b.spacingPermille);
  band("opacityPermille", b.opacityPermille); band("flowPermille", b.flowPermille);
  band("hardnessPermille", b.hardnessPermille);
  if (!r.blendModes.includes(b.blendMode)) out.push(`${b.id}: 未知混合模式 ${b.blendMode}`);
  if (!r.layerRoles.includes(b.layerRole)) out.push(`${b.id}: 未知图层角色 ${b.layerRole}`);
  const ob = r.layerOpacityBand[b.layerRole];
  if (ob && (b.opacityPermille < ob[0] || b.opacityPermille > ob[1])) {
    out.push(`${b.id}: ${b.layerRole} 图层不透明度 ${b.opacityPermille} 越界 [${ob[0]},${ob[1]}]`);
  }
  return out;
}
/** 色域校验:逐条检查饱和度上限、明度下限、色相带。 */
export function validateGamut(colors: RGB[], g: Gamut): string[] {
  const out: string[] = [];
  colors.forEach((c, i) => {
    const sat = satPermille(c), luma = lumaPermille(c), h = hueDeg(c);
    if (sat > g.satCeilPermille) out.push(`#${i} 饱和度 ${sat} > 上限 ${g.satCeilPermille}`);
    if (luma < g.lumaFloorPermille) out.push(`#${i} 明度 ${luma} < 下限 ${g.lumaFloorPermille}`);
    if (h !== null && (h < g.hueBandDeg[0] || h > g.hueBandDeg[1])) {
      out.push(`#${i} 色相 ${h} 不在带 [${g.hueBandDeg[0]},${g.hueBandDeg[1]}]`);
    }
  });
  return out;
}

色域校验的输入不是凭空造的颜色,而是 config/sp16_themes.json 里那 6 个主题包的角色色——这正是「同一份规范、多处消费」在卷三的落法:第 15 章定义的主题数据,到了第 7 章成了色域闸门的被检对象。

负例:越界与霓虹色

一个校验器最怕的不是报错,是明明越界了却没人报。所以测试的力气全在负例上:

// file: core/test/st07_brush_gamut.test.ts
// tier: P
// verify: node --test 'core/test/st07_*.test.ts'
// 笔刷与色域规范的断言:D 层配置与 P 层内核常量逐字一致(防漂移);5 支笔刷全部合格;
// 色域对 sp16_themes.json 六个主题的角色色做闸门(暖色域只放行 handdrawn/cozy-clean);
// 负例:越界参数、未知混合模式、图层带不符、霓虹色越色域,都必须被判出来。
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 {
  BRUSH_RULES, GAMUT, validateBrush, validateGamut, satPermille, lumaPermille, hueDeg,
} from "../src/st07/brush_gamut.ts";
import type { Brush, RGB } from "../src/st07/brush_gamut.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const load = (p: string): Record<string, never> => JSON.parse(readFileSync(resolve(ROOT, p), "utf8"));
const spec = load("config/st07_brush_gamut.json") as unknown as {
  brushRules: typeof BRUSH_RULES; gamut: typeof GAMUT; brushes: Brush[];
};
const themes = load("config/sp16_themes.json") as unknown as {
  themes: { id: string; tokens: Record<string, RGB> }[];
};
const roleRGB = (tokens: Record<string, RGB>): RGB[] => GAMUT.roleColors.map((k) => tokens[k]);
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);

test("D 层规范与 P 层内核常量逐字一致(防漂移)", () => {
  assert.deepEqual(BRUSH_RULES, spec.brushRules);
  assert.deepEqual(GAMUT, spec.gamut);
});

test("5 支笔刷全部合格,角色带与不透明度自洽", () => {
  assert.equal(spec.brushes.length, 5);
  for (const b of spec.brushes) assert.deepEqual(validateBrush(b, BRUSH_RULES), [], b.id);
  assert.equal(spec.brushes.filter((b) => b.layerRole === "base").length, 1);
});

test("色域闸门:暖色域只放行 handdrawn 与 cozy-clean,其余 4 主题被拦", () => {
  const pass: string[] = [];
  const fail: string[] = [];
  for (const t of themes.themes) {
    (validateGamut(roleRGB(t.tokens), GAMUT).length === 0 ? pass : fail).push(t.id);
  }
  assert.deepEqual(pass, ["handdrawn", "cozy-clean"]);
  assert.equal(fail.length, 4);
  // 具体量级:手绘主题的四角色色全部落在暖色域内
  assert.deepEqual(roleRGB(themes.themes[2].tokens).map(satPermille), [48, 81, 257, 693]);
  assert.deepEqual(roleRGB(themes.themes[2].tokens).map(lumaPermille), [246, 226, 61, 102]);
  assert.deepEqual(roleRGB(themes.themes[2].tokens).map(hueDeg), [40, 37, 26, 15]);
});

test("负例:越界、未知模式、图层带不符、id 文法都必须被抓", () => {
  const base = spec.brushes[0];
  const one = (patch: Partial<Brush>): string[] => validateBrush({ ...base, ...patch }, BRUSH_RULES);
  assert.deepEqual(one({}), []);
  assert.equal(one({ spacingPermille: 500 }).length, 1);
  assert.equal(one({ blendMode: "add" }).length, 1);
  assert.equal(one({ opacityPermille: 800 }).length, 1); // base 图层带 [900,1000]
  assert.equal(one({ id: "brush_bad" }).length, 1);
  assert.equal(one({ layerRole: "glow" }).length, 1);
});

test("负例:霓虹色(retro-cyber 强调色)必须被暖色域拦下", () => {
  const neon: RGB = [255, 60, 140];
  assert.equal(satPermille(neon), 764);
  assert.equal(hueDeg(neon), 335);
  const v = validateGamut([neon], GAMUT);
  assert.equal(v.length, 2); // 饱和度超上限 + 色相越带
});

test("P 层摘要(digest):本卷无 S 层,故只有内核自洽摘要", () => {
  const canon = JSON.stringify({
    brushCount: spec.brushes.length,
    pass: themes.themes.filter((t) => validateGamut(roleRGB(t.tokens), GAMUT).length === 0).map((t) => t.id),
    satCeil: GAMUT.satCeilPermille, lumaFloor: GAMUT.lumaFloorPermille, hueBand: GAMUT.hueBandDeg,
  });
  assert.equal(sha16(canon), "d7818f4387d86f7c");
  console.log(`st07 brush/gamut digest = ${sha16(canon)}`);
});

在本机跑 node --test 'core/test/st07_*.test.ts',本节 6 条(三节合计 17 条),真实输出:

st07 brush/gamut digest = d7818f4387d86f7c
✔ D 层规范与 P 层内核常量逐字一致(防漂移) (0.833916ms)
✔ 5 支笔刷全部合格,角色带与不透明度自洽 (0.213458ms)
✔ 色域闸门:暖色域只放行 handdrawn 与 cozy-clean,其余 4 主题被拦 (0.220834ms)
✔ 负例:越界、未知模式、图层带不符、id 文法都必须被抓 (0.106792ms)
✔ 负例:霓虹色(retro-cyber 强调色)必须被暖色域拦下 (0.06675ms)
✔ P 层摘要(digest):本卷无 S 层,故只有内核自洽摘要 (0.685084ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0

色域闸门放行了谁

把 6 个主题包的角色色(bg / surface / ink / accent)逐个过一遍暖色域,结果一目了然:

主题结果被拦的原因(首个)
handdrawn通过饱和度 48–693‰、明度 61–246‰、色相 15–40° 全在带内
cozy-clean通过与 handdrawn 同属暖色系
pixel拦下背景明度 28‰ < 下限 50‰、色相 233° 不在带
lowpoly拦下强调色饱和度 787‰ > 上限 750‰
voxel拦下背景明度 36‰ < 下限
retro-cyber拦下强调色 [255,60,140] 饱和度 764‰、色相 335°

这张表不是「谁更好看」,而是色域这条规范在起作用:暖色域天然只放行暖色系的两套主题,霓虹那套被拦下是设计如此。换成 retro-cyber 的规范,被放行的会是另一批——规范是相对的,这正是第 15 章「同源但可区分」的另一种读法。

为什么这一章没有 S 层

前几章的 S 层都是 Python 数值账:对比度、字节、装箱率、体素数——它们算的是客观量。厚涂没有这样的量。笔触的质量没有客观量,任何「笔触自然度 0.87」都是编出来的。所以本章不设 S 层,只走 D+P+X:D 层把规范写成数据,P 层把数据变成可断言的校验,X 层(7.3)把不可复现的部分显式标出来。

这不代表本章「不严谨」。恰恰相反——P 层的 digest 就是它的严谨性证据:brushCount=5、放行主题 ["handdrawn","cozy-clean"]、色域三条阈值,序列化后取 sha256 前 16 位恒为 d7818f4387d86f7c。它不是「两个语言算同一个数」,而是同一份规范在内核与配置里逐字一致——一旦有人只改了 JSON 没改内核,D 层规范与 P 层内核常量逐字一致 这条会立刻失败。没有 S 层,不等于没有账;只是这章的账是结构账,不是数字账。

自检表

检查项合格标准
参数可判每支笔刷落到唯一的角色带,越界即失败
色域可判角色色逐条过饱和度/明度/色相三条,越界即失败
配置与代码一致D 层 JSON 与 P 层常量逐字一致,漂移即失败
边界诚实笔触质量标 X,不写成「实测」,也不硬凑数字

参数定下来了,但一支笔刷再对,也架不住「每个资产都从头画一遍」。下一节把厚涂拆成图层与复用:同一套图层模板怎么被多个资产共享,贴图页怎么被多个图层引用,以及为什么「复用率」是一个能算的整数。

阅读导航:上一节:6.3 写实的性能代价 · 下一节:7.2 手绘贴图的分层与复用 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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