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:笔触质量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。