内容工厂. / 演示空间 / PBR 高级效果 · 第 4 课 · 阴影:加一个可见性 段 1 / 3
PBR 高级效果 | 第 4 课 | 上一课:换一个约束:能量守恒

阴影:加一个可见性

读时 约 5 分钟 本课 3 段 · 已上线 3 段 每段 三件套 保真度 真机可测
课题骨架(本课题只声明一次)

着色不是「给表面涂颜色」,而是解一个积分:对每个着色点,把所有入射方向的贡献按 BRDF 加权累加。 PBR 的全部内容就是——怎么把这一个积分算得既接近物理、又跑得动。 这个课题沿着一代代模型走:每一代都是对同一个积分更好的一次近似。

4.1
把「看不看得见光」乘进去
4.2
影子的边为什么不硬
4.3
一片影要花多少代价
4.1

把「看不看得见光」乘进去

真机可测 · 本机渲染

本节点在骨架的下一环 —— 上一课把「光打过来,这一点怎么反射」算全了,却把可见性整个留着没答:从这一点朝光源的路,中途有没有被挡。上一课那条算式里,压根没有「挡」这个字。

上一课只解决了「有光打过来时这一点怎么反射」:ρ = k_d·n·l·括号 + k_s·(n·h)^n·C·n·l (k_d 是写进积分的 BRDF 系数 = 预算份额 k_d0 = 预算 − k_s ÷ π; C = (n+8)/(8π)、瓣的积分对 dω_l —— 都是上一课定下的口径), 两项封在同一份预算里。可它从没过问「这一点看不看得见光源」。真实场景里挡光的东西遍地都是: 灯下伸一只手,墙上就按出一片很硬的黑影。这一课加的就是那个有没有东西挡在中间的量 —— 可见性 V。它不是一种新颜色、也不是一个新的 BRDF,它就是一个乘在直接光上的 0 或 1: 从表面点朝光源打一条射线,路上撞到东西 → 0,没撞到 → 1。 本段只增加一样东西:可见性 V(沿光方向的一条遮挡射线,撞到东西就返回 0)。 光照项、光源方向、粗糙度 σ、高光怎么算、高光瓣归一,五组控件与上一课完全相同; 另加一档「可见性 关 / 开」开关,好把两种口径摆在同一场景里对比。
与上一课相比,本段补了一件场景上的事(它不是一个新变量,只是把「影」摆到看得懂的地方): 主球脚下放一块地板:y = −1 的平面,球正好坐在它上面, 于是影有了最标准的接收面 —— 地板上那块暗斑就是本段的主看点: 因(球)与果(地板上的暗斑)在同一个画面里,不必再让读者去猜「这块暗斑是谁的」。 画面里没有第二颗球:被挡的那颗就是主球自己 —— 主球面上每个点朝光源的射线, 只会撞上自己脚下的球体一次,所以对主球它问的是空遮挡(半径 0,永不命中), 球面上恒 V = 1、没有影;那块影全落在地板上,由主球自己投下。 同一段管线、同一段 WGSL,两次 draw 画两样物(主球 + 地板;地板走它自己那块四边面网格)。 于是地板上逐像素的可见性问的就是一句话:从这一点朝光源的射线,有没有被那颗球挡到。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列 → 中间真源码 → 右列。框里是件二里真正在执行的那段 WGSL(逐字抄出,页面对它做了自检); 右列三个数与件三、与画面同源。中间那几行就是本段的全部新东西:先算一条从表面点出发、朝光源打出去的射线 (w = wp − 球心、q = |w|² − R²),二次方程 t² + b·t + q = 0 的近根 t = (−b − √disc)/2 大于 0,就说明路上撞到了东西 —— 那一行返回 0,否则返回 1。 求交那六行带一个参数 o(问哪一颗球),最后两行 shadowVis 把 occ 与 occ2 问两次取小 —— 单球场景里 occ2 恒为空遮挡 (半径 0,永不命中),所以真正起作用的就是主球那一次:主球问空遮挡恒 V = 1、地板问主球(那块影就是它投的)。 这里的表面点是世界坐标:球是 自球心 + 自半径 × n(主球就是 n), 地板是平面上的交点 —— 于是同一段 WGSL、同一条管线,画出了两样物(每样一份 uniform)。 右列第三个「可见性 V」就是这件事的是/否结论,它随后乘在直接光的两项上(漫反射与高光都要它)。

下面这张小图就说一件事:主球投在地板上那块影的位置与宽度是怎么定出来的。 它是把场景沿「光源方向的竖直平面」切开的侧视图(横轴 = 光源方向的水平分量,竖轴 = 世界 y), 图上每一样都由同一份常量(光源方向、球心、地板高度)算出,不是另画一张: 那条虚线从影心穿过球心指向光源方向 —— 影心就是「球心沿光源方向打到地板上的落点」; 球面上那条垂直于光源方向的直径是明暗界线,它两端沿光源方向打到地板的两个落点, 正好夹出影斑的两端(地板上粗黑的那一段)。影斑的宽度 = 球直径 / |l·n| = 2.000 / 0.707 = 2.828。 画面里没有第二颗球 —— 影只由主球自己投下。

注意看

右列三个数是一条链:n·l 是几何给的(这一点的朝向对光源的余弦), 遮挡射线近根 t 是那条射线与主球相交的近根,可见性 V 是它的是/否结论 —— 只可能是 1.000 或 0.000。 白圈钉在地板影心上(默认档那儿正压在影里):V = 0.000、近根 t = 0.414(正数,撞上了); 把「光源方位角」慢慢拖开、让影扫过去,白圈翻成 1.000(近根 t 那格变成「— 无命中」)。 它不是渐变:路上有东西就是 0,没有就是 1。同一个 V 也压在整块地板上 —— 默认档地板上那片影有 63744 个像素(占可见地板的 32.91%),连成一整片(最大连通 63744,就是整片一块)。

件二 可操作活例 · 纯 WebGPU · 控件与上一课相同,只多一档「可见性」
拖画面 → 转视角(看地板上的影怎么走)
启动中…
光照项(与上一课相同)
光源方向(与上一课相同 · 影斑跟着它走)
粗糙度 σ(与上一课相同)
高光怎么算(与上一课相同)
高光瓣要不要归一(与上一课相同)
本段新增 · 这一点看不看得见光源

先切到「可见性关」。整颗球和你上一课看到的逐像素相同,地板也只剩环境光那一层 —— 右下读数里「球面V=0 / 地板V=0」是 0 / 0。再切回「可见性开」: 主球面仍和上一课长一个样(它不自己挡自己,球面上恒 V = 1), 只是地板上那片影出现了 —— 读数跳到 球面V=0 0 与 地板V=0 63744(占可见地板 32.91%、占整幅画面 11.07%,连成整片一块)。 这块黑不是涂上去的:它是主球自己投的 —— 球就坐在自己影子的正上方, 因(球)与果(地板上那片暗斑)在同一个画面里。

再拖两个角度。方位角只让影绕着球转,大小不变 —— 影的大小只取决于高度角的 sin el: 光越斜(|l·n 越小)影拉得越长,正上方 el = 90° 时缩成一个半径 R 的圆。 因为「挡不挡得住」是光源与球之间的事,不是表面上刷的一层。 V 不会「越来越小」,它只有「撞上 / 没撞上」两个值。 拖「画面」转视角时,影钉在场景里不动 —— 它属于场景,不属于屏幕; 视角一换,你从另一个方向看同一片影。

白圈就是件一右列 / 件三 ② 监视的那一点(地板影心)。默认档它正压在自己影子里: 件一的 V = 0.000、近根 t = 0.414(正数,撞上了);把「光源方位角」拖到约 110°~335° (光绕到球的另一侧、影跟着转走),白圈出影:翻成 1.000、近根 t 变成「— 无命中」。 要看最清楚的一跳,看件三③ 影缘那两行:影里 ρ 含V 是 0.000,一出影立刻从 0 蹦到 0.17 —— 而 ρ 不含V 在整块地板上几乎不动(那是地板 n·l 恒定的缘故,见③ 的说明)。

切到「只环境光」:环境光那一项照样亮 —— 它没有光源可挡,所以 V 碰不到它。 这是本段要看的一个旁证:V 只乘在直接光上。

动手

先在「本段新增」那一档来回切:注意看主球面始终不变(它不自己挡自己,球面上恒 V = 1), 只有地板上那片影开关时出现 / 消失;右下读数的「球面V=0 / 地板V=0」在 0 / 0(关)与 0 / 90597(开)之间跳。再把「光源方位角」从 40° 慢慢拖到 40°+360°: 地板上那块影绕着球稳稳转一圈、大小不变(大小只随高度角变)。最后拖「画面」转半圈: 影跟着场景走,不跟着屏幕走 —— 它属于场景,不属于屏幕。 想核几何的话,件三 ① 的最后两行就写着地板影心 T 与半轴。

件三 中间量数值表 · 与件二同源
① 场景参数 · 随控件变
项值
② 被监视的那一点 · 逐项
项值
③ 影缘剖面 · 手算可核
αn·l|Δ⊥|Vρ 含Vρ 不含V
③ 每一行都能拿笔算。它是在地板上沿影的长轴从影心(α = 0)往影外走一横排, 每一行都是一个真实的地板表面点(世界坐标 = 影心 + α × 长轴方向,长轴 = 光源方向的水平投影)。 先算两件事:① 光照余弦 n·l —— 地板法线恒朝上,而 n·l = l.y = sin el = 0.707 在整个地板上处处同名(光打到每一寸地面都一样亮,只要没被挡);② 那条遮挡射线撞不撞得上 —— 把 C − p 在垂直于 l 的平面上的投影长度记作 |Δ⊥|(表里第 3 列), 它小于主球半径 R = 1 就撞上(影缘即 |Δ⊥| = 1,对应 α = R/|l·n| = 1.414),那一行 V 就是 0。
ρ 含V = V·(k_d·n·l·括号 + k_s·(n·h)^n·C·n·l)、 ρ 不含V 就是上一课那条算式(一个 V 都不乘); 两项各自都带出射余弦 max(0, n·l)(与上一课同一处)。 (本课与上一课同口径:k_d 是写进积分的 BRDF 系数 = 预算份额 ÷ π,C = (n+8)/(8π)。) 于是表里有个很干净的对比:因为 n·l 恒定,ρ 不含V 在整排几乎不动 (0.193 → 0.171,只有菲涅尔随视线角微变);而 ρ 含V 在影里是 0.000、 一出影(α = 1.50、|Δ⊥| = 1.061 越过 1.0)立刻蹦到 0.174 —— 那个跳就是本段加的一个乘子。
② 里探针那一点就是上面 α = 0 的影心(地板影心 T = 球心 − ((球心.y − FY)/l.y)·l, 本档 = (−0.766, −1.000, −0.643)):默认档它正压在影里,n·v = 0.514、n·l = 0.707、 近根 t = 0.414(正数,撞上了)、V = 0.000,ρ 含V = 0.000 而 ρ 不含V = 0.193 —— 同一份预算,只被乘了一个 0。 把「光源方位角」拖到约 110°~335°,影绕着球转走、白圈出影:近根 t 变成「— 无命中」、V 翻成 1.000。
地板那几行怎么核:影心就是上面那个 T(件三 ① 里照抄那一行);影的大小也一核: 沿光源水平方向的半轴 = R/|l·n| = 1.4142、垂直方向 = R = 1.000。 页面自检里另有一份实测:在地板平面上按 0.02 步长解析采样,凡那条射线命中主球者计入、取质心 —— 实测 (−0.7662, −0.6429),与理论差 0.0005(世界单位);半轴实测 1.4123 / 0.9978(差 ≤ 0.002,来自采样步长)。 这两条就是验收判据 ⑤。整个画面里就一个物体(主球)一块接收面(地板)—— 没有第二颗球,影只由主球自己投下。
本节获得

知道「阴影」在这条链上可以落成一个很朴素的乘子:一条从表面点朝光源打出去的射线, 路上撞到东西返回 0、没撞到返回 1,乘在直接光的两项上(漫反射和高光都要它)。 把它关掉,画面与上一课逐像素相同;打开,被挡的那些地方立刻暗掉 —— 默认档在 960×600 上是一处:地板上 63744 个像素 (占可见地板的 32.91%、占整幅画面 11.07%,连成一块 63744 像素的整片); 主球面上一个都没有(它不自己挡自己,球面 154476 个像素全在亮侧)。 而环境光一分不减 —— 因为环境光那一项没有光源可挡。 也看到那条射线问的是场景里的物体:本批把接收面从球面扩到球面 + 地板, 最要紧的是地板上那块影 —— 球就坐在自己影子的正上方,因(球)与果(地板上的暗斑) 在同一个画面里,读者能直接指着说:这块影是这颗球投的。地板上的每一像素问的还是同一句话: 从这一点朝光源的射线有没有被球挡到;那条几何关系(光源方向 + 球心 + 地板高度 → 影心)页面自己核过 (理论 vs 实测差 0.0005)。 还看到它很粗:V 只有 0 与 1 两个值,所以影子的边是一刀切的。真实的光源有面积, 影子的边该有一段从暗到亮的渐变(半影);它现在连「挡住的那块是不是该沾一点余光」都答不了。 那个渐变,是下一段要加的东西。

4.2

影子的边为什么不硬

真机可测 · 本机渲染

本节点在骨架的下一环 —— 上一段把「看不看得见光」落成了一个 0 或 1 的乘子。那个乘子很好用,可它答不出一件天天都能看见的事:影子的边,从来不是一刀切的。

上一段只解决了「这一点看不看得见光源」:从表面点朝光源打一条射线,撞到东西返回 0、没撞到返回 1,乘在直接光的两项上。可现实里没有「一个点」的灯 —— 太阳、灯管、窗户,光源本身都有大小。 光源一有大小,「这一点看得见光源吗」就不再是「看得见 / 看不见」两句话:它可能只看得见光源的一部分。 本段只增加一样东西:光源不再是一个点,而是一个有张角的圆盘 —— 角半径 θ。 其余全部与上一段完全相同:同一份 BRDF、同一套射线–球求交、同一个 V 乘在直接光两项上、同一颗主球与同一块地板;上一段那条射线只是这里 θ = 0 的特例。 于是那条射线变成一簇:绕光源方向 l 在圆盘上撒 N = 32 个方向,逐个问「这条撞到球没有」,数出被挡住的个数 k,V = 1 − k/N —— V 就从「0 或 1」变成「0 到 1 之间的一个比例」。 本段要看的就是这个比例在地面上铺出来的东西:半影,影子里那条从暗到亮的过渡带。它有两件可以按笔核的事 —— ① V = 0.5 的那条线钉在上一段那条硬影缘上不动(遮挡界线的仰角恰好等于光源中心仰角); ② 半影的宽度正比于 θ,也正比于这一点到遮挡物的距离 —— 所以同一片影的两条边,宽度能差好几倍。 (现实里太阳的角直径只有 0.53°,这里把 θ 放大到几度量级才看得见这个渐变;方向对了,量级是故意放大的。)

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列 → 中间真源码 → 右列。框里是件二里真正在执行的那段 WGSL(逐字抄出,页面对它做了自检); 右列三个数与件三、与画面同源。跟上一段比,中间那几行只有一处不同:一条射线换成一簇射线 —— e1 / e2 是垂直于 l 的一组正交基,diskAt(i) 用黄金角把 32 个采样点均匀铺在圆盘上 (rr 取开方,点才是按面积均匀的),sampleDir 再把它们搬到垂直于 l 的平面上、 放大到 ang = tan θ 的张角; 循环体里还是上一段那一句 hitOcc(二次方程近根 > 0 就算撞到),只是对它撒了 32 次, k 数出撞上的次数,最后 V = 1 − k/32。θ = 0 时 32 个方向全都退化成 l, 这一行就悄悄变回上一段那个 0 / 1。 第一行是空遮挡的直通:两组挡光球的半径加起来还是 0(主球自己不挡自己)就直接返回 1、不进采样循环 —— 这不是特例,是那一行 min(h1, h2) 在半径都是 0 时的恒等式;于是整颗球面一个采样都没花,恒 V = 1, 球的样子与上一段逐像素相同。32 这个数固定不动 —— 它要花多少代价,是 4.3 的事。

下面这张小图就说一件事:半影的两条边是怎么定出来的。 还是上一张那张侧视切面(横轴 = 光源方向的水平分量,竖轴 = 世界 y),每一样都由同一份常量算出,不是另画一张。 光源不再是一个方向,而是仰角 el − θ 到 el + θ 的一段:从地面上的某一点出发, 仰角落在这一段里的射线,有的撞得到球、有的撞不到 —— 撞得到的比例就是那一点的 V。 把这把「仰角尺子」贴着球推一圈,就切出三段:全都能撞到的是全影(仍然是纯黑,只是比上一段缩了), 全撞不到的是全亮,中间那两段就是半影。四条分界线上,恰好有一条射线与球相切。 图上的数字全是这一份几何给的:影心不动(u = −cot el = −1.000), V = 0.5 的那条线就是上一段那条硬影缘 u = ∓cot(el/2)(= −2.414 / +0.414),θ 再大也不挪; 本图这一档(θ = 6°)两条半影分别宽 0.727(外侧)与 0.123(内侧),比 5.92, 而闭式给的比是 cot²(el/2) = 5.83 —— 差的这一点来自「宽度正比于该点到遮挡物的距离」这条一阶近似。

注意看

把「本段新增」那一档的 光源角半径 θ 从 0° 慢慢拖到 12°, 盯住地板影子的左边那条边(离光源远的那一侧)。θ = 0 时它与上一段一样是一刀切; 一离开 0 就开始散开成一条带,越拖越宽、越拖越软。同时看右下角的读数与白圈: 白圈钉在地板上一个固定点 (−1.762, −1.000, −1.478) —— 它一直在影子里, 可 V 会从 0.000 一路爬到 0.469(被挡住的光源从 32/32 掉到 17/32): 影子还在那儿,只是漏进来快一半的光。这就是半影之所以是「从暗到亮」而不是「换个颜色」的原因 —— 它数的是一簇射线里有多少条还在路上被挡着。

再把视线移到右边那条边。同一次拖动里,它几乎没动 —— 因为那一条边紧挨着球, 从它上面出发的射线没走多远就撞上球了,光源两端射来的射线落在球上的位置几乎重合。 θ = 6° 时两侧半影的宽度是 0.727 与 0.123,差 5.9 倍。 所以「影子的边软不软」不是光源一个人的事:离遮挡物越远越软,越近越硬。 把「光源高度角」拖到底(el 很小)这个差别会更极端 —— 影子被拉得越长,远端越糊。

件二 可操作活例 · 纯 WebGPU · 控件与上一段相同,只多一档「光源角半径」
拖画面 → 转视角(看半影在地面上怎么铺)
启动中…
光照项(与上一段相同)
光源方向(与上一段相同 · 影斑跟着它走)
粗糙度 σ(与上一段相同)
高光怎么算(与上一段相同)
高光瓣要不要归一(与上一段相同)
这一点看不看得见光源(上一段新增)
本段新增 · 光源不是一个点

先把 θ 归零。画面与上一段逐像素相同:地板上那片影是一块边沿锋利的暗斑, 右下的「本影 / 半影」读数是 63744 / 0 —— 一个半影像素都没有。

再把 θ 拖到 6°。影子的左边散成一条带,本影缩到 45697 个像素(占地板 23.60%), 半影自己占 36807 个(19.01%,占整幅画面 6.39%);右边几乎还是硬的。 读数里的「挡住的光源」从 32/32 变成 22/32——V 就是 1 减去这个比例。

拖到 12°。半影涨到 60352 个像素(31.16%),比本影(32454)还宽; 白圈那一点的 V 走到 0.469,被挡住的光源只剩 17/32。 θ 只让影缘散开,不让影心挪动:V = 0.5 那条线始终压在上一段那条硬影缘上 (θ = 6° 时实测在 u = −2.4075,闭式 −2.4142,差 0.007)。

切到「可见性关」:影整个没了,半影当然也没了。半影不是另一种影,它就是 V 本身在铺开 —— 这条链上没有任何新的乘子,只是那个乘子不再只取 0 和 1。

件三 中间量数值表 · 与件二同源
① 场景参数 · 随控件变
项值
② 被监视的那一点 · 逐项
项值
③ 影缘剖面 · 手算可核
α|Δ⊥|挡住的光源Vρ 含Vρ 不含V
③ 每一行都能拿笔算,而且比上一段的③多一列。它是在地板上沿影的长轴、从影心(α = 0) 朝远离光源的那一侧往外走(上一段走的是靠近光源那侧,那里半影窄;半影宽的那一侧才看得出这条带)。 每一行都是一个真实的地板表面点(世界坐标 = 影心 − α × 光源水平方向)。三列是连着的: |Δ⊥| 是球心到这条射线的垂距(上一段那套,小于 R = 1 就撞上);θ = 6° 时地面上 |Δ⊥| = 0.707·α,所以上一段那条硬影缘落在 α = 1.414 这一行。 第 3 列是这一段的全部新东西:32 个采样方向里有几条撞上了球 —— 它给出一个整数, V 就是 1 − 这个数 / 32。
看那串整数怎么走:32 → 32 → 32 → 32 → 32 → 27 → 22 → 15 → 7 → 0 —— 前五行还扎在全影里(整块光源都被球挡住),一到 α = 1.20 就开始漏光,最后一行漏光漏满。 在 α = 1.41 那一行(上一段那条硬影缘)它是 15/32,V = 0.531 —— 差不多正好一半: 那一点出发的射线里,仰角高于 el 的那一半够得着光源、低于 el 的那一半被球挡着。 于是有一条很好记的结论:θ 只把影缘向两侧摊开,影子的「重心」不动 —— V 恰好等于 0.500 的那条线永远钉在 θ = 0 时的硬影缘上(页面自检扫出来在 u = −2.4075, 与硬影缘 −2.4142 差 0.007)。 (15 而不是 16:32 个采样点是按黄金角铺的,正好骑在那条线上的那一点分到哪边是它自己的事 —— 所以这一列给的是整数采样,V 是量化的;要更细就得加采样数,那是 4.3 的账。) 带到 ρ 上看:ρ 不含V 在整排几乎不动(0.193 → 0.204,只有菲涅尔随视线角在微动); ρ 含V 从影里的 0.000 一路平滑爬到 0.204 —— 上一段那个「跳」变成了这条斜坡。
① 里另有三行把半影的两条边摊开:本影边 u = ∓cot((el+θ)/2)、 全亮边 u = ∓cot((el−θ)/2)(右端是 tan)。本档(θ = 6°)代进去是 本影 [−2.097, +0.354]、全亮在 [−2.824, +0.477] 之外 —— 两条半影宽度 0.727 与 0.123,比 5.92,与闭式 cot²(el/2) = 5.83 对得上。 页面自检里另有一份实测:在 ehL 轴上按 0.0002 步长扫出四条边界, 与闭式的最大偏差 0.047(世界单位,θ ≤ 12°)—— 这一点偏差来自 32 个采样点铺不满整个圆盘。
本节获得

知道「软影」并不需要一条新公式:把上一段那一条射线换成撒在光源圆盘上的一簇射线, 数出撞上的比例,V 自然就从 0 / 1 变成 0 到 1 之间的连续值 —— 那个渐变就是半影。 θ = 0 时这一簇退化成一条,画面与上一段逐像素相同(读数 63744 / 0);θ = 6° 时本影缩到 45697 个像素 (占地板 23.60%)、半影自己占 36807 个(19.01%、占整幅画面 6.39%);θ = 12° 时半影 60352 个(31.16%), 比本影还宽,而它横跨了 31 个不同的 V 值 —— 是连续过渡,不是几档跳变。 也才知道这条带的两条边能被笔算出来:本影边 ∓cot((el+θ)/2)、 全亮边 ∓cot((el−θ)/2),本档代进去是 [−2.097, +0.354] 与 [−2.824, +0.477], 页面用 0.0002 的步长在地面上扫过一遍,与闭式最大差 0.047。 最有用的一条是带子往哪边长:V = 0.5 那条线钉在上一段的硬影缘上不动(实测差 0.007), 影缘向两侧摊开,两侧的宽度却差 5.92 倍(闭式 cot²(el/2) = 5.83)—— 因为宽度正比于这一点到遮挡物的距离,不只是光源有多大。影心不动、边在散开、 散开的量两边不一样:这三件事合起来,就是「影子的边为什么不硬」。 也看到那颗球一个采样都没花(空遮挡直接返回、恒 V = 1,球面 154476 个像素与上一段一模一样) —— 而每个地板像素要撒 32 条射线。这个账,是下一段要算的。

4.3

一片影要花多少代价才算得过来

真机可测 · 本机渲染

本节点在骨架的下一环 —— 上一段把影子的边做出了从暗到亮的渐变。可它换来那个渐变的办法,是每个地板像素都撒 32 条射线;而每一条射线都要去问「路上有没有东西」。一颗球还问得起,一片草、一座楼就问不起了。

上一段的代价写得明明白白:地板上的每一个像素,都要把「路上有没有东西」重新问一遍,而「东西」就是场景本身。 本段场景里只有一颗球,一帧就要问 6,197,248 次(193,664 个地板像素 × 32 条);场景里多一颗球,这个数再翻一倍 —— 这支开销里有一项是「场景有多复杂」,而那一项会随美术资源一直涨。 本段只增加一样东西:把「问场景」这一步挪到前面去 —— 先换一个视角(光源自己)把场景渲一遍,渲成一张深度图;之后每个着色点只查这张图的一个格子。 其余与上一段完全相同:同一颗球、同一块地板、同一份 BRDF、同一批 32 个采样方向、同一个 V = 1 − k/N。 θ 还是那个 θ,只是它现在决定「在图上查多大一圈」(半径 = tanθ·d,d 就是上一段那个到遮挡物的距离)。 于是账单从「每像素 × 每采样 × 每物体」变成「一次渲染 + 每像素 × 每采样」: 着色阶段一次都不再碰场景,那一项随场景增长的成本被提前一次性付掉了(本档 6,197,248 → 7,160,约 866 倍)。 代价不是白拿的:图是一张有限的格子。影缘的准度从此不再由采样数决定,而是由一格在地面上盖多大决定 —— 这就是本段要看的那个新误差。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列 → 中间真源码 → 右列。框里是件二里真正在执行的那段 WGSL(逐字抄出,页面对它做了自检); 右列三个数与件三、与画面同源。跟上一段比,中间那几行只有一处不同:打一条射线换成查一个格子 —— diskAt(i) 还是那 32 个采样点(黄金角圆盘),ta / tb 把它们搬到图上, mapAt 读一个图格(越界返回 1.0 = 图上没东西),Ldb < NOGEO && Ldb < Ldp − u.map.w 就是「图里记着的东西比这一点更靠近光源」——和上一段那句 hitOcc 的回答完全对应, 只是回答它的是一张预先算好的图,不是场景本身。rad = tanθ·d 是采样半径(上一段的半影结论原样搬过来), NOGEO 是「这一格上没有东西」的哨兵(清值 1.0 不能被当成一个更近的遮挡物), u.map.w 是深度比较的偏置。 框外还有几行(页面对它们也做了逐字自检):空遮挡的直通「主球自己不挡自己,于是它一个格子都不查、恒 V = 1」, 以及这一点的图上坐标 ap = wp·e1、bp = wp·e2 与它自己的深度 Ldp = (Cmax − wp·l)/range。

这张小图就是本段新增的那个东西本身:光源视角的一张深度图。 它是一张 W×W 的方格纸(本图 256×256,画成 1 格 = 1 像素),每格写着一个数: 从这一格出发、沿着光源方向看过去,最近的东西在多远。 光是从 +l 方向来的,所以球在这一视角下就是一个圆盘: 圆心(a = b = 0)最近,Ld = 0.250;越靠边越远,圆边(|(a,b)| = 1)是 Ld = 0.500; 圆盘之外什么都没有,清成 1.000(这一格上没东西)—— 这几个数都能拿笔算:Ld = (2 − √(1 − a² − b²)) / 4。 白点是被监视的那一点在图上落的位置,周围那一小圈就是它的 32 个采样点(半径 tanθ·d = 0.245, 合 10.5 格)。它们有的落在圆盘里、有的落在圆盘外 —— 落在圆盘里的那几条,就是挡住光的那几条。 外框是这张图的覆盖范围(|a|, |b| ≤ 3):图是有边的,边外这张图答不了。

注意看

把「本段新增」那一档的 深度图分辨率 从 512 拖到 64, 盯住地板影子的左边那条从暗到亮的带。格子在变粗,可是那条带的宽度几乎没变 —— 变的是它被格子啃出的一级级台阶:W = 512 时一格在地面上只占 0.008 个世界单位(约 1 个画面像素), W = 64 时是 0.066(约 8 个像素),一条本该平滑的边就被啃成了锯齿。 这跟上一段的旋钮是两种东西:θ 换的是「影子有多软」,W 换的是「影缘有多准」。

再看读数。分辨率一降,半影像素反而变多了(W = 512 时 37017 → W = 64 时 40025,+8.1%)—— 这不是影子变软了,是那条边被粗格子摊开了:本该一步跨过去的明暗界线,现在被几格糊成了一段。 同时右下那行「本帧问场景」始终是 0 次 —— 着色阶段一次都没碰过场景, 它读的是那张提前算好的图(W = 256 时 6,197,248 次读图,加一次渲染的 7,160)。 而 V = 0.5 那条线照样钉在上一段的硬影缘上(W = 256 时 −2.3960 vs −2.4142,差 0.018):图粗了,影子的重心没动。

件二 可操作活例 · 纯 WebGPU · 控件与上一段相同,只多一档「深度图分辨率」
拖画面 → 转视角(看影缘的台阶)
启动中…
光照项(与上一段相同)
光源方向(与上一段相同 · 也决定图摆在哪个视角)
粗糙度 σ(与上一段相同)
高光怎么算(与上一段相同)
高光瓣要不要归一(与上一段相同)
这一点看不看得见光源(上一段相同)
光源角半径 θ(上一段相同 · 决定在图上查多大一圈)
本段新增 · 那张图有多少格

先把分辨率拖到最大(512)。影子的边是一条平滑的带,跟上一段打射线得到的几乎一模一样 —— 件三 ③ 就摆着两版逐行的对照:32 个采样里最多只差 3 个。

再一路拖到 64。那条带开始被啃出台阶:一格在地面上盖 0.066 个世界单位(约 8 个画面像素), 一级台阶就这么宽。半影像素反而涨到 40025(512 时是 37017)—— 不是影子更软了,是那条边被粗格子摊开了。

拖「光源角半径 θ」。上一段的结论在这里原样成立:采样半径 = tanθ·d, 离遮挡物越远那一圈越大,半影也就越宽。区别只在于:上一段那一圈是 32 条射线, 这一段那一圈是 32 次查图 —— 同一个 θ,换了一种问法。

看右下那行读数。「问场景」永远是 0 次;每帧要付的是 193664×32 = 6197248 次读图, 加一次渲染(球覆盖的 5720 格 + 1440 个三角形)。读图那一项跟场景里有多少东西无关 —— 这正是这张图买到的东西。

件三 中间量数值表 · 与件二同源
① 场景参数与本帧的账 · 随控件变
项值
② 被监视的那一点 · 逐项
项值
③ 影缘剖面 · 两版逐行对照
α|Δ⊥|射线/32图/32射线 V图 V
③ 是本段的验收件:同一条剖面、同一批 32 个采样点,两条路各挡下几个。 它证明「换了一张图」没有把影换掉 —— 32 个采样里最多差 3 个(W = 256,整条剖面细扫的结果; ③ 里这 11 行最多差 1 个),其余行两版逐行相同。α 是地板上的影心到这一点距离(世界单位), |Δ⊥| 是球心到这条射线的垂距(上一段那套,< 1 就撞上)。 W 拖到 64 时这一列会开始出现 ±7 的差,且越靠影缘差得越多 —— 那不是算错了,是图粗了。
② 把被监视点在图上的那几个数摊开:它在图上落在第 (113, 91) 格, 那一格记着 Ld_b = 0.4023;这一点自己的深度是 Ld_p = 1.0834; 图上记着的东西比它近,所以它被挡。32 个采样点的半径 tanθ·d = 0.1051 × 2.3335 = 0.2453 个世界单位 = 10.5 格,于是查图落在离 (113, 91) 十来格的地方 —— 落在圆盘里的那 22 个算被挡,V = 1 − 22/32 = 0.313,与上一段打射线得到的 0.313 一样。
① 里另有两行是本帧的账单:打射线口径要问场景 6,197,248 次(而且场景每多一颗球就翻倍); 查图口径着色阶段问场景 0 次,代价是一次渲染(球覆盖的 5,720 格 + 1,440 个三角形 ≈ 7,160) 加上同样次数的读图。两个数都能按笔算。
本节获得

知道「一片影的代价」可以重新分配:把「问场景」从每个像素问一遍改成一个新视角下先渲一遍, 存成一张深度图,之后每个着色点查一个格子。着色阶段的账单里从此没有「场景有多复杂」这一项 —— 本档问场景 6,197,248 次变成 7,160 次(约 866 倍),而 32 个采样点、V = 1 − k/N、 rad = tanθ·d 这些一个都没变:换的是问法,不是影子(件三 ③ 里两版逐行对照, 32 个采样最多差 3 个)。 也看到了这张图自己长什么样:它是光源视角下的一张高度场,球在图上就是一个圆盘, 每格的数能按笔算 Ld = (2 − √(1 − a² − b²))/4(圆心 0.250、圆边 0.500、圆外清值 1.000)。 最要紧的是它把误差换了一种:影缘准不准不再看采样数,而看一格在地面上盖多大 —— W = 512 时 0.008 个世界单位,W = 64 时 0.066(八倍),影子的边就从一条平滑的带被啃成锯齿; 而 V = 0.5 那条线照样钉在上一段的硬影缘上(W = 256 时差 0.018)——图粗了,影子的重心没动。 还看到一个边界:图是有边的一个盒子(本档 |a|, |b| ≤ 3),盒子外这张图答不了 —— 真实工程里为此要级联好几张图(本课附录 A1)。 到这里,阴影这条链就走完了:可见性是乘子 → 面积光源给它渐变 → 预渲一张图把它付得起。 下一课换一条链:材质与形状。

运行时自检 等待脚本…

跑不起来先看设备要求:活例由真 GPU 出画面 —— 手机需 Android 12+ 且 Chrome 121+,或 iOS / iPadOS 26+;电脑需 Chrome / Edge 113+ 或 Safari 26+。Android 上取不到适配器多是设备不支持 Vulkan 1.1+,会自动改走兼容模式(需 Chrome 146+)。不满足时画面区会写出原因与自查步骤,同一页的示意图与数值表照常可读。