站内那篇 游戏美术风格指南 用提纲把「二次元要有干净线稿、要赛璐璐上色」讲成了方向;本节不重复那套口号,而是回答一个更硬的问题:当一种风格的核心是画师的手感时,规范里到底还剩下什么能被写成整数。前一节 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 角色比例与面部规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。