内容工厂. / 演示空间 / PBR 高级效果 · 第 1 课 · 光从哪里来 段 1 / 3
PBR 高级效果 | 第 1 课 | 本课题第 1 课

光从哪里来

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

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

1.1
三种入射项
1.2
衰减为什么要夹住
1.3
面光源与高光形状
1.1

三种入射项

真机可测 · 本机渲染

本节点在骨架的第一步 —— 积分的第一项是入射光本身。它的形状决定了后面一切。

讲着色的人常常默认「光是一个方向」——那是方向光。可现实里的光有位置: 灯泡有位置,所以离得近的墙面更亮;舞台灯还有锥角,只有一圈被打亮。 这三种光源给着色点的入射项形状完全不同,而着色代码本身一个字都不用改—— 换的只是入射项怎么算。 本段只增加一样东西:入射项的形状(方向光 / 点光源 / 聚光灯)。

件一 含公式的数据流示意图 · 三种入射项的形状
左列是 kind 的三个取值,中间是件二里真正在跑的那段 WGSL,右列是形状。 注意右列第一行是常数——方向光的入射项跟位置无关,这是它和另外两种最根本的区别。
注意看

光源类型换掉时,球上的亮斑形状完全不同:方向光是一整片斜坡,点光源是一个随距离变亮的斑, 聚光灯是一个圆。可球本身、相机、着色代码一个字都没动。

件二 可操作活例 · 纯 WebGPU · 同一组滑块驱动三种光源
拖画面 → 转视角(光源不动)
启动中…
光源类型
光源位置 / 方向

先拖「距离」。点光源下球明显变亮变暗;切到方向光再拖——一点反应都没有, 因为方向光没有位置,距离对它没有意义。

再切「聚光灯」,拖「锥角指数」:指数越大,打亮的圆越小越硬。这就是舞台灯的那种感觉。

切回「点光源」把距离拖到最近,球的近侧会整片爆白——那不是页面的 bug, 正是下一段要解决的问题:1/d² 在近处会失控。

动手

白圈是被监视的那一点。注意看:切到方向光时,不管把光源拖多远,件三那行的 d 有意义但衰减恒为 1——距离不进它的式子。

件三 中间量数值表 · 与件二同源
① 光源 · 当前档
项值
② 被监视的那一点 · 逐项
项值
③ 同一点在三种光源下
光源亮度
③ 把同一个点在三种光源下的亮度并排放着——它们同时算出来,但只有当前那一档会画到画面上。 亮度可以手算:(α/π) · max(0, n·l) · 衰减 —— α 是反照率(值 0.80,无量纲), 而漫反射是 BRDF、单位是 sr⁻¹,所以算式里必须带那个 1/π(f_r = α/π)。 这一笔就是白炉(能量守恒)要的:∫ f_r·cosθ dω = α ≤ 1。
本节获得

知道"光"不只是一种:方向光没有位置、点光源有位置(所以有距离衰减)、聚光灯还多一个锥角。 三种光源换的只是入射项 L_i 的形状,而着色代码不用改—— 这一点在后面每一代模型里都成立:算法管的是怎么用 L_i,不管 L_i 从哪来。

1.2

衰减为什么要夹住

真机可测 · 本机渲染

本节点在骨架的第二步 —— 上一段末尾把光源拖到最近,球的近侧整片爆白了。这一段就解决它。

原因很简单:1/d² 在 d→0 时会失控。光源贴近表面时,衰减因子能涨到几十上百, 算出来的亮度远远超过显示器能表示的上限——于是近侧全白,明暗细节整个丢掉。 最朴素的夹法是在分母上加一个常数。可这笔交易有代价,而代价不在近处。 本段只增加一样东西:衰减分母上的常数 eps。

件一 含代码的数据流示意图 · 件二里真正在跑的 WGSL
右列是同一个球上远近两点的亮度。加 eps 之后近处被压住了——但远处那行也在往下走。
注意看

eps 不是"只影响近处的开关"。它在分母里,对远近一视同仁——所以它是拿远处的对比度来换近处不过曝。

件二 可操作活例 · 纯 WebGPU · 控件与上一段相同,只多一个
拖画面 → 转视角(光源不动)
启动中…
光源类型(与上一段相同)
光源位置(与上一段相同)
本段新增

把 eps 停在 0,看近侧那片白。再慢慢加大 eps:白区收住了、球的明暗回来了。

然后把目光移到靠近明暗交界的那一圈——它也在变平。 这就是这个夹法的账:近处的过曝是拿边缘的对比换来的。

切到「方向光」再拖 eps:毫无变化——方向光根本没有衰减项,没有东西可夹。

动手

件三 ③ 把两个点并排放着。注意看:eps 从 0 加到 2,近处那一列掉得很快,远处那一列也在掉—— 而且只要 eps 不为 0,远处就永远回不到不加 eps 时的对比。

件三 中间量数值表 · 与件二同源
① 参数 · 当前档
项值
② 被监视的那一点
项值
③ eps 扫描 · 远近两点
eps近处远处
③ 的数据全部可手算:亮度 = (α/π) · n·l / (d² + eps)。 注意 eps 改的是入射项(它进的是衰减因子的分母),不是 BRDF —— 衰减属于 L_i 那一侧;算式里那个 1/π 才是 BRDF 自己的(f_r = α/π)。 「近处」取正对光源那一点;「边缘」取偏 35° 那一点——它仍在受光侧,但离光源更远、也很暗。
本节获得

知道 1/d² 在近处会失控,而最朴素的夹法(分母加常数)是拿远处的对比换近处的不过曝。 真实引擎里用的是带窗口的衰减函数——只在近处压、不动远处,而且让它在有限距离真的归零 (这样光源才有"射程"可以剔除)。本段只演示了最朴素那种夹法,以及它的代价。

1.3

面光源与高光形状

真机可测 · 本机渲染

本节点在骨架的第三步 —— 前两段里,光无论怎么调都是一个点:入射项一变,球上的亮斑只是换个位置、换个大小。可骨架要的是「把所有入射方向的贡献累加起来」,一个点只是这个积分的一个采样。

现实里的灯有大小:灯管是一条线,窗户是一块面。最朴素的做法是把它拆成 N 个点光源,各自算一遍再平均—— N 越大,越接近真正的面积光。
而这件事只有高光会如实照出来:漫反射只看夹角,几乎不管光源长什么样(它只把明暗交界磨软一点点); 高光则把光源的形状直接印在球面上。本段只增加一样东西:采样数 N(灯管拆成几个点)。 滑块、位置、衰减全部与上一段相同,变的只有这一个数。
为了看得见形状,这一段把高光项打开——用的是最老的那种,把 max(0, n·l) 取一个很高的幂。 它只跟光源方向有关、跟视角无关,是这一段的观察工具;观察方向进到高光式子里,是下一课的事。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段 WGSL
左列是这条灯管的几何,中间是件二里真正在执行的源码(常量与循环体逐字抄出,只省了行尾注释), 右列是高光形状的三个数。右列与件三、与画面同源:都由同一个 JS 算式产出。 页面对「框里的每行是否真是 shader 的逐字片段」做了自检。
注意看

右列第三行是判定,它只比两个角:「相邻两个采样点相对脊点张开的角度」和「高光本身的半宽」。 谁大谁小,决定球上的高光是一串分开的点还是一条连续的带——而这两个角都能拿笔算出来。

件二 可操作活例 · 纯 WebGPU · 控件与上一段相同,只多一个采样数
拖画面 → 转视角(灯管不动)
启动中…
光源类型(与上一段相同)
光源位置(与上一段相同)
衰减(与上一段相同)
本段新增

把「采样数 N」从 1 慢慢推到 16:1 是一个针尖,2 变成两个点,4 还是几个分开的点, 8 以上连成一条带——带的方向就是灯管的方向。(件三 ③ 那个临界值是 6.3。)

同时留意两件事:带的峰值只有那个针尖的约四分之一(件三 ② 的「亮度」会从 2.10 掉到 0.52—— 同一份能量摊在更长的一条上);而同一片漫反射几乎没动,只差 0.0163,且差在明暗交界那一圈(半影的苗头)。

默认这一档灯管正对相机:亮带横着铺在球的正中。拖画面把视角转开,带会跟着挪到侧面—— 贴着球的轮廓时,带会被投影挤扁、看不全,转回中间才舒展。

切到「方向光」再拖 N:一动不动——方向光没有位置,也就没有形状。聚光灯下 N 照样起作用 (灯管的形状一样透出来),只是锥角把偏离轴线的采样点压暗了,那条带因此更淡。

动手

白圈钉在高光最亮的那一点上(正对光源中心的那点)。注意看:N=1 时它圈住一个圆点; N 推到 16,它圈住的是一条横着的亮带,而它自己的位置一直没动——动的是光源的形状。

件三 中间量数值表 · 与件二同源
① 面光源参数 · 当前档
项值
② 高光脊点 · 最亮的那一点
项值
③ N 扫描 · 手算可核
N间距 s角间距2θ½判定
③ 全部可手算:采样间距 s = 2·half / N;脊点到光源中心 d = dist − 1(球半径是 1); 相邻采样相对脊点张开的角度 Δθ = s / d(灯管垂直于「脊点→光源」,所以系数正好是 1); 高光半宽 θ½ = √(2·ln2 / P)。Δθ 比 2θ½ 小,点就连成带。
注意两件事:Δθ 不能用 s 直接除球心到光源的距离——要除的是脊点到光源的距离 d,两者差的正是球半径那 1; 另外 s/d 是小角近似,采样点离中心远时真实张角略小(N=2 那行:给 33.4°,实际 32.5°),判据结论不变。
这一段本来就是整课唯一的「真积分」:Σj f_r · L_i(lj) · cosθj / N 就是 ∫ f_r · L_i · cosθ dω 的 N 点离散;N = 1 时退化成单点求值—— 前两段一直用的那个式子,正是它 N = 1 的样子。
一处如实说的风险:这里的高光还不算 BRDF —— SPEC_K = 3.0 若当 BRDF 系数就是 3.0 sr⁻¹, 没有归一。这一刻它不越界(白炉只有 0.116,因为 P = 160 的瓣极窄、均匀环境里几乎采不到它), 但"没归一"这件事在瓣变宽时会失控 —— 那正是 3.3 要收的账。
② 的「亮度」取 G 通道(三个通道的系数略有差异:0.62 / 0.68 / 0.74);高光项与漫反射一样要乘出射的余弦 cosθ = max(0, n·l)——这里就是 SPEC_K · (n·l)^P 再乘一次 n·l。
本节获得

知道「光有多大」这件事只有高光会如实照出来:漫反射只看夹角,光源形状只让它把明暗交界磨软一点; 高光把光源的形状直接印在球面上。也知道最朴素的面光源做法——拆成 N 个点光源再平均: N 越大越接近真实的面积光,高光越宽、峰值越低(同一份能量摊开了),代价是计算量变成 N 倍。 这就是为什么实时渲染至今仍在用「少数几个代表点」或按重要性采样方向来近似它。

运行时自检 等待脚本…

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