上一节 5.1 色阶带与描边参数 把一条光照曲线量化成几段色阶带;本节把光照拆成一层层——基础色、阴影、主光、边缘光、高光,每层一个可独立开关的视觉层。光照模型与阴影算法本身是站内 渲染原理专题 的家(光照与阴影 一篇专讲),后处理 也是;本节不重推这些原理,只回答美术侧的问题:一层光照要写哪些参数,档位越高多开哪几层,这些层凭什么算「对」。
一层光照 = 一个明度乘数
卡通渲染的每一层光照,视觉上就是「把基础色按某个比例调暗或提亮」。所以一层的核心参数只有一个——相对基础色的明度乘数 shadePermille(整数千分比,1000 是原色,小于 1000 压暗,大于 1000 提亮)。乘数是整数,着色就是 clamp(channel * shadePermille // 1000, 0..255),整数地板除加夹取,没有浮点。
层与层之间有一条不能破的关系:按 shadow → base → key → rim → spec 的顺序,明度必须严格递增。这条「阴影最暗、高光最亮」的直觉,在规范里必须写成一条会失败的判据——否则某天有人把阴影乘数填得比基础色还高,画面会暗得很自然,却没人拦得住。下面这份 D 层参数表把 5 层的乘数、遮罩通道与成本单元写在一起:
{
"version": 1,
"project": "stardust-inn",
"note": "NPR 光照分层的视觉规范(D 层)。一层 = 光照里一个可独立开关的视觉层;shadePermille 是该层相对基础色(albedoToken)的明度乘数(整数千分比,>1000 为提亮),mask 是该层要用的遮罩通道,costUnits 是该层的着色成本单元(建模输入,不是任何渲染器的实测)。tiers 是光照分层档位,每档启用的层由层的 tiers 字段给出。全整数无浮点;真实渲染下每层的观感需真实渲染器,命令行不可复现(X)。",
"albedoToken": "accent",
"lumaOrder": ["shadow", "base", "key", "rim", "spec"],
"tiers": ["lite", "standard", "rich"],
"layers": [
{ "id": "base", "name": "基础色", "shadePermille": 1000, "mask": "none", "costUnits": 1, "tiers": ["lite", "standard", "rich"] },
{ "id": "shadow", "name": "阴影", "shadePermille": 620, "mask": "none", "costUnits": 2, "tiers": ["lite", "standard", "rich"] },
{ "id": "key", "name": "主光", "shadePermille": 1180, "mask": "none", "costUnits": 2, "tiers": ["standard", "rich"] },
{ "id": "rim", "name": "边缘光", "shadePermille": 1320, "mask": "rim", "costUnits": 3, "tiers": ["rich"] },
{ "id": "spec", "name": "高光", "shadePermille": 1600, "mask": "spec", "costUnits": 3, "tiers": ["rich"] }
]
}
表里有两个字段容易被误读,先说清口径。mask 是遮罩通道:rim 与 spec 需要一张额外的通道图来限定作用范围,base/shadow/key 用满表面、不需要遮罩,所以是 none——层越多,遮罩通道需求越多,这也是 rich 档更贵的一部分原因。costUnits 是建模输入,用来比较档位之间的相对开销,不是任何渲染器实测的 GPU 周期——真实 GPU 耗时需真实渲染器与真机,命令行不可复现(X),本节不写「提升 N 倍」式结论。
| 层 | 乘数 shadePermille | 遮罩 | 启用档位 |
|---|---|---|---|
shadow 阴影 | 620 | none | lite / standard / rich |
base 基础色 | 1000 | none | lite / standard / rich |
key 主光 | 1180 | none | standard / rich |
rim 边缘光 | 1320 | rim | rich |
spec 高光 | 1600 | spec | rich |
S 层:着色账与档位账
下面这段是 S 层脚本,读上面那份参数表,算出每层的着色结果与整数亮度,断言明度严格递增,并汇总每档启用几层、成本单元合计:
#!/usr/bin/env python3
# file: sim/st05_lighting.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st05_lighting.py
"""NPR 光照分层的核算(S 层)。
读 config/st05_lighting.json(光照分层参数表)与 config/visual_spec.json(取 albedo 真源色):
① 每层着色 color = clamp(channel * shadePermille // 1000, 0..255),整数地板除;
② 明度单调:按 lumaOrder 用整数亮度 (2126r+7152g+722b)//10000 断言严格递增——「阴影最暗、
高光最亮」不是口号,是一条会失败的判据;③ 档位账:每档启用哪些层、共几层、成本单元合计。
落 out/st05_lighting.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__)))
def shade(c, permille):
"""按千分比缩放一个 sRGB 整数三元组并夹到 0..255,全程整数。"""
return tuple(min(255, v * permille // 1000) for v in c)
def luma(c):
"""整数相对亮度近似(0..255 量级),与 sp16 风格向量的明度口径同源。"""
return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st05_lighting.json"), encoding="utf-8"))
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
albedo = spec["tokens"][cfg["albedoToken"]]
rows, order = {}, cfg["lumaOrder"]
for lay in cfg["layers"]:
color = shade(albedo, lay["shadePermille"])
rows[lay["id"]] = {"color": list(color), "luma": luma(color),
"shadePermille": lay["shadePermille"], "mask": lay["mask"],
"costUnits": lay["costUnits"]}
luma_seq = [rows[lid]["luma"] for lid in order]
monotonic = all(luma_seq[i] < luma_seq[i + 1] for i in range(len(luma_seq) - 1))
violations = [] if monotonic else [f"luma 非严格递增:{luma_seq}"]
tiers = []
for t in cfg["tiers"]:
ids = [lay["id"] for lay in cfg["layers"] if t in lay["tiers"]]
tiers.append({"id": t, "layers": ids, "layerCount": len(ids),
"costUnits": sum(rows[i]["costUnits"] for i in ids),
"maskCount": sum(1 for i in ids if rows[i]["mask"] != "none")})
canonical = json.dumps({"luma": {lid: rows[lid]["luma"] for lid in order},
"tiers": [[t["id"], t["layerCount"], t["costUnits"]] for t in tiers]},
separators=(",", ":"), ensure_ascii=False)
rep = {"project": cfg["project"], "version": cfg["version"], "albedoToken": cfg["albedoToken"],
"albedo": albedo, "order": order, "layers": rows,
"luma": {lid: rows[lid]["luma"] for lid in order}, "lumaSeq": luma_seq,
"monotonic": monotonic, "violations": violations, "tiers": tiers,
"layerCount": len(cfg["layers"]), "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_lighting.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
print(f"albedo={cfg['albedoToken']}{albedo} layers={len(cfg['layers'])} monotonic={monotonic}")
for lid in order:
r = rows[lid]
print(f" {lid:<7} x{r['shadePermille']:<5} color={tuple(r['color'])} luma={r['luma']} "
f"mask={r['mask']}")
for t in tiers:
print(f" tier {t['id']:<9} layers={t['layerCount']} costUnits={t['costUnits']} "
f"maskCount={t['maskCount']}")
print(f"violations={len(violations)} digest={rep['digest']}")
print("wrote out/st05_lighting.json")
return 0
if __name__ == "__main__":
sys.exit(main())
本机跑 /opt/homebrew/bin/python3 sim/st05_lighting.py,真实输出:
albedo=accent[245, 158, 11] layers=5 monotonic=True
shadow x620 color=(151, 97, 6) luma=101 mask=none
base x1000 color=(245, 158, 11) luma=165 mask=none
key x1180 color=(255, 186, 12) luma=188 mask=none
rim x1320 color=(255, 208, 14) luma=203 mask=rim
spec x1600 color=(255, 252, 17) luma=235 mask=spec
tier lite layers=2 costUnits=3 maskCount=0
tier standard layers=3 costUnits=5 maskCount=0
tier rich layers=5 costUnits=11 maskCount=2
violations=0 digest=1eb71d5ba2e60a1f
wrote out/st05_lighting.json
读出来三件事。着色是整数账:shadow 用 620‰ 把 accent [245,158,11] 压成 (151,97,6),spec 用 1600‰ 提到 (255,252,17)——注意后两通道被夹到 255,这是整数夹取不是浮点四舍五入。明度单调:五层亮度 101 < 165 < 188 < 203 < 235,monotonic=True、violations=0。档位账:lite 只开基础色 + 阴影(2 层、3 个成本单元),standard 加主光(3 层、5 单元),rich 再开边缘光与高光(5 层、11 单元,且用到 2 个遮罩通道)——档位越高,层数与成本单元同步上升,这条关系是可核的。
P 层:同一套判据的 TS 侧
同一套判据,TypeScript 侧独立实现一遍。它只读同一份配置,不 import Python 的任何东西:
// file: core/src/st05/lighting.ts
// tier: P
// verify: node --test 'core/test/st05_*.test.ts'
// NPR 光照分层内核(P 层):与 sim/st05_lighting.py 共用 config/st05_lighting.json。
// 着色是整数地板除加夹取,明度是整数加权和,档位账是整数加总,故两侧逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Layer = {
id: string; name: string; shadePermille: number; mask: string;
costUnits: number; tiers: string[];
};
/** 按千分比缩放一个 sRGB 整数三元组并夹到 0..255,全程整数。 */
export function shade(c: number[], permille: number): number[] {
return c.map((v) => Math.min(255, Math.floor((v * permille) / 1000)));
}
/** 整数相对亮度近似(0..255 量级),与 sp16 风格向量的明度口径同源。 */
export function luma(c: number[]): number {
return Math.floor((2126 * c[0] + 7152 * c[1] + 722 * c[2]) / 10000);
}
/** 档位账:该档启用哪些层、共几层、成本单元合计、用到几个遮罩通道。 */
export function tierSummary(layers: Layer[], tiers: string[]) {
return tiers.map((t) => {
const on = layers.filter((l) => l.tiers.includes(t));
return { id: t, layers: on.map((l) => l.id), layerCount: on.length,
costUnits: on.reduce((a, l) => a + l.costUnits, 0),
maskCount: on.filter((l) => l.mask !== "none").length };
});
}
/** 明度单调性:按 order 断言整数亮度严格递增,返回违例说明数组。 */
export function orderViolations(lumaMap: Record<string, number>, order: string[]): string[] {
for (let i = 0; i + 1 < order.length; i++) {
if (lumaMap[order[i]] >= lumaMap[order[i + 1]]) {
return [`luma 非严格递增:${order.map((k) => lumaMap[k]).join(",")}`];
}
}
return [];
}
/** canonical 摘要:键序固定 luma → tiers,无空白,与 Python 侧同形。 */
export function canonical(lumaMap: Record<string, number>, order: string[],
tiers: { id: string; layerCount: number; costUnits: number }[]): string {
const l: Record<string, number> = {};
for (const k of order) l[k] = lumaMap[k];
return JSON.stringify({ luma: l, tiers: tiers.map((t) => [t.id, t.layerCount, t.costUnits]) });
}
测试读 Python 落盘的 JSON 做跨语言对照,并带负例(把某层亮度压低必须被判出)。完整测试见 core/test/st05_lighting.test.ts,本机跑 node --test 'core/test/st05_lighting.test.ts':
✔ 着色:整数地板除并夹到 0..255;跨语言逐层一致 (0.871792ms)
✔ 明度单调:阴影最暗、高光最亮是一条会失败的判据 (0.172291ms)
✔ 档位账:层数、成本单元、遮罩通道数全整数加总,跨语言一致 (0.110667ms)
✔ 跨语言:canonical 摘要与 digest 逐位一致(整数类) (0.373875ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
跨语言摘要:两侧独立复算的 canonical 摘要 sha256 前 16 位都是 1eb71d5ba2e60a1f——着色、亮度、档位账全是整数类,逐位一致。第三条测试把 key 的亮度改成 90(比 shadow 还暗),orderViolations 立刻返回一条违例:这条判据真的会拦下东西。
自检表
| 检查项 | 合格标准 |
|---|---|
| 参数完整 | 每层有明度乘数、遮罩通道、成本单元,档位归属明确 |
| 关系可判 | 按 lumaOrder 的整数亮度严格递增,越界即判失败 |
| 账可复算 | 着色、亮度、档位账全整数,跨语言逐位一致 |
| 边界清晰 | 光照模型与后处理外链,真实渲染观感标 X |
必须再强调一次边界:本节算的是层的参数与账,不是光照的画面。光照模型、阴影算法、后处理泛光怎么实现,是 渲染原理专题 的事,本节只外链不重推;真渲染一帧、真在引擎里调参观察这些需真实环境,命令行不可复现,标 X。下一节专门做划界:把 NPR 里「本书讲什么、站内哪篇讲什么、哪些根本做不了」写成一张可校验的归属表,并把「真渲染」逐处标 X。
阅读导航:上一节:5.1 色阶带与描边参数 · 下一节:5.3 X:真实渲染外链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。