内容工厂. / 演示空间 / PBR 高级效果 · 第 2 课 · 经验模型那一代 段 1 / 4
PBR 高级效果 | 第 2 课 | 上一课:光从哪里来

经验模型那一代

读时 约 14 分钟 本课 4 段 每段 三件套 保真度 真机可测
课题骨架(本课题只声明一次)

着色不是「给表面涂颜色」,而是解一个积分:对每个着色点,把所有入射方向的贡献按 BRDF 加权累加。 PBR 的全部内容就是——怎么把这一个积分算得既接近物理、又跑得动。 这个课题沿着一代代模型走:每一代都是对同一个积分更好的一次近似。
被解的那个积分本体现在写出来:L_o(p→v) = ∫Ω f_r(l,v) · L_i(p,l) · max(0, n·l) · V(p,l) dωl —— 被积函数是 BRDF × 入射辐亮度 × 入射余弦 × 可见性 四项之积,积分域是法线那一侧的半球。 本课只用点光源与方向光,所以每一段画面上算的都是它在该光源方向上的单点离散。
单位也对一遍:f_r 是 sr⁻¹,乘上 L_i · dω(radiance × sr)才回到 radiance —— 所以漫反射写出来必须带 1/π(f_r = α/π):少了它,白炉 ∫ f_r·cosθ dω 会正好大 π 倍。 本课从这一版起把这一步显式化,每一页的算式里都能看见那个 1/π。

2.1
环境光与朗伯
2.2
漫反射也有粗糙度
2.3
高光的形状
2.4
它什么时候崩
2.1

环境光与朗伯

真机可测 · 本机渲染

本节点在骨架的第二步 —— 光已经有了形状,接下来是拿它算一个表面。最早的算法只有两项。

最早的一代着色只有两项。一项是环境光:假装环境里到处都是均匀的散射光,没有方向。 另一项是朗伯漫反射:粗糙表面把光均匀散射开,亮度只取决于光与法线的夹角。 两项合起来就能给出一个"有立体感的球"——但这个立体感的来源值得追问一句:它跟观察者有关系吗? 本段只增加一样东西:环境光与漫反射这两项。

件一 含公式的数据流示意图 · 两项各自的性子
左列是两项的输入,中间是它们各自的式子,右列是结果。右列的那行字是本段的关键: 漫反射的式子里没有 v。
注意看

纯环境光那一档,球会变成一个没有层次的平盘——因为它的式子跟位置、跟法线都无关。

件二 可操作活例 · 纯 WebGPU · 拖画面转视角,看数动不动
在球上拖 → 转视角(光源不动)
启动中…
光照项
光源方向

先在球上拖一拖。整个球在转、明暗区域在屏幕上移动,可件三的 n·l 和漫反射值一个都不动——因为式子里没有观察方向。

再拖光源那两根滑块。这次 n·l 会立刻跟着变。 两件事的效果差别,就是"与视角有关 / 无关"的实操区别。

动手

球上白圈是在监视的那个点。注意看:转视角时,圈会随着表面一起转、甚至转到背面去,但它的 n·l 始终是同一个值(—)——表面点没变,法线就没变,夹角就没变。

件三 中间量数值表 · 与件二同源
① 场景参数 · 随滑块变
项值
② 被监视的那一点 · 逐项
项值
③ 转视角的记录 · 它变了吗
视角n·l漫反射
③ 每松开一次鼠标记一行。视角一路在变,后两列却始终不变——这就是可验算的「与视角无关」。 ② 的漫反射值也能手算:(α/π) · I_l · max(0, n·l)(α = 0.86 是反照率, 而漫反射是 BRDF、单位 sr⁻¹,所以算式里带 1/π)。
本节获得

知道最早的一代着色由两项组成,而这两项的性质完全不同:环境光没有方向,所以它不产生任何层次; 漫反射有方向(n·l),所以它能塑形——但它的式子里没有观察方向, 因此漫反射与视角无关。记住这一点:后面高光会引入观察方向,而那正是它和漫反射最根本的区别。

2.2

漫反射也有粗糙度

真机可测 · 本机渲染

本节点在骨架的第二步 —— 上一段把表面当成「已经散得足够匀」,于是只剩夹角 n·l。可「匀到什么程度」本身就是一个量。

上一段只解决了「光有方向,所以有立体感」,可它偷偷假设了表面是理想粗糙的: 光一进来就被打散得彻彻底底,亮度只由夹角 n·l 决定。真实表面不是这样 —— 它的微面朝向有一圈散布,散布多大,就是粗糙度 σ。σ 一进式子,漫反射就多出两个系数: 一个把整体压低(A),一个在「光与眼同一侧」时补一点回来(B)。 本段只增加一样东西:粗糙度 σ(也就是 Oren–Nayar 的 A、B 这两个系数)。 光照项、光源方向、拖视角三样与上一段完全相同,只多一根 σ 滑块。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列 → 中间真源码 → 右列。框里是件二里真正在执行的那段 WGSL(逐字抄出,页面对它做了自检); 右列三个数与件三、与画面同源。式子里那个观察方向 v 只乘在回射项 B 上—— σ 拖回 0 时 B = 0,它便从式子里消失,式子退回上一段的朗伯。
注意看

右列三个数其实是同一个括号的三级读数,而 A 与 B 的走向相反:σ 越大,A 越小(把整体压低)、 B 越大(在回射方向补一点)。可 σ=1 时 B(0.413)还不到 A(0.624)的量级,所以净效果是变暗,不是变亮。

件二 可操作活例 · 纯 WebGPU · 控件与上一段相同,只多一根粗糙度
拖画面 → 转视角(光源不动)
启动中…
光照项(与上一段相同)
光源方向(与上一段相同)
本段新增

先拖「粗糙度 σ」。它只改那个括号:正对光源那一点的括号就是 A 本身(那里 s = 0), 越往明暗交界走 B 补得越多。于是 σ 推大时,峰值掉得最狠、交界几乎没被压 —— 整片对比度被摊平,球变得又暗又"粉"。

留意 σ=0 这一档:把滑块拖到最左,括号回到 1.000、A = 1、B = 0, 本节完全退回上一段。粗糙度不是"多加一项",它是把朗伯当成一个极限包含进来。

再拖光源那两根滑块。这次变化比 σ 大得多——光源一动,夹角 n·l 与括号同时变。 两件事的差别就在这儿:σ 只动括号,光源动的是夹角。

动手

先在球上拖一拖:n·l 一动不动(它只认表面与光源,和上一段一样;件三 ② 的 n·l 行可以盯着看)。 再拖「粗糙度 σ」:白圈钉住那一点的读数开始掉,而明暗交界的位置一动不动—— σ 不动 n·l,它只动括号。把 σ 拖回 0,读数回到朗伯值。

件三 中间量数值表 · 与件二同源
① 场景参数 · 随滑块变
项值
② 被监视的那一点 · 逐项
项值
③ σ 扫描 · 手算可核
σAB括号漫反射
③ 每行都能拿笔算:A = 1 − 0.5σ²/(σ²+0.33)、B = 0.45σ²/(σ²+0.09)、 括号 = A + B·(s/t),而 s/t = 0.0893 由几何定死(就是 ② 的 s、t 两行),σ 改不动它; 漫反射 = (α/π)·I_l · n·l · 括号 = (0.86/π) × 0.9440 × 括号—— 括号整个属于 BRDF(f_r = (α/π)·括号),所以 1/π 在括号外面; 括号这一列作为"相对朗伯的倍数"不受它影响。 括号这一列同时就是「相对朗伯」的倍数——σ=1 那行的 0.661,意思就是只剩朗伯的 66.1%。
② 里拖视角只有 n·v 与 s/t 会动(它们进的只有回射项);n·l 与括号里的 A 与视角无关。
本节获得

知道朗伯只是「粗糙度为零」的那一档:粗糙度不改变明暗交界在哪,它改的是一个括号—— 正对光那点乘 A、越往掠射由 B 补回来,于是峰值掉得最狠、交界几乎没掉,漫反射被摊平。 也顺带看到这个模型的一个毛病:σ 一大,漫反射的总能量就少了(σ=1 时正对光那点只剩 62.4%)—— 那是「换一个约束:能量守恒」那一课要修的事。

2.3

高光的形状

真机可测 · 本机渲染

本节点在骨架的第二步 —— 上一段给漫反射加了粗糙度,可它改来改去只动一个括号,球面上始终是一片缓坡。真实表面还缺一样东西:那道又小又亮的斑。

上一段只解决了「粗糙的漫反射」,可它有一个改不掉的性子:不管站在哪儿看,那片缓坡都一模一样 —— 因为漫反射的式子里只有夹角 n·l,没有观察者。真实表面还有第二种光:镜面高光。 它像一面镜子 —— 只有当你恰好站在光源的反射方向上,那块亮斑才照进你眼里。 所以高光的式子里必须多出一个量:观察方向 v。v 一动,亮斑就在表面上滑动; 这是它和漫反射最根本的区别。算这块亮斑有两种经典写法:Phong 先算出光源的反射向量 r, 再看 r 与 v 有多重合;Blinn-Phong 不去算 r,改用一个夹在光源与视线中间的半程向量 h。 本段只增加一样东西:镜面高光项(它把观察方向 v 引进来),并在 Phong 与 Blinn-Phong 之间切换。 光照项、光源方向、粗糙度 σ 三样与上一段完全相同,只多一组按钮。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列 → 中间真源码 → 右列。框里是件二里真正在执行的那段 WGSL(逐字抄出,页面对它做了自检); 右列三个数与件三、与画面同源。式子里除了 n、l,还多了一个 v —— 它就是高光随视角移动的原因;r 是反射向量(Phong 用它),h 是半程向量(Blinn-Phong 用它)。 最后一行里那个 ct 是出射余弦 max(0, n·l)(定义在片元开头), 高光与漫反射共用它 —— 少乘它,高光就没有对应的入射投影。
注意看

右列前两个数是两种算法的底数:r·v 与 n·h。同一几何下它们常常差得很远 —— 在默认这条视线下,r·v 只有 0.20 上下,而 n·h 有 0.75。底数被同一个指数一放大,差距就拉成天壤: 一个几乎为 0,另一个还有 0.1 —— 这就是「半程向量把高光加宽」的算术来源。

件二 可操作活例 · 纯 WebGPU · 控件与上一段相同,只多一组「高光怎么算」
拖画面 → 转视角(高光会跟着走)
启动中…
光照项(与上一段相同)
光源方向(与上一段相同)
粗糙度 σ(与上一段相同)
本段新增

先拖画面转视角。球上的亮斑会随之滑动 —— 因为它由观察方向 v 决定; 而明暗交界(由 n·l 定)纹丝不动。件三 ③ 里 n·l 那一列可以盯着看。

再切 Phong / Blinn-Phong:同一条视线、同一个光源,两个亮斑的形状不同 —— Phong 的斑更窄更硬;Blinn-Phong 的斑更宽,而且在掠射角不会整块消失 (Phong 的 r·v 会掉到 0 以下,一取 max(0,·) 就被掐掉)。

切到「高光关」,画面就退回上一段。

动手

先在球上拖一拖:白圈钉住那一点的高光值在变,而它的 n·l 一动不动 —— v 进了高光的式子,却进不了漫反射的式子。再把高光切到「关」:亮斑消失,画面与上一段逐像素一样。 最后在两档 Phong / Blinn-Phong 之间来回切:注意看亮斑的大小 —— Blinn-Phong 的大一圈。

件三 中间量数值表 · 与件二同源
① 场景参数 · 随控件变
项值
② 被监视的那一点 · 逐项
项值
③ 视角扫描 · 拖画面上下
pitchn·lr·vn·hPhong·n·lBlinn·n·l
③ 每一行都能拿笔算:底数两列由几何定死(r·v、n·h), 两列高光各自是它们的 8 次方、再乘出射余弦 cosθ = max(0, n·l) (高光指数 n = 8,本段固定,不驱动)—— 漫反射与高光都带这个余弦,后面的白炉才平。 pitch 上下扫的时候,n·l 那一列纹丝不动,而 r·v 与 n·h 一路在动 —— 漫反射的底数不认视角,高光的底数认视角:这就是「观察方向进了式子」。
② 里 r 是反射向量、h 是半程向量;切换模型只换底数,其余一项都不动。
本节获得

知道高光与漫反射是两路来头不同的光:漫反射由 n·l 定,跟站在哪儿无关; 高光由观察方向 v 定,所以随视角移动 —— 同一个表面点,视角一转,高光值就变。 也看到两种算法的差别落在同一个底数上:Phong 用反射向量、底数 r·v; Blinn-Phong 用半程向量、底数 n·h。同一几何下 n·h 通常比 r·v 大, 指数一放大,Blinn 的斑更宽;而 Phong 的 r·v 一旦掉到 0 以下就被 max(0,·) 掐掉, 亮斑会在掠射角整块消失。这一课的下一页才回答:这样拼出来的高光,能量对得上吗。

2.4

它什么时候崩

真机可测 · 本机渲染

本节点在骨架的第二步 —— 上一段把漫反射与高光拼到一起、给了一个"看起来对"的球,却留了一句话没答:这样拼出来的高光,能量对得上吗。

上一段只解决了"高光随视角移动",可它是把两条来路完全不同的式子直接相加: 漫反射那一项拿 α/π·I_l,高光那一项拿 k_s,两个系数各自独立、谁也没保证"瓣发得完"。 账面把它们加起来是 0.274 + 0.60 = 0.874,看着没越界 —— 但那只是记账:真判据是白炉(把这一点放进均匀环境光,看它出射多少),而经验那一列当场越过 1。 那么"某个条件"到底是哪个条件? 本段只增加一样东西:把画面换成 PBR 并排对比(同光源、同粗糙度),让左边那一代经验模型自己去撞这条线。 光照项、光源方向、粗糙度、高光怎么算,四组控件与上一段完全相同。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列 → 中间真源码 → 右列。框里是件二里真正在执行的那段 WGSL(逐字抄出,页面对它做了自检)。 看它最后两行:漫反射项与高光项各自乘一个系数,然后相加,中间没有任何东西把它们的和压住 —— 右列第三个"合计"就是这两项相加(不含环境光)。它越过 1 的那一档,就是本段的答案。
注意看

右列三个数:前两个是经验模型的漫反射与高光(各自都到不了 1),第三个是它们的和。 把两项写成辐射度量形(漫反射 α/π、高光再乘出射余弦)之后, 账面和只有 0.274 + 0.60 = 0.874 —— 系数相加不再是判据。 真判据是白炉:把这一点放进均匀环境光(L_i ≡ 1、V ≡ 1)看它出射多少 —— 件三 ③ 的「经验白炉」那一列就是它,每一档都远大于 1,而且粗糙度越大越狠。 越界依旧成立,只是判据从"系数相加"换成了"白炉积分"。右边 PBR 那一列同样有上限, 但它被同一份预算绑在 1 以下(见件三)。

件二 可操作活例 · 纯 WebGPU · 一画布两视口:左经验 / 右 PBR(同光源 · 同粗糙度)
左:经验 | 右:PBR
启动中…
光照项(与上一段相同)
光源方向(与上一段相同)
粗糙度 σ(与上一段相同)
高光怎么算(与上一段相同)

先只盯左边。两边的光源、粗糙度、视角是同一套,差别只在着色口径。 默认 σ = 0.5 时左边球面上就已经有一片反射预算越过 1 的像素(画面右下那行读数给出个数,约 0.7 万)—— 那片就是高光瓣与漫反射叠在一处的地方;把 σ 继续推大,越界的面积还会涨(σ = 0.9 时约 0.8 万)。 右边 PBR 在同一个 σ 下始终没有这种像素。

再拖画面转视角:高光的落点在两边一起移动(两边用的是同一个 v)。 把它拖到球面上"正对光"的那一侧,经验那一边越过 1 的面积会更大 —— 因为"又亮又有高光"的地方,两项的和最容易冲过 1。

切到「高光关」,两边都只剩漫反射:经验那一边是 Oren–Nayar 的括号,PBR 那一边是朗伯乘 (1−F0), 左球比右球略暗一点,但两边都远在 1 以下 —— 那一片的差别不是本段要看的。

动手

把「粗糙度 σ」推到 0.9:左球那片泛白明显变大,右球仍是一颗正常的球;画面右下读数的末段 从 越界 约0.7万 涨到 越界 约0.8万(读数里会给确切值)。 注意看左边那片泛白不是高光变亮,而是高光瓣被铺得很开之后, 与漫反射叠在了同一片区域上 —— 0.86 与 0.60 加到了一处。

件三 中间量数值表 · 与件二同源
① 场景参数 · 随控件变
项值
② 被监视的那一点 · 逐项
项值
③ σ 扫描 · 手算可核
σn(σ)经验 ρPBR ρ经验白炉越界
③ 每一行都能拿笔算:先把粗糙度换算成高光指数 n = 2/σ² − 2(经验模型没有"粗糙度"这个量, 只能硬造这一步),再 ρ(经验) = (α/π)·I_l·n·l·括号 + k_s·(n·h)^n·n·l、 ρ(PBR) = (1−F0)·(α/π)·I_l·n·l + F0·(n·h)^n·n·l。 ρ 只算画面里开着的那两项(光照项关掉就没漫反射、高光关掉就没高光); 两边的高光都带出射余弦 n·l,所以这两列可以直接并排比。 括号就是 2.2 那个 A + B·s/t;三个系数 α = 0.86、k_s = 0.60、 F0 = 0.35 本段固定。PBR 的 ρ 从不超过 (1−F0)·α/π + F0 = 0.528; 经验那一列的账面上限是 α/π + k_s = 0.874 —— 它已经不再越过 1。
所以"越界"这件事从账面搬到了白炉(见右边那一行):ρ 是记账 (两项系数 × 形状因子),它只说"预算怎么分",不保证瓣发得完; 守恒的真判据是 ∫ f_r·cosθ dω ≤ 1 —— 而经验那一列并不满足它。 ② 里 ρ 是不含环境光的反射预算(环境光属于另一路的入射项,混进来会说不清); 括号与高光底数 n·h 都在 ② 里逐项列出,可以对着 ③ 的算式核。
本节获得

知道经验模型那一代为什么会在某个条件下崩:它的漫反射与高光来自两套互不相干的经验, 两个系数各自独立、直接相加,没有任何机制让这个和停在 1 以下; 而"粗糙度"在它这里根本不存在,只能硬造一个 σ → 高光指数 的换算 —— 粗糙度一推高,高光瓣被铺开,正好落在"又亮又有高光"那一片上,于是和越过 1: 表面反射出的光多于照进去的,不可能。也看到 PBR 在同一光源、同一粗糙度下没有这个问题 —— 它的两个系数被同一个 F0 绑住,和天生封在 1 以下。 为什么它能这样绑、那个 F0 又是什么,是下一课要拆的事。

运行时自检 等待脚本…

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