1.1 美术生产链路:概念 → 制作 → 接入 → 验收

美术资产从一句需求到进引擎,要穿过概念、制作、接入、验收四道门。本节把这条链路写成一份可校验的 schema:每段的出口交付物、缺件时卡在哪、以及一个可跑的 TypeScript 门禁校验器与它的负例测试,最后给出卡点归因与一份可照做的裁剪顺序。

站内 渲染原理专题 (45 篇)是渲染原理的家——光栅化、着色、后处理、色彩管理 HDR 都在那里讲透;《渲染引擎》卷首的分工说明 把「引擎实现」与「渲染原理」的边界划得很清楚。本套不重推任何一条渲染原理,也不重推引擎实现:本书只回答一个更靠前、也更常被跳过的问题——一件美术资产,凭什么算「做完了」。原理归 graphics/,实现归引擎卷,规范归这一卷。

四段链路,不是四个部门

新手最容易犯的错,是把「概念 → 制作 → 接入 → 验收」当成四个部门的先后顺序:概念组做完交给制作组,制作组做完交给程序。真做起来你会发现,这四段是一条链路上的四道门,每道门只认「出口交付物齐不齐」,跟谁坐在哪个工位无关。一个人可以连过两道门,一个部门也可能同时卡在两道门上。

阶段一句话定义出口交付物(缺一即卡)常见误区
概念 concept把形容词变成可对齐的画面参考板 moodboard、风格帧 styleframe用「差不多这样」代替参考图
制作 production产出符合规格的资产本体模型 model、贴图 texture边做边改风格,返工吃掉预算
接入 integration让资产在引擎里被正确引用导出件 export、清单项 manifest把「导入报错」当成程序的问题
验收 acceptance判定资产是否可发布截图 screenshot、签字 signoff进引擎后才发现不合规

四段里只有「制作」是真在画画,另外三段都是「判定」。这正是本章要立的第一条规矩:判定必须有交付物作凭据,不能靠口头。

每段的出口交付物:一份可校验的链路 schema

下面这段是 D 层链路 schema,把四段链路的容量与出口交付物记成一份数据。它是后面所有门禁判定的输入,也是本套「一份规范、三处消费」里被校验器消费的那一份。数值是建模输入,不是任何团队的生产实测。

{
  "version": 1,
  "project": "stardust-inn",
  "note": "美术生产链路的阶段容量与需求。capacityPerWeek=每阶段每周可处理的资产件数(整数,建模假设);demand=一次性投放的需求件数;reworkPermille=验收阶段返工回制作阶段的千分比;weeks=仿真周数。全部为「可复现的链路模型」口径;真实画师产能与返工率需真实生产环境,命令行不可复现(X)。全整数,无浮点。",
  "demand": 96,
  "weeks": 20,
  "reworkPermille": 180,
  "reworkTarget": "production",
  "stages": [
    { "id": "concept", "name": "概念", "capacityPerWeek": 12, "exit": ["moodboard", "styleframe"] },
    { "id": "production", "name": "制作", "capacityPerWeek": 9, "exit": ["model", "texture"] },
    { "id": "integration", "name": "接入", "capacityPerWeek": 15, "exit": ["export", "manifest"] },
    { "id": "acceptance", "name": "验收", "capacityPerWeek": 18, "exit": ["screenshot", "signoff"] }
  ]
}

读这份 schema 要抓住两点。第一,exit 数组就是门禁清单:concept 段没有 styleframe,就不许进 production——不是「建议补」,是「物理上过不去」。第二,reworkPermille 是返工回流:验收不合格的件按千分比退回制作段,这条边是链路里最贵的,因为它同时吃掉制作产能和验收产能。

S 层:链路吞吐仿真

同一份 config/sp01_pipeline.json,P 层读它做门禁判定,S 层读它做吞吐仿真——这正是「一份规范、多处消费」。全整数地板除,无浮点。本机跑 /opt/homebrew/bin/python3 sim/sp01_pipeline_flow.py,真实输出:瓶颈在 production(9 件/周),峰值在制品 peakWip=96,返工 totalRework=12,第 17 周才全部验收完毕(completionWeek=17)。96 件需求被 9 件/周的制作产能死死卡住——这就是为什么「加人」往往无效:瓶颈不在人最多的那一段。真实画师产能与返工率需真实生产环境,命令行不可复现(X)。

#!/usr/bin/env python3
# file: sim/sp01_pipeline_flow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp01_pipeline_flow.py
"""美术生产链路吞吐仿真:读 config/sp01_pipeline.json,用整数地板除算四段链路
(概念→制作→接入→验收)的周吞吐、在制品 WIP、返工量与完成周数。需求在周 0 一次性
投放第一阶段,每阶段每周最多 capacityPerWeek 件,同一周从后往前结算(禁止同周穿透),
验收按 reworkPermille‰ 返工回 reworkTarget。全整数、无浮点。真实画师产能与返工率需
真实生产环境,命令行不可复现(X)。产出 out/ 下 json + csv + svg。"""
import csv
import json
import os


def simulate(stages, demand, weeks, rp, target):
    order = [s["id"] for s in stages]
    cap = {s["id"]: s["capacityPerWeek"] for s in stages}
    q = dict.fromkeys(order, 0)
    q[order[0]] = demand
    acc = rew = 0
    weekly = []
    for w in range(weeks):
        for sid in reversed(order):
            n = min(cap[sid], q[sid])
            q[sid] -= n
            i = order.index(sid)
            if i + 1 < len(order):
                q[order[i + 1]] += n
            else:
                b = n * rp // 1000
                q[target] += b
                rew += b
                acc += n - b
        weekly.append({"week": w + 1, "wip": sum(q.values()), "accepted": acc, "rework": rew})
    return weekly


def main():
    cfg = json.load(open("config/sp01_pipeline.json", encoding="utf-8"))
    st, dm, wk = cfg["stages"], cfg["demand"], cfg["weeks"]
    weekly = simulate(st, dm, wk, cfg["reworkPermille"], cfg["reworkTarget"])
    bn = min(st, key=lambda s: s["capacityPerWeek"])
    last = weekly[-1]
    comp = next((r["week"] for r in weekly if r["accepted"] >= dm), None)
    os.makedirs("out", exist_ok=True)
    svg = '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="256">' % (8 + 30 * len(weekly))
    svg += "".join(f'<rect x="{8 + i * 30}" y="{236 - 2 * r["wip"]}" width="24" height="{2 * r["wip"]}"'
                   f' fill="#93c5fd"/>' for i, r in enumerate(weekly))
    open("out/sp01_pipeline_flow.svg", "w", encoding="utf-8").write(svg + "</svg>")
    rep = {"version": cfg["version"], "demand": dm, "weeks": wk, "bottleneck": bn["id"],
           "bottleneckCapacity": bn["capacityPerWeek"], "reworkPermille": cfg["reworkPermille"],
           "peakWip": max(r["wip"] for r in weekly), "totalRework": last["rework"],
           "totalAccepted": last["accepted"], "completionWeek": comp, "weekly": weekly}
    json.dump(rep, open("out/sp01_pipeline_flow.json", "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    with open("out/sp01_pipeline_flow.csv", "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["week", "wip", "accepted", "rework"])
        for r in weekly:
            wr.writerow([r["week"], r["wip"], r["accepted"], r["rework"]])
    print(json.dumps({k: rep[k] for k in ("bottleneck", "bottleneckCapacity", "peakWip", "totalRework",
                                          "totalAccepted", "completionWeek")}, ensure_ascii=False, indent=2))
    return 0


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

P 层:阶段门禁校验器

下面这段是 P 层 TypeScript 内核 core/src/sp01/pipeline.ts。它把上面的 schema 变成可断言的函数:canAdvance 判断一件资产能否推进到下一阶段,pipelineStatus 做整批资产的卡点归因。

// file: core/src/sp01/pipeline.ts
// tier: P
// verify: node --test 'core/test/sp01_*.test.ts'
// 美术生产链路的阶段门禁校验器:概念 → 制作 → 接入 → 验收。
// 每个阶段有「出口交付物」清单;资产只有交付物齐全才允许推进到下一阶段。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type StageId = "concept" | "production" | "integration" | "acceptance";
export type DeliverableId =
  | "moodboard" | "styleframe"
  | "model" | "texture"
  | "export" | "manifest"
  | "screenshot" | "signoff";

export const STAGE_ORDER: StageId[] = ["concept", "production", "integration", "acceptance"];

/** 每个阶段的出口交付物:缺任何一项即「卡在门禁前」。 */
export const STAGE_GATES: Record<StageId, DeliverableId[]> = {
  concept: ["moodboard", "styleframe"],
  production: ["model", "texture"],
  integration: ["export", "manifest"],
  acceptance: ["screenshot", "signoff"],
};

export type AssetRecord = {
  id: string;
  stage: StageId;
  deliverables: DeliverableId[];
};

export function stageIndex(s: StageId): number {
  return STAGE_ORDER.indexOf(s);
}

export function gateFor(s: StageId): DeliverableId[] {
  return STAGE_GATES[s];
}

/** 资产能否从当前阶段推进到下一阶段:出口交付物必须齐全,且当前不是终点。 */
export function canAdvance(rec: AssetRecord): { ok: boolean; next: StageId | null; missing: DeliverableId[] } {
  const i = stageIndex(rec.stage);
  const missing = STAGE_GATES[rec.stage].filter((d) => !rec.deliverables.includes(d));
  const next = i + 1 < STAGE_ORDER.length ? STAGE_ORDER[i + 1] : null;
  return { ok: missing.length === 0 && next !== null, next, missing };
}

/** 链路完整度:各阶段资产分布 + 卡点(缺交付物最多的阶段,平局取更靠前的阶段)。 */
export function pipelineStatus(recs: AssetRecord[]): {
  byStage: Record<StageId, number>;
  blocked: number;
  bottleneck: StageId;
} {
  const byStage: Record<StageId, number> = { concept: 0, production: 0, integration: 0, acceptance: 0 };
  const miss: Record<StageId, number> = { concept: 0, production: 0, integration: 0, acceptance: 0 };
  let blocked = 0;
  for (const r of recs) {
    byStage[r.stage] += 1;
    const m = canAdvance(r);
    if (!m.ok) {
      blocked += 1;
      miss[r.stage] += 1;
    }
  }
  let bottleneck: StageId = STAGE_ORDER[0];
  for (const s of STAGE_ORDER) {
    if (miss[s] > miss[bottleneck]) bottleneck = s;
  }
  return { byStage, blocked, bottleneck };
}

这里有一个刻意的设计:canAdvance 在终点阶段(acceptance)永远返回 ok: false,因为 next 是 null。别把它当 bug——「没有下一阶段可推」本身就是链路走完的信号,把终点当成可推进会让卡点归因把已完成的资产也数进去。

负例:缺交付物必须真的被拦下

一个门禁最危险的失败模式不是「报错」,而是该拦的没拦。所以这段 P 层测试的重点全在负例上。

// file: core/test/sp01_pipeline.test.ts
// tier: P
// verify: node --test 'core/test/sp01_*.test.ts'
// 阶段门禁的断言:D 层链路配置与 TS 门禁一致、四段顺序固定、交付物齐全才推进、终点无下一阶段。
// 负例是重点 —— 缺交付物必须真的被拦下,不能静默通过。
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 {
  STAGE_ORDER,
  STAGE_GATES,
  stageIndex,
  gateFor,
  canAdvance,
  pipelineStatus,
} from "../src/sp01/pipeline.ts";
import type { AssetRecord } from "../src/sp01/pipeline.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp01_pipeline.json"), "utf8"));

test("D 层链路配置与 TS 门禁一致(防漂移)", () => {
  assert.deepEqual(cfg.stages.map((s: { id: string }) => s.id), STAGE_ORDER);
  for (const s of cfg.stages) assert.deepEqual(STAGE_GATES[s.id], s.exit, `${s.id} 门禁不一致`);
});

test("阶段顺序固定为四段", () => {
  assert.deepEqual(STAGE_ORDER, ["concept", "production", "integration", "acceptance"]);
  assert.equal(stageIndex("concept"), 0);
  assert.equal(stageIndex("acceptance"), 3);
});

test("每段门禁非空且交付物不重复", () => {
  for (const s of STAGE_ORDER) {
    const g = gateFor(s);
    assert.ok(g.length >= 2, `${s} 门禁过少`);
    assert.equal(new Set(g).size, g.length, `${s} 交付物重复`);
  }
  assert.deepEqual(STAGE_GATES.concept, ["moodboard", "styleframe"]);
});

test("交付物齐全才允许推进,且给出下一阶段", () => {
  const ok = canAdvance({ id: "a", stage: "concept", deliverables: ["moodboard", "styleframe"] });
  assert.equal(ok.ok, true);
  assert.equal(ok.next, "production");
  assert.deepEqual(ok.missing, []);
});

test("负例:缺 styleframe 时概念阶段不得推进", () => {
  const bad = canAdvance({ id: "b", stage: "concept", deliverables: ["moodboard"] });
  assert.equal(bad.ok, false);
  assert.notEqual(bad.ok, true);
  assert.deepEqual(bad.missing, ["styleframe"]);
});

test("负例:验收是链路终点,无下一阶段", () => {
  const end = canAdvance({
    id: "c",
    stage: "acceptance",
    deliverables: ["screenshot", "signoff"],
  });
  assert.equal(end.next, null);
  assert.equal(end.ok, false);
});

test("卡点归因:缺交付物最多的阶段被识别", () => {
  const recs: AssetRecord[] = [
    { id: "a", stage: "production", deliverables: ["model"] },
    { id: "b", stage: "production", deliverables: [] },
    { id: "c", stage: "concept", deliverables: ["moodboard", "styleframe"] },
  ];
  const st = pipelineStatus(recs);
  assert.equal(st.byStage.production, 2);
  assert.equal(st.byStage.concept, 1);
  assert.equal(st.blocked, 2);
  assert.equal(st.bottleneck, "production");
});

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

✔ D 层链路配置与 TS 门禁一致(防漂移) (2.520125ms)
✔ 阶段顺序固定为四段 (0.292833ms)
✔ 每段门禁非空且交付物不重复 (0.215166ms)
✔ 交付物齐全才允许推进,且给出下一阶段 (0.122167ms)
✔ 负例:缺 styleframe 时概念阶段不得推进 (0.146708ms)
✔ 负例:验收是链路终点,无下一阶段 (0.124209ms)
✔ 卡点归因:缺交付物最多的阶段被识别 (0.13575ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

两条负例各守一个方向:缺 styleframe 守住「该拦的必须拦」,验收是终点 守住「别把终点当中间站」。如果这两条里的任何一条从「拦下」变成「放行」,门禁就已经失效了,而不是测试写错了。

卡点归因与裁剪顺序

当一批资产卡在链路里,别急着「加人」。先按下面的顺序归因,再决定裁什么:

卡点症状正确的裁剪顺序
概念卡需求含糊,风格反复推翻先锁死 moodboard,没有参考板不进制作
制作卡面数 / 贴图 / 材质槽超预算先减面数,再降贴图分辨率,最后才减材质槽
接入卡导出丢轴向 / 丢单位 / 引用悬空接入期只做数值校验,不回炉改美术
验收卡进引擎后才发现不合规把校验前移到接入期,验收只做最终签字

裁剪顺序不是拍脑袋:面数是显存与填充率的大头,贴图次之,材质槽最少——先动影响最小、恢复最贵的那个,是通用原则。这一条在第 7 章「美术资产预算」里会落成可算的整数账。

自检表

检查项合格标准
四段齐全概念 / 制作 / 接入 / 验收各有明确定义与出口
门禁可执行出口是清单不是口头承诺,且判定有可跑的校验器
判定可验「缺件必须被拦」有测试锁死,且能算出卡点阶段与卡住件数
边界清晰真实产能与返工率标 X,不写成「实测」

链路的第一个反直觉点是:它看起来是流程问题,实际是判定问题。谁做、做几周,都不如「出口交付物齐不齐」这一条硬。下一节我们把「谁判定」这件事落到角色上——同一件交付物,谁执行、谁会签、谁最终拍板,必须唯一。

阅读导航:上一节:目录 · 下一节:1.2 角色分工与职责矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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