1.1 技术美术的三类工作:工具、管线、性能

技术美术常被当成「会写脚本的美术」或「懂美术的程序」,两种说法都没说清 TA 到底交付什么。本节把 TA 的工作拆成工具、管线、性能三类,落成一份有序归属规则表与一份投入账:为什么「性能分析工具」必须判成性能类,为什么千分比与回本轮数只能用整数地板除,以及一条可跑的 TypeScript 路由器如何与 Python 侧逐位对齐。

站内 渲染原理专题 是渲染原理的家,《渲染引擎》卷首的分工说明 划清了「引擎实现」与「渲染原理」的边界。第一卷 《美术制作与视觉规范》 已经给出美术生产链路与 TA 在其中的位置(见 1.1 美术生产链路 与 1.3 技术美术的位置 )。本卷换一个更贴身的问题:一个 TA 的工时到底花在哪几类事上,这些事凭什么算「同一类」。

三类工作不是三个标签

「工具、管线、性能」听起来像三个工种,其实是同一批工作按主要交付物归的三类。一件工作同时沾边很正常:导入期 lint 既是工具,也是管线里的一个环节。所以分类不是打标签,而是回答「这次交付的主产物是什么」——这条判据不写下来,排期表就会各说各话。

类别主要交付物典型工作项判错的代价
工具 tool可复用的脚本 / 看板 / 报告导入期命名尺寸 lint、清单差异工具把一次性脚本当工具,无人维护
管线 pipeline打通两端的预设与设置DCC 导出预设、导入设置表、CI 门禁把「能跑」当「已收敛」,换人即断
性能 performance可核算的预算与归因面数 / 骨骼 / 材质槽预算、LOD 分级只报超标,不给裁剪顺序

三类里只有「性能」是判定,工具与管线是建设。这条区别决定了账怎么算:建设有回本期,判定没有——判定每做一次就消耗一次。

归属规则表:优先级必须显式

下面这段是 D 层工作项目录,把三类工作记成一份数据:每项的投入(人天 × 100)与交付后每轮能替代的人工量。它是 S 层投入账与 P 层路由器的共同输入。数值是建模输入,不是任何团队的生产实测;真实团队产能与自动化替代率需真实生产环境,命令行不可复现(X)。

{
  "version": 1,
  "project": "stardust-inn",
  "note": "TA 三类工作(工具 tool / 管线 pipeline / 性能 performance)的工作项目录。personDayX100 = 人天 × 100;automatesX100 = 该工作项交付后每轮替代的人工量(人天 × 100);roundsPerQuarter = 每季度复用轮数。全部为「可复现的工作结构模型」口径;真实团队产能与自动化替代率需真实生产环境,命令行不可复现(X)。全整数,无浮点。",
  "roundsPerQuarter": 6,
  "items": [
    { "id": "tool-import-lint", "kind": "tool", "name": "导入期命名与尺寸 lint", "personDayX100": 1400, "automatesX100": 900 },
    { "id": "tool-manifest-diff", "kind": "tool", "name": "清单差异与回滚工具", "personDayX100": 1100, "automatesX100": 700 },
    { "id": "pipeline-export", "kind": "pipeline", "name": "DCC 导出预设与命名收敛", "personDayX100": 1600, "automatesX100": 400 },
    { "id": "pipeline-ci-gate", "kind": "pipeline", "name": "CI 门禁分级与阻断策略", "personDayX100": 1800, "automatesX100": 1000 },
    { "id": "perf-budget-account", "kind": "performance", "name": "面数 / 骨骼 / 材质槽预算账", "personDayX100": 1000, "automatesX100": 300 },
    { "id": "perf-lod-triangle", "kind": "performance", "name": "LOD 分级与三角预算", "personDayX100": 1500, "automatesX100": 400 }
  ]
}

规则表的关键不在匹配词,而在顺序。「性能分析工具」同时含「性能」与「工具」,按关键字出现先后判会判成 tool,按主要交付物判应判成 performance。所以规则表是有序数组,下标即优先级——这条暗规则必须写成显式数据,否则它会在每个评审会上被重新吵一遍。

S 层:三类投入账

同一份 config/ta01_work.json,S 层读它算投入结构与复用收益,全整数地板除、无浮点。本机跑 /opt/homebrew/bin/python3 sim/ta01_workmix.py,真实输出:三类总额 tool=2500 / pipeline=3400 / performance=2500(人天 × 100),千分比 297 / 404 / 297,地板除余数 permilleRemainder=2;六项回本轮数依次 2 / 2 / 4 / 2 / 4 / 4,季度净收益 quarterlyNetX100=13800。主导类别是管线(404‰),而最贵的单项 pipeline-ci-gate(1800)回本只要 2 轮——贵不等于慢,替代量才是分母。真实产能与替代率需真实生产环境,命令行不可复现(X)。

#!/usr/bin/env python3
# file: sim/ta01_workmix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta01_workmix.py
"""TA 三类工作(工具 / 管线 / 性能)的投入结构与复用收益账。

读 config/ta01_work.json,全整数无浮点:① 三类投入总额与千分比(地板除,余数显式
记下);② 回本轮数 ceil(投入 / 每轮替代量),上取整用 (a + b - 1) // b;③ 每季度净
收益 = 替代量 × roundsPerQuarter − 投入。产出 out/ 下 json + csv + svg,另落 canonical
摘要(totals + permille)与 sha256 前 16 位,供 P 层 TS 侧对照。
真实团队产能与自动化替代率需真实生产环境,命令行不可复现(X)。"""
import csv
import hashlib
import json
import os

KINDS = ("tool", "pipeline", "performance")
COLORS = {"tool": "#93c5fd", "pipeline": "#f59e0b", "performance": "#22c55e"}


def analyze(cfg):
    rounds = cfg["roundsPerQuarter"]
    totals = {k: 0 for k in KINDS}
    for it in cfg["items"]:
        totals[it["kind"]] += it["personDayX100"]
    total = sum(totals.values())
    pm = {k: totals[k] * 1000 // total for k in KINDS}
    items = [{"id": it["id"], "kind": it["kind"], "personDayX100": it["personDayX100"],
              "automatesX100": it["automatesX100"],
              "paybackRounds": (it["personDayX100"] + it["automatesX100"] - 1)
              // it["automatesX100"],
              "quarterlyNetX100": it["automatesX100"] * rounds - it["personDayX100"]}
             for it in cfg["items"]]
    canon = json.dumps({"totals": {k: totals[k] for k in KINDS},
                        "permille": {k: pm[k] for k in KINDS}},
                       separators=(",", ":"), ensure_ascii=False)
    return {"version": cfg["version"], "project": cfg["project"], "roundsPerQuarter": rounds,
            "totals": totals, "permille": pm, "permilleRemainder": 1000 - sum(pm.values()),
            "totalPersonDayX100": total, "dominant": max(KINDS, key=lambda k: totals[k]),
            "items": items, "quarterlyNetX100": sum(r["quarterlyNetX100"] for r in items),
            "canonical": canon, "digest": hashlib.sha256(canon.encode()).hexdigest()[:16]}


def write_svg(rep, path):
    """三类投入的横向条形图:条长 = 千分比 × 2,全整数;坐标算好后拼成 SVG。"""
    parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="360" height="168">']
    for i, k in enumerate(KINDS):
        y, ln = 16 + i * 42, rep["permille"][k] * 2
        parts.append(f'<text x="16" y="{y + 12}" fill="#94a3b8" font-family="monospace" '
                     f'font-size="11">{k} {rep["permille"][k]}‰</text>'
                     f'<rect x="112" y="{y}" width="{ln}" height="26" rx="4" '
                     f'fill="{COLORS[k]}"/>')
    parts.append(f'<text x="16" y="162" fill="#94a3b8" font-family="monospace" font-size="10">'
                 f'total={rep["totalPersonDayX100"]} dominant={rep["dominant"]}</text></svg>')
    open(path, "w", encoding="utf-8").write("".join(parts) + "\n")


def main():
    rep = analyze(json.load(open("config/ta01_work.json", encoding="utf-8")))
    os.makedirs("out", exist_ok=True)
    json.dump(rep, open("out/ta01_workmix.json", "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    cols = ("id", "kind", "personDayX100", "automatesX100", "paybackRounds", "quarterlyNetX100")
    with open("out/ta01_workmix.csv", "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(cols)
        for r in rep["items"]:
            wr.writerow([r[c] for c in cols])
    write_svg(rep, "out/ta01_workmix.svg")
    print(json.dumps({"totals": rep["totals"], "permille": rep["permille"],
                      "permilleRemainder": rep["permilleRemainder"],
                      "totalPersonDayX100": rep["totalPersonDayX100"],
                      "dominant": rep["dominant"],
                      "paybackRounds": {r["id"]: r["paybackRounds"] for r in rep["items"]},
                      "quarterlyNetX100": rep["quarterlyNetX100"],
                      "digest": rep["digest"]}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

P 层:归属路由器

下面这段是 P 层 TypeScript 内核 core/src/ta01/work_kind.ts。它把上面的目录变成可断言的函数:classify 按优先级路由一条工作请求,permille / paybackRounds 用整数地板除与 (a + b - 1) / b 上取整,canonical 产出与 Python 侧同键序的摘要。

// file: core/src/ta01/work_kind.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// TA 三类工作(工具 / 管线 / 性能)的归属路由器与投入账内核。
// 三类工作不是互斥标签,而是「主要交付物」的归属;故规则表**有序**——数组顺序即
// 优先级,先命中者胜。没有这条显式优先级,同一句「性能分析工具」会同时命中「性能」
// 与「工具」两条规则,谁先谁后就成了暗规则。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type WorkKind = "tool" | "pipeline" | "performance";

export const WORK_KINDS: WorkKind[] = ["tool", "pipeline", "performance"];

export type WorkRule = { id: string; kind: WorkKind; match: string[] };

/** 有序规则表:下标越小优先级越高。 */
export const WORK_RULES: WorkRule[] = [
  { id: "perf-budget", kind: "performance", match: ["性能", "预算", "面数", "骨骼", "材质槽", "显存"] },
  { id: "perf-lod", kind: "performance", match: ["lod", "三角", "屏幕占比", "剔除"] },
  { id: "perf-batch", kind: "performance", match: ["批次", "合批", "draw call", "drawcall"] },
  { id: "pipe-export", kind: "pipeline", match: ["导出", "dcc", "单位", "轴向", "坐标系"] },
  { id: "pipe-import", kind: "pipeline", match: ["导入", "meta", "运行时"] },
  { id: "pipe-ci", kind: "pipeline", match: ["ci", "门禁", "阻断", "白名单", "回归"] },
  { id: "tool-lint", kind: "tool", match: ["lint", "校验", "检查器", "规则引擎"] },
  { id: "tool-preview", kind: "tool", match: ["预览", "看板", "可视化", "报告"] },
  { id: "tool-diff", kind: "tool", match: ["差异", "diff", "回滚", "清单"] },
];

/** 归属一条工作请求:按优先级返回首个命中规则,全不命中返回 null。 */
export function classify(text: string): WorkRule | null {
  const t = text.toLowerCase();
  for (const r of WORK_RULES) if (r.match.some((m) => t.includes(m))) return r;
  return null;
}

export type WorkItem = {
  id: string;
  kind: WorkKind;
  name: string;
  personDayX100: number;
  automatesX100: number;
};

/** 三类投入总额:整数加总,逐位一致。 */
export function kindTotals(items: WorkItem[]): Record<WorkKind, number> {
  const out: Record<WorkKind, number> = { tool: 0, pipeline: 0, performance: 0 };
  for (const it of items) out[it.kind] += it.personDayX100;
  return out;
}

/** 千分比:整数地板除,禁用浮点。 */
export function permille(part: number, total: number): number {
  return Math.floor((part * 1000) / total);
}

/** 回本轮数:整数上取整 (a + b - 1) / b,禁用 Math.ceil。 */
export function paybackRounds(investX100: number, automatesX100: number): number {
  return Math.floor((investX100 + automatesX100 - 1) / automatesX100);
}

/** canonical 摘要:键序固定 tool → pipeline → performance,无空白。 */
export function canonical(totals: Record<WorkKind, number>, pm: Record<WorkKind, number>): string {
  const t: Record<string, number> = {};
  const p: Record<string, number> = {};
  for (const k of WORK_KINDS) {
    t[k] = totals[k];
    p[k] = pm[k];
  }
  return JSON.stringify({ totals: t, permille: p });
}

负例:关键字先出现不等于归属正确

一个路由器最危险的失败模式是静默错判——把性能工作记成工具工作,投入账整体偏掉,而没有任何一处报错。所以这段 P 层测试的重点全在负例上;跨语言那两条读的是 Python 落盘的 out/ta01_workmix.json,不是同一份源码。

// file: core/test/ta01_work_kind.test.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// 三类工作路由器 + 投入账的断言,并做跨语言对照:TS 侧独立复算 config/ta01_work.json,
// 与 Python(sim/ta01_workmix.py)落盘的 out/ta01_workmix.json 逐位比对,再比 canonical
// 摘要的 sha256。对照的是 Python 的真产物,不是同一份源码。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
  WORK_KINDS, WORK_RULES, classify, kindTotals, permille, paybackRounds, canonical,
} from "../src/ta01/work_kind.ts";
import type { WorkItem, WorkKind } from "../src/ta01/work_kind.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta01_work.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta01_workmix.json"), "utf8"));
const items = cfg.items as WorkItem[];
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
const totals = kindTotals(items);
const total = totals.tool + totals.pipeline + totals.performance;
const pm: Record<WorkKind, number> = { tool: 0, pipeline: 0, performance: 0 };
for (const k of WORK_KINDS) pm[k] = permille(totals[k], total);

test("规则表:id 唯一、每类至少一条、match 非空", () => {
  const ids = WORK_RULES.map((r) => r.id);
  assert.equal(new Set(ids).size, ids.length, "规则 id 重复");
  for (const r of WORK_RULES) assert.ok(r.match.length > 0, `${r.id} 无匹配词`);
  for (const k of WORK_KINDS) assert.ok(WORK_RULES.some((r) => r.kind === k), `${k} 无规则`);
});

test("正例:三类工作各自可被命中", () => {
  assert.equal(classify("面数与骨骼预算")?.kind, "performance");
  assert.equal(classify("DCC 导出单位对齐")?.kind, "pipeline");
  assert.equal(classify("命名 lint 检查器")?.kind, "tool");
});

test("负例:朴素路由器按关键字先后判,真规则表必须按优先级判", () => {
  // 朴素路由器:谁的关键字先出现就算谁 —— 「工具」在句尾,却被它先判成 tool
  const naive = (t: string): WorkKind =>
    t.includes("工具") ? "tool" : t.includes("管线") ? "pipeline" : "performance";
  assert.equal(naive("性能分析工具"), "tool"); // 朴素路由器确实会错判
  const r = classify("性能分析工具");
  assert.ok(r);
  assert.equal(r.kind, "performance", `应判 performance,实得 ${r.kind}`);
  assert.equal(r.id, "perf-budget");
});

test("负例:未覆盖主题返回 null,而不是静默归到某一类", () => {
  assert.equal(classify("完全没听过的主题域"), null);
  assert.equal(classify(""), null);
});

test("跨语言:总额、千分比、地板余数、回本轮数逐位一致(整数类)", () => {
  assert.deepEqual(totals, py.totals);
  assert.equal(total, py.totalPersonDayX100);
  assert.equal(cfg.roundsPerQuarter, py.roundsPerQuarter);
  assert.deepEqual(pm, py.permille);
  assert.equal(1000 - (pm.tool + pm.pipeline + pm.performance), py.permilleRemainder);
  for (const it of items) {
    const mine = paybackRounds(it.personDayX100, it.automatesX100);
    assert.equal(mine, py.items.find((r: { id: string }) => r.id === it.id).paybackRounds, it.id);
  }
});

test("跨语言:canonical 摘要与 digest 完全一致", () => {
  assert.equal(canonical(totals, pm), py.canonical);
  assert.equal(sha16(canonical(totals, pm)), py.digest);
});

test("地板除锁死:千分比与上取整不得用浮点", () => {
  assert.equal(permille(1, 3), 333);
  assert.equal(permille(2, 3), 666);
  assert.notEqual(permille(2, 3), 667);
  assert.equal(paybackRounds(1600, 400), 4);
  assert.equal(paybackRounds(1501, 400), 4); // ceil(3.7525) = 4
  assert.equal(paybackRounds(1500, 500), 3);
});

在本机跑 node --test 'core/test/ta01_*.test.ts',真实输出(同机相对量级,耗时不可移植):

✔ 规则表:id 唯一、每类至少一条、match 非空 (0.551458ms)
✔ 正例:三类工作各自可被命中 (0.107833ms)
✔ 负例:朴素路由器按关键字先后判,真规则表必须按优先级判 (0.073125ms)
✔ 负例:未覆盖主题返回 null,而不是静默归到某一类 (0.066667ms)
✔ 跨语言:总额、千分比、地板余数、回本轮数逐位一致(整数类) (0.461833ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.337292ms)
✔ 地板除锁死:千分比与上取整不得用浮点 (0.076083ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

三条负例各守一个方向:性能分析工具 守住「按主要交付物判」,未覆盖主题返回 null 守住「不知道就别猜」,地板除锁死 守住「别用浮点」。任何一条从「拦下」变成「放行」,投入账就已经不可信了。

自检表

检查项合格标准
分类可判每条工作请求落到唯一一类,或明确返回「未覆盖」
优先级显式规则表有序且写在数据里,不靠口头约定
账可复算千分比、余数、回本轮数全整数,跨语言逐位一致
边界清晰真实产能与替代率标 X,不写成「实测」

三类工作里最反直觉的一条是:投入最多的那类不一定是回本最慢的那类。下一节把「三类工作」落到人身上——同一份能力清单,不同职级的达标线差在哪,以及为什么「平均分够」会掩盖一个硬缺口。

阅读导航:上一节:目录 · 下一节:1.2 能力矩阵与产出物 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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