站内 《游戏美术外包实务》 讲的是外包怎么切分与计价;本节讲的是同一批资产在品类之间搬家时,哪些层能留下、哪些必须重做。这套分层判据来自卷二 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 读账:省多少,取决于复用有多深
| 迁移路径 | 件数 | 全新建人天 | 改造人天 | 节省人天 | 节省率 | 复用深度指数 |
|---|---|---|---|---|---|---|
| 休闲 → 中度 | 32 | 2480 | 1002 | 1478 | 595‰ | 2750 |
| 中度 → 重度 | 47 | 6320 | 3919 | 2401 | 379‰ | 2255 |
| 中度 → 休闲 | 37 | 2200 | 872 | 1328 | 603‰ | 2729 |
| 重度 → 独立 | 34 | 4250 | 1761 | 2489 | 585‰ | 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 写成浮点取整 | 83 | 82 |
- 第一陷阱最隐蔽:把一件几何层资产当成「只改参数」,
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 主题包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。