着色不是「给表面涂颜色」,而是解一个积分:对每个着色点,把所有入射方向的贡献按 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ω 会正好大 π 倍。
本节点在骨架的第一步 —— 积分的第一项是入射光本身。它的形状决定了后面一切。
讲着色的人常常默认「光是一个方向」——那是方向光。可现实里的光有位置: 灯泡有位置,所以离得近的墙面更亮;舞台灯还有锥角,只有一圈被打亮。 这三种光源给着色点的入射项形状完全不同,而着色代码本身一个字都不用改—— 换的只是入射项怎么算。 本段只增加一样东西:入射项的形状(方向光 / 点光源 / 聚光灯)。
kind 的三个取值,中间是件二里真正在跑的那段 WGSL,右列是形状。
注意右列第一行是常数——方向光的入射项跟位置无关,这是它和另外两种最根本的区别。
光源类型换掉时,球上的亮斑形状完全不同:方向光是一整片斜坡,点光源是一个随距离变亮的斑, 聚光灯是一个圆。可球本身、相机、着色代码一个字都没动。
先拖「距离」。点光源下球明显变亮变暗;切到方向光再拖——一点反应都没有, 因为方向光没有位置,距离对它没有意义。
再切「聚光灯」,拖「锥角指数」:指数越大,打亮的圆越小越硬。这就是舞台灯的那种感觉。
切回「点光源」把距离拖到最近,球的近侧会整片爆白——那不是页面的 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/d² 在 d→0 时会失控。光源贴近表面时,衰减因子能涨到几十上百, 算出来的亮度远远超过显示器能表示的上限——于是近侧全白,明暗细节整个丢掉。 最朴素的夹法是在分母上加一个常数。可这笔交易有代价,而代价不在近处。 本段只增加一样东西:衰减分母上的常数 eps。
eps 不是"只影响近处的开关"。它在分母里,对远近一视同仁——所以它是拿远处的对比度来换近处不过曝。
把 eps 停在 0,看近侧那片白。再慢慢加大 eps:白区收住了、球的明暗回来了。
然后把目光移到靠近明暗交界的那一圈——它也在变平。 这就是这个夹法的账:近处的过曝是拿边缘的对比换来的。
切到「方向光」再拖 eps:毫无变化——方向光根本没有衰减项,没有东西可夹。
件三 ③ 把两个点并排放着。注意看:eps 从 0 加到 2,近处那一列掉得很快,远处那一列也在掉—— 而且只要 eps 不为 0,远处就永远回不到不加 eps 时的对比。
| 项 | 值 | |
|---|---|---|
| 项 | 值 | |
|---|---|---|
| eps | 近处 | 远处 |
|---|
亮度 = (α/π) · n·l / (d² + eps)。
注意 eps 改的是入射项(它进的是衰减因子的分母),不是 BRDF ——
衰减属于 L_i 那一侧;算式里那个 1/π 才是 BRDF 自己的(f_r = α/π)。
「近处」取正对光源那一点;「边缘」取偏 35° 那一点——它仍在受光侧,但离光源更远、也很暗。
知道 1/d² 在近处会失控,而最朴素的夹法(分母加常数)是拿远处的对比换近处的不过曝。
真实引擎里用的是带窗口的衰减函数——只在近处压、不动远处,而且让它在有限距离真的归零
(这样光源才有"射程"可以剔除)。本段只演示了最朴素那种夹法,以及它的代价。
本节点在骨架的第三步 —— 前两段里,光无论怎么调都是一个点:入射项一变,球上的亮斑只是换个位置、换个大小。可骨架要的是「把所有入射方向的贡献累加起来」,一个点只是这个积分的一个采样。
现实里的灯有大小:灯管是一条线,窗户是一块面。最朴素的做法是把它拆成 N 个点光源,各自算一遍再平均——
N 越大,越接近真正的面积光。
而这件事只有高光会如实照出来:漫反射只看夹角,几乎不管光源长什么样(它只把明暗交界磨软一点点);
高光则把光源的形状直接印在球面上。本段只增加一样东西:采样数 N(灯管拆成几个点)。
滑块、位置、衰减全部与上一段相同,变的只有这一个数。
为了看得见形状,这一段把高光项打开——用的是最老的那种,把 max(0, n·l) 取一个很高的幂。
它只跟光源方向有关、跟视角无关,是这一段的观察工具;观察方向进到高光式子里,是下一课的事。
右列第三行是判定,它只比两个角:「相邻两个采样点相对脊点张开的角度」和「高光本身的半宽」。 谁大谁小,决定球上的高光是一串分开的点还是一条连续的带——而这两个角都能拿笔算出来。
把「采样数 N」从 1 慢慢推到 16:1 是一个针尖,2 变成两个点,4 还是几个分开的点, 8 以上连成一条带——带的方向就是灯管的方向。(件三 ③ 那个临界值是 6.3。)
同时留意两件事:带的峰值只有那个针尖的约四分之一(件三 ② 的「亮度」会从 2.10 掉到 0.52—— 同一份能量摊在更长的一条上);而同一片漫反射几乎没动,只差 0.0163,且差在明暗交界那一圈(半影的苗头)。
默认这一档灯管正对相机:亮带横着铺在球的正中。拖画面把视角转开,带会跟着挪到侧面—— 贴着球的轮廓时,带会被投影挤扁、看不全,转回中间才舒展。
切到「方向光」再拖 N:一动不动——方向光没有位置,也就没有形状。聚光灯下 N 照样起作用 (灯管的形状一样透出来),只是锥角把偏离轴线的采样点压暗了,那条带因此更淡。
白圈钉在高光最亮的那一点上(正对光源中心的那点)。注意看:N=1 时它圈住一个圆点; N 推到 16,它圈住的是一条横着的亮带,而它自己的位置一直没动——动的是光源的形状。
| 项 | 值 | |
|---|---|---|
| 项 | 值 | |
|---|---|---|
| N | 间距 s | 角间距 | 2θ½ | 判定 |
|---|
s = 2·half / N;脊点到光源中心 d = dist − 1(球半径是 1);
相邻采样相对脊点张开的角度 Δθ = s / d(灯管垂直于「脊点→光源」,所以系数正好是 1);
高光半宽 θ½ = √(2·ln2 / P)。Δθ 比 2θ½ 小,点就连成带。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 的样子。SPEC_K = 3.0 若当 BRDF 系数就是 3.0 sr⁻¹,
没有归一。这一刻它不越界(白炉只有 0.116,因为 P = 160 的瓣极窄、均匀环境里几乎采不到它),
但"没归一"这件事在瓣变宽时会失控 —— 那正是 3.3 要收的账。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+)。不满足时画面区会写出原因与自查步骤,同一页的示意图与数值表照常可读。