4.1 阴影贴图的原理
站内的 实时 PBR 与阴影贴图 讲的是级联阴影贴图的工程实现——怎么把视锥切成几段、每段配一张深度图、怎么在接缝处做混合。本节把级联、抖动、优化全部拿掉,只留最核心的那一步:一个点到底在不在阴影里,并让这一步的每个数字都能在整数上复现。
一句话概括阴影贴图:从光源的角度渲染一遍场景,记下每个方向上看得到的最近深度;再从相机角度渲染时,问「我这个点,在光源看来是不是比记录的最近深度更远」。 更远就说明中间有东西挡着,也就是阴影。
两趟渲染
把这句话拆成两趟:
- 光源趟:用光源的正交矩阵把同一份几何投到一张 256x256 的深度图。这一趟不关心颜色,只写深度,而且只保留每个纹素上最近的深度——正是卷一卷二的
cglib.rasterize已经在做的事(take = mask & (z < sub_d))。复用同一个光栅化器,意味着两趟的覆盖判定、深度插值、背面剔除规则完全一致。 - 主相机趟:顶点阶段除了算屏幕坐标,还要把同一个顶点用光源矩阵再算一遍,得到光源屏幕坐标
(lu, lv)与光源深度ld;光栅化时这三个量随颜色一起被重心插值。片元阶段把(lu, lv)量化到纹素,取出map[ty, tx],比较ld - map[ty, tx] > bias。
关键在于第二趟插值的不是「屏幕深度」,而是光源空间的三个量。这一步最容易被写错,本节末尾专门配了一个负例。
S 层脚本读 config/scene_spec.json(卷一同一份场景)与 config/cg_shade_04_shadow.json(本章的光源参数),跑完两趟并落盘:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_04_1_shadowmap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_04_1_shadowmap.py
"""4.1《阴影贴图的原理》:两趟渲染的完整链路与整数遮蔽判定。
第一趟把 487 个顶点 / 896 个三角形用**光源正交矩阵**投到 256×256 深度图(复用卷一的
`cglib.rasterize`,只读深度缓冲);第二趟在主相机屏幕逐像素插值出光源空间坐标
`(lu, lv, ld)`,片元阶段比对 `ld - map[ty, tx] > bias`。
输出:光源深度图的灰度 PNG、最终帧 PNG、以及一份数字 JSON(供正文引用与 P 层对拍)。
"""
import hashlib
import json
import os
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib # noqa: E402
import cgshade # noqa: E402
import cg_shade_04_shadowlib as lib # noqa: E402
OUT = os.path.join(ROOT, "out")
os.makedirs(OUT, exist_ok=True)
spec = cglib.load_spec(os.path.join(ROOT, "config", "scene_spec.json"))
sh = cglib.load_spec(os.path.join(ROOT, "config", "cg_shade_04_shadow.json"))
res = sh["lightRes"]
bias = sh["pcfBias"] # 4.2 会解释这个「很小但非零」的偏差从何而来
r = lib.render_shadow_frame(spec, sh, res, bias, 1)
rgb = np.clip(r["color"], 0, 255).astype(np.uint8)
sha = hashlib.sha256(rgb.tobytes()).hexdigest()
cglib.save_gray_png(os.path.join(OUT, "cg_shade_04_1_depthmap.png"), r["shadow_depth"])
cglib.save_png(os.path.join(OUT, "cg_shade_04_1_frame.png"), r["color"])
with open(os.path.join(OUT, "cg_shade_04_1_frame.rgb"), "wb") as f:
f.write(rgb.tobytes())
light = cglib.v_norm(tuple(sh["lightDirQ16"]))
report = {
"canvas": [spec["width"], spec["height"]],
"lightRes": res,
"bias": bias,
"vertices": r["verts"],
"triangles": r["tris"],
"light_dir_q16": list(light),
"map_covered": r["map_covered"],
"map_drawn": r["map_drawn"],
"map_culled": r["map_culled"],
"map_texels": res * res,
"main_covered": r["covered"],
"main_culled": r["culled"],
"tris_drawn": r["tris_drawn"],
"dropped": r["dropped"],
"shadow_pixels": r["shadow_pixels"],
"shadow_permille": round(1000.0 * r["shadow_pixels"] / max(1, r["covered"]), 1),
"per_pixel_sha256": sha,
}
with open(os.path.join(OUT, "cg_shade_04_1_shadowmap.json"), "w") as f:
json.dump(report, f, indent=2)
for k, v in report.items():
print(f"{k:20s}: {v}")
canvas : [320, 240]
lightRes : 256
bias : 256
vertices : 487
triangles : 896
light_dir_q16 : [-37837, 37837, 37837]
map_covered : 11409
map_drawn : 896
map_culled : 192
map_texels : 65536
main_covered : 43731
main_culled : 290
tris_drawn : 896
dropped : 0
shadow_pixels : 1360
shadow_permille : 31.1
per_pixel_sha256 : 33fba58af2a34f1b7c5b1510f5307512ad3ca801eb185bd98aeaf38959bccbcf
几何与卷一、卷二基线完全一致:487 顶点 / 896 三角形,主相机覆盖 43731 像素(569‰)、剔除 290。光源趟多出来的两个数是这一章独有的:深度图上只有 11409 个纹素被写到(65536 里的 17.4%),因为光源正交投影只框住了山谷的一部分;光源视角下剔除 192 个三角形——同一个三角形在主相机是正面、在光源可能就背对着光,这是两趟渲染必须各自做背面剔除的原因。最终硬阴影 1360 像素(31.1‰)。
光源正交矩阵
方向光没有位置,只有方向,所以它的「相机」是一个正交投影:把光源放在场景中心沿光照反方向 dist 远处,看向中心,投影盒半边长 orthoHalf,近远平面 [near, far]。light_mvp 就是 mat_look_at(eye, center, up) 乘上一个正交矩阵:
# snippet: lantern/sim/cg_shade_04_shadowlib.py(光源正交矩阵)
def ortho_matrix(half_q16, near_q16, far_q16):
sx = fdiv(ONE, half_q16)
sz = fdiv(-2 * ONE, far_q16 - near_q16)
tz = fdiv(-(far_q16 + near_q16), far_q16 - near_q16)
return (sx, 0, 0, 0, 0, sx, 0, 0, 0, 0, sz, tz, 0, 0, 0, ONE)
half_q16 是 2621440(世界单位 40),near / far 是 1310720 / 6553600(世界单位 20 / 100)。这个投影盒要刚好包住整个山谷:太小会截断,太大则深度分辨率白白浪费。orthoHalf 与 far - near 一起决定了下一节要推的偏差量级,所以它们是本章最重要的两个参数。
逐像素的整数遮蔽判定
主相机趟的三角形布局是在卷二 rasterize_shaded 的 27 项后面接 9 项:光源屏幕 Q6 x3(27–32,每个顶点的 lu / lv)与光源深度 Q16.16 x3(33–35)。片元里只做整数:
# snippet: lantern/sim/cg_shade_04_shadowlib.py(逐像素整数遮蔽判定)
def shadow_lit(ld, lu, lv, shadow_depth, res, bias, pcf_n):
shift = res_shift(res) # 4096 -> 0, 256 -> 4, 64 -> 6
tx = (lu >> shift) >> 6 # Q6 屏幕坐标 -> 纹素索引
ty = (lv >> shift) >> 6
occ = 0
for dy in range(-hw, hw + 1):
for dx in range(-hw, hw + 1):
if (ld - shadow_depth[ty + dy, tx + dx]) > bias:
occ += 1
return (n2 - occ, n2)
两次右移是关键:>> shift 把「在 4096 分辨率下算出的 Q6 屏幕坐标」降到当前分辨率,>> 6 再去掉 Q6 的小数位。res_shift 只接受 2 的幂,非 2 的幂直接抛错——分辨率必须是 2 的幂,否则移位就不等价于除法,定点链也就不成立了。
P 层:逐字节相同
P 层是 core/src/lantern/cg_shade_04_shadow.ts 里同一套两趟管线(顶点用 BigInt、逐像素用 Number + Math.floor),断言对象是最终帧的颜色缓冲而不是 PNG 容器字节:
// snippet: core/test/cg_shade_04_shadow.test.ts(节选:逐字节判据与负例)
// verify: node --test 'core/test/cg_shade_04_shadow.test.ts'
test("两趟阴影管线的像素缓冲与 S 层逐字节相同", () => {
const { frame } = render(sh.lightRes, sh.pcfBias, 1);
assert.equal(sha(frame.rgb), golden.per_pixel_sha256, "颜色缓冲必须逐字节相同");
assert.equal(frame.coveredCount, golden.main_covered);
assert.equal(frame.shadowPixels, golden.shadow_pixels);
});
test("负例:漏掉光源空间插值(用主相机深度冒充光源深度)必须分叉", () => {
const { frame, out } = render(sh.lightRes, sh.pcfBias, 1);
// 把第 33–35 项(光源深度)整体替换成主相机深度:光源趟结果就完全没被用上
for (const t of out) { t[33] = t[2]; t[34] = t[5]; t[35] = t[8]; }
const bad = rasterizeShadowed(out, /* 同一份深度图 */ sh.lightRes, sh.pcfBias, 1);
assert.notEqual(sha(bad.rgb), sha(frame.rgb), "错的深度来源必须让逐字节判据分叉");
assert.ok(mae > 10, `负例 MAE ${mae.toFixed(4)} 必须远超阈值`);
});
✔ 两趟阴影管线的像素缓冲与 S 层逐字节相同
✔ 光源趟:深度图的覆盖纹素与背面剔除数与 S 层相同
✔ 负例:漏掉光源空间插值(用主相机深度冒充光源深度)必须分叉
✔ 分辨率右移位数与 2 的幂约束
ℹ tests 4 ℹ pass 4 ℹ fail 0
负例的构造很直白:把每个三角形里第 33–35 项(光源深度)覆盖成第 2 / 5 / 8 项(主相机深度),等于「光源趟白跑了」。此时逐字节判据分叉,MAE 实测 30.4586——判据必须能拒掉它,否则「通过」两个字没有意义。
W 层:把深度图真的存进纹理
W 层用 WebGL2 做同一件事,但深度图不再是 numpy 数组,而是一张 DEPTH_COMPONENT24 纹理:第一趟把光源空间三角形渲染进一个挂深度附件的帧缓冲,第二趟在主相机的片元里 texelFetch 这张纹理:
// snippet: web/build_shade04.mjs(主相机片元着色器核心:纹理取样 + 邻域比对)
uniform sampler2D uDepth; uniform float uRes, uShift, uBias; uniform int uN;
void main() {
float lu = floor(vLightQ6.x / uShift);
float lv = floor(vLightQ6.y / uShift);
int tx0 = int(floor(lu / 64.0));
int ty0 = int(floor(lv / 64.0));
float ld = vLightDepthQ16 / 65536.0;
int hw = (uN - 1) / 2; int occ = 0; int total = 0;
for (int dy = -3; dy <= 3; dy++) {
if (dy < -hw || dy > hw) continue;
for (int dx = -3; dx <= 3; dx++) {
if (dx < -hw || dx > hw) continue;
total++;
int tx = tx0 + dx; int ty = ty0 + dy;
if (tx >= 0 && tx < int(uRes) && ty >= 0 && ty < int(uRes)) {
float md = texelFetch(uDepth, ivec2(tx, int(uRes) - 1 - ty), 0).r;
if (md < 1.0 && (ld - md) > uBias) occ++;
}
}
}
float litQ = floor(float(total - occ) * 65536.0 / float(total));
o = vec4(floor(vAlb * (uAmbient + floor(ndlQ * uIntensity / 65536.0) * litQ / 65536.0) / 65536.0) / 255.0, 1.0);
}
int(uRes) - 1 - ty 这个垂直翻转不是可选项:GL 的纹理第 0 行在底部(y 轴朝上),而 CPU 侧的深度图是从上往下存的。少了这一项,整个阴影会上下颠倒,表现出来就是大片自遮挡——这个缺陷在本章的 W 层真实发生过,靠 Python 逐纹素对比才定位到。
GPU 与 CPU 的差异来源是明确的:覆盖归属由 top-left rule 决定,深度与法线走浮点插值,而 CPU 是 Q16.16 定点。所以逐位一致在数学上不可能,判据只用对舍入不敏感的统计量,并提前声明阈值:
| 判据 | 阈值 | 实测(硬阴影 N=1) | 实测(PCF N=3) |
|---|---|---|---|
| MAE | ≤ 1.0 | 0.3695 | 0.4886 |
| PSNR | ≥ 30 dB | 38.82 dB | 40.45 dB |
| 覆盖归属差异 | ≤ 20‰ | 0.08‰ | 0.08‰ |
canvasDrawn : true
场景 : 320x240 / 深度图 256² / 896 三角形 / bias 256
深度图覆盖纹素 : 11409 / 65536(背面剔除 192)
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本 : WebGL 2.0 (OpenGL ES 3.0 Chromium) glError : 0
① 页面内 CPU vs S 层缓冲:不同字节 = 0
② CPU 定点 vs WebGL2(硬阴影 N=1):
覆盖归属不同 : 6 / 76800 (0.08‰) 最大通道差 : 159 MAE : 0.3695 PSNR : 38.82 dB
③ CPU 定点 vs WebGL2(PCF N=3):
覆盖归属不同 : 6 / 76800 (0.08‰) 最大通道差 : 159 MAE : 0.4886 PSNR : 40.45 dB
④ 负例(bias=0,自遮挡): MAE=15.3889 PSNR=18.57 dB
PASS
「任一通道差 > 0」的像素占比是 404.06‰——看着吓人,其实毫无区分力,因为它把 ±1 的舍入也算进去了;有区分力的是 MAE、PSNR 与单独统计的覆盖归属差异。另外必须说明:本机无头环境的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,上面这些差异不得归因于真实硬件;需要真实硬件数据的论断属于 X 档、命令行不可复现。
链在这里闭合:同一份几何走两趟,光源趟产出深度图,主相机趟逐像素做整数比对。但 bias = 256 这个数是从哪来的?为什么是 256 而不是 0 或 1024?下一节把它推出来。
阅读导航:上一节:3.3 法线贴图的解压与重建 · 下一节:4.2 分辨率、偏差与走样 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。