上一节 5.2 光照分层的视觉规范 把光照拆成一层层参数,本节不再加参数,而是做划界。理由很直接:NPR 里有一整块本机(以及这套书稿的构建环境)根本做不了——真渲染一帧卡通画面、真在引擎里拖色阶带参数看观感。本机没有任何渲染器,也没有任何 DCC。所以本节换一种写法:把 NPR 相关主题分成三类,每类各自落到该落的地方。站内 渲染原理专题 是原理的家(光照与阴影 、后处理 各有一篇专讲),引擎实现侧见 《渲染引擎》卷首的分工说明 ,风格方向的提纲见 游戏美术风格指南 ——本节只外链、不重推它们。
三档可复现性,与一条不许越界的线
先把「可复现」分档,这是本节所有取舍的前提:
| 档 | 含义 | 本套的处理 |
|---|---|---|
| 可命令行复现 | 给定输入与代码,本机跑出的数字/文件完全一致 | S / P 层,正文给实跑输出与 digest |
| 需真实环境 | 要渲染器 / DCC / 真机 GPU 才能产生 | X 层,正文每处显式标注「需真实环境,命令行不可复现」 |
| 不可复现 | 画师审美、笔触手感 | 只作公开可观察机制引用,不写成「实测」 |
X 层的纪律不是「不写」,而是换一种写:把「本机做不了的事」编码成一张可校验的归属表——哪些主题归本书(讲参数与账)、哪些归站内别处(外链原理)、哪些步骤必须真实环境(X)。下面这份划界账就是这张表的真源:
{
"version": 1,
"project": "stardust-inn",
"note": "NPR 章节的划界账(X 层)。rules 是有序归属表:下标越小优先级越高,一条主题先命中者胜;owner=this-book 的条目只讲参数与整数账(target 为 null),owner=external 的条目一律外链到已验证的站内 URL,本节不重推其原理。xSteps 是必须真实渲染器/真实环境才能做的步骤,reproducible 只允许 requires-real-env——无渲染器环境下声明 cli 就是过度承诺,审计会当场拦下。verifiedUrls 是已逐个核验存在的绝对 URL 白名单。",
"verifiedUrls": [
"/posts/graphics/",
"/graphics-lighting-shadows/",
"/graphics-post-processing/",
"/graphics-shader-compilation-pipeline/",
"/graphics-color-management-hdr-pipeline/",
"/graphics-mobile-rendering-optimization/",
"/graphics-gpu-driven-rendering-lod/",
"/graphics-mesh-shaders-nextgen/",
"/helio-render-01-3-scope-vs-graphics/",
"/game-art-direction-style-bible-2026/",
"/game-performance-budget-mobile-pc-2026/"
],
"rules": [
{ "id": "book-ramp", "owner": "this-book", "match": ["色阶带", "描边宽度", "光照分层", "带边界"], "target": null },
{ "id": "book-style-param", "owner": "this-book", "match": ["风格参数", "主题参数", "风格向量", "美术预算账"], "target": null },
{ "id": "ext-lighting", "owner": "external", "match": ["光照模型", "阴影", "漫反射", "光照算法"], "target": "/graphics-lighting-shadows/" },
{ "id": "ext-postproc", "owner": "external", "match": ["后处理", "bloom", "泛光", "描边后处理"], "target": "/graphics-post-processing/" },
{ "id": "ext-shader", "owner": "external", "match": ["着色器编译", "shader 编译", "变体", "管线编译"], "target": "/graphics-shader-compilation-pipeline/" },
{ "id": "ext-hdr", "owner": "external", "match": ["hdr", "色彩管理", "色调映射"], "target": "/graphics-color-management-hdr-pipeline/" },
{ "id": "ext-mobile", "owner": "external", "match": ["移动端渲染", "gpu 优化", "带宽"], "target": "/graphics-mobile-rendering-optimization/" },
{ "id": "ext-engine", "owner": "external", "match": ["引擎实现", "rhi", "帧图", "渲染管线实现"], "target": "/helio-render-01-3-scope-vs-graphics/" },
{ "id": "ext-bible", "owner": "external", "match": ["风格指南", "美术方向", "art bible"], "target": "/game-art-direction-style-bible-2026/" }
],
"probes": [
"色阶带数量与边界",
"描边宽度像素账",
"光照分层的层数与档位",
"光照模型与阴影实现",
"描边后处理与泛光",
"着色器编译与变体",
"hdr 色调映射",
"引擎实现侧的渲染管线",
"完全没听过的主题"
],
"xSteps": [
{ "id": "st05-render-frame", "step": "真渲染一帧 NPR 画面", "reproducible": "requires-real-env" },
{ "id": "st05-outline-mesh-export", "step": "真导出反向外扩描边网格", "reproducible": "requires-real-env" },
{ "id": "st05-ramp-tune", "step": "在引擎里拖色阶带参数并观察", "reproducible": "cli" },
{ "id": "st05-rim-gpu", "step": "真机 GPU 上的边缘光与后处理观感", "reproducible": "requires-real-env" }
]
}
S 层:归属路由与诚实性审计
下面这段是 S 层脚本,把 probes 逐条按有序 rules 分类(先命中者胜,全不命中返回 null),再对归属表做结构审计与诚实性审计:
#!/usr/bin/env python3
# file: sim/st05_scope.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st05_scope.py
"""NPR 章节划界账(S 层)。
读 config/st05_scope.json(划界账,X 层),做三件全整数/字符串的事:
① 归属路由:把 probes 逐条按有序 rules 分类,先命中者胜,全不命中返回 null(不猜);
② 结构审计:owner 只能取 this-book/external;external 的 target 必须在 verifiedUrls 里,
this-book 的 target 必须为 null;③ 诚实性审计:xSteps 声明 cli 即 E_OVERCLAIM——无渲染器
环境下,真渲染、真调参不可能命令行复现,声明 cli 就是过度承诺。
落 out/st05_scope.json(含 canonical 摘要与 sha256 前 16 位)供 P 层对照。
真实渲染本身需真实渲染器,命令行不可复现(X);本脚本只审计划界表与诚实性。"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OWNERS = ("this-book", "external")
REPRO = ("cli", "requires-real-env")
def classify(text, rules):
"""有序归属:先命中者胜,全不命中返回 None。"""
t = text.lower()
for r in rules:
if any(m in t for m in r["match"]):
return r
return None
def audit(cfg):
"""结构 + 诚实性审计,返回按 id/code 排序的违例列表。"""
verified, violations = set(cfg["verifiedUrls"]), []
for r in cfg["rules"]:
if r["owner"] not in OWNERS:
violations.append({"id": r["id"], "code": "E_OWNER", "token": r["owner"]})
if r["owner"] == "external":
if r["target"] not in verified:
violations.append({"id": r["id"], "code": "E_TARGET", "token": str(r["target"])})
elif r["target"] is not None:
violations.append({"id": r["id"], "code": "E_TARGET", "token": str(r["target"])})
for s in cfg["xSteps"]:
if s["reproducible"] not in REPRO:
violations.append({"id": s["id"], "code": "E_REPRO", "token": s["reproducible"]})
if s["reproducible"] == "cli":
violations.append({"id": s["id"], "code": "E_OVERCLAIM", "token": "cli"})
return sorted(violations, key=lambda v: (v["id"], v["code"]))
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st05_scope.json"), encoding="utf-8"))
rules, probes = cfg["rules"], cfg["probes"]
classified = [[p, (r["owner"] if r else None), (r["id"] if r else None)]
for p, r in ((p, classify(p, rules)) for p in probes)]
violations = audit(cfg)
book = [r for r in rules if r["owner"] == "this-book"]
ext = [r for r in rules if r["owner"] == "external"]
overclaim = sum(1 for v in violations if v["code"] == "E_OVERCLAIM")
canonical = json.dumps({"classify": [[p, o] for p, o, _ in classified],
"violations": [f"{v['id']}:{v['code']}:{v['token']}" for v in violations]},
separators=(",", ":"), ensure_ascii=False)
rep = {"project": cfg["project"], "version": cfg["version"],
"ruleCount": len(rules), "thisBook": len(book), "external": len(ext),
"verifiedUrlCount": len(cfg["verifiedUrls"]), "xStepCount": len(cfg["xSteps"]),
"classified": classified, "unclassified": [p for p, o, _ in classified if o is None],
"violations": violations, "overclaimCount": overclaim,
"canonical": canonical, "digest": hashlib.sha256(canonical.encode()).hexdigest()[:16]}
os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
json.dump(rep, open(os.path.join(ROOT, "out/st05_scope.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
print(f"rules={len(rules)} this-book={len(book)} external={len(ext)} "
f"verifiedUrls={len(cfg['verifiedUrls'])} xSteps={len(cfg['xSteps'])}")
for p, owner, rid in classified:
print(f" {p:<18} -> {owner or 'null':<9} {rid or ''}")
for v in violations:
print(f" VIOLATION {v['id']:24s} {v['code']:12s} {v['token']}")
print(f"violations={len(violations)} overclaim={overclaim} digest={rep['digest']}")
print("wrote out/st05_scope.json")
return 0
if __name__ == "__main__":
sys.exit(main())
本机跑 /opt/homebrew/bin/python3 sim/st05_scope.py,真实输出:
rules=9 this-book=2 external=7 verifiedUrls=11 xSteps=4
色阶带数量与边界 -> this-book book-ramp
描边宽度像素账 -> this-book book-ramp
光照分层的层数与档位 -> this-book book-ramp
光照模型与阴影实现 -> external ext-lighting
描边后处理与泛光 -> external ext-postproc
着色器编译与变体 -> external ext-shader
hdr 色调映射 -> external ext-hdr
引擎实现侧的渲染管线 -> external ext-engine
完全没听过的主题 -> null
VIOLATION st05-ramp-tune E_OVERCLAIM cli
violations=1 overclaim=1 digest=01e39f3ee84fabdd
wrote out/st05_scope.json
读出来三件事。归属有序:描边宽度像素账 先命中 book-ramp(本书讲),光照模型与阴影实现 命中 ext-lighting(外链)——同一条 描边 词根,一个留在本书、一个外链,靠的是规则顺序而不是关键字先后。不猜:完全没听过的主题 返回 null,不硬塞进某一类。诚实性拦下一条:st05-ramp-tune(在引擎里拖参数观察)声明了 cli,被判 E_OVERCLAIM——在无渲染器环境下,这一步不可能命令行复现,声明 cli 就是过度承诺,门禁当场拦下。这条违例是故意留的,它证明这道闸门真的会拦东西;其余 3 个 xSteps 都老实标了 requires-real-env,overclaim 只剩这 1 条。
P 层:同一套判据的 TS 侧
同一套判据,TypeScript 侧独立实现一遍。它只读同一份配置,不 import Python 的任何东西:
// file: core/src/st05/scope.ts
// tier: P
// verify: node --test 'core/test/st05_*.test.ts'
// NPR 章节划界内核(P 层):与 sim/st05_scope.py 共用 config/st05_scope.json。
// 判据全是字符串(owner、URL、reproducible),故两侧的归属结论与违例集合逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type ScopeRule = { id: string; owner: string; match: string[]; target: string | null };
export type XStep = { id: string; step: string; reproducible: string };
export type ScopeCfg = { verifiedUrls: string[]; rules: ScopeRule[]; probes: string[]; xSteps: XStep[] };
export type Violation = { id: string; code: string; token: string };
/** 有序归属:先命中者胜,全不命中返回 null(不猜)。 */
export function classify(text: string, rules: ScopeRule[]): ScopeRule | null {
const t = text.toLowerCase();
for (const r of rules) if (r.match.some((m) => t.includes(m))) return r;
return null;
}
/** 结构 + 诚实性审计:owner 合法、external 的 target 在已验证白名单里、
* this-book 的 target 为 null、xStep 声明 cli 即过度承诺。 */
export function auditScope(cfg: ScopeCfg): Violation[] {
const verified = new Set(cfg.verifiedUrls);
const out: Violation[] = [];
for (const r of cfg.rules) {
if (r.owner !== "this-book" && r.owner !== "external") {
out.push({ id: r.id, code: "E_OWNER", token: r.owner });
}
if (r.owner === "external") {
if (!verified.has(String(r.target))) out.push({ id: r.id, code: "E_TARGET", token: String(r.target) });
} else if (r.target !== null) {
out.push({ id: r.id, code: "E_TARGET", token: String(r.target) });
}
}
for (const s of cfg.xSteps) {
if (s.reproducible !== "cli" && s.reproducible !== "requires-real-env") {
out.push({ id: s.id, code: "E_REPRO", token: s.reproducible });
}
if (s.reproducible === "cli") out.push({ id: s.id, code: "E_OVERCLAIM", token: "cli" });
}
return out.sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
}
/** canonical 摘要:键序固定 classify → violations,无空白,与 Python 侧同形。 */
export function canonical(classified: [string, string | null][], violations: Violation[]): string {
return JSON.stringify({ classify: classified,
violations: violations.map((v) => `${v.id}:${v.code}:${v.token}`) });
}
测试读 Python 落盘的 JSON 做跨语言对照,并带负例(越界 target、未知 owner、声明 cli 都必须被判出)。完整测试见 core/test/st05_scope.test.ts,本机跑 node --test 'core/test/st05_scope.test.ts':
✔ 有序归属:书内条目优先,external 条目命中且不猜 (0.46225ms)
✔ 负例:external 的 target 越界必须被拦,this-book 不得带 target (0.470541ms)
✔ 负例:声明 cli 即过度承诺,无渲染器环境下必被判出 (0.117292ms)
✔ 跨语言:归属结论、违例集合、canonical 与 digest 逐位一致 (0.418666ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
跨语言摘要:两侧独立复算的 canonical 摘要 sha256 前 16 位都是 01e39f3ee84fabdd——归属结论、违例集合、摘要全是字符串/整数类,逐位一致。三条负例各守一个方向:越界 target 守住「外链必须来自已验证清单」,未知 owner 守住「不知道就别猜」,声明 cli 守住「本机做不了的别写成能复现」。
外链去哪:一张分工表
划界账里 7 条 external 规则,落到下面这张分工表(URL 全部取自已逐个核验的清单):
| NPR 相关主题 | 归谁 | 目标 |
|---|---|---|
| 光照模型 / 阴影算法 | 渲染原理专题 | /graphics-lighting-shadows/ |
| 描边后处理 / 泛光 | 渲染原理专题 | /graphics-post-processing/ |
| 着色器编译 / 变体 | 渲染原理专题 | /graphics-shader-compilation-pipeline/ |
| HDR 色彩管理 / 色调映射 | 渲染原理专题 | /graphics-color-management-hdr-pipeline/ |
| 移动端渲染优化 / 带宽 | 渲染原理专题 | /graphics-mobile-rendering-optimization/ |
| 引擎实现 / RHI / 帧图 | 《渲染引擎》卷首分工 | /helio-render-01-3-scope-vs-graphics/ |
| 风格方向 / Art Bible 提纲 | 行业短文 | /game-art-direction-style-bible-2026/ |
分工口径一句话:本书讲参数与账(色阶带、描边、光照分层的整数规范),原理与实现归站内那几篇。凡本节正文提到「光照模型怎么算」「后处理怎么写」,都是指向外链,不在这里展开——这也正是「一份视觉规范」与「一个渲染器」的分界。
边界:逐处标 X
本节所有涉及真实画面的判断,一律需真实环境(渲染器 / DCC / 真机 GPU),命令行不可复现,标 X:
- 真渲染一帧 NPR 画面:需真实渲染器,命令行不可复现(X);本节只给参数与账。
- 真导出反向外扩的描边网格:需真实 DCC,命令行不可复现(X)。
- 在引擎里拖色阶带参数观察观感:需真实引擎与渲染器,命令行不可复现(X)——这正是被
E_OVERCLAIM拦下的那条。 - 真机 GPU 上的边缘光与后处理观感:需真机,命令行不可复现(X)。
这套划界账能做的,是在渲染之前就把「什么归谁、什么做不了」写死成可校验的数据;它不能做的,是证明画面好看——那是画师与真实渲染器的判断,本卷不评价。
阅读导航:上一节:5.2 光照分层的视觉规范 · 下一节:6.1 贴图通道与打包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。