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) 是整数安全的
「整数安全」在这里有精确含义:钳制不引入新的舍入,也不改变符号判定。
N·L的符号由整数比较直接得出。v_dot返回int(Python)或bigint(JS),ndl < 0是精确比较,没有浮点 epsilon 的模糊地带。- 钳制是赋值,不是运算。
ndl = 0不产生任何舍入。 - 钳制保持单调性。
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 与高光 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。