10.1 色彩与线条规范

二次元的「好看」几乎全在画师手上,但色彩与线条里仍有一半能写成整数:角色色的饱和度上限与明度下限、色阶带的档数与档间明度差、线宽区间,以及线色不许近似纯黑、不许近似中性灰、色相必须落在冷色带内。本节把这些落成一份 D 层规范与一段可跑的 TypeScript 校验器,并说清本章为什么只走 D+P+X。

站内那篇 游戏美术风格指南 用提纲把「二次元要有干净线稿、要赛璐璐上色」讲成了方向;本节不重复那套口号,而是回答一个更硬的问题:当一种风格的核心是画师的手感时,规范里到底还剩下什么能被写成整数。前一节 9.3 等距资产的制作约束 处理的是等距投影的整数约束;二次元反过来——它的「可爱」几乎全在真实画师的笔上,能算的只剩参数边界。

二次元里能写进规范的部分

把二次元的画面拆开看,它其实由两类东西组成:

可写进规范的不可写进规范的
角色色的饱和度上限、明度下限这个肤色「讨不讨喜」
色阶带的档数(高光 / 中间 / 阴影)与档间明度差分档切在哪儿「最像二次元」
线宽区间(外轮廓 / 内线 / 发丝 / 面部)线稿「顺不顺」
线色的三条约束:不许近似纯黑、不许近似中性灰、色相落在冷色带线色「配不配」这个角色
面部的比例与特征线位置(下一节)这张脸「可不可爱」

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

这里要与两节既有内容划清边界:3.1 扁平色块与线宽规范 讲的是通用扁平风格的线宽阶梯,5.1 色阶带与描边参数 讲的是 3D NPR 在着色器里的分档与描边——本节讲的是2D 赛璐璐色板侧的分档与线色约束,同一概念,两处消费。

D 层:色彩与线条参数规范

下面这段是 D 层规范 config/st10_color_line.json。它把角色色的色彩约束、色阶带的结构、线条的宽度与线色约束落成数据。注意所有数字都是规范声明的,不是测量值:

{
  "version": 1,
  "project": "stardust-inn",
  "themeId": "handdrawn",
  "note": "二次元 / 日式的色彩与线条参数规范(第 10 章 10.1,D 层)。全部是**声明值**:角色色的饱和度上限与明度下限、色阶带的档数与档间明度差、线条的宽度区间与线色三条约束(线色不许近似纯黑、不许近似中性灰、色相必须落在冷色带内),都是规范写下的整数,不是从任何一幅画里量出来的。真实线稿观感与上色质量需真实画师与真实环境,命令行不可复现(X)。色板只约束 skin / hair / cloth / accent 四个角色色;线条另立一套约束。",
  "colorRules": {
    "roleColors": ["skin", "hair", "cloth", "accent"],
    "satCeilPermille": 700,
    "lumaFloorPermille": 80,
    "bandCount": 3,
    "bandMinGapPermille": 10
  },
  "lineRules": {
    "idPattern": "^ln_[a-z][a-z0-9-]*$",
    "widthPx": [1, 12],
    "inkLumaFloorPermille": 40,
    "inkLumaCeilPermille": 220,
    "inkSatFloorPermille": 40,
    "hueBandDeg": [200, 340],
    "roles": ["outer", "inner", "hair", "face"]
  },
  "palette": {
    "skin": [255, 228, 214],
    "hair": [232, 156, 108],
    "cloth": [168, 196, 240],
    "accent": [244, 114, 150]
  },
  "ramps": {
    "skin": [[255, 246, 238], [255, 228, 214], [226, 186, 168]],
    "hair": [[246, 196, 148], [232, 156, 108], [176, 108, 72]],
    "cloth": [[214, 228, 250], [168, 196, 240], [116, 142, 188]],
    "accent": [[252, 176, 196], [244, 114, 150], [186, 74, 108]]
  },
  "lines": [
    { "id": "ln_outer", "role": "outer", "widthPx": 4, "color": [58, 52, 84] },
    { "id": "ln_inner", "role": "inner", "widthPx": 2, "color": [92, 78, 110] },
    { "id": "ln_hair", "role": "hair", "widthPx": 2, "color": [76, 64, 96] },
    { "id": "ln_face", "role": "face", "widthPx": 1, "color": [120, 96, 112] }
  ]
}

这份规范里最值得解释的是 lineRules 那三条线色约束。二次元线稿最容易被外行写成「黑色描边」,但真正成熟的日式线稿几乎从不用纯黑:inkLumaFloorPermille: 40 要求线色明度不低于 40‰(纯黑 [0,0,0] 的明度是 0,当场出局);inkSatFloorPermille: 40 要求线色带一点色彩倾向(中性灰 [128,128,128] 的饱和度是 0,也出局);hueBandDeg: [200,340] 把线色锁在冷色带(深蓝 / 蓝紫 / 紫红),因为暖色线会把角色「烤糊」。三条合起来,线色只能是低明度、低饱和、冷色相的那一小片区域——这正是日式线稿的做法。

ramps 是色阶带:每个角色色派生三档(高光 / 中间 / 阴影),档数必须等于 bandCount,且档间明度严格递减、差不小于 bandMinGapPermille。它不是「画得好不好」的标准,而是一条结构性约束:如果三档画成了同一块颜色,画面会立刻「糊」——这条断言会当场把它拦下。

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

下面这段是 P 层 TypeScript 内核 core/src/st10/color_line.ts。它把规范变成三个整数色彩度量(satPermille / lumaPermille / hueDeg)与三个校验器(validatePalette / validateRamp / validateLine)。它一行都不评价画面,只做区间与枚举判定:

// file: core/src/st10/color_line.ts
// tier: P
// verify: node --test 'core/test/st10_*.test.ts'
// 二次元 / 日式的色彩与线条参数规范(P 层校验内核)。
// 只做整数账与枚举校验:角色色的饱和度上限 / 明度下限、色阶带的档数与档间明度差、
// 线条的宽度区间与线色三条约束(线色不许近似纯黑、不许近似中性灰、色相必须落在冷色带内)。
// **不评价画面好坏**——二次元的「画得对不对」是真实画师与真实环境的事,命令行不可复现(X)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type RGB = [number, number, number];
export type Range = [number, number];

export type ColorRules = {
  roleColors: string[]; satCeilPermille: number; lumaFloorPermille: number;
  bandCount: number; bandMinGapPermille: number;
};
export type LineRules = {
  idPattern: string; widthPx: Range; inkLumaFloorPermille: number; inkLumaCeilPermille: number;
  inkSatFloorPermille: number; hueBandDeg: Range; roles: string[];
};
export type Line = { id: string; role: string; widthPx: number; color: RGB };
export type ColorLineSpec = {
  colorRules: ColorRules; lineRules: LineRules;
  palette: Record<string, RGB>; ramps: Record<string, RGB[]>; lines: Line[];
};

/** 饱和度千分比(整数地板除,灰度返回 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 validatePalette(palette: Record<string, RGB>, r: ColorRules): string[] {
  const out: string[] = [];
  for (const role of r.roleColors) {
    const c = palette[role];
    if (!c) { out.push(`角色色缺失 ${role}`); continue; }
    const sat = satPermille(c), luma = lumaPermille(c);
    if (sat > r.satCeilPermille) out.push(`${role}: 饱和度 ${sat} > 上限 ${r.satCeilPermille}`);
    if (luma < r.lumaFloorPermille) out.push(`${role}: 明度 ${luma} < 下限 ${r.lumaFloorPermille}`);
  }
  return out;
}

/** 色阶带校验:档数必须等于 bandCount,档间明度严格递减且差不小于下限。 */
export function validateRamp(role: string, ramp: RGB[], r: ColorRules): string[] {
  const out: string[] = [];
  if (ramp.length !== r.bandCount) return [`${role}: 色阶带 ${ramp.length} 档 != ${r.bandCount}`];
  for (let i = 1; i < ramp.length; i++) {
    const prev = lumaPermille(ramp[i - 1]), cur = lumaPermille(ramp[i]);
    if (cur >= prev) out.push(`${role}: 第 ${i + 1} 档明度 ${cur} 未低于上一档 ${prev}`);
    else if (prev - cur < r.bandMinGapPermille) out.push(`${role}: 第 ${i + 1} 档明度差 ${prev - cur} < 下限 ${r.bandMinGapPermille}`);
  }
  return out;
}

/** 单条线校验:id 文法、线条角色、宽度区间、线色三条约束。 */
export function validateLine(l: Line, r: LineRules): string[] {
  const out: string[] = [];
  if (!new RegExp(r.idPattern).test(l.id)) out.push(`${l.id}: id 不合文法`);
  if (!r.roles.includes(l.role)) out.push(`${l.id}: 未知线条角色 ${l.role}`);
  if (l.widthPx < r.widthPx[0] || l.widthPx > r.widthPx[1]) {
    out.push(`${l.id}: 线宽 ${l.widthPx} 越界 [${r.widthPx[0]},${r.widthPx[1]}]`);
  }
  const luma = lumaPermille(l.color), sat = satPermille(l.color), h = hueDeg(l.color);
  if (luma < r.inkLumaFloorPermille) out.push(`${l.id}: 线色明度 ${luma} < 下限 ${r.inkLumaFloorPermille}(近似纯黑)`);
  if (luma > r.inkLumaCeilPermille) out.push(`${l.id}: 线色明度 ${luma} > 上限 ${r.inkLumaCeilPermille}`);
  if (sat < r.inkSatFloorPermille) out.push(`${l.id}: 线色饱和度 ${sat} < 下限 ${r.inkSatFloorPermille}(近似中性灰)`);
  if (h !== null && (h < r.hueBandDeg[0] || h > r.hueBandDeg[1])) {
    out.push(`${l.id}: 线色色相 ${h} 不在带 [${r.hueBandDeg[0]},${r.hueBandDeg[1]}]`);
  }
  return out;
}

/** 全规范分析:违例 + 整数摘要。 */
export function analyze(spec: ColorLineSpec) {
  const violations: string[] = [];
  violations.push(...validatePalette(spec.palette, spec.colorRules));
  for (const role of spec.colorRules.roleColors) {
    const ramp = spec.ramps[role];
    if (!ramp) violations.push(`色阶带缺失 ${role}`);
    else violations.push(...validateRamp(role, ramp, spec.colorRules));
  }
  for (const l of spec.lines) violations.push(...validateLine(l, spec.lineRules));
  return {
    roleCount: spec.colorRules.roleColors.length, lineCount: spec.lines.length,
    bandCount: spec.colorRules.bandCount, satCeilPermille: spec.colorRules.satCeilPermille,
    inkLumaFloorPermille: spec.lineRules.inkLumaFloorPermille, hueBandDeg: spec.lineRules.hueBandDeg,
    violations, pass: violations.length === 0,
  };
}

三个度量全部是整数地板除,所以跨语言逐位一致:satPermille 用 (mx−mn)×1000 ÷ mx,lumaPermille 用 Rec.709 的整数权重 2126/7152/722(和 10000),hueDeg 取整到度。色彩真源始终是 sRGB 8-bit 整数三元组,这三个量都是从它派生的整数。

负例:纯黑线、灰线与霓虹色

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

// file: core/test/st10_color_line.test.ts
// tier: P
// verify: node --test 'core/test/st10_*.test.ts'
// 色彩与线条规范的断言:4 角色色与 4 条线全部合格、色阶带 4×3 档明度严格递减;
// 负例:纯黑线、中性灰线、暖色线、超粗线、过饱和角色色、档数错 / 档差不足,都必须被判出来。
// 这层校验不产生任何画质数字——它只保证参数越界会被拦下。
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 {
  analyze, validateLine, validatePalette, validateRamp, satPermille, lumaPermille, hueDeg,
} from "../src/st10/color_line.ts";
import type { ColorLineSpec, Line, RGB } from "../src/st10/color_line.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/st10_color_line.json"), "utf8")) as ColorLineSpec;
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);

test("基线:4 角色色 + 4 条线零违例,角色色与色阶带都在带上", () => {
  const r = analyze(spec);
  assert.deepEqual(r.violations, []);
  assert.equal(r.pass, true);
  assert.deepEqual([r.roleCount, r.lineCount, r.bandCount], [4, 4, 3]);
  assert.deepEqual(spec.colorRules.roleColors.map((k) => satPermille(spec.palette[k])), [160, 534, 300, 532]);
  assert.deepEqual(spec.colorRules.roleColors.map((k) => lumaPermille(spec.palette[k])), [232, 168, 193, 144]);
  assert.deepEqual(validatePalette(spec.palette, spec.colorRules), []);
  for (const role of spec.colorRules.roleColors) {
    const ramp = spec.ramps[role];
    assert.equal(ramp.length, 3);
    assert.deepEqual(validateRamp(role, ramp, spec.colorRules), []);
    const lumas = ramp.map(lumaPermille);
    assert.ok(lumas[0] > lumas[1] && lumas[1] > lumas[2]);
  }
  assert.deepEqual(spec.ramps.skin.map(lumaPermille), [247, 232, 193]);
});

test("线色三条约束:4 条线都不是纯黑、不是中性灰、色相在冷色带内", () => {
  assert.deepEqual(spec.lines.map((l) => lumaPermille(l.color)), [55, 83, 68, 102]);
  assert.deepEqual(spec.lines.map((l) => satPermille(l.color)), [380, 290, 333, 200]);
  assert.deepEqual(spec.lines.map((l) => hueDeg(l.color)), [251, 266, 262, 320]);
  for (const l of spec.lines) assert.deepEqual(validateLine(l, spec.lineRules), [], l.id);
});

test("负例:纯黑线 / 中性灰线 / 暖色线 / 超粗线 / 过饱和色 / 档差异常都必须被抓", () => {
  const base = spec.lines[0];
  const one = (patch: Partial<Line>): string[] => validateLine({ ...base, ...patch }, spec.lineRules);
  assert.equal(one({ color: [0, 0, 0] }).length, 2);        // 明度下限 + 饱和度下限
  assert.ok(one({ color: [0, 0, 0] }).some((e) => /近似纯黑/.test(e)));
  assert.equal(one({ color: [128, 128, 128] }).length, 1);  // 只有饱和度下限
  assert.ok(one({ color: [128, 128, 128] }).some((e) => /近似中性灰/.test(e)));
  assert.equal(one({ color: [200, 80, 40] }).length, 1);    // 暖色越色相带
  assert.equal(one({ widthPx: 20 }).length, 1);             // 线宽越界
  assert.equal(one({ role: "glow" }).length, 1);            // 未知线条角色
  assert.equal(one({ id: "line_bad" }).length, 1);          // id 文法
  const badPal = { ...spec.palette, accent: [255, 0, 0] as RGB };
  assert.ok(validatePalette(badPal, spec.colorRules).some((e) => /饱和度 1000/.test(e)));
  const dark = { ...spec.palette, cloth: [40, 44, 52] as RGB };
  assert.ok(validatePalette(dark, spec.colorRules).some((e) => /明度 43 < 下限 80/.test(e)));
  assert.ok(validateRamp("skin", spec.ramps.skin.slice(0, 2), spec.colorRules).some((e) => /2 档 != 3/.test(e)));
  const flat = spec.ramps.skin.map((): RGB => [200, 200, 200]);
  assert.ok(validateRamp("skin", flat, spec.colorRules).some((e) => /未低于上一档/.test(e)));
});

test("P 层摘要(digest):本卷无 S 层,故只有规范自洽摘要", () => {
  const r = analyze(spec);
  const canon = JSON.stringify({
    roleCount: r.roleCount, lineCount: r.lineCount, bandCount: r.bandCount,
    satCeilPermille: r.satCeilPermille, inkLumaFloorPermille: r.inkLumaFloorPermille,
    hueBandDeg: r.hueBandDeg, pass: r.pass,
  });
  assert.equal(sha16(canon), "bfa644d38012ffcd");
  console.log(`st10 color/line digest = ${sha16(canon)}`);
});

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

st10 color/line digest = bfa644d38012ffcd
✔ 基线:4 角色色 + 4 条线零违例,角色色与色阶带都在带上 (1.344667ms)
✔ 线色三条约束:4 条线都不是纯黑、不是中性灰、色相在冷色带内 (0.1575ms)
✔ 负例:纯黑线 / 中性灰线 / 暖色线 / 超粗线 / 过饱和色 / 档差异常都必须被抓 (0.235708ms)
✔ P 层摘要(digest):本卷无 S 层,故只有规范自洽摘要 (0.68625ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

两条反直觉的约束

第一条:线色「越黑越糟」。 外行的直觉是「线稿当然是黑的」,但 inkLumaFloorPermille: 40 把纯黑直接判出局。原因不是审美偏好,而是可读性:纯黑线在暗色背景(如 voxel 主题的 bg [40,34,52])上会与背景糊在一起,而带一点冷色的深蓝紫线在任何底色上都保持边界。这条约束是可以复现的整数判定,不是「我觉得深蓝好看」。

第二条:色阶带的档差有下限。 bandMinGapPermille: 10 的意思是:高光与中间色、中间色与阴影的明度差至少 10‰。这不是「10 这个数最美」,而是它把「三档画成一块」这种失败变成可判定的。本项目四个角色色的三档明度(高光 / 中间 / 阴影)与档差分别是:skin 247 / 232 / 193(15 / 39)、hair 203 / 168 / 119(35 / 49)、cloth 226 / 193 / 139(33 / 54)、accent 193 / 144 / 100(49 / 44)。

为什么这一章没有 S 层

前几章的 S 层都是 Python 数值账:对比度、字节、装箱率、体素数——它们算的是客观量。二次元没有这样的量。「画得像不像二次元」没有客观量,任何「画风相似度 0.92」都是编出来的。所以本章不设 S 层,只走 D+P+X:D 层把规范写成数据,P 层把数据变成可断言的校验,X 层(10.3)把不可复现的部分显式标出来。

这不代表本章「不严谨」。恰恰相反——P 层的 digest 就是它的严谨性证据:roleCount=4、lineCount=4、bandCount=3、饱和度上限 700‰、线色明度下限 40‰、冷色带 [200,340]、pass=true,序列化后取 sha256 前 16 位恒为 bfa644d38012ffcd。它不是「两个语言算同一个数」,而是同一份规范在内核与配置里逐字一致——一旦有人只改了 JSON 没改内核,基线断言会立刻失败。没有 S 层,不等于没有账;只是这章的账是结构账,不是数字账。

自检表

检查项合格标准
角色色可判每个角色色的饱和度不超上限、明度不低于下限
色阶带可判档数等于 bandCount、档间明度严格递减且差不小于下限
线色可判每条线都不是近似纯黑、不是近似中性灰、色相在冷色带内
线宽可判每条线的宽度落在区间内
边界诚实画面好坏标 X,不写成「实测」,也不硬凑数字

色彩与线条定下来了,但它们只解决「一个角色用什么颜色、描多粗的线」。二次元还有一个更硬的结构问题:角色的头身比与面部特征线落在哪儿。下一节把它写成一张可校验的比例表。

阅读导航:上一节:9.3 等距资产的制作约束 · 下一节:10.2 角色比例与面部规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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