7.2 手绘贴图的分层与复用

厚涂最贵的不是笔刷,是「每个资产都从头画一遍」。本节把手绘贴图拆成图层角色与贴图页,落成一份 D 层清单与一段可跑的 TypeScript 校验器:图层角色序列必须与模板逐位一致,每个资产用到的贴图页数有上限,两级复用率是整数千分比——地板除,跨语言逐位一致。

7.1 把厚涂的笔刷与色域写成了规范,但规范只解决「一笔怎么画」,没解决「一套资产怎么画得省」。手绘贴图真正的成本不在单笔,而在每个资产都从头画一遍。前一章 6.1 贴图通道与打包 讲的是贴图在显存里怎么打包;本节讲的是它在画之前怎么被组织——图层怎么分、模板怎么共享、贴图页怎么复用。贴图与显存的关系另见 纹理内存专题 ,本节不重推。

分层:手绘贴图的图层角色

厚涂不是一层画到底。业界通行的做法是把一幅手绘贴图拆成有序的图层角色:

角色作用混合模式典型不透明度带
base铺底大色块normal接近不透明
shadow压暗部、给体积multiply半透明
light提亮部、给高光screen半透明
texture叠加画布/材质纹理overlay轻
detail边缘、缝线、小结构normal轻

这张表与 7.1 的 layerOpacityBand 是同一件事的两种写法:角色决定它在叠层里的位置,位置决定它能有多重。顺序本身就是规范——base 永远在底,detail 永远在顶。顺序错了,画面不会报错,只会「看着不对」,所以它必须写成可断言的序列。

D 层:分层与复用清单

下面这段是 D 层清单 config/st07_layer_reuse.json。它用 templates 定义三种图层模板,用 assets 把 6 个资产挂到模板上,每个图层再引用一张贴图页。复用在这里是可数的:模板被几个资产共享、贴图页被几个图层引用,都是整数:

{
  "version": 1,
  "project": "stardust-inn",
  "themeId": "handdrawn",
  "note": "手绘贴图的分层与复用清单(第 7 章 7.2,D 层)。每个资产引用一个图层模板,模板规定图层角色顺序;每个图层引用一张贴图页。复用分两级:模板被多个资产共享(模板复用),贴图页被多个图层引用(贴图页复用)。复用率是整数千分比(地板除),跨语言逐位一致。真实贴图观感需真实画师与真实环境,命令行不可复现(X)。",
  "roleOrder": ["base", "shadow", "light", "texture", "detail"],
  "rules": {
    "idPattern": "^ly_[a-z][a-z0-9-]*$",
    "blendModes": ["normal", "multiply", "screen", "overlay"],
    "maxPagesPerAsset": 3,
    "templateReuseFloorPermille": 400,
    "pageReuseFloorPermille": 500
  },
  "texturePages": ["tex_canvas-warm", "tex_canvas-cool", "tex_canvas-detail"],
  "templates": [
    { "id": "tpl_furniture", "roles": ["base", "shadow", "light", "texture"] },
    { "id": "tpl_character", "roles": ["base", "shadow", "light", "texture", "detail"] },
    { "id": "tpl_ui", "roles": ["base", "light"] }
  ],
  "assets": [
    { "id": "furn_lamp", "templateId": "tpl_furniture", "layers": [ { "id": "ly_base", "role": "base", "blendMode": "normal", "page": "tex_canvas-warm" }, { "id": "ly_shadow", "role": "shadow", "blendMode": "multiply", "page": "tex_canvas-warm" }, { "id": "ly_light", "role": "light", "blendMode": "screen", "page": "tex_canvas-cool" }, { "id": "ly_texture", "role": "texture", "blendMode": "overlay", "page": "tex_canvas-warm" } ] },
    { "id": "furn_chair", "templateId": "tpl_furniture", "layers": [ { "id": "ly_base", "role": "base", "blendMode": "normal", "page": "tex_canvas-warm" }, { "id": "ly_shadow", "role": "shadow", "blendMode": "multiply", "page": "tex_canvas-cool" }, { "id": "ly_light", "role": "light", "blendMode": "screen", "page": "tex_canvas-cool" }, { "id": "ly_texture", "role": "texture", "blendMode": "overlay", "page": "tex_canvas-detail" } ] },
    { "id": "char_innkeeper", "templateId": "tpl_character", "layers": [ { "id": "ly_base", "role": "base", "blendMode": "normal", "page": "tex_canvas-warm" }, { "id": "ly_shadow", "role": "shadow", "blendMode": "multiply", "page": "tex_canvas-warm" }, { "id": "ly_light", "role": "light", "blendMode": "screen", "page": "tex_canvas-cool" }, { "id": "ly_texture", "role": "texture", "blendMode": "overlay", "page": "tex_canvas-cool" }, { "id": "ly_detail", "role": "detail", "blendMode": "normal", "page": "tex_canvas-detail" } ] },
    { "id": "char_guest", "templateId": "tpl_character", "layers": [ { "id": "ly_base", "role": "base", "blendMode": "normal", "page": "tex_canvas-warm" }, { "id": "ly_shadow", "role": "shadow", "blendMode": "multiply", "page": "tex_canvas-cool" }, { "id": "ly_light", "role": "light", "blendMode": "screen", "page": "tex_canvas-cool" }, { "id": "ly_texture", "role": "texture", "blendMode": "overlay", "page": "tex_canvas-warm" }, { "id": "ly_detail", "role": "detail", "blendMode": "normal", "page": "tex_canvas-cool" } ] },
    { "id": "ui_panel", "templateId": "tpl_ui", "layers": [ { "id": "ly_base", "role": "base", "blendMode": "normal", "page": "tex_canvas-warm" }, { "id": "ly_light", "role": "light", "blendMode": "screen", "page": "tex_canvas-cool" } ] },
    { "id": "ui_button", "templateId": "tpl_ui", "layers": [ { "id": "ly_base", "role": "base", "blendMode": "normal", "page": "tex_canvas-warm" }, { "id": "ly_light", "role": "light", "blendMode": "screen", "page": "tex_canvas-cool" } ] }
  ]
}

读这份清单有三个看点。第一,资产只挂模板,不自己定义图层结构——furn_lamp 与 furn_chair 共用 tpl_furniture,两者的图层角色序列因此必须逐位相同。第二,贴图页是共享资源:tex_canvas-warm 被 8 个图层引用,这正是复用。第三,每个资产用到的贴图页数有上限(maxPagesPerAsset),页数越多意味着运行时采样越散,与 10.2 图集装箱率 是同一类约束。

P 层:清单校验内核

下面这段是 P 层内核 core/src/st07/layer_reuse.ts。它做三件事:校验单资产的图层序列与模板逐位一致,统计两级复用率(整数千分比、地板除),并找出孤儿模板(定义了却没人用的模板):

// file: core/src/st07/layer_reuse.ts
// tier: P
// verify: node --test 'core/test/st07_*.test.ts'
// 手绘贴图分层与复用的清单校验内核(P 层)。
// 校验三件事:① 资产的图层角色序列必须与其模板逐位一致(顺序即结构);② 图层 id、
// 混合模式、贴图页、图层角色都在允许集内,且每个资产用到的贴图页数不超上限;③ 两级
// 复用率(模板复用 / 贴图页复用)是整数千分比,地板除 → 跨语言逐位一致。
// 复用率是清单级的计数,不是笔触观感;真实贴图观感需真实画师与真实环境(X)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Layer = { id: string; role: string; blendMode: string; page: string };
export type Asset = { id: string; templateId: string; layers: Layer[] };
export type Template = { id: string; roles: string[] };
export type ReuseRules = {
  idPattern: string;
  blendModes: string[];
  maxPagesPerAsset: number;
  templateReuseFloorPermille: number;
  pageReuseFloorPermille: number;
};
export type ReuseSpec = {
  roleOrder: string[];
  rules: ReuseRules;
  texturePages: string[];
  templates: Template[];
  assets: Asset[];
};

/** 单资产校验:返回违例(空数组即合格)。 */
export function validateAsset(a: Asset, tpl: Template | undefined, spec: ReuseSpec): string[] {
  const out: string[] = [];
  if (!tpl) return [`${a.id}: 模板不存在 ${a.templateId}`];
  const roles = a.layers.map((l) => l.role);
  if (roles.join(">") !== tpl.roles.join(">")) {
    out.push(`${a.id}: 图层角色序列 ${roles.join(">")} != 模板 ${tpl.roles.join(">")}`);
  }
  const seen = new Set<string>();
  for (const l of a.layers) {
    if (!new RegExp(spec.rules.idPattern).test(l.id)) out.push(`${a.id}: 图层 id 不合文法 ${l.id}`);
    if (seen.has(l.id)) out.push(`${a.id}: 图层 id 重复 ${l.id}`);
    seen.add(l.id);
    if (!spec.roleOrder.includes(l.role)) out.push(`${a.id}: 未知图层角色 ${l.role}`);
    if (!spec.rules.blendModes.includes(l.blendMode)) out.push(`${a.id}: 未知混合模式 ${l.blendMode}`);
    if (!spec.texturePages.includes(l.page)) out.push(`${a.id}: 未知贴图页 ${l.page}`);
  }
  const pages = new Set(a.layers.map((l) => l.page)).size;
  if (pages > spec.rules.maxPagesPerAsset) {
    out.push(`${a.id}: 贴图页数 ${pages} > 上限 ${spec.rules.maxPagesPerAsset}`);
  }
  return out;
}

/** 千分比:整数地板除。 */
export function permille(part: number, total: number): number {
  return total === 0 ? 0 : Math.floor((part * 1000) / total);
}

/** 全清单分析:违例 + 两级复用率 + 孤儿模板。 */
export function analyze(spec: ReuseSpec) {
  const tplById = new Map(spec.templates.map((t) => [t.id, t]));
  const used = new Set(spec.assets.map((a) => a.templateId));
  const violations: string[] = [];
  for (const a of spec.assets) violations.push(...validateAsset(a, tplById.get(a.templateId), spec));
  const orphanTemplates = spec.templates.filter((t) => !used.has(t.id)).map((t) => t.id);
  const assetCount = spec.assets.length;
  const templateCount = spec.templates.length;
  const layerRefs = spec.assets.reduce((n, a) => n + a.layers.length, 0);
  const uniquePagesUsed = new Set(spec.assets.flatMap((a) => a.layers.map((l) => l.page))).size;
  const templateReusePermille = permille(assetCount - templateCount, assetCount);
  const pageReusePermille = permille(layerRefs - uniquePagesUsed, layerRefs);
  const r = spec.rules;
  const pass = violations.length === 0 && orphanTemplates.length === 0
    && templateReusePermille >= r.templateReuseFloorPermille
    && pageReusePermille >= r.pageReuseFloorPermille;
  return { assetCount, templateCount, layerRefs, uniquePagesUsed, templateReusePermille,
    pageReusePermille, orphanTemplates, violations, pass };
}

两级复用率的公式都是整数地板除,所以跨语言逐位一致:

  • 模板复用率 = (资产数 − 模板数) / 资产数 → (6 − 3) / 6 = 500‰
  • 贴图页复用率 = (图层引用总数 − 用到的不同贴图页数) / 图层引用总数 → (22 − 3) / 22 = 863‰(地板除)

负例:结构漂移与孤儿模板

清单校验最怕的不是报错,是结构漂了却没人报。所以测试的力气全在负例上:

// file: core/test/st07_layer_reuse.test.ts
// tier: P
// verify: node --test 'core/test/st07_*.test.ts'
// 分层与复用清单的断言:基线 6 资产 / 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, validateAsset, permille } from "../src/st07/layer_reuse.ts";
import type { Asset, ReuseSpec, Template } from "../src/st07/layer_reuse.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/st07_layer_reuse.json"), "utf8")) as ReuseSpec;
const tplFurn = spec.templates.find((t) => t.id === "tpl_furniture") as Template;
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);

test("基线:6 资产 / 3 模板零违例、无孤儿模板、pass 为真", () => {
  const r = analyze(spec);
  assert.deepEqual(r.violations, []);
  assert.deepEqual(r.orphanTemplates, []);
  assert.equal(r.pass, true);
  assert.deepEqual([r.assetCount, r.templateCount, r.layerRefs, r.uniquePagesUsed],
    [6, 3, 22, 3]);
});

test("两级复用率是整数千分比(地板除),跨语言逐位一致", () => {
  const r = analyze(spec);
  assert.equal(r.templateReusePermille, 500); // (6-3)/6
  assert.equal(r.pageReusePermille, 863);     // (22-3)/22 → 地板除 863
  assert.equal(permille(19, 22), 863);
  assert.notEqual(permille(19, 22), 864);
});

test("负例:图层角色序列与模板不符必须被抓", () => {
  const bad: Asset = { ...spec.assets[0], layers: [...spec.assets[0].layers].reverse() };
  const v = validateAsset(bad, tplFurn, spec);
  assert.equal(v.length, 1);
  assert.match(v[0], /图层角色序列/);
});

test("负例:模板缺失 / 贴图页超上限 / 未知页 / 未知混合模式 / 重复 id", () => {
  const missing = validateAsset({ ...spec.assets[0], templateId: "tpl_none" }, undefined, spec);
  assert.equal(missing.length, 1);
  const spec4: ReuseSpec = { ...spec, texturePages: [...spec.texturePages, "tex_canvas-extra"] };
  const fourPages: Asset = { ...spec.assets[1],
    layers: spec.assets[1].layers.map((l, i) => ({ ...l, page: spec4.texturePages[i] })) };
  assert.ok(validateAsset(fourPages, tplFurn, spec4).some((e) => /贴图页数/.test(e)));
  const badPage = validateAsset({ ...spec.assets[0],
    layers: [{ ...spec.assets[0].layers[0], page: "tex_nope" }] }, tplFurn, spec);
  assert.ok(badPage.some((e) => /未知贴图页/.test(e)));
  const badBlend = validateAsset({ ...spec.assets[0],
    layers: [{ ...spec.assets[0].layers[0], blendMode: "add" }] }, tplFurn, spec);
  assert.ok(badBlend.some((e) => /未知混合模式/.test(e)));
  const dup = validateAsset({ ...spec.assets[0],
    layers: [spec.assets[0].layers[0], { ...spec.assets[0].layers[0], role: "shadow" }] }, tplFurn, spec);
  assert.ok(dup.some((e) => /重复/.test(e)));
});

test("负例:删掉一个模板的资产后,复用率跌破下限、pass 变假", () => {
  const thin: ReuseSpec = { ...spec, assets: spec.assets.slice(0, 2) };
  const r = analyze(thin);
  assert.equal(r.templateReusePermille, -500); // (2-3)/2 → 地板除 -500,负值即复用为负
  assert.equal(r.pass, false);
  assert.deepEqual(r.orphanTemplates, ["tpl_character", "tpl_ui"]);
});

test("P 层摘要(digest):本卷无 S 层,故只有清单自洽摘要", () => {
  const r = analyze(spec);
  const canon = JSON.stringify({
    assetCount: r.assetCount, templateCount: r.templateCount, layerRefs: r.layerRefs,
    uniquePagesUsed: r.uniquePagesUsed, templateReusePermille: r.templateReusePermille,
    pageReusePermille: r.pageReusePermille, pass: r.pass,
  });
  assert.equal(sha16(canon), "0c95ee163ed5fc7b");
  console.log(`st07 layer/reuse digest = ${sha16(canon)}`);
});

在本机跑 node --test 'core/test/st07_*.test.ts',本节 6 条,真实输出:

st07 layer/reuse digest = 0c95ee163ed5fc7b
✔ 基线:6 资产 / 3 模板零违例、无孤儿模板、pass 为真 (1.019333ms)
✔ 两级复用率是整数千分比(地板除),跨语言逐位一致 (0.116375ms)
✔ 负例:图层角色序列与模板不符必须被抓 (0.117125ms)
✔ 负例:模板缺失 / 贴图页超上限 / 未知页 / 未知混合模式 / 重复 id (0.160584ms)
✔ 负例:删掉一个模板的资产后,复用率跌破下限、pass 变假 (0.08675ms)
✔ P 层摘要(digest):本卷无 S 层,故只有清单自洽摘要 (0.636708ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0

复用率不是「画得快」,是「改得动」

最后一个负例最值得讲。把 6 个资产砍到 2 个,模板复用率从 500‰ 掉成 -500‰——负值不是 bug:(2−3)/2 地板除就是负的,它精确地表达了「模板比资产还多」这件事,而 pass 随之变假。这条断言守的是一种很常见的失败:先设计了 5 个模板,最后只用了 2 个。模板本身不是资产,它是为了复用而存在的结构;没有资产用的模板是纯负债。

所以复用率真正的意义不是「画得更快」,而是改得动。当客户说「所有家具的暗部再压暗一点」,如果 furn_lamp 与 furn_chair 共用 tpl_furniture,改一处就够了;如果每个资产各画各的,就得改 N 处,且必然漏掉一个。模板复用率度量的是「一次改动能覆盖多少资产」——这才是规范存在的理由。

自检表

检查项合格标准
结构可判每个资产的图层角色序列与其模板逐位一致
资源可判贴图页、混合模式、图层角色都在允许集内,页数不超上限
复用可数两级复用率是整数千分比,地板除,跨语言逐位一致
无负债没有定义了却没人用的孤儿模板
边界诚实贴图观感标 X,不写成「实测」

分层与复用都定下来了,但它们管的仍是能写成数据的部分。真正决定这套厚涂「好不好看」的那一半——笔触本身——只能由真实画师判断。下一节整节标 X,把「不可复现」写成一条会失败的门禁。

阅读导航:上一节:7.1 笔刷与色域参数规范 · 下一节:7.3 X:笔触质量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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