3.2 手绘资产的切分与复用

手绘资产该拆成几个切分件、拆完有没有人复用,是两笔能算清的账。本节把「切分件数、复用率、单件重绘成本」写成整数模型:用 Python 算复用率与节省成本,用 TypeScript 复算并对拍,再用「只被用一次的件」这个信号抓出切分过度——拆了一堆没人复用的件,账面上看得见。

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:画质与笔触 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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