14.1 品类与美术预算的关系

同一支团队做休闲和做重度,能出的资产量差一倍。本节把品类写成一笔可复算的整数账:四分品类(休闲/中度/重度/独立)各给一个迭代的团队规模与资产需求,产能口径与卷二 14.1 同源(有效系数 595‰),算出利用率 781/1058/1092/966‰ 与吞吐 134/120/85/95,并给出档位加权几何预算 52500~123000 面。

站内 《移动端与 PC 的美术性能预算》 讲的是「一台机器能画多少面」,第一卷 7.1 面数与骨骼分级预算 讲的是「一件资产允许多少面」。两者都回答「上限是多少」,本节回答另一个问题:同一个品类里,这些上限要乘多少件,才算得出一支团队一个迭代的真实美术预算。产能口径直接复用卷二 14.1 产能度量:人天与吞吐 的有效系数与人天×100 记账法,不另造一套数字。

14.1 品类与美术预算的关系

14.1.1 品类四分

行业里「品类」这个词在不同场合指不同东西。本节按美术产能的口径把它分成四格,与站内既有品类讨论的用法保持一致:

品类典型规模美术特征产能含义
休闲小团队、短迭代件数少、单件便宜、细节低单位产能出的件数最多
中度中等团队、标准迭代件数与单件成本都居中基准档
重度大团队、高规格件数多、单件贵、档位高单位产能出的几何量最少
独立极小团队、长周期件数少但单件慢用「少人 × 长周期」换产能

四格的分界不是「好玩不好玩」,而是三个可量化的东西:迭代规模(人数 × 工作日)、资产需求(件数 × 单件人天×100)、档位分布(每件资产落在哪个面数档)。把这三样写成数据,品类与预算的关系就从「重度项目美术贵」这句废话,变成一张能逐格对账的表。

14.1.2 D 层:四品类的迭代规模与资产需求

下面这份配置是 D 层的数据模型。它给四个品类各一份「一个迭代」的输入,tiers 里的档位上限对共享真源 config/visual_spec.json 的 budget 段做逐位对齐(道具档 = trisLow、主要角色档 = trisMid)。

{
  "project": "stardust-inn",
  "chapter": 14,
  "section": "14.1",
  "specRef": "config/visual_spec.json",
  "note": "品类与美术预算的关系。四分品类(休闲/中度/重度/独立)各给一个「一个迭代」的团队规模与资产需求。产能口径与卷二 14.1 同源:有效系数 = 出勤率‰ × 专注率‰ // 1000 = 850 × 700 // 1000 = 595‰,人天一律 ×100 整数。档位上限 tiers[].maxTris 里 prop 直接对齐只读真源 config/visual_spec.json 的 budget.trisLow(1500),npc-major 对齐 budget.trisMid(6000),另两档是本节新增的章级参数。所有团队规模、件数与单件成本都是本套构造的示例参数,不代表任何真实团队或公司。全整数,跨语言逐位一致。",
  "caliber": {
    "availabilityPermille": 850,
    "focusPermille": 700,
    "sourceRef": "art-ta-14-1-capacity-person-day-throughput"
  },
  "tiers": [
    { "id": "prop", "cn": "道具与家具", "maxTris": 1500, "specKey": "trisLow" },
    { "id": "npc-minor", "cn": "次要角色", "maxTris": 3000, "specKey": null },
    { "id": "npc-major", "cn": "主要角色", "maxTris": 6000, "specKey": "trisMid" },
    { "id": "hero", "cn": "主角", "maxTris": 12000, "specKey": null }
  ],
  "genres": [
    {
      "id": "casual",
      "cn": "休闲",
      "people": 4,
      "workdays": 10,
      "demand": [
        { "id": "furn_icon", "cn": "家具图标", "units": 8, "costPerUnit100": 50, "tier": "prop" },
        { "id": "furn_prefab", "cn": "家具 prefab", "units": 2, "costPerUnit100": 180, "tier": "prop" },
        { "id": "portrait", "cn": "角色头像", "units": 1, "costPerUnit100": 300, "tier": "npc-major" },
        { "id": "ui_icon", "cn": "UI 图标", "units": 20, "costPerUnit100": 30, "tier": "prop" },
        { "id": "vfx_burst", "cn": "特效", "units": 1, "costPerUnit100": 200, "tier": "prop" }
      ]
    },
    {
      "id": "mid-core",
      "cn": "中度",
      "people": 6,
      "workdays": 10,
      "demand": [
        { "id": "furn_icon", "cn": "家具图标", "units": 12, "costPerUnit100": 60, "tier": "prop" },
        { "id": "furn_prefab", "cn": "家具 prefab", "units": 4, "costPerUnit100": 250, "tier": "prop" },
        { "id": "portrait", "cn": "角色头像", "units": 2, "costPerUnit100": 400, "tier": "npc-major" },
        { "id": "ui_icon", "cn": "UI 图标", "units": 24, "costPerUnit100": 40, "tier": "prop" },
        { "id": "vfx_burst", "cn": "特效", "units": 1, "costPerUnit100": 300, "tier": "prop" }
      ]
    },
    {
      "id": "hard-core",
      "cn": "重度",
      "people": 12,
      "workdays": 10,
      "demand": [
        { "id": "furn_icon", "cn": "家具图标", "units": 18, "costPerUnit100": 80, "tier": "prop" },
        { "id": "furn_prefab", "cn": "家具 prefab", "units": 8, "costPerUnit100": 320, "tier": "prop" },
        { "id": "portrait", "cn": "角色头像", "units": 3, "costPerUnit100": 500, "tier": "hero" },
        { "id": "ui_icon", "cn": "UI 图标", "units": 30, "costPerUnit100": 50, "tier": "prop" },
        { "id": "vfx_burst", "cn": "特效", "units": 2, "costPerUnit100": 400, "tier": "prop" }
      ]
    },
    {
      "id": "indie",
      "cn": "独立",
      "people": 2,
      "workdays": 30,
      "demand": [
        { "id": "furn_icon", "cn": "家具图标", "units": 10, "costPerUnit100": 70, "tier": "prop" },
        { "id": "furn_prefab", "cn": "家具 prefab", "units": 3, "costPerUnit100": 260, "tier": "prop" },
        { "id": "portrait", "cn": "角色头像", "units": 2, "costPerUnit100": 420, "tier": "npc-major" },
        { "id": "ui_icon", "cn": "UI 图标", "units": 18, "costPerUnit100": 45, "tier": "prop" },
        { "id": "vfx_burst", "cn": "特效", "units": 1, "costPerUnit100": 320, "tier": "prop" }
      ]
    }
  ]
}

配置里有两个设计点值得说明。第一,四个品类共用同一份档位表:档位是「一件资产允许多少面」,由风格与平台决定,不由品类决定;品类决定的是每档各要多少件。重度品类把角色头像放到 hero 档(12000 面),休闲品类只放到 npc-major 档(6000 面),差的就是这一格。第二,独立品类用 2 人 × 30 天换到与中度 6 人 × 10 天相同的有效产能——人数少不等于产能低,把周期拉长可以补回来,但补不回来的是「同时并行的角色数」(见卷二 14.1 的「产能不能跨角色平移」)。

14.1.3 S 层:五笔整数账

下面这段是 S 层脚本,读上面的配置与只读真源 visual_spec.json,对四个品类各算五笔账:有效产能、需求人天、利用率、吞吐与几何预算。全整数、地板除。

#!/usr/bin/env python3
# file: sim/st14_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st14_budget.py
"""14.1 品类与美术预算的关系(S 层,卷三第 14 章)。

读 config/st14_genre_budget.json(四个品类的迭代规模 + 资产需求 + 档位上限)与只读真源
config/visual_spec.json 的 budget 段(做档位对齐断言),算五笔整数账:

1. **有效产能**:有效系数 = 出勤率‰ × 专注率‰ // 1000;日历人天×100 = 人数 × 工作日 × 100;
   有效人天×100 = 日历人天×100 × 有效系数‰ // 1000(与卷二 14.1 同一套口径,不另造);
2. **需求人天×100** = Σ 件数 × 单件人天×100;
3. **利用率‰** = 需求人天×100 × 1000 // 有效人天×100(>1000 即超产能);
4. **吞吐×100** = 件数 × 10000 // 有效人天×100(每有效人天的资产数);
5. **几何预算** = Σ 件数 × 该档位 tris 上限(档位上限里 prop / npc-major 与共享真源逐位对齐)。

再算「每有效人天的 tris 产出×100」把品类与几何预算挂上钩。全整数、地板除,跨语言逐位一致。
所有团队规模与成本都是本套构造的示例参数,真实产能需真实生产环境,命令行不可复现(X)。
产出 out/st14_budget.json + out/st14_budget.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_genre_budget.json")
SPEC = os.path.join(ROOT, "config/visual_spec.json")
OUT = os.path.join(ROOT, "out")


def align_tiers(spec_budget, tiers):
    """档位上限与共享真源 visual_spec.json 的 budget 段逐位对齐(只对声明了 specKey 的档)。"""
    rows = []
    for t in tiers:
        key = t.get("specKey")
        if key:
            rows.append({"tier": t["id"], "specKey": key,
                         "lhs": t["maxTris"], "rhs": spec_budget[key]})
    return rows


def genre_account(g, factor, tier_cap):
    """单个品类的整数账。"""
    calendar100 = g["people"] * g["workdays"] * 100
    effective100 = calendar100 * factor // 1000
    demand100 = sum(d["units"] * d["costPerUnit100"] for d in g["demand"])
    units = sum(d["units"] for d in g["demand"])
    tris = sum(d["units"] * tier_cap[d["tier"]] for d in g["demand"])
    by_tier = {}
    for d in g["demand"]:
        by_tier[d["tier"]] = by_tier.get(d["tier"], 0) + d["units"]
    return {
        "id": g["id"], "cn": g["cn"], "people": g["people"], "workdays": g["workdays"],
        "calendar100": calendar100, "effective100": effective100,
        "demand100": demand100, "units": units, "tris": tris,
        "utilPermille": demand100 * 1000 // effective100 if effective100 else 0,
        "naiveUtilPermille": demand100 * 1000 // calendar100 if calendar100 else 0,
        "throughput100": units * 10000 // effective100 if effective100 else 0,
        "avgCost100": demand100 // units if units else 0,
        "trisPerDayX100": tris * 100 // effective100 if effective100 else 0,
        "byTier": by_tier,
    }


def main() -> int:
    cfg = json.load(open(CFG, encoding="utf-8"))
    spec = json.load(open(SPEC, encoding="utf-8"))
    factor = cfg["caliber"]["availabilityPermille"] * cfg["caliber"]["focusPermille"] // 1000
    tier_cap = {t["id"]: t["maxTris"] for t in cfg["tiers"]}
    align = align_tiers(spec["budget"], cfg["tiers"])
    misaligned = [r["tier"] for r in align if r["lhs"] != r["rhs"]]

    rows = [genre_account(g, factor, tier_cap) for g in cfg["genres"]]
    lines = [f'{r["id"]}:{r["effective100"]}:{r["demand100"]}:{r["utilPermille"]}:{r["tris"]}'
             for r in rows]
    digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]

    report = {
        "project": cfg["project"], "section": cfg["section"],
        "availabilityPermille": cfg["caliber"]["availabilityPermille"],
        "focusPermille": cfg["caliber"]["focusPermille"],
        "effectiveFactorPermille": factor, "sourceRef": cfg["caliber"]["sourceRef"],
        "tierCap": tier_cap, "tierAlign": align, "misaligned": misaligned,
        "genreCount": len(rows), "genres": rows,
        "digest": digest,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st14_budget.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_budget.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["genre", "cn", "people", "workdays", "effective100", "demand100",
                    "units", "utilPermille", "throughput100", "avgCost100", "tris"])
        for r in rows:
            w.writerow([r["id"], r["cn"], r["people"], r["workdays"], r["effective100"],
                        r["demand100"], r["units"], r["utilPermille"], r["throughput100"],
                        r["avgCost100"], r["tris"]])

    print(f'factor={factor}permille tiers={tier_cap} misaligned={misaligned}')
    for r in rows:
        print(f'  {r["id"]:<10} ppl={r["people"]:>2} days={r["workdays"]:>2} '
              f'eff100={r["effective100"]:>5} dem100={r["demand100"]:>5} '
              f'util={r["utilPermille"]:>4}permille units={r["units"]:>2} '
              f'thr100={r["throughput100"]:>4} avgCost100={r["avgCost100"]:>3} '
              f'tris={r["tris"]:>6} trisPerDayX100={r["trisPerDayX100"]:>5}')
    print(f'digest={digest}')
    print("wrote out/st14_budget.json out/st14_budget.csv")
    return 1 if misaligned else 0


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

脚本开头就把档位上限与共享真源对齐(misaligned=[] 才继续),这是本套「单一真源」的常规护栏:同一份 visual_spec.json 被四卷、多章消费,任何一处自己写死一个 1500 都会让对账失效。

14.1.4 实跑输出

本机实跑 /opt/homebrew/bin/python3 sim/st14_budget.py(同机相对量级,绝对数值不可移植):

factor=595permille tiers={'prop': 1500, 'npc-minor': 3000, 'npc-major': 6000, 'hero': 12000} misaligned=[]
  casual     ppl= 4 days=10 eff100= 2380 dem100= 1860 util= 781permille units=32 thr100= 134 avgCost100= 58 tris= 52500 trisPerDayX100= 2205
  mid-core   ppl= 6 days=10 eff100= 3570 dem100= 3780 util=1058permille units=43 thr100= 120 avgCost100= 87 tris= 73500 trisPerDayX100= 2058
  hard-core  ppl=12 days=10 eff100= 7140 dem100= 7800 util=1092permille units=61 thr100=  85 avgCost100=127 tris=123000 trisPerDayX100= 1722
  indie      ppl= 2 days=30 eff100= 3570 dem100= 3450 util= 966permille units=34 thr100=  95 avgCost100=101 tris= 60000 trisPerDayX100= 1680
digest=823e80b588fa77d6
wrote out/st14_budget.json out/st14_budget.csv

14.1.5 读表:品类改的是哪几个数

把四行摊成一张表(人天单位是 ×100):

品类人数 × 天有效人天需求人天利用率吞吐平均单件几何预算tris/人天
休闲 casual4 × 1023801860781‰13458525002205
中度 mid-core6 × 10357037801058‰12087735002058
重度 hard-core12 × 10714078001092‰851271230001722
独立 indie2 × 3035703450966‰95101600001680

四条结论:

  • 品类首先改的是「平均单件成本」:58 → 87 → 127,从休闲到重度翻了约 2.2 倍。原因是同一类资产在更重的品类里被放到更高档位、要更多细节与变体。品类预算的差异,大半藏在单件成本里,而不是件数里。
  • 吞吐反向下降:134 → 120 → 85,每有效人天能出的件数从 1.34 掉到 0.85。重度品类不是「多做几件」,而是「每件都更贵」。
  • tris/人天 也在下降:2205 → 2058 → 1722。这有点反直觉——重度资产的面数更高,为什么单位产能出的面数反而更少?因为面数涨得比人天慢:角色头像从 6000 面升到 12000 面(×2),人天却从 400 涨到 500(×1.25)再叠加细节工,净效应是每单位产能承载的几何量下降。这说明「品类越重 → 几何预算越大」这句话只在总量层面成立,在单位产能层面是反的。
  • 独立档用长周期换产能:2 人 × 30 天与 6 人 × 10 天都拿到 3570 有效人天,利用率 966‰ 甚至比中度更松。人数少不等于排不下,但独立档的件数少、单件贵,几何预算只有 60000——省下的是总量,不是单件。

14.1.6 边界

  • 所有数字都是示例参数。团队规模、件数、单件成本都是本套构造的,用来演示算法而非给出可移植的产能基准。真实产能需真实环境(真实人员与项目数据),命令行不可复现(X)。
  • 档位表是风格与平台的函数,不是品类的函数。本节只让「每档要多少件」随品类变;如果项目换平台(移动端 → 主机),档位上限本身要改,那是卷一第 7 章与卷二第 7 章的活。
  • 不做「提升 N 倍」的断言。四行之间是同机、同口径的相对账,换一组示例参数整张表都会变,算法与对账口径不变。
  • 利用率超过 1000‰ 不是错误,是信号。中度 1058‰ 与重度 1092‰ 都超产能,说明这份需求按当前团队规模排不下;能动的杠杆(加人 / 砍范围 / 提系数)在卷二 14.1 已算过,本节只负责把品类这一层接上去。

14.1.7 自检表

检查项合格标准
口径同源有效系数 595‰ 与人天×100 直接复用卷二 14.1,不另造
档位对齐prop / npc-major 上限与 visual_spec.json 的 trisLow / trisMid 逐位一致
全整数人天、件数、面数、千分比全整数,除法一律地板除
边界诚实团队规模与成本标 X(示例参数),不写成「实测」
可对账落 JSON + CSV,同一份数据既是账本又是下游 14.2 / 14.3 的输入

品类这一层接上之后,预算账就完整了:档位决定单件上限,品类决定件数与单件成本,两者相乘才是美术预算。下一节换一个角度,看品类怎样决定「同一份调色板算不算可读」。

阅读导航:上一节:13.3 主题切换与变体管理 · 下一节:14.2 品类与视觉可读性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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