4.1 阴影贴图的原理

把 487 顶点 / 896 三角形的烛影山谷用光源正交矩阵投到 256x256 深度图,再在主相机逐像素插值出光源空间坐标 (lu,lv,ld),整数比对 ld-map>bias 判定遮蔽:深度图覆盖 11409 纹素、光源趟剔除 192 三角形、主视图 43731 像素、硬阴影 1360 像素(31.1‰)。P 层与 S 层像素缓冲逐字节相同,W 层按声明容差收敛。

4.1 阴影贴图的原理

站内的 实时 PBR 与阴影贴图 讲的是级联阴影贴图的工程实现——怎么把视锥切成几段、每段配一张深度图、怎么在接缝处做混合。本节把级联、抖动、优化全部拿掉,只留最核心的那一步:一个点到底在不在阴影里,并让这一步的每个数字都能在整数上复现。

一句话概括阴影贴图:从光源的角度渲染一遍场景,记下每个方向上看得到的最近深度;再从相机角度渲染时,问「我这个点,在光源看来是不是比记录的最近深度更远」。 更远就说明中间有东西挡着,也就是阴影。

两趟渲染

把这句话拆成两趟:

  1. 光源趟:用光源的正交矩阵把同一份几何投到一张 256x256 的深度图。这一趟不关心颜色,只写深度,而且只保留每个纹素上最近的深度——正是卷一卷二的 cglib.rasterize 已经在做的事(take = mask & (z < sub_d))。复用同一个光栅化器,意味着两趟的覆盖判定、深度插值、背面剔除规则完全一致。
  2. 主相机趟:顶点阶段除了算屏幕坐标,还要把同一个顶点用光源矩阵再算一遍,得到光源屏幕坐标 (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.00.36950.4886
PSNR≥ 30 dB38.82 dB40.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 分辨率、偏差与走样 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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