3.2 手绘资产的切分与复用
站内 游戏美术外包 那篇讲的是「要不要外包、怎么分包、怎么验收」;本节不碰外包流程,只回答站内的一笔账:一件手绘资产该拆成几个切分件,拆出来的件有没有被别人复用。切分的目的是复用——把「腿」从每把椅子里拆出来,让所有椅子共用同一个腿件;但拆得太细,件多到没人复用,反而增加管理与对齐成本。这个权衡能用整数算。卷一 11.3 场景构图与模块化 kit 讲了模块化 kit 的构图口径,卷二 13.2 工具链分层与复用 讲了工具链怎么分层复用;本节把它们落到手绘切分件的复用率与成本账上。上一节 3.1 扁平色块与线宽规范 定死了单件内部的色块数,本节把单件再往上拼成资产。
切分不是越细越好
把一件资产拆成切分件,收益来自复用:一个件被 N 件资产引用,就省下 N−1 次重绘。但切分本身有成本——件越多,命名、对齐、版本管理越贵,而且没人复用的件是纯亏损:它既没省重绘,又多了一份管理负担。所以切分决策要回答两个问题:复用率有多高、哪些件其实不该拆。两个问题都是整数账。
| 量 | 定义 | 算法 |
|---|---|---|
totalPartUses | 全站切分件的引用总次数 | 每件资产的 parts 长度求和 |
distinctParts | 去重后的切分件数 | 件 id 集合大小 |
reuseCount | 被省下的重绘次数 | totalPartUses − distinctParts |
reusePermille | 复用率(千分比) | reuseCount × 1000 // totalPartUses |
savingX100 | 节省的重绘成本(人天 ×100) | naiveDrawCostX100 − distinctDrawCostX100 |
除法一律整数地板除,故复用率与节省率跨语言逐位一致。reusePermille 是切分质量的核心指标:它越低,说明拆出来的件越多是「一次性」的。
S 层:切分与复用账
下面这段是 S 层脚本。它读本章的 config/st03_split.json(每件资产的 parts 列表 + 每个切分件的重绘成本 partCostX100),算两笔整数账——复用账(引用次数、去重件数、复用率)与成本账(去重后重绘成本、朴素逐引用重绘成本、节省额),并标出只被用一次的件:
#!/usr/bin/env python3
# file: sim/st03_split.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st03_split.py
"""手绘资产的切分与复用账(第 3 章 3.2,S 层)。
读 config/st03_split.json:每件资产由若干「切分件」拼成,同一件被多件资产引用即为复用。
算三笔整数账:
* **切分件数**:每件资产引用几个件(splitCount),全站共引用几次(totalPartUses);
* **复用率**:distinctParts = 去重后的件数;reuseCount = totalPartUses − distinctParts;
reusePermille = reuseCount × 1000 // totalPartUses(整数地板除);
* **绘制成本**:distinctDrawCostX100 = 每个去重件单独重绘成本之和;naiveDrawCostX100 = 每个
引用各画一遍的成本之和;saving = naive − distinct。
并标出「只被用一次」的切分件(singleUseParts)——它们是**切分过度**的候选:拆了却没人复用。
落 out/st03_split.json 供 P 层对照。真实画师单件耗时需真实生产环境,命令行不可复现(X)。
"""
import csv
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def analyze(cfg):
cost = cfg["partCostX100"]
uses, assets = {}, []
for a in cfg["assets"]:
for p in a["parts"]:
uses[p] = uses.get(p, 0) + 1
assets.append({"id": a["id"], "splitCount": len(a["parts"]),
"distinctCount": len(set(a["parts"]))})
distinct = sorted(uses)
total_uses = sum(uses.values())
distinct_cost = sum(cost[p] for p in distinct)
naive_cost = sum(cost[p] * n for p, n in uses.items())
unknown = [p for p in distinct if p not in cost]
shared = [p for p in distinct if uses[p] >= 2]
single = [p for p in distinct if uses[p] == 1]
counts = {"shared": len(shared), "single": len(single)}
return {
"project": cfg["project"], "themeId": cfg["themeId"], "assetCount": len(cfg["assets"]),
"totalPartUses": total_uses, "distinctParts": len(distinct), "sharedParts": len(shared),
"singleUseParts": len(single), "reuseCount": total_uses - len(distinct),
"reusePermille": (total_uses - len(distinct)) * 1000 // total_uses,
"distinctDrawCostX100": distinct_cost, "naiveDrawCostX100": naive_cost,
"savingX100": naive_cost - distinct_cost,
"savingPermille": (naive_cost - distinct_cost) * 1000 // naive_cost,
"unknownParts": unknown, "counts": counts, "assets": assets,
"singleUseList": single, "sharedList": shared,
}
def write_svg(rep, path):
h = 28 * len(rep["assets"]) + 24
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="360" height="{h}">',
f'<rect x="0" y="0" width="360" height="{h}" fill="#faf6ee"/>']
for i, a in enumerate(rep["assets"]):
y = 12 + i * 28
reused = a["splitCount"] - a["distinctCount"]
color = "#b05636" if reused else "#5c5044"
parts.append(f'<text x="12" y="{y + 12}" fill="{color}" font-family="monospace" '
f'font-size="11">{a["id"]} split={a["splitCount"]} distinct={a["distinctCount"]} '
f'reused={reused}</text>')
parts.append("</svg>")
open(path, "w", encoding="utf-8").write("".join(parts) + "\n")
def main() -> int:
cfg = json.load(open(os.path.join(ROOT, "config/st03_split.json"), encoding="utf-8"))
rep = analyze(cfg)
canon = json.dumps({"totalPartUses": rep["totalPartUses"], "distinctParts": rep["distinctParts"],
"reuseCount": rep["reuseCount"], "reusePermille": rep["reusePermille"],
"savingX100": rep["savingX100"], "savingPermille": rep["savingPermille"]},
separators=(",", ":"), ensure_ascii=False)
rep["canonical"] = canon
rep["digest"] = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
json.dump(rep, open(os.path.join(ROOT, "out/st03_split.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2, sort_keys=True)
with open(os.path.join(ROOT, "out/st03_split.csv"), "w", encoding="utf-8", newline="") as f:
wr = csv.writer(f)
wr.writerow(["id", "splitCount", "distinctCount", "reused"])
for a in rep["assets"]:
wr.writerow([a["id"], a["splitCount"], a["distinctCount"], a["splitCount"] - a["distinctCount"]])
write_svg(rep, os.path.join(ROOT, "out/st03_split.svg"))
print(f"assets={rep['assetCount']} totalPartUses={rep['totalPartUses']} "
f"distinctParts={rep['distinctParts']} shared={rep['sharedParts']} single={rep['singleUseParts']}")
print(f"reuseCount={rep['reuseCount']} reusePermille={rep['reusePermille']}")
print(f"distinctDrawCostX100={rep['distinctDrawCostX100']} naiveDrawCostX100={rep['naiveDrawCostX100']} "
f"savingX100={rep['savingX100']} savingPermille={rep['savingPermille']}")
print(f"unknownParts={rep['unknownParts']} singleUseList={rep['singleUseList']}")
print(f"digest={rep['digest']}")
print("wrote out/st03_split.json out/st03_split.csv out/st03_split.svg")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st03_split.py:
assets=7 totalPartUses=18 distinctParts=11 shared=4 single=7
reuseCount=7 reusePermille=388
distinctDrawCostX100=1850 naiveDrawCostX100=2740 savingX100=890 savingPermille=324
unknownParts=[] singleUseList=['p_coin-a', 'p_cushion-red', 'p_frame-a', 'p_heart-a', 'p_lamp-base', 'p_lamp-shade', 'p_leaf-b']
digest=11bfea15573e4de7
wrote out/st03_split.json out/st03_split.csv out/st03_split.svg
读出来:7 件资产共引用切分件 18 次、去重后 11 个件,reuseCount=7、reusePermille=388——每一千次引用里 388 次是靠复用省下的。成本账:逐件各画一遍要 2740(人天 ×100),去重后只需 1850,savingX100=890、savingPermille=324。11 个去重件里 4 个被复用(shared)、7 个只用过一次(single),unknownParts 为空说明没有引用未登记的件。reusePermille 与 savingPermille 都是整数地板除的结果,换一份 st03_split.json 必须重跑,绝对数值不可移植。
P 层:同一套判据的 TS 侧
同一套判据,TypeScript 侧独立实现一遍 core/src/st03/split.ts。它只读同一份配置,不 import Python 的任何东西:
// file: core/src/st03/split.ts
// tier: P
// verify: node --test 'core/test/st03_*.test.ts'
// 手绘资产切分与复用账的 TS 侧实现(第 3 章 3.2)。与 sim/st03_split.py 共用 config/st03_split.json,
// 判据全是整数(引用次数、去重件数、复用率、成本 ×100),故两侧逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Asset = { id: string; parts: string[] };
export type SplitCfg = { partCostX100: Record<string, number>; assets: Asset[] };
export type AssetRow = { id: string; splitCount: number; distinctCount: number };
export type SplitReport = {
assetCount: number; totalPartUses: number; distinctParts: number; sharedParts: number;
singleUseParts: number; reuseCount: number; reusePermille: number; distinctDrawCostX100: number;
naiveDrawCostX100: number; savingX100: number; savingPermille: number; unknownParts: string[];
counts: { shared: number; single: number }; assets: AssetRow[]; singleUseList: string[];
sharedList: string[]; canonical: string; digest: string;
};
/** 千分比:整数地板除,禁用浮点。 */
export function permille(part: number, total: number): number {
return Math.floor((part * 1000) / total);
}
export function analyzeSplit(cfg: SplitCfg): SplitReport {
const cost = cfg.partCostX100;
const uses = new Map<string, number>();
const assets: AssetRow[] = [];
for (const a of cfg.assets) {
for (const p of a.parts) uses.set(p, (uses.get(p) ?? 0) + 1);
assets.push({ id: a.id, splitCount: a.parts.length, distinctCount: new Set(a.parts).size });
}
const distinct = [...uses.keys()].sort();
const totalUses = [...uses.values()].reduce((s, n) => s + n, 0);
const distinctCost = distinct.reduce((s, p) => s + cost[p], 0);
const naiveCost = [...uses.entries()].reduce((s, [p, n]) => s + cost[p] * n, 0);
const shared = distinct.filter((p) => (uses.get(p) ?? 0) >= 2);
const single = distinct.filter((p) => uses.get(p) === 1);
const reuseCount = totalUses - distinct.length;
const report: SplitReport = {
assetCount: cfg.assets.length, totalPartUses: totalUses, distinctParts: distinct.length,
sharedParts: shared.length, singleUseParts: single.length, reuseCount,
reusePermille: permille(reuseCount, totalUses), distinctDrawCostX100: distinctCost,
naiveDrawCostX100: naiveCost, savingX100: naiveCost - distinctCost,
savingPermille: permille(naiveCost - distinctCost, naiveCost),
unknownParts: distinct.filter((p) => !(p in cost)),
counts: { shared: shared.length, single: single.length }, assets,
singleUseList: single, sharedList: shared, canonical: "", digest: "",
};
report.canonical = canonical(report);
return report;
}
/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(r: SplitReport): string {
return JSON.stringify({
totalPartUses: r.totalPartUses, distinctParts: r.distinctParts, reuseCount: r.reuseCount,
reusePermille: r.reusePermille, savingX100: r.savingX100, savingPermille: r.savingPermille,
});
}
node --test 'core/test/st03_*.test.ts'(本节 4 条,三节合计 11 条)真实输出:
split digest = 11bfea15573e4de7
✔ 正例:复用率与成本账可复算,且不引用未登记的切分件 (0.799042ms)
✔ 跨语言:复用账、成本账与摘要逐位一致(整数类) (0.7225ms)
✔ 负例:未登记切分件必须被标出,而不是静默按 0 成本算 (0.112792ms)
✔ 地板除锁死:复用率与节省率不得用浮点 (0.070833ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
跨语言那两条把 TS 复算的复用账、成本账、singleUseList / sharedList 与 canonical 摘要逐位对上 Python 落盘的 out/st03_split.json,sha256 前 16 位都是 11bfea15573e4de7——引用次数、去重件数、复用率、成本全是整数类,逐位一致。第三条是负例:引用一个未登记的件 p_ghost 必须被 unknownParts 标出,而不是被静默按 0 成本算——静默按 0 算会让复用率虚高。
切分过度的信号:只被用一次的件
singleUseParts 是只被引用一次的切分件——它们是切分过度的候选。本节样本里 11 个去重件中有 7 个只用过一次(p_coin-a、p_cushion-red、p_frame-a、p_heart-a、p_lamp-base、p_lamp-shade、p_leaf-b),只有 4 个被复用。这 7 个件是否该合回宿主资产,取决于它们未来会不会被复用:一个只服务单件资产的「腿」,不如直接画进那件资产里。这就是复用账的用法——它不是判决,是把「拆了没人用」这件事从直觉变成可查的清单。真实画师画一个切分件要多久,需真实生产环境,命令行不可复现(X);本节算的只是引用关系与建模成本。
自检表
| 检查项 | 合格标准 |
|---|---|
| 切分件有登记 | 每个件都在 partCostX100 里,未登记判 unknownParts |
| 复用率可算 | reusePermille 整数地板除,跨语言逐位一致 |
| 成本账可算 | savingX100 由去重成本与朴素成本相减,非浮点 |
| 过度切分可见 | singleUseParts 显式列出,供人判断是否合回 |
| 边界清晰 | 真实画师单件耗时标 X,不写成「实测」 |
切分件的复用率是同一份规范的又一次消费:S 层算复用账、P 层复算对拍。下一节整节标 X——把「画质与笔触」这类命令行做不了的事显式划出去,而不是硬凑一个可跑数字。
阅读导航:上一节:3.1 扁平色块与线宽规范 · 下一节:3.3 X:画质与笔触 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。