5.2 光照分层的视觉规范

上一节把光照量化成色阶带,本节把光照拆成一层层:基础色、阴影、主光、边缘光、高光。每层一个整数参数,档位越高层越多。光照模型与后处理实现外链站内渲染原理专题,本节只做参数表与整数账——每层着色结果怎么算、为什么「阴影最暗、高光最亮」必须写成一条会失败的判据。真实渲染观感标 X。

上一节 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 阴影620nonelite / standard / rich
base 基础色1000nonelite / standard / rich
key 主光1180nonestandard / rich
rim 边缘光1320rimrich
spec 高光1600specrich

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:真实渲染外链 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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