1.1 Lambert 与漫反射

卷一封版了几何到像素的骨架,卷二只换着色环节。本节从 N·L 的几何含义出发,把 Lambert 漫反射写成纯整数点积加一次算术右移:66 个方向实测受光 30、背光 36,去掉 max(0, N·L) 后出现 81 个负通道,而钳制版所有通道落在 [0, 200]。

1.1 Lambert 与漫反射

站内 /graphics-lighting-shadows/ 那篇讲的是实时 PBR 与阴影贴图在 GPU 上的工程实现:材质参数怎么组织、级联阴影怎么切、画质与帧率怎么权衡。本卷换一个角度——把着色写成可逐像素断言的整数运算,让每一次光照求值的中间量都能落盘、比对、跨语言复现。这一章从最老的那个光照模型开始。

Lambert 漫反射的全部内容是一句话:表面收到的辐照度正比于法线与光线方向夹角的余弦。写进代码就是 N·L。真正需要讲清楚的是两件事:N·L 在定点算术里怎么算,以及 max(0, N·L) 这个看似随手的钳制为什么不能省。

N·L 的几何含义

设 N 与 L 都是单位向量,则 N·L = cos θ,θ 是两者夹角。三种情形:

  • θ = 0:光线垂直照在表面上,N·L = 1,最亮。
  • θ = 90°:光线与表面平行,N·L = 0。这一圈叫终结线(terminator),是明暗交界。
  • θ > 90°:光线从背面来,N·L < 0。

第三种情形是问题的核心。物理上「背面的入射辐照度」不是负数——它压根不存在,是 0。而 cos θ 在 θ > 90° 时确实是负的。所以漫反射项必须写成 max(0, N·L):这不是为了好看,而是把「背面不发光」这条物理事实补进一个在数学上无界的连续函数里。

一次漫反射求值的定点实现

本节取八面体细分球(subdiv=2)的 66 个顶点当一组近似均匀分布的单位方向,对每个方向算两遍漫反射项:带钳制与不带钳制。配置是一份全整数 JSON:

{
  "name": "lantern-lambert-sweep",
  "ambientQ16": 13107,
  "directionalLight": {
    "dirQ16": [-37837, 37837, 37837],
    "colorQ8": [255, 236, 200],
    "intensityQ16": 58982
  },
  "albedoQ8": [200, 200, 190],
  "sphereSubdiv": 2,
  "terminatorBandQ16": 4096
}

ambientQ16 = 13107 就是 0.2,intensityQ16 = 58982 是 0.9,光源方向 [-37837, 37837, 37837] 就是《烛影山谷》里那盏方向光——它的模长是 sqrt(3) × 37837 ≈ 65536,恰好是 Q16.16 的 1.0。

求值函数只有七行,是全节的重点:

# snippet: 节选自 lantern/sim/cg_shade_01_1_lambert.py 的 lambert()
ndl_raw = cglib.v_dot(normal, dir_q16)   # 纯整数点积:3 次 fmul + 2 次加,无除法
ndl = ndl_raw
if clamp and ndl < 0:                    # ← 这一行是「漫反射」与「负辐照度」的分界
    ndl = 0
k = ambient + cglib.fmul(ndl, intensity)
if clamp and k > cglib.ONE:              # 上钳制:k 不能超过 1
    k = cglib.ONE
return ndl_raw, k, tuple((albedo[i] * k) >> 16 for i in range(3))

v_dot 展开就是 fmul(a0,b0) + fmul(a1,b1) + fmul(a2,b2),而 fmul(a,b) = (a*b) >> 16。整条链上只有整数乘、整数加、算术右移,没有一次除法。

完整脚本:

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_01_1_lambert.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_01_1_lambert.py
"""1.1 Lambert 与漫反射:把 N·L 的几何含义与它的定点实现摆在同一张表上。

取八面体细分球(subdiv=2)的 66 个近似均匀分布的单位方向当法线,每个方向算两遍:

  带钳制   k     = min(ONE, ambient + fmul(max(0, N·L), intensity))
  不带钳制 k_raw = ambient + fmul(N·L, intensity)

源码差别只有一行 `if ndl < 0: ndl = 0`,却是「漫反射」与「负辐照度」的分界。
核对五件事:N·L 是无除法的整数点积;受光/背光由符号精确切分;钳制版通道落在
[0,255] 而不钳制版出现负通道;k 对 N·L 单调不减;v_dot 的归一化误差不越界。

产物:out/cg_shade_01_1_lambert.csv、.json、.svg
"""

import json
import os
import sys
import xml.etree.ElementTree as ET

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPEC = os.path.join(ROOT, "config", "cg_shade_01_1_lambert.json")


def sample_normals(subdiv):
    """八面体细分球顶点当「近似均匀的单位方向」,与场景里球体法线同算法。"""
    verts, _faces = cglib.build_sphere((0, 0, 0), cglib.ONE, subdiv)
    return [cglib.v_norm(v) for v in verts]


def lambert(normal, dir_q16, ambient, intensity, albedo, clamp=True):
    """一次漫反射求值。返回 (ndl_raw, k, rgb):ndl_raw 永远是未钳制的整数点积。"""
    ndl_raw = cglib.v_dot(normal, dir_q16)
    ndl = ndl_raw
    if clamp and ndl < 0:
        ndl = 0
    k = ambient + cglib.fmul(ndl, intensity)
    if clamp and k > cglib.ONE:
        k = cglib.ONE
    return ndl_raw, k, tuple((albedo[i] * k) >> 16 for i in range(3))


def response_svg(path, points, ambient, intensity):
    """纯 xml.etree 的响应曲线:横轴 N·L(cos θ),纵轴漫反射系数 k。"""
    W, H, L, R, T, B = 640, 400, 64, 24, 28, 48
    px = lambda x: L + (x + 1.0) / 2.0 * (W - L - R)
    py = lambda y: H - B - min(1.0, max(0.0, y)) * (H - T - B)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H),
                                "fill": "#0e1016"})
    for v in (-1.0, -0.5, 0.0, 0.5, 1.0):
        ET.SubElement(svg, "line", {"x1": f"{px(v):.1f}", "y1": str(T), "x2": f"{px(v):.1f}",
                                    "y2": str(H - B), "stroke": "#2a2f3a"})
        ET.SubElement(svg, "text", {"x": f"{px(v):.1f}", "y": str(H - B + 18), "fill": "#8b93a7",
                                    "font-size": "11", "text-anchor": "middle"}).text = f"{v:+.1f}"
    for v in (0.0, 0.25, 0.5, 0.75, 1.0):
        ET.SubElement(svg, "line", {"x1": str(L), "y1": f"{py(v):.1f}", "x2": str(W - R),
                                    "y2": f"{py(v):.1f}", "stroke": "#2a2f3a"})
        ET.SubElement(svg, "text", {"x": str(L - 10), "y": f"{py(v) + 4:.1f}", "fill": "#8b93a7",
                                    "font-size": "11", "text-anchor": "end"}).text = f"{v:.2f}"
    line = " ".join(f"{px(-1.0 + 2.0 * i / 200.0):.1f},"
                    f"{py(min(1.0, ambient + intensity * max(0.0, -1.0 + 2.0 * i / 200.0))):.1f}"
                    for i in range(201))
    ET.SubElement(svg, "polyline", {"points": line, "fill": "none", "stroke": "#5b8cff",
                                    "stroke-width": "2"})
    for p in points:
        ET.SubElement(svg, "circle", {"cx": f"{px(p[0]):.1f}", "cy": f"{py(p[1]):.1f}", "r": "3",
                                      "fill": "#ffb347"})
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main():
    spec = json.load(open(SPEC, encoding="utf-8"))
    dir_q16 = tuple(spec["directionalLight"]["dirQ16"])
    intensity = spec["directionalLight"]["intensityQ16"]
    ambient, albedo = spec["ambientQ16"], tuple(spec["albedoQ8"])
    band = spec["terminatorBandQ16"]
    normals = sample_normals(spec["sphereSubdiv"])

    rows, golden = [], []
    for i, n in enumerate(normals):
        ndl, k, rgb = lambert(n, dir_q16, ambient, intensity, albedo, clamp=True)
        _raw, k_raw, rgb_raw = lambert(n, dir_q16, ambient, intensity, albedo, clamp=False)
        rows.append({"i": i, "n": n, "ndl": ndl, "k": k, "rgb": rgb,
                     "k_raw": k_raw, "rgb_raw": rgb_raw})
        golden.append({"n": list(n), "ndl": ndl, "k": k, "rgb": list(rgb)})

    ndl_arr = np.array([r["ndl"] for r in rows], dtype=np.int64)
    k_arr = np.array([r["k"] for r in rows], dtype=np.int64)
    kraw_arr = np.array([r["k_raw"] for r in rows], dtype=np.int64)
    rgb_arr = np.array([r["rgb"] for r in rows], dtype=np.int64)
    rgbraw_arr = np.array([r["rgb_raw"] for r in rows], dtype=np.int64)

    lit = int((ndl_arr >= 0).sum())
    shadowed = int((ndl_arr < 0).sum())
    in_band = int((np.abs(ndl_arr) <= band).sum())
    out_of_range = int(((ndl_arr < -cglib.ONE) | (ndl_arr > cglib.ONE)).sum())
    order = np.argsort(ndl_arr, kind="stable")
    monotonic = bool(np.all(np.diff(k_arr[order]) >= 0))

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    cglib.write_csv(
        os.path.join(out, "cg_shade_01_1_lambert.csv"),
        ["index", "nx", "ny", "nz", "ndl_q16", "ndl_cos", "k_q16", "k_float",
         "r", "g", "b", "kraw_q16", "kraw_float", "rraw", "graw", "braw"],
        [[r["i"], r["n"][0], r["n"][1], r["n"][2], r["ndl"], f"{r['ndl'] / cglib.ONE:.6f}",
          r["k"], f"{r['k'] / cglib.ONE:.6f}", *r["rgb"], r["k_raw"],
          f"{r['k_raw'] / cglib.ONE:.6f}", *r["rgb_raw"]] for r in rows])
    response_svg(os.path.join(out, "cg_shade_01_1_lambert.svg"),
                 [(r["ndl"] / cglib.ONE, r["k"] / cglib.ONE) for r in rows],
                 ambient / cglib.ONE, intensity / cglib.ONE)

    stats = {
        "normals": len(rows), "ambientQ16": ambient, "intensityQ16": intensity,
        "dirQ16": list(dir_q16), "albedoQ8": list(albedo), "terminatorBandQ16": band,
        "lit": lit, "shadowed": shadowed, "terminator_band_count": in_band,
        "ndl_min_q16": int(ndl_arr.min()), "ndl_max_q16": int(ndl_arr.max()),
        "ndl_out_of_unit_range": out_of_range,
        "k_min_q16": int(k_arr.min()), "k_max_q16": int(k_arr.max()),
        "k_upper_clamped": int((k_arr == cglib.ONE).sum()),
        "kraw_min_q16": int(kraw_arr.min()), "kraw_negative": int((kraw_arr < 0).sum()),
        "rgb_min": int(rgb_arr.min()), "rgb_max": int(rgb_arr.max()),
        "rgbraw_min": int(rgbraw_arr.min()),
        "rgbraw_negative_channels": int((rgbraw_arr < 0).sum()),
        "monotonic_k_in_ndl": monotonic, "golden": golden,
    }
    with open(os.path.join(out, "cg_shade_01_1_lambert.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"方向数            : {stats['normals']}(八面体细分球 subdiv=2 的顶点)")
    print(f"受光 / 背光       : {lit} / {shadowed}")
    print(f"终结线带内 |N·L|<=1/16 : {in_band}")
    print(f"N·L 范围          : [{stats['ndl_min_q16']}, {stats['ndl_max_q16']}] Q16.16 "
          f"(越出 [-ONE,ONE] 的方向数 {out_of_range})")
    print(f"k(钳制)范围     : [{stats['k_min_q16']}, {stats['k_max_q16']}]  "
          f"上钳制到 ONE 的方向数 {stats['k_upper_clamped']}")
    print(f"k_raw(不钳制)   : min {stats['kraw_min_q16']}  负值方向数 {stats['kraw_negative']}")
    print(f"通道范围 钳制     : [{stats['rgb_min']}, {stats['rgb_max']}]")
    print(f"通道范围 不钳制   : min {stats['rgbraw_min']}  负通道数 "
          f"{stats['rgbraw_negative_channels']}")
    print(f"k 对 N·L 单调不减 : {monotonic}")
    print("csv/json/svg      : out/cg_shade_01_1_lambert.{csv,json,svg}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一次:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_shade_01_1_lambert.py
方向数            : 66(八面体细分球 subdiv=2 的顶点)
受光 / 背光       : 30 / 36
终结线带内 |N·L|<=1/16 : 12
N·L 范围          : [-61789, 61786] Q16.16 (越出 [-ONE,ONE] 的方向数 0)
k(钳制)范围     : [13107, 65536]  上钳制到 ONE 的方向数 3
k_raw(不钳制)   : min -42503  负值方向数 27
通道范围 钳制     : [37, 200]
通道范围 不钳制   : min -130  负通道数 81
k 对 N·L 单调不减 : True
csv/json/svg      : out/cg_shade_01_1_lambert.{csv,json,svg}

四组数字怎么读

受光 30 / 背光 36。 66 个方向被 N·L 的符号切成两半。不是 33/33,是因为八面体细分球的方向并不均匀:六个轴向顶点与八个 (±1,±1,±1) 面心比边中点密。

N·L 落在 [-61789, 61786],越界 0 个。 两个 Q16.16 单位向量的点积理论上落在 [-65536, 65536],实测最大值 61786、最小值 -61789,都在界内。这是 v_dot 的舍入量级:每项 fmul 各截断一次,三项累计误差远小于 1 个 Q16.16 单位。归一化误差不会把符号算反,这是本节后面所有判据成立的前提。

k_raw 最小 -42503,负值方向 27 个——但背光方向有 36 个。 差的那 9 个是掠射背面:N·L 只是略微为负,fmul(N·L, intensity) 的绝对值还没超过环境光 13107,于是 k_raw 仍为正。换句话说,环境光把浅背光方向偷偷照亮了。这是环境光作为「廉价补光」的典型副作用:它不受几何约束,会给本应全黑的地方一个下限。第 5 章讲环境光遮蔽时,要处理的正是这件事。

钳制版通道 [37, 200],不钳制版最小 -130、负通道 81 个。 81 = 27 × 3,正好是负 k_raw 的方向数乘以三个通道。钳制版没有一个通道越界,不钳制版则有 81 个负值。另外注意上钳制:有 3 个方向的 k 被从 68721 压回 ONE,对应通道从 209 压回 200——max(0, ·) 只是下界,上界同样要钳。

为什么 max(0, N·L) 是整数安全的

「整数安全」在这里有精确含义:钳制不引入新的舍入,也不改变符号判定。

  1. N·L 的符号由整数比较直接得出。v_dot 返回 int(Python)或 bigint(JS),ndl < 0 是精确比较,没有浮点 epsilon 的模糊地带。
  2. 钳制是赋值,不是运算。ndl = 0 不产生任何舍入。
  3. 钳制保持单调性。k 是 N·L 的单调不减函数,钳制只是把左侧压平——实测 66 个方向按 N·L 排序后 k 非减(输出里的 k 对 N·L 单调不减 : True)。单调性意味着终结线是一条干净的边界,不会出现明暗交替的假边。

顺带说清 >> 在这里的角色。(albedo[i] * k) >> 16 是 8 位通道乘 Q16.16 系数再降精度。当 k_raw 为负时,>> 是算术右移,把负数向负无穷方向取整——Python 与 JS BigInt 在这一点上语义一致(卷一定下的铁律)。所以负通道不会因为语言差异而分叉,只是物理上无意义:负的辐照度会被后续裁剪悄悄压成 0,信息在这一步丢了,而且丢得不声不响。

跨语言核对

P 层用 BigInt 重算同一份配置,与 S 侧的 golden 表逐项比对:

// snippet: 节选自 lantern/core/test/cg_shade_01_1_lambert.test.ts
const raw = vDot(n, dir);              // 与 Python 的 cglib.v_dot 同序
const ndl = clamp && raw < 0n ? 0n : raw;
let k = ambient + fmul(ndl, intensity);
if (clamp && k > ONE) k = ONE;
const rgb = albedo.map((a) => Number((BigInt(a) * k) >> 16n));

两个负例把「判据有区分力」钉死:去掉下钳制必须产生 81 个负通道(实测相等);漏掉上钳制必须让最大通道越过 200(实测 209)。两者都被判据拒绝,所以「钳制正确」这个结论才可信。

阅读导航:上一节:目录 · 下一节:1.2 Phong 与高光 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配