10.1 把颜色与线宽写成了整数,但二次元还有一个更硬的结构问题:角色的头身比与面部特征线落在哪儿。真人角色的骨骼、蒙皮与绑定数学归 骨骼动画专题 讲,本节不重推;本节只做静态比例参数——头身比、头高与全身高、面部三条特征线在头部框内的相对位置,全是整数,不涉及任何骨骼、绑定或动画。
头身比与面部:另一类整数
前一节 10.1 色彩与线条规范 处理的是「一个角色长什么样」的颜色侧;这一节处理形状侧。二次元的形状侧其实高度结构化——它有一套几乎被行业固化的比例语言:
| 可写进规范的 | 不可写进规范的 |
|---|---|
| 头身比(头高与全身高的整数比) | 这个头身比「配不配」这个角色 |
| 头高与全身高(像素整数) | 画出来的头「圆不圆」 |
| 面部三条特征线(眼 / 鼻 / 嘴)在头部框内的位置 | 眼睛「有没有神」 |
| 双眼宽度与眼距占头宽的比例 | 五官「搭不搭」 |
| 体型档(Q 版 / 标准 / 修长)与头身比区间的对应 | 这个档位「适不适合」这个品类 |
左列是声明值——规范写下的整数;右列是真实画师在真实环境里的判断,命令行不可复现(X)。本节只做左列。注意这里的每一条都是比例而不是像素:比例可以跨分辨率复用,像素不行。
D 层:角色比例与面部规范
下面这段是 D 层规范 config/st10_proportion.json。它用三个体型档(chibi / standard / tall)把比例语言落成数据:
{
"version": 1,
"project": "stardust-inn",
"themeId": "handdrawn",
"note": "二次元 / 日式的角色比例与面部规范(第 10 章 10.2,D 层)。全部是**声明值**:每个体型档的头身比(headUnits)、头高与全身高(像素整数),面部三条特征线(眼 / 鼻 / 嘴)在头部框内的相对位置(千分比整数),以及双眼宽度与眼距(占头宽的千分比整数)。头身比恒等式 heightPx == headUnits × headHeightPx 必须成立;面部特征线必须自上而下(眼 < 鼻 < 嘴)且相邻间距不小于下限。真实角色的观感与面部表情需真实画师与真实环境,命令行不可复现(X)。",
"canvas": { "width": 512, "height": 768 },
"rules": {
"tierIdPattern": "^tp_[a-z][a-z0-9-]*$",
"classBand": { "chibi": [2, 4], "standard": [6, 8], "tall": [8, 10] },
"featureOrder": ["eyeLine", "noseLine", "mouthLine"],
"minFeatureGapPermille": 40,
"eyeSpanFloorPermille": 600,
"eyeSpanCeilPermille": 1000
},
"tiers": [
{ "id": "tp_chibi", "class": "chibi", "headUnits": 3, "headHeightPx": 128, "heightPx": 384,
"eyeLinePermille": 560, "noseLinePermille": 680, "mouthLinePermille": 780,
"eyeWidthPermille": 230, "eyeGapPermille": 160 },
{ "id": "tp_standard", "class": "standard", "headUnits": 7, "headHeightPx": 96, "heightPx": 672,
"eyeLinePermille": 540, "noseLinePermille": 660, "mouthLinePermille": 760,
"eyeWidthPermille": 210, "eyeGapPermille": 200 },
{ "id": "tp_tall", "class": "tall", "headUnits": 8, "headHeightPx": 88, "heightPx": 704,
"eyeLinePermille": 520, "noseLinePermille": 640, "mouthLinePermille": 740,
"eyeWidthPermille": 195, "eyeGapPermille": 215 }
]
}
读这份表有三个看点。第一,头身比是一个恒等式:heightPx == headUnits × headHeightPx,chibi 是 384 = 3 × 128,standard 是 672 = 7 × 96,tall 是 704 = 8 × 88。这不是冗余字段,而是一条可断言的自洽性约束:只要有人改了头高忘了改全身高,这条立刻失败。第二,面部特征线是有序的:眼线在最上、鼻线居中、嘴线在下,且相邻间距不小于 minFeatureGapPermille。第三,双眼加眼距是一条比例带:2 × eyeWidth + eyeGap 必须在 [600, 1000] 之间——二次元的眼睛大,双眼加眼距通常占头宽六成以上,但不能顶满整张脸。
P 层:把比例表变成可断言的校验器
下面这段是 P 层 TypeScript 内核 core/src/st10/proportion.ts。它把比例表变成四个整数函数(headUnitsFromPx / eyeSpanPermille / validateTier / analyze)。它一行都不评价角色,只做恒等式与区间判定:
// file: core/src/st10/proportion.ts
// tier: P
// verify: node --test 'core/test/st10_*.test.ts'
// 二次元 / 日式的角色比例与面部规范(P 层校验内核)。
// 只做整数账:头身比恒等式(heightPx == headUnits × headHeightPx)、体型档与头身比区间、
// 面部特征线(眼 / 鼻 / 嘴)的先后顺序与最小间距、双眼加眼距占头宽的比例带。
// **不评价角色好不好看**——那是真实画师与真实环境的事,命令行不可复现(X)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Range = [number, number];
export type Tier = {
id: string;
class: string;
headUnits: number;
headHeightPx: number;
heightPx: number;
eyeLinePermille: number;
noseLinePermille: number;
mouthLinePermille: number;
eyeWidthPermille: number;
eyeGapPermille: number;
};
export type ProportionRules = {
tierIdPattern: string;
classBand: Record<string, Range>;
featureOrder: string[];
minFeatureGapPermille: number;
eyeSpanFloorPermille: number;
eyeSpanCeilPermille: number;
};
export type ProportionSpec = {
canvas: { width: number; height: number };
rules: ProportionRules;
tiers: Tier[];
};
// 面部三条特征线与规范里的 featureOrder 同序;测试断言二者一致(防漂移)。
const FEATURE_NAMES = ["eyeLine", "noseLine", "mouthLine"];
/** 头身比 = 全身高地板除头高(整数)。 */
export function headUnitsFromPx(t: Tier): number {
return Math.floor(t.heightPx / t.headHeightPx);
}
/** 双眼加眼距占头宽的千分比(整数)。 */
export function eyeSpanPermille(t: Tier): number {
return 2 * t.eyeWidthPermille + t.eyeGapPermille;
}
/** 单个体型档校验:返回违例(空数组即合格)。 */
export function validateTier(t: Tier, r: ProportionRules): string[] {
const out: string[] = [];
if (!new RegExp(r.tierIdPattern).test(t.id)) out.push(`${t.id}: id 不合文法`);
const band = r.classBand[t.class];
if (!band) out.push(`${t.id}: 未知体型档 ${t.class}`);
else if (t.headUnits < band[0] || t.headUnits > band[1]) {
out.push(`${t.id}: 头身比 ${t.headUnits} 越界 [${band[0]},${band[1]}]`);
}
const derived = headUnitsFromPx(t);
if (derived !== t.headUnits) {
out.push(`${t.id}: 头身比不自洽 heightPx(${t.heightPx}) ÷ headHeightPx(${t.headHeightPx}) = ${derived} != ${t.headUnits}`);
}
const vals = [t.eyeLinePermille, t.noseLinePermille, t.mouthLinePermille];
for (let i = 0; i < vals.length; i++) {
if (vals[i] <= 0 || vals[i] >= 1000) {
out.push(`${t.id}: ${FEATURE_NAMES[i]} 位置 ${vals[i]} 不在 (0,1000)`);
}
if (i > 0) {
const gap = vals[i] - vals[i - 1];
if (gap <= 0) out.push(`${t.id}: ${FEATURE_NAMES[i]} 未低于 ${FEATURE_NAMES[i - 1]}(面部顺序错)`);
else if (gap < r.minFeatureGapPermille) {
out.push(`${t.id}: ${FEATURE_NAMES[i]} 与上一线间距 ${gap} < 下限 ${r.minFeatureGapPermille}`);
}
}
}
const span = eyeSpanPermille(t);
if (span < r.eyeSpanFloorPermille) out.push(`${t.id}: 双眼加眼距 ${span} < 下限 ${r.eyeSpanFloorPermille}`);
if (span > r.eyeSpanCeilPermille) out.push(`${t.id}: 双眼加眼距 ${span} > 上限 ${r.eyeSpanCeilPermille}`);
return out;
}
/** 全表分析:违例 + 整数摘要。 */
export function analyze(spec: ProportionSpec) {
const violations: string[] = [];
for (const t of spec.tiers) violations.push(...validateTier(t, spec.rules));
return {
tierCount: spec.tiers.length,
featureOrder: spec.rules.featureOrder,
headUnits: spec.tiers.map((t) => t.headUnits),
eyeSpans: spec.tiers.map((t) => eyeSpanPermille(t)),
featureGaps: spec.tiers.map((t) => [
t.noseLinePermille - t.eyeLinePermille,
t.mouthLinePermille - t.noseLinePermille,
]),
violations,
pass: violations.length === 0,
};
}
/** 规范里的 featureOrder 与内核的特征线顺序是否一致(防漂移)。 */
export function featureOrderMatches(r: ProportionRules): boolean {
return r.featureOrder.length === FEATURE_NAMES.length
&& r.featureOrder.every((k, i) => k === FEATURE_NAMES[i]);
}
这里每一笔都是整数:头身比是 Math.floor(heightPx / headHeightPx),双眼加眼距是 2 × eyeWidth + eyeGap,面部间距是两条千分比的差。没有任何浮点参与,所以这套账在 Python 与 TS 两侧逐位一致——这也是本章虽无 S 层,却仍然能做跨语言对拍的原因。
负例:比例不自洽与面部顺序错
比例表最怕的不是报错,是比例漂了却没人报。所以测试的力气全在负例上:
// file: core/test/st10_proportion.test.ts
// tier: P
// verify: node --test 'core/test/st10_*.test.ts'
// 角色比例与面部规范的断言:3 个体型档零违例、头身比恒等式成立、面部特征线自上而下;
// 负例:头身比不自洽、面部顺序错、间距不足、越体型档、眼距比例越界、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 {
analyze, validateTier, headUnitsFromPx, eyeSpanPermille, featureOrderMatches,
} from "../src/st10/proportion.ts";
import type { ProportionSpec, Tier } from "../src/st10/proportion.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/st10_proportion.json"), "utf8")) as ProportionSpec;
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
test("基线:3 个体型档零违例、pass 为真、头身比 [3,7,8]", () => {
const r = analyze(spec);
assert.deepEqual(r.violations, []);
assert.equal(r.pass, true);
assert.deepEqual(r.headUnits, [3, 7, 8]);
});
test("头身比恒等式:heightPx == headUnits × headHeightPx(三档全成立)", () => {
for (const t of spec.tiers) {
assert.equal(t.heightPx, t.headUnits * t.headHeightPx, t.id);
assert.equal(headUnitsFromPx(t), t.headUnits, t.id);
}
});
test("面部特征线与眼距比例:眼 < 鼻 < 嘴、间距 ≥ 下限、双眼加眼距在带上", () => {
assert.deepEqual(spec.rules.featureOrder, ["eyeLine", "noseLine", "mouthLine"]);
assert.equal(featureOrderMatches(spec.rules), true);
const r = analyze(spec);
assert.deepEqual(r.featureGaps, [[120, 100], [120, 100], [120, 100]]);
assert.deepEqual(r.eyeSpans, [620, 620, 605]);
assert.equal(eyeSpanPermille(spec.tiers[0]), 620);
});
test("负例:头身比不自洽 / 面部顺序错 / 间距不足 / 越体型档都必须被抓", () => {
const t = spec.tiers[0];
const one = (patch: Partial<Tier>): string[] => validateTier({ ...t, ...patch }, spec.rules);
assert.deepEqual(one({}), []);
assert.equal(one({ headUnits: 4 }).length, 1);
assert.ok(one({ headUnits: 4 }).some((e) => /头身比不自洽/.test(e)));
assert.ok(one({ eyeLinePermille: 700, noseLinePermille: 680 }).some((e) => /面部顺序错/.test(e)));
assert.ok(one({ noseLinePermille: 590 }).some((e) => /间距 30 < 下限 40/.test(e)));
assert.ok(one({ class: "chibi", headUnits: 7, heightPx: 896 }).some((e) => /越界 \[2,4\]/.test(e)));
assert.ok(one({ id: "tier_bad" }).some((e) => /id 不合文法/.test(e)));
});
test("负例:双眼加眼距越过下限与上限都必须被抓", () => {
const t = spec.tiers[0];
const one = (patch: Partial<Tier>): string[] => validateTier({ ...t, ...patch }, spec.rules);
assert.ok(one({ eyeWidthPermille: 150, eyeGapPermille: 100 }).some((e) => /400 < 下限 600/.test(e)));
assert.ok(one({ eyeWidthPermille: 400, eyeGapPermille: 400 }).some((e) => /1200 > 上限 1000/.test(e)));
});
test("P 层摘要(digest):本卷无 S 层,故只有比例表自洽摘要", () => {
const r = analyze(spec);
const canon = JSON.stringify({
tierCount: r.tierCount, headUnits: r.headUnits, eyeSpans: r.eyeSpans,
featureGaps: r.featureGaps, pass: r.pass,
});
assert.equal(sha16(canon), "85f0dbb44a62e721");
console.log(`st10 proportion digest = ${sha16(canon)}`);
});
在本机跑 node --test 'core/test/st10_*.test.ts',本节 6 条(三节合计 15 条),真实输出:
st10 proportion digest = 85f0dbb44a62e721
✔ 基线:3 个体型档零违例、pass 为真、头身比 [3,7,8] (1.078875ms)
✔ 头身比恒等式:heightPx == headUnits × headHeightPx(三档全成立) (0.085166ms)
✔ 面部特征线与眼距比例:眼 < 鼻 < 嘴、间距 ≥ 下限、双眼加眼距在带上 (0.125ms)
✔ 负例:头身比不自洽 / 面部顺序错 / 间距不足 / 越体型档都必须被抓 (0.202291ms)
✔ 负例:双眼加眼距越过下限与上限都必须被抓 (0.094333ms)
✔ P 层摘要(digest):本卷无 S 层,故只有比例表自洽摘要 (0.666291ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
三条比例约束为什么这么定
头身比恒等式看着像废话,其实是这张表里最有用的一条。它把「头身比」从一个形容词变成一个可推导的量:只要给了头高与全身高,头身比就定了,多写一个 headUnits 字段只是为了让表可读,而恒等式保证它不会与像素值脱节。负例里把 headUnits 从 3 改成 4,384 ÷ 128 = 3 ≠ 4 立刻被抓——这正是「先定了 Q 版三头身,后来有人偷偷把全身高改高」这类漂移的拦法。
面部顺序与间距守的是另一种失败:五官画歪。眼线 560 / 鼻线 680 / 嘴线 780,间距 120 / 100;把鼻线挪到眼线上方(680 → 590 会变成间距 30),断言立刻报「面部顺序错」或「间距不足」。它不判断「这张脸好不好看」,只判断「五官的相对位置有没有被改乱」。
双眼加眼距的 60%~100% 带是二次元与写实最直观的分野。写实人脸遵循「五眼」——眼距约等于一只眼宽,双眼加眼距约占头宽 60%;二次元把眼睛放大,chibi 是 2×230 + 160 = 620,standard 是 620,tall 是 605,三档都落在 600‰ 以上,且都远未顶满 1000‰。这条带子不是普适真理,是这一套主题下的选择——换成写实档,眼距比例会显著缩小。
| 体型档 | 头身比 | 头高 / 全身高(px) | 眼 / 鼻 / 嘴(‰) | 双眼加眼距(‰) |
|---|---|---|---|---|
tp_chibi | 3 | 128 / 384 | 560 / 680 / 780 | 620 |
tp_standard | 7 | 96 / 672 | 540 / 660 / 760 | 620 |
tp_tall | 8 | 88 / 704 | 520 / 640 / 740 | 605 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 头身比可判 | heightPx == headUnits × headHeightPx,且头身比落在体型档区间内 |
| 面部可判 | 眼 < 鼻 < 嘴,相邻间距不小于下限,位置都在 (0,1000) |
| 眼距可判 | 双眼加眼距落在 [600, 1000] 的千分比带内 |
| 全整数 | 头身比、眼距、间距都是整数运算,无浮点 |
| 边界诚实 | 角色观感标 X,不写成「实测」 |
比例表定下来了,但比例只回答「结构对不对」,回答不了「画得好不好看」。下一节整节标 X,把「不可复现」本身写成一条会失败的门禁。
阅读导航:上一节:10.1 色彩与线条规范 · 下一节:10.3 X:画质 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。