10.2 角色比例与面部规范

10.1 管的是颜色与线宽,这一节管结构:角色的头身比、头高与全身高、面部三条特征线(眼 / 鼻 / 嘴)在头部框内的相对位置、双眼加眼距占头宽的比例,全部是整数。本节把它们落成一份 D 层比例表与一段可跑的 TypeScript 校验器,头身比恒等式与面部顺序都会被断言。

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_chibi3128 / 384560 / 680 / 780620
tp_standard796 / 672540 / 660 / 760620
tp_tall888 / 704520 / 640 / 740605

自检表

检查项合格标准
头身比可判heightPx == headUnits × headHeightPx,且头身比落在体型档区间内
面部可判眼 < 鼻 < 嘴,相邻间距不小于下限,位置都在 (0,1000)
眼距可判双眼加眼距落在 [600, 1000] 的千分比带内
全整数头身比、眼距、间距都是整数运算,无浮点
边界诚实角色观感标 X,不写成「实测」

比例表定下来了,但比例只回答「结构对不对」,回答不了「画得好不好看」。下一节整节标 X,把「不可复现」本身写成一条会失败的门禁。

阅读导航:上一节:10.1 色彩与线条规范 · 下一节:10.3 X:画质 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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