站内 渲染原理专题 是渲染原理的家,《渲染引擎》卷首的分工说明 划清了「引擎实现」与「渲染原理」的边界。第一卷 《美术制作与视觉规范》 已经给出美术生产链路与 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 能力矩阵与产出物 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。