7.3 预算超支的归因与裁剪顺序

超支之后先归因再动手:把每个超支项拆成「可裁空间」与「不可裁地板」,再按每 1 人天换回的单位数排一条确定性、可重复的裁剪顺序。本节给出可跑的三类归因判定、贪心裁剪计划,以及「超支项裁剪前必须真的 fail、不可裁项裁剪后仍必须 fail」的负例断言。

7.3.1 超支之后先归因,再动手

7.1 判出了 5 个超限资产,7.2 判出了拆槽变体超预算。知道「超了」之后,最容易犯的错是直接开始删面——删掉的往往是设计上不能动的地方,改到一半才发现还是超标。正确顺序是先归因:这一项超了多少、其中有多少是可裁的、有多少是地板。

工程与生产管理口径的预算散文(移动端与 PC 的预算口径 、性能优化的生产管理 )讲的是「谁该为超支负责、怎么排期」;本节把它做成一份可跑的裁剪计划:给定超支项与候选动作,算出一条完全确定性、可重复的裁剪顺序,并明确标出裁不动的项。承 7.1 的分级账与 7.2 的合槽结论——「合槽」正是本节里性价比最高的一类动作。

7.3.2 可裁与不可裁

每个超支项只有三个整数要算:

量定义含义
neededvalue − cap必须砍掉的量,砍不够就是没解决
cullablevalue − lockedFloor可裁空间:删面、降 LOD、合槽能动的部分
actionableneeded ≤ cullable是否裁得动

lockedFloor 是不可裁的地板:剪影、设计识别度、剧情要求锁死的量。于是超支归成三类:

  • 可裁(needed ≤ cullable):按裁剪顺序取动作,取到累计 saving ≥ needed 即停,裁完转 PASS。
  • 不可裁(needed > cullable):动作全做完还是超,只能升级预算或改设计,标 ESCALATE。
  • 归错档:value 落在更高一档的上限内,说明是分级判错,改档即可,不必裁。

裁剪顺序不看「哪个资产重要」,只看性价比 ratio = saving × 100 // cost(cost 单位是人天 ×100,即每 1 人天换回多少单位)。全局按 ratio 降序排,比值相同按 saving 降序、再按 id 升序——无随机,两次跑结果必须逐字节相同。

7.3.3 裁剪计划配置

配置里 5 个超支项、8 个候选动作。注意 furn_wardrobe-base_v01 是故意留的不可裁案例:它只有 80 的可裁空间,却要砍 180。

{
  "project": "stardust-inn",
  "ruleSet": "trim",
  "specVersion": 1,
  "specRef": "config/sp07_budget.json",
  "unit": "tris",
  "note": "超支归因与裁剪顺序。每个超支项给出 value(现状)、lockedFloor(不可裁的地板:设计/剪影/剧情要求,裁不下去)与一组 actions。cullable = value - lockedFloor 是可裁空间;needed = value - cap 是必须砍掉的量。若 needed > cullable 就是「不可裁超支」,只能升级预算或改设计。裁剪顺序 = 全局按 ratio = saving*100//cost(每 1 人天换回的单位数)降序,比值相同按 saving 降序、再按 id 升序——完全确定性、无随机。cost 单位是人天×100。",
  "items": [
    { "id": "char_innkeeper-coat_v01", "tier": "hero", "metric": "tris", "value": 12480, "lockedFloor": 11800,
      "actions": [
        { "action": "LOD1 减面", "saving": 300, "cost": 200 },
        { "action": "删除内衬面片", "saving": 400, "cost": 500 },
        { "action": "合槽:外套并入 skin", "saving": 260, "cost": 100 } ] },
    { "id": "char_guest-bard_v01", "tier": "npc-major", "metric": "tris", "value": 6340, "lockedFloor": 5700,
      "actions": [
        { "action": "LOD1 减面", "saving": 200, "cost": 150 },
        { "action": "简化挂饰", "saving": 300, "cost": 400 } ] },
    { "id": "char_townsfolk-b_v01", "tier": "npc-minor", "metric": "tris", "value": 3120, "lockedFloor": 2900,
      "actions": [
        { "action": "合并 3 个帽饰面片", "saving": 150, "cost": 80 } ] },
    { "id": "furn_wardrobe-base_v01", "tier": "prop", "metric": "tris", "value": 1680, "lockedFloor": 1600,
      "actions": [
        { "action": "降低抽屉细节", "saving": 80, "cost": 60 } ] },
    { "id": "prop_teapot-base_v01", "tier": "prop", "metric": "bones", "value": 12, "lockedFloor": 0,
      "actions": [
        { "action": "移除蒙皮(道具不需骨骼)", "saving": 12, "cost": 50 } ] }
  ]
}

prop_teapot-base_v01 超的是 bones(道具档上限 0,却蒙了 12 根骨骼)——这类超支的裁剪空间是满的(lockedFloor = 0),一删到底。

7.3.4 S 层:确定性裁剪顺序

下面这段是 S 层脚本,先全局排顺序,再逐资产贪心取动作,落 out/sp07_trim.json:

#!/usr/bin/env python3
# file: sim/sp07_trim.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp07_trim.py
"""7.3 超支归因与裁剪顺序(S 层)。

每个超支项拆成两类:cullable(可裁空间 = value - lockedFloor)与 lockedFloor(不可裁的
地板:设计/剪影/剧情要求);needed = value - cap 是必须砍掉的量。裁剪顺序 = 全局按
ratio = saving*100//cost(每 1 人天换回的单位数)降序,比值相同按 saving 降序、再按 id
升序——完全确定性、无随机。逐资产按此顺序贪心取动作,直到累计 saving >= needed;
needed > cullable 的项裁不下去,判 escalate。落 out/sp07_trim.json,供 P 层对照。
"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
METRIC_OF = {"tris": "maxTris", "bones": "maxBones", "materialSlots": "maxMaterialSlots"}


def ratio(saving, cost):
    """每 1 人天(cost = 人天×100)换回的单位数,整数地板除。"""
    return (saving * 100) // cost


def main():
    plan = json.load(open(os.path.join(ROOT, "config/sp07_trim.json"), encoding="utf-8"))
    budget = json.load(open(os.path.join(ROOT, "config/sp07_budget.json"), encoding="utf-8"))
    cap = {t["tier"]: t for t in budget["tiers"]}
    actions = sorted(
        ({"id": it["id"], "metric": it["metric"], "action": a["action"], "saving": a["saving"],
          "cost": a["cost"], "ratio": ratio(a["saving"], a["cost"])}
         for it in plan["items"] for a in it["actions"]),
        key=lambda a: (-a["ratio"], -a["saving"], a["id"], a["action"]))
    results = []
    for it in sorted(plan["items"], key=lambda i: i["id"]):
        c = cap[it["tier"]][METRIC_OF[it["metric"]]]
        needed, cullable = it["value"] - c, it["value"] - it["lockedFloor"]
        acc, picked = 0, []
        for a in actions:
            if a["id"] == it["id"] and a["metric"] == it["metric"] and acc < needed:
                picked.append(a["action"])
                acc += a["saving"]
        after = it["value"] - acc
        results.append({"id": it["id"], "tier": it["tier"], "metric": it["metric"], "value": it["value"],
                        "cap": c, "needed": needed, "lockedFloor": it["lockedFloor"], "cullable": cullable,
                        "actionable": needed <= cullable, "picked": picked, "saved": acc, "after": after,
                        "pass": after <= c})
    escalated = [r["id"] for r in results if not r["pass"]]
    summary = {"project": plan["project"], "unit": plan["unit"], "itemCount": len(results),
               "actionCount": len(actions),
               "order": [f"{a['id']}:{a['metric']}:{a['action']}" for a in actions], "results": results,
               "fixed": len(results) - len(escalated), "escalated": len(escalated),
               "escalateIds": escalated, "totalSaved": sum(r["saved"] for r in results)}
    with open(os.path.join(ROOT, "out/sp07_trim.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"items={summary['itemCount']} actions={summary['actionCount']} fixed={summary['fixed']} "
          f"escalated={summary['escalated']} totalSaved={summary['totalSaved']}")
    print("裁剪顺序(ratio 降序):")
    for a in actions:
        print(f"  r={a['ratio']:<4} {a['id']} {a['action']} (saving {a['saving']}, cost {a['cost']})")
    for r in results:
        print(f"  {'PASS' if r['pass'] else 'ESCALATE':<9} {r['id']} {r['metric']} {r['value']}->{r['after']} "
              f"(cap {r['cap']}, saved {r['saved']}, picked {len(r['picked'])})")
    print("wrote out/sp07_trim.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/sp07_trim.py:

items=5 actions=8 fixed=4 escalated=1 totalSaved=1302
裁剪顺序(ratio 降序):
  r=260  char_innkeeper-coat_v01 合槽:外套并入 skin (saving 260, cost 100)
  r=187  char_townsfolk-b_v01 合并 3 个帽饰面片 (saving 150, cost 80)
  r=150  char_innkeeper-coat_v01 LOD1 减面 (saving 300, cost 200)
  r=133  char_guest-bard_v01 LOD1 减面 (saving 200, cost 150)
  r=133  furn_wardrobe-base_v01 降低抽屉细节 (saving 80, cost 60)
  r=80   char_innkeeper-coat_v01 删除内衬面片 (saving 400, cost 500)
  r=75   char_guest-bard_v01 简化挂饰 (saving 300, cost 400)
  r=24   prop_teapot-base_v01 移除蒙皮(道具不需骨骼) (saving 12, cost 50)
  PASS      char_guest-bard_v01 tris 6340->5840 (cap 6000, saved 500, picked 2)
  PASS      char_innkeeper-coat_v01 tris 12480->11920 (cap 12000, saved 560, picked 2)
  PASS      char_townsfolk-b_v01 tris 3120->2970 (cap 3000, saved 150, picked 1)
  ESCALATE  furn_wardrobe-base_v01 tris 1680->1600 (cap 1500, saved 80, picked 1)
  PASS      prop_teapot-base_v01 bones 12->0 (cap 0, saved 12, picked 1)
wrote out/sp07_trim.json

读法有三处值得注意。第一,性价比最高的是「合槽」(r=260),它同时缓解 7.1 的材质槽与 7.2 的 Draw Call——这也解释了为什么 7.2 的拆槽反例代价那么大。第二,贪心是按需取、不是全取:旅店老板可裁空间有 680,但只需要砍 480,所以只取了 合槽(260)+ LOD1(300)= 560 就停了,代价最高的「删除内衬面片」(r=80)没被动。第三,furn_wardrobe-base_v01 是 ESCALATE:可裁只有 80、需要 180,动作全做完还是 1600 > 1500——这一项必须回给设计,不能硬裁。

7.3.5 P 层内核

同一套排序与贪心规则,TypeScript 侧独立实现一遍:

// file: core/src/sp07/trim.ts
// tier: P
// verify: node --test 'core/test/sp07_*.test.ts'
// 7.3 超支归因与裁剪顺序的 TS 内核。与 sim/sp07_trim.py 同一套规则:cullable =
// value - lockedFloor,needed = value - cap;裁剪顺序 = 全局按 ratio = saving*100//cost
// 降序(比值相同按 saving 降序、再按 id 升序),逐资产贪心取到刚好不超支。
// 完全确定性、无随机。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type TrimAction = { action: string; saving: number; cost: number };
export type TrimItem = {
  id: string; tier: string; metric: string; value: number; lockedFloor: number; actions: TrimAction[];
};
export type RankedAction = { id: string; metric: string; action: string; saving: number; cost: number; ratio: number };
export type TrimResult = {
  id: string; tier: string; metric: string; value: number; cap: number; needed: number;
  lockedFloor: number; cullable: number; actionable: boolean; picked: string[];
  saved: number; after: number; pass: boolean;
};
export const METRIC_OF: Record<string, string> = {
  tris: "maxTris", bones: "maxBones", materialSlots: "maxMaterialSlots",
};

/** 每 1 人天(cost = 人天×100)换回的单位数,整数地板除。 */
export function ratio(saving: number, cost: number): number {
  return Math.floor((saving * 100) / cost);
}

export function rankActions(items: TrimItem[]): RankedAction[] {
  const out: RankedAction[] = [];
  for (const it of items) {
    for (const a of it.actions) {
      out.push({ id: it.id, metric: it.metric, action: a.action, saving: a.saving, cost: a.cost,
                 ratio: ratio(a.saving, a.cost) });
    }
  }
  return out.sort((x, y) =>
    y.ratio - x.ratio || y.saving - x.saving ||
    (x.id < y.id ? -1 : x.id > y.id ? 1 : x.action < y.action ? -1 : x.action > y.action ? 1 : 0));
}

/** 逐资产按全局顺序贪心裁剪,直到累计 saving >= needed。 */
export function planTrim(items: TrimItem[], tiers: Record<string, Record<string, number>>): TrimResult[] {
  const ranked = rankActions(items);
  const results: TrimResult[] = [];
  for (const it of [...items].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
    const cap = tiers[it.tier][METRIC_OF[it.metric]];
    const needed = it.value - cap;
    const cullable = it.value - it.lockedFloor;
    let acc = 0;
    const picked: string[] = [];
    for (const a of ranked) {
      if (a.id !== it.id || a.metric !== it.metric || acc >= needed) continue;
      picked.push(a.action);
      acc += a.saving;
    }
    const after = it.value - acc;
    results.push({ id: it.id, tier: it.tier, metric: it.metric, value: it.value, cap, needed,
                   lockedFloor: it.lockedFloor, cullable, actionable: needed <= cullable,
                   picked, saved: acc, after, pass: after <= cap });
  }
  return results;
}

7.3.6 P 层测试:负例断言

这一节的测试负例比正例更重要——它必须证明「超支项裁剪前真的 fail」「不可裁项裁剪后仍然 fail」,否则整套裁剪逻辑就是自说自话:

// file: core/test/sp07_trim.test.ts
// tier: P
// verify: node --test 'core/test/sp07_*.test.ts'
// 跨语言对照:读 sim/sp07_trim.py 落盘的 out/sp07_trim.json,断言 TS 侧的裁剪顺序与结果
// 逐位一致。**负例是本节核心**:超支项裁剪前必须真的 fail,不可裁项裁剪后仍然 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { planTrim, rankActions, ratio, METRIC_OF } from "../src/sp07/trim.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const plan = JSON.parse(readFileSync(resolve(ROOT, "config/sp07_trim.json"), "utf8"));
const budget = JSON.parse(readFileSync(resolve(ROOT, "config/sp07_budget.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp07_trim.json"), "utf8"));
const tiers: Record<string, Record<string, number>> = {};
for (const t of budget.tiers) tiers[t.tier] = t;

test("裁剪顺序与结果:TS 复算与 Python 产物逐位一致", () => {
  assert.deepEqual(rankActions(plan.items).map((a) => `${a.id}:${a.metric}:${a.action}`), py.order);
  const mine = planTrim(plan.items, tiers);
  assert.deepEqual(mine, py.results);
  assert.equal(py.itemCount, plan.items.length);
  assert.equal(py.totalSaved, mine.reduce((s, r) => s + r.saved, 0));
  assert.equal(py.fixed, mine.filter((r) => r.pass).length);
  assert.equal(py.escalated, mine.filter((r) => !r.pass).length);
});

test("负例:裁剪前每个超支项都必须真的 fail(value > cap)", () => {
  for (const it of plan.items) {
    const cap = tiers[it.tier][METRIC_OF[it.metric]];
    assert.ok(it.value > cap, `${it.id} 裁剪前应超限:${it.value} > ${cap}`);
  }
  assert.equal(plan.items.length, 5);
});

test("负例:不可裁超支裁剪后仍 fail,且必须被标 escalate", () => {
  const w = py.results.find((r: any) => r.id === "furn_wardrobe-base_v01");
  assert.equal(w.actionable, false);
  assert.equal(w.cullable, 80);
  assert.equal(w.needed, 180);
  assert.equal(w.pass, false);
  assert.equal(w.after, 1600);
  assert.deepEqual(py.escalateIds, ["furn_wardrobe-base_v01"]);
});

test("正例:可裁项按 ratio 降序贪心,裁到刚好不超支即停(无随机、可重复)", () => {
  const a = rankActions(plan.items);
  for (let i = 1; i < a.length; i += 1) assert.ok(a[i - 1].ratio >= a[i].ratio, `第 ${i} 项 ratio 应非递增`);
  assert.equal(ratio(260, 100), 260);
  assert.equal(ratio(400, 500), 80);
  const inn = py.results.find((r: any) => r.id === "char_innkeeper-coat_v01");
  assert.deepEqual(inn.picked, ["合槽:外套并入 skin", "LOD1 减面"]);
  assert.equal(inn.saved, 560);
  assert.equal(inn.after, 11920);
  assert.equal(inn.pass, true);
  assert.deepEqual(planTrim(plan.items, tiers), planTrim(plan.items, tiers));
});

实跑 node --test 'core/test/sp07_*.test.ts'(此处只列本节相关的 4 条):

✔ 裁剪顺序与结果:TS 复算与 Python 产物逐位一致
✔ 负例:裁剪前每个超支项都必须真的 fail(value > cap)
✔ 负例:不可裁超支裁剪后仍 fail,且必须被标 escalate
✔ 正例:可裁项按 ratio 降序贪心,裁到刚好不超支即停(无随机、可重复)
ℹ tests 11
ℹ pass 11
ℹ fail 0

最后一条测试里的 assert.deepEqual(planTrim(...), planTrim(...)) 是确定性自证:同一输入跑两遍必须完全相同——这正是「无随机」这条纪律的可执行形式。

7.3.7 边界与取舍

  • 真实 Draw Call 与真实帧时间只能由引擎 Profiler 采集(RenderDoc / Unity Frame Debugger / Xcode GPU Capture),本机无引擎、无 Profiler,标 X:需真实环境,命令行不可复现。本节的 saving / cost 是美术侧的估算台账,不是采集数据。
  • cost(人天 ×100)与 saving(单位数)都需要美术组长与实际制作人校准,本节的数字是示例口径。换一组数字,排序会变——但排序规则本身不变。
  • ratio 只按性价比排序,不考虑动作之间的依赖(如「先合槽再减面」比反过来省事)。真实排期还要叠加依赖与人力约束。
  • 三节合起来是一条闭环:7.1 定分级上限 → 7.2 把材质槽折算成 Draw Call → 7.3 在超支时给出确定性裁剪顺序与升级判据。下一章转向纹理与资源体积,把预算从几何面数扩展到显存与包体。

阅读导航:上一节:7.2 材质槽与 Draw Call 预算 · 下一节:8.1 纹理分辨率与 mip 链字节账 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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