14.3 跨品类复用与移植

跨品类移植不是复制粘贴,而是一笔账。本节把复用分成参数级/贴图层/几何层/全新建四层,各给一个整数改造系数,对四条迁移路径算改造人天:节省率从 379‰(中度→重度,要动几何)到 603‰(降级精简,只改参数),并给出层级成本结构与两条口径陷阱。

站内 《游戏美术外包实务》 讲的是外包怎么切分与计价;本节讲的是同一批资产在品类之间搬家时,哪些层能留下、哪些必须重做。这套分层判据来自卷二 13.2 工具链的分层与复用 (「消费方 ≥ 2 才下沉」的同一种思路),资产的切分口径则沿用卷三 3.2 手绘资产的切分与复用 与 7.2 手绘贴图的分层与复用 。人天一律 ×100 整数,与卷二 14.1 同源。

14.3 跨品类复用与移植

14.3.1 复用不是复制粘贴

「这批资产能复用到新品类吗」听起来是个是非题,实际上是个分层题。一件资产不是铁板一块,它由几层叠成:参数层(token 引用、材质参数、命名)、贴图层(配色与纹理)、几何层(拓扑与 UV)、规范层(命名与目录)。换品类时,每一层受到的冲击不同:

复用层级改什么改造系数复用深度权
参数级只改 token / 材质参数 / 命名150‰3
贴图层几何不动,贴图重画或重配色400‰2
几何层拓扑要改,贴图随之重做700‰1
全新建只复用命名与规范,其余重做1000‰0

「改造系数」是改造工作量占「全新建」的比例,千分比整数。参数级 150‰ 意味着省下 85%,全新建 1000‰ 意味着一点没省(它只是「按同一份规范重做」,不是复用)。把一件资产笼统说成「可复用」,等于把四层压成一个数——这正是移植预算算不准的头号原因。

14.3.2 D 层:四个层级与四条迁移路径

下面这份配置把层级系数与四条迁移路径写成数据。每条路径给一批待移植资产,每件标明件数、全新建单件人天×100、以及它落在哪一层。

{
  "project": "stardust-inn",
  "chapter": 14,
  "section": "14.3",
  "note": "跨品类复用与移植的账。四个复用层级(参数级/贴图层/几何层/全新建)各给一个改造系数‰(改造工作量占「全新建」的比例)与一个复用深度权(0–3)。四条迁移路径各给一批待移植资产(件数 × 全新建单件人天×100 × 所属层级)。改造人天×100 = 件数 × 全新建单件人天×100 × 改造系数‰ // 1000(整数地板除);节省人天×100 = 全新建人天×100 − 改造人天×100。层级改造系数、件数与单件成本都是本套构造的示例参数,真实移植成本需真实生产环境,命令行不可复现(X)。全整数,跨语言逐位一致。",
  "caliber": {
    "costSource": "art-ta-14-1-capacity-person-day-throughput",
    "reuseSource": "art-ta-13-2-toolchain-layering-reuse"
  },
  "layers": [
    { "id": "param", "cn": "参数级", "reworkPermille": 150, "depth": 3,
      "note": "只改 token / 材质参数 / 命名,几何与贴图不动" },
    { "id": "texture", "cn": "贴图层", "reworkPermille": 400, "depth": 2,
      "note": "几何不动,贴图按目标品类重画或重配色" },
    { "id": "geometry", "cn": "几何层", "reworkPermille": 700, "depth": 1,
      "note": "拓扑要改,贴图随之重做" },
    { "id": "rebuild", "cn": "全新建", "reworkPermille": 1000, "depth": 0,
      "note": "只复用命名与规范,其余重做" }
  ],
  "paths": [
    {
      "id": "casual-to-mid", "cn": "休闲 → 中度",
      "items": [
        { "asset": "furn_icon", "units": 8, "newCost100": 60, "layer": "param" },
        { "asset": "furn_prefab", "units": 2, "newCost100": 250, "layer": "geometry" },
        { "asset": "portrait", "units": 1, "newCost100": 400, "layer": "texture" },
        { "asset": "ui_icon", "units": 20, "newCost100": 40, "layer": "param" },
        { "asset": "vfx_burst", "units": 1, "newCost100": 300, "layer": "rebuild" }
      ]
    },
    {
      "id": "mid-to-hard", "cn": "中度 → 重度",
      "items": [
        { "asset": "furn_icon", "units": 10, "newCost100": 80, "layer": "texture" },
        { "asset": "furn_prefab", "units": 6, "newCost100": 320, "layer": "geometry" },
        { "asset": "portrait", "units": 3, "newCost100": 500, "layer": "rebuild" },
        { "asset": "ui_icon", "units": 26, "newCost100": 50, "layer": "param" },
        { "asset": "vfx_burst", "units": 2, "newCost100": 400, "layer": "geometry" }
      ]
    },
    {
      "id": "mid-to-casual", "cn": "中度 → 休闲",
      "items": [
        { "asset": "furn_icon", "units": 10, "newCost100": 50, "layer": "param" },
        { "asset": "furn_prefab", "units": 3, "newCost100": 180, "layer": "geometry" },
        { "asset": "portrait", "units": 1, "newCost100": 300, "layer": "texture" },
        { "asset": "ui_icon", "units": 22, "newCost100": 30, "layer": "param" },
        { "asset": "vfx_burst", "units": 1, "newCost100": 200, "layer": "rebuild" }
      ]
    },
    {
      "id": "hard-to-indie", "cn": "重度 → 独立",
      "items": [
        { "asset": "furn_icon", "units": 14, "newCost100": 70, "layer": "param" },
        { "asset": "furn_prefab", "units": 6, "newCost100": 260, "layer": "texture" },
        { "asset": "portrait", "units": 2, "newCost100": 420, "layer": "geometry" },
        { "asset": "ui_icon", "units": 11, "newCost100": 50, "layer": "param" },
        { "asset": "vfx_burst", "units": 1, "newCost100": 320, "layer": "rebuild" }
      ]
    }
  ]
}

四条路径覆盖两个方向:升级(休闲→中度、中度→重度)与降级/精简(中度→休闲、重度→独立)。升级通常要动几何(目标档位更高),降级常常只改参数(砍细节、换配色)。

14.3.3 S 层:改造人天账

下面这段是 S 层脚本,逐路径算全新建人天、改造人天、节省人天、节省率与复用深度指数,并附两条口径陷阱的对照值。

#!/usr/bin/env python3
# file: sim/st14_reuse.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st14_reuse.py
"""14.3 跨品类复用与移植(S 层,卷三第 14 章)。

读 config/st14_reuse.json(四个复用层级的改造系数‰ 与深度权、四条迁移路径的待移植资产),
逐路径算五笔整数账:

1. **全新建人天×100** = Σ 件数 × 单件全新建人天×100(对照基线,改造没省之前的成本);
2. **改造人天×100** = Σ 件数 × 单件全新建人天×100 × 改造系数‰ // 1000(整数地板除);
3. **节省人天×100** = 全新建 − 改造;**节省率‰** = 节省 × 1000 // 全新建;
4. **复用深度指数‰** = Σ 件数 × 深度权 × 1000 // Σ 件数(3 = 全是参数级,0 = 全是新建);
5. **层级成本结构**:每个层级贡献的件数与改造人天,用来解释节省率差在哪。

再给两条负例口径:把几何层误当参数级、把改造系数当浮点乘(不用地板除)会让节省率虚高。
全整数,跨语言逐位一致。层级系数与件数是示例参数,真实移植成本需真实生产环境(X)。
产出 out/st14_reuse.json + out/st14_reuse.csv。
"""
import csv
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/st14_reuse.json")
OUT = os.path.join(ROOT, "out")


def path_account(p, layer_of, permille_of=None):
    """单条迁移路径的整数账。permille_of 可覆盖某层级的改造系数(用于负例口径对照)。"""
    pm = permille_of or {L: layer_of[L]["reworkPermille"] for L in layer_of}
    new_total = sum(i["units"] * i["newCost100"] for i in p["items"])
    rework_total = 0
    units_total = 0
    depth_sum = 0
    by_layer = {}
    rows = []
    for i in p["items"]:
        L = layer_of[i["layer"]]
        units = i["units"]
        new100 = units * i["newCost100"]
        rw100 = new100 * pm[i["layer"]] // 1000
        rework_total += rw100
        units_total += units
        depth_sum += units * L["depth"]
        b = by_layer.setdefault(i["layer"], {"units": 0, "new100": 0, "rework100": 0})
        b["units"] += units
        b["new100"] += new100
        b["rework100"] += rw100
        rows.append({"asset": i["asset"], "units": units, "layer": i["layer"],
                     "newCost100": i["newCost100"], "new100": new100, "rework100": rw100})
    save_total = new_total - rework_total
    return {
        "id": p["id"], "cn": p["cn"], "units": units_total,
        "newTotal100": new_total, "reworkTotal100": rework_total, "saveTotal100": save_total,
        "savePermille": save_total * 1000 // new_total if new_total else 0,
        "depthIndexPermille": depth_sum * 1000 // units_total if units_total else 0,
        "byLayer": by_layer, "items": rows,
    }


def main() -> int:
    cfg = json.load(open(CFG, encoding="utf-8"))
    layer_of = {L["id"]: L for L in cfg["layers"]}
    rows = [path_account(p, layer_of) for p in cfg["paths"]]

    # 负例口径一:把几何层误当参数级(改造系数 700‰ 当 150‰),节省率会虚高。
    wrong_pm = {L["id"]: L["reworkPermille"] for L in cfg["layers"]}
    wrong_pm["geometry"] = layer_of["param"]["reworkPermille"]
    neg_a = path_account(next(p for p in cfg["paths"] if p["id"] == "casual-to-mid"), layer_of, wrong_pm)
    # 负例口径二:改造人天用四舍五入而非整数地板除(550 × 150 = 82500 → 82 vs 83)。
    raw = 550 * 150
    neg_b = {"item": "hard-to-indie/ui_icon", "new100": 550, "permille": 150,
             "floored": raw // 1000, "rounded": int(raw / 1000 + 0.5)}

    lines = [f'{r["id"]}:{r["newTotal100"]}:{r["reworkTotal100"]}:{r["savePermille"]}'
             f':{r["depthIndexPermille"]}' for r in rows]
    digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]

    report = {
        "project": cfg["project"], "section": cfg["section"],
        "layers": cfg["layers"], "pathCount": len(rows), "paths": rows,
        "negatives": {
            "geometryAsParam": {"path": neg_a["id"], "reworkTotal100": neg_a["reworkTotal100"],
                                "savePermille": neg_a["savePermille"],
                                "trueSavePermille": rows[0]["savePermille"]},
            "roundNotFloor": neg_b,
        },
        "digest": digest,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st14_reuse.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    with open(os.path.join(OUT, "st14_reuse.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["path", "cn", "units", "newTotal100", "reworkTotal100",
                    "saveTotal100", "savePermille", "depthIndexPermille"])
        for r in rows:
            w.writerow([r["id"], r["cn"], r["units"], r["newTotal100"], r["reworkTotal100"],
                        r["saveTotal100"], r["savePermille"], r["depthIndexPermille"]])

    print(f'layers=' + ",".join(f'{L["id"]}:{L["reworkPermille"]}permille/d{L["depth"]}'
                                for L in cfg["layers"]))
    for r in rows:
        print(f'  {r["id"]:<15} units={r["units"]:>2} new100={r["newTotal100"]:>5} '
              f'rework100={r["reworkTotal100"]:>5} save100={r["saveTotal100"]:>5} '
              f'save={r["savePermille"]:>4}permille depth={r["depthIndexPermille"]:>5}')
    for L in cfg["layers"]:
        tot_u = sum(r["byLayer"].get(L["id"], {}).get("units", 0) for r in rows)
        tot_rw = sum(r["byLayer"].get(L["id"], {}).get("rework100", 0) for r in rows)
        print(f'  layer {L["id"]:<9} units={tot_u:>3} rework100={tot_rw:>6}')
    print(f'neg geometryAsParam rework100={neg_a["reworkTotal100"]} '
          f'save={neg_a["savePermille"]}permille (true {rows[0]["savePermille"]}permille)')
    print(f'neg roundNotFloor {neg_b["item"]} {neg_b["new100"]}*{neg_b["permille"]}//1000='
          f'{neg_b["floored"]} round={neg_b["rounded"]}')
    print(f'digest={digest}')
    print("wrote out/st14_reuse.json out/st14_reuse.csv")
    return 0


if __name__ == "__main__":
    sys.exit(main())

14.3.4 实跑输出

本机实跑 /opt/homebrew/bin/python3 sim/st14_reuse.py:

layers=param:150permille/d3,texture:400permille/d2,geometry:700permille/d1,rebuild:1000permille/d0
  casual-to-mid   units=32 new100= 2480 rework100= 1002 save100= 1478 save= 595permille depth= 2750
  mid-to-hard     units=47 new100= 6320 rework100= 3919 save100= 2401 save= 379permille depth= 2255
  mid-to-casual   units=37 new100= 2200 rework100=  872 save100= 1328 save= 603permille depth= 2729
  hard-to-indie   units=34 new100= 4250 rework100= 1761 save100= 2489 save= 585permille depth= 2617
  layer param     units=111 rework100=   790
  layer texture   units= 18 rework100=  1224
  layer geometry  units= 15 rework100=  3220
  layer rebuild   units=  6 rework100=  2320
neg geometryAsParam rework100=727 save=706permille (true 595permille)
neg roundNotFloor hard-to-indie/ui_icon 550*150//1000=82 round=83
digest=a908fa7134da7094
wrote out/st14_reuse.json out/st14_reuse.csv

14.3.5 读账:省多少,取决于复用有多深

迁移路径件数全新建人天改造人天节省人天节省率复用深度指数
休闲 → 中度32248010021478595‰2750
中度 → 重度47632039192401379‰2255
中度 → 休闲3722008721328603‰2729
重度 → 独立34425017612489585‰2617

四条结论:

  • 复用深度指数与节省率同向。中度 → 重度 的深度指数最低(2255),节省率也最低(379‰);另外三条深度都在 2600 以上,节省率都过了 580‰。「能复用多少」这个问题的答案,就是「多少件资产停在参数级与贴图层」。
  • 升级比降级贵得多。中度 → 重度 要省下的是「把资产推到更高档位」——几何与全新建占了 5 件中的 5 件(3 件重建角色 + 2 件几何),改造人天 3919 吃掉全新建 6320 的六成。而 中度 → 休闲 是砍细节,参数级占 32/37 件,改造人天只有 872。升级是「重做」,降级是「裁剪」。
  • 层级成本极度不均(四路径合计):参数级 111 件只花 790 改造人天,几何层 15 件就花 3220,全新建 6 件花 2320。件数最多的层最便宜,件数最少的层最贵——按件数估移植成本会系统性低估。
  • 全新建的「节省」是 0。rebuild 系数 1000‰ 意味着改造人天等于全新建人天,它出现在账里只是为了让「全新建件数」显式可见,不是一种复用。

14.3.6 两条口径陷阱

上面两条负例各守一个方向:

陷阱错误算法结果正确结果
把几何层当参数级几何件按 150‰ 算改造改造人天 727、节省率 706‰改造人天 1002、节省率 595‰
改造人天用四舍五入550 × 150 // 1000 写成浮点取整8382
  • 第一陷阱最隐蔽:把一件几何层资产当成「只改参数」,casual-to-mid 的节省率会从 595‰ 虚高到 706‰——多报 111 个千分点。层级判错,整张移植预算就不可信。
  • 第二陷阱只在边界分岔:550 × 150 = 82500,整数地板除得 82,四舍五入得 83。单个件差 1 不算什么,但移植账里有上百件,每件都可能在 .5 边界分岔,累计下来会让两侧对账失败。这正是本套「除法一律整数地板除」的由来。

14.3.7 边界

  • 层级系数与件数都是示例参数。真实的移植成本取决于工具链成熟度、资产库质量与团队熟练度,需真实生产环境(真实资产与真实工具链),命令行不可复现(X)。本节演示的是记账法,不是一份可移植的系数表。
  • 层级划分是判据,不是自动分类器。本节假定每件资产已被正确归层;「一件资产到底该归哪层」需要看它的实际改造范围,本节不做自动判定。
  • 不做「提升 N 倍」的断言。四条路径之间是同机、同口径的相对账,换一组件数整张表都会变,层级与地板除口径不变。
  • 移植省下的是人天,不是质量。降级路径省得多,是因为它砍掉了细节;省下的人天能不能补回质量,不在本节的账里。

14.3.8 自检表

检查项合格标准
口径同源人天×100 复用卷二 14.1,分层判据呼应卷二 13.2
全整数件数、人天、千分比全整数,除法一律地板除
层级显式每件资产标明层级,层级成本结构单独汇总
负例可复算两条陷阱的错误值与正确值都落 JSON
边界诚实系数与件数标 X(示例参数),不写成「实测」

品类这一层到这里收口:14.1 把品类接上预算,14.2 把品类接上可读性,14.3 把品类之间的资产搬家写成复用层级与改造人天。三者共用同一套人天×100 与千分比整数口径。下一章回到风格本身,把同一份规范实例化成 6 个主题包。

阅读导航:上一节:14.2 品类与视觉可读性 · 下一节:15.1 规范实例化:6 主题包 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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