着色不是「给表面涂颜色」,而是解一个积分:对每个着色点,把所有入射方向的贡献按 BRDF 加权累加。 PBR 的全部内容就是——怎么把这一个积分算得既接近物理、又跑得动。
本课把积分搬进了介质内部:前面四课的光都在表面上交代完了 —— 反射掉、或者透进去之后就不再管它。 可水、血、叶子、布、雾不是这样:光进去以后还要走一段路,路上一直在被吃掉; 而雾更狠 —— 被吃掉的那部分还会从别的方向散射回来;蜡和玉再狠一层 —— 那部分还要在里面散很多次,从别的地方出来。于是「颜色」这个词第一次离开了表面: 它变成沿途剩下多少、再加上沿途又加进来多少。
本节点在骨架的换域环上 —— 上一课的收束句是「参数表是艺术接口,不是物理」,可那句话有个前提:光在表面就交代完了。这一课把这个前提拿掉。
到上一课为止,光只有两种结局:在表面被反射(那就走了),或者透进去 ——
而「透进去」之后的那个颜色,前面几课一直当作一件已经定了的事(就是底色 baseColor)。
可你只要看过一杯浓茶就明白这件事是错的:同一杯茶,薄薄一层是琥珀色,厚厚一杯近乎黑 ——
颜色在路上就变了。
本段只增加一样东西:一条穿过介质的路径(厚度 d)与「每单位长度吃掉多少」(吸收系数 σa)。
光走完这条路之后剩下的比例只有一条算式 —— Beer–Lambert:T = exp(−σa·d)。
三个要点,件三都能拿笔核:① 三个通道各吃各的(σa 是按波长给的),谁先吃完谁先退场 ——
所以「水的蓝」不是水有一层蓝漆,而是红光走不了几米就没了、剩下来的是蓝;
② 衰减是指数,不是线性:厚度翻倍不是亮度减半(件三 ③ 把这两条并排摆着,差多少一目了然);
③ 有一条只由 σ 决定的特征长度 —— 走 1/σa 那么远,只剩 1/e ≈ 0.368。它就是「这杯茶有多浓」的物理刻度。
d 是这一列光走过的路(左端 0、右端 D);T 是三个通道各自剩下的比例,
只与 σ 和 d 有关;右列第一个数就是红光那一项,顺手就能拿计算器核
(exp(−0.35×2) = 0.4966)。
为什么这一段的件二不是真机 GPU:介质内部发生的事在图形 API 层不可观测 ——
没有一条硬件指令会告诉你「这束光在水里被吃了多少」。按本站的保真度口径,这一档只能是
示意 · 等价重现:行为真实存在,算式可逐位复现,但不声称硬件产出。
把厚度 d 从 0 慢慢拖到最长,盯住上面那条色带:左端(d = 0)必须是纯白 ——
因为 T ≡ 1,一个光子都还没被吃掉(这就是本段最要紧的还原性判据,件三 ① 最后一行在报它)。
往右拖的过程中注意两件事:整体变暗(所有通道一起掉),而且 色调在走 ——
它会从白偏到青、再偏到蓝,因为红光的 σ 最大、最先退场。水的「蓝」就是这么来的:不是染上去的,是剩下的。
再把线性对照打开,比较同一条色带的形状:指数那条在开头掉得很快、尾巴拖得很长, 线性那条是一条直着下去的斜线。三件 ③ 把同一个 d 下两边的数并排摆着 —— 例如清水档走到红光的特征长度(1/σR = 2.857)时,指数还剩 0.3679,线性已经归零(差 0.3679)。 换个说法你就记得住:「浓一倍就暗一半」是错的 —— 厚度翻倍是平方关系(件三 ① 里 0.7047² = 0.4966,正是 d = 2 那一行)。
| d | TR | TG | TB | 剩下的颜色 |
|---|
| 项 | 值 | |
|---|---|---|
T = exp(−σ·d),最后一列是三通道一起剩下的那个颜色。
注意 d = 0 那一行必须是 1.0000 / 1.0000 / 1.0000(逐位) —— 这是本段的还原性判据。
② 里第一行是各自的特征长度 1/σ(走那么远只剩 1/e ≈ 0.36788),
它把「这个介质有多浓」压成了三个数;最后两行是同一档下指数与线性的并排读数。
本段只加了一条路径,就把「颜色」这个词从表面上搬了下来:
1/σ 那个特征长度才是「浓淡」的刻度。上一段把颜色从表面上搬了下来(光走过一段路被吸收)。可直到现在,那个「表面」还是一块不动的板 —— 本段给它装上几何。
真实水面由许多列方向、波长不同的波叠加而成,本段按海洋学里最常用的做法取这一组:每列的波长自己决定自己的速度,
由深水色散关系 ω = √(g·k) 给出 —— 这不是装饰,它才是「长浪跑得快、细浪拖着走」的来源。
波面用 Gerstner 摆线:把「水平坐标也一起推一下」,于是波峰变尖、波谷变平(chop 就是这一下的量)。
本段只增加一样东西:一个会动的高度场,以及随之而来的三件事 ——
① 法线由高度场的解析导数直接给出(不用差分,也不多算一次高度);
② 界面按 Schlick Fresnel 分账(空气/水 F₀ ≈ 0.02:俯看几乎全透、掠看几乎全反);
③ 光进水之后仍按上一段的 Beer–Lambert 衰减,而路径长度就是视线在水里走的那一段。
另外本段给出泡沫的物理判据:波面被推得自交时(Jacobian ≤ 0)才起白沫 —— 而不是「高度超过某个值就白」。
ω²=gk(件三 ①)、Fresnel 端点 F(0°)=F₀=0.02(件三 ②)、
吸收 T(1/σ)=e⁻¹(接上一段)。泡沫那一行是本段的判据:只有折叠(J ≤ 0)才起白,
拖大 chop 能看着它从「个别浪尖」涨成「成片白冠」。
先把风速拖到中档,看三件事:① 远处的浪比近处「慢」—— 因为它们波长更长(ω=√(gk),件三 ① 每行的相速列);
② 俯看时水面透、掠看时水面反 —— 把视高拖低,整片水会突然变成一面镜子(Fresnel 在 80° 之后陡涨,件三 ② 有数);
③ 水色随浊度变 —— 同一片波、同样的天,只是光在水里被吃得快些。
再把 chop 一直拖到底(3.0 —— 它是让波面「自交」的那个量,折叠判据就在这一档触发),盯住浪尖:泡沫是从自交处冒出来的(不是从「高」处冒出来的)——
可以先关掉泡沫开关看一眼尖浪,再打开,看白沫怎么沿着那些尖峰铺开。件三 ③ 里报的是本帧的折叠像素比例。
| 列 | λ (m) | k (1/m) | ω (rad/s) | 相速 c=ω/k |
|---|
| 项 | 值 | |
|---|---|---|
ω=√(9.81·k) 手核;相速那一列就是「长浪跑得快」的数值证据。
② 上半是 Schlick Fresnel 在四个入射角的值(F(0°) 必须逐位等于 F₀=0.02、F(89°)→1);
下半是本帧的水面读数:折叠像素比例(泡沫只在它非零处出现)、平均路径长、以及帧时与内部分辨率。
本段只加了一个会动的高度场,于是「表面」这个词终于立起来了:
ω=√(gk)):长浪快、细浪慢,海面的「层次」来自这一条,不是来自振幅大小。J ≤ 0 决定,与「哪里高」无关 —— 这一条把泡沫从贴图拉回了物理。本节点在骨架的换域环末端 —— 上一段给介质装上了会动的表面,可那个表面背后的世界仍然一片空白:光进去、被吃掉,然后就没有下文了。
到现在为止,本课的介质全都是半无限的:水底下还有好几米,光走进去就再也回不来。 所以上一段那块「水底」,其实只是一件用来看折射的摆设 —— 它自己不发光,也没有任何光从它那一侧穿过来。 可叶子不是这样:它只有零点几毫米厚,光从背面进去、穿过、再从正面出来。 逆光下的叶子之所以是亮的,就是因为它比水少了「半无限」这个前提。 本段只增加一样东西:一个出口界面(把「半无限的水」换成「一张薄片」)。 随之而来三笔账,件三全部摊开: ① 光是进来扣一次、出去扣一次 —— 两道界面各按 Fresnel 扣(叶面蜡质 n = 1.5 ⇒ F0 = 0.04,正入射扣 4%); ② 中间那段路照旧按 6.1 的 Beer–Lambert 衰减,只是路径换成了薄片里的那一条(正对叶片时它就等于厚度 d); ③ 还有一笔最容易漏的:光在片内会来回折,每折一趟多扣一次 F² 和一段 2d 的吸收 —— 把这些趟数全加起来是个几何级数。 件三 ② 会告诉你这一笔在叶子上值多少:三个通道分别只有 0.031% / 0.083% / 0.008% —— 几乎为零,因为叶绿素把每一趟都吃掉一遍。 它只在透明薄片上才显著,而把角度推到掠射就能把它放大 —— 那是下一段的事。
g。
先把 厚度 d 从 0.05 拖到 0.6,盯住叶肉:薄到 0.05 mm 时绿通道还透过 0.85(叶肉是亮的黄绿), 厚到 0.6 mm 只剩 0.34、红蓝几乎归零(变成一块深绿近黑的剪影)—— 这三个数就是件三 ① 的三列。 中途注意叶脉:主脉厚 6.6 倍(基部)、侧脉 3.2 倍,所以总是先暗下去,逆光下那些暗线就是叶脉网本身 (实测主脉亮度 23,叶肉 132 —— 只有一成七)。下方面板是过叶脉的一条剖面:厚度曲线每起伏一次,三条透过率曲线就同时下陷一次。
再把下面那个开关按到「退回上一段:光出不来」—— 整片叶子会塌成近乎全黑。 这一步是本段的判据:上一段的半无限模型里,光进了介质就永远不会从另一侧出来, 所以那里根本没有透射光这一项(件三 ② 第六行报的就是这个 0)。叶子在逆光下的亮度,全部来自本段多出来的那一个界面。
最后把上面那个开关按到「只算单趟」,你会发现画面几乎没变 —— 因为「来回折」这一笔在叶子上最大也只有 0.083% (件三 ② 报它)。它要等到介质几乎不吸收、且掠射时才显出量级:把叶绿素浓度拖到最低可以看着它涨起来。
两个开关都按到右边,就是 6.2 的模型(半无限):光进去就没了。
exp,于是本段不需要再写一条 WGSL:
6.2 那条 GPU 通路是为射线步进准备的,这里没有射线步进。画面尺寸就是读数:
下方面板与状态条报的是「写入 N 像素、其中多少落在叶内」。
叶脉与叶形不是贴图,全是厚度的画法:主脉从基部 6.6 倍收到叶尖 4.7 倍、侧脉 3.2 倍(越靠叶缘越细、
越朝叶尖弯)、细脉网 1.24 倍、叶缘 0.68 倍(越往叶尖越薄,叶基最厚),叶肉还有一层大尺度的厚薄不匀。
叶形也不再是那个「宽 0.83、长 0.73 叶单位」的梭形:现在是一条长宽比 1.42 的卵形叶 ——
圆钝的叶基、渐尖的叶尖、两侧叶基不在同一排、叶缘一列非对称锯齿,叶基之下那段叶柄按 6.0 倍画、还朝一侧弯。
所以逆光下叶脉是暗线网(主脉实测 23、叶肉 132)、叶缘是一条亮边、叶柄是黑的一段(叶子因此长在枝上,不是浮着的)。
这一组数不是随手取的:它们就是判据 ⑧ 量到的东西(叶形长宽比 1.44 与扫线上的叶脉暗线条数 7 / 7 / 8)。
| d (mm) | τ(R/G/B) | 单趟 e−τ | T(R/G/B) | 叶肉的颜色 |
|---|
| 项 | 值 | |
|---|---|---|
τ = σ·d、T = (1−F)·e^(−τ)·(1−F)·g,最后一列是叶肉处三通道一起剩下的颜色。
注意 d = 0.05 mm 那一行:三个通道里最大的 τ 也只有 0.37,所以 T 很大、颜色是亮黄绿;到 0.6 mm 时红蓝早已归零,只剩一点绿。
② 第一行是 F₀(由 n = 1.5 逐位给出 ((n−1)/(n+1))² = 0.04);第二、三行是两道界面在正入射下的总账
(再薄的叶片也挡掉 7.7%:(1−F)/(1+F) = 0.923077,这是几何级数的和,不是近似);
第四、五行是「来回折」那一笔的具体大小 —— 在叶子上它只有万分之几,因为往返两趟都被叶绿素吃掉;
第六行是那个开关的语义:关掉出口界面 ⇒ 透射项为 0(退回上一段的半无限 —— 光进去出不来,只剩正面那 4% 的反光)。
本段只加了一个出口界面,却把「介质」这个词补完了:
(1−F)/(1+F) —— 正入射下再薄的叶片也只透 92.3%。上一段把介质做成了一张薄片,但它有一个前提在段首就钉死了:镜头正对叶片(入射角 0) —— 所以内部路程就是厚度 d,Schlick 给的 F 也恒等于 F0。本段把这个前提拿掉。
角度一放开,同样一件「薄片」会给出两个相反的答案,而差别不在角度,在表面是什么形状:
① 对一块平板,F 从 0.04 一路涨到 0.92 —— 透射被两道界面掐死,掠射下的平板就是一面镜子;
而内部那一段路反而涨不动:Snell 把介质里的角度封在临界角上(n = 1.5 ⇒ θt ≤ 41.81°),
余弦最多压到 0.7454,路程最多长 1.3416 倍。上一段预告过的那笔「来回」正是在这里才涨起来
(g 从 1.0008 涨到 1.5349)—— 可它涨起来也没用,(1−F)² 已经把它压没了。
② 可绒布在掠射处反而更亮。原因不在 Fresnel,在几何:布的表面不是一块板,
是一层竖起来的纤维。这一层里大部分是空气 —— 光走直线,没有临界角给它封顶,
于是「路径 = 厚度 ÷ cosθ」这条 6.3 已经写下的式子,在平板里被压在 1.3416 倍,在绒毛里可以长到几十倍。
更要紧的是:一条纤维是一根圆柱,圆柱的高光条件是半程向量垂直于轴;
而绒毛的轴就是法线 —— 于是只有掠射那一圈才满足条件,边缘因此比中心亮。
本段只增加一样东西:入射角。6.3 里钉死在 0 的那个角,这一段放开;放开之后才知道,
同一个角落在两种几何上,答案是反的。
h / cosθ,没有任何上限。
下半框里那几行就是件二画每个像素时真正执行的代码(件二是本页自己算的,所以放的是 JS 而不是 WGSL)。
先把 光照方向从 0 拖到 80°,盯住每条褶的坡面(不是褶脊):亮带不是跟着光走,是绕着光走 ——
它永远落在「半程向量与纤维垂直」的那一圈上(T·H = 0),所以光一转,亮带就绕到另一边去
(头灯下两条坡面各一条,光拖到 60° 就只剩一侧亮)。而正对着你的褶脊一点不变。
这一步说明绒布的高光不由光源方向「照亮」,由纤维的朝向筛出来。
再把 绒毛层厚度 h 从 0.1 拖到 3 mm:短绒毛时坡面比褶脊还暗(那只是一块普通酒红布, cosθ 一压就掉下去),颜色也还是深酒红;光晕比在 0.4 mm 附近越过 1,之后一路涨到 10 倍以上 —— 但亮度很快饱和(绒毛层再厚也不会比「全不透明」更厚)。同时注意颜色:绒毛越长, 坡面越是被冲成灰白,而褶脊的深酒红一直没动。
最后按到「拔掉绒毛:一块平板」—— 同一幅帘子立刻退回一块深酒红的布:亮带塌掉, 坡面的饱和度从 0.20 回到 0.78。这一步是本节的正题:掠射本身不会让东西变亮, 是掠射那圈上的纤维正对着光;而「高光处褪色」是调研补上的那一条 —— 真绒布在那里褪成灰白, 靠的就是这一层不带底色的表面反射。
两个开关都按到右边,绒布就被拆成了「一块布」加「一层纤维」两项。
T·H = 0。
纤维表面自己的 Fresnel 只有 0.04(掠射那一圈上,光正好落在纤维的侧面,局部接近正入射)——
这正是它不像镜子的原因:单根只反 4%,只是被照到、又被看见的纤维有成千上万条。
KS = 1/F₀ = 25 就是「有效纤维条数 × 那 4%」的合计,是本页唯一的模型常数:
它取成 1/F₀,意思是「这一层绒毛恰好在最亮的那一圈把 4% 凑成满值」,所以它是天花板而不是测量值。
底下那条浅色直方条是这块布的法线倾角分布——它告诉你这条曲线被"走"到了哪一段。
底布是深酒红、绒毛项几乎不带色(换色是调研补上的第三条:Disney 说表面反射不带底色),
于是亮带在坡面上褪成灰白、褶脊上的深酒红一直不动 —— 那正是绒布最容易被认出来的地方。
叶片那一侧的数(41.81° / 1.3416 / 0.9191)由 Snell 与 Schlick 逐位给出,可以拿笔核。
| θv | cosθv | 路径 1/cosθ | 绒毛项 | 底布项 | sheen 占比 |
|---|
| 项 | 值 | |
|---|---|---|
路径 = h/cosθ,绒毛项在头灯(光跟着眼睛)下就是
KS·ρ·(1−e^(−βh))·F(sinθ)·sinp+2θ —— 把光照方向拖到 0 可以逐行核。
注意 θv = 0 那一行:绒毛项必须是 0.0000(正对着看时 T·H = 1,条件不成立),
这是本段的还原性判据。最后一列是 sheen 在总亮度里的占比 —— 它就是"褪色"的来源:
45° 时一半、60° 时八成七、到最陡处 100%(底布那一路已经全被绒毛挡掉了)。
② 同一个 89° 落在两种几何上:平板那一边路径封在 1.3416 倍、F 涨到 0.9191(变镜子);
绒毛那一边路径长 57.30 倍、而纤维表面的 Fresnel 只有 0.0400 —— 差 23 倍,还差在相反的方向上。
平板的 τ 用的就是 6.3 那一档(d = 0.2 mm 的绿通道 = 0.33),两个数不是各写一份。
最后两行是这一版按查证结果补上的:参考形状换成垂褶(球的用处与局限),以及
高光带的颜色(表面反射不带底色,所以坡面褪成灰白)。
本段只放开了一个角,就把「介质」这一课的最后一层补上了:
前面四段的光,账都记在表面上:弹回来(6.2),或者进去走一段再出来(6.1 / 6.3 / 6.4)。介质在这四段里只做一件事 —— 吃掉光:6.1 那条 e^(−τ) 只说「还剩多少」,从没说过被吃掉的那部分去哪儿了。
雾把答案摆得最清楚:它没被吃掉,是被散到别的方向去了 —— 其中就有你眼睛这个方向。
所以雾不是把远处变暗,是把它变亮。
本段只增加一样东西:介质自己也把光发出来(沿视线一路累积)。这条一加上,三件反直觉的事立刻出现:
① 远处的黑柱子不会变成黑,会变成雾的颜色。透射那一路 e^(−τ) 确实把它推向 0,
可沿途那一项 (1−e^(−τ)) 正把它推向饱和值 —— 两条账合起来,远处物体的亮度趋于「空气光」,
而不是趋于 0。6.1 里没有这一项,所以那时的水是越远越黑。
② 于是对比度衰减与亮度饱和是两件事:对比度按 e^(−τ) 掉(远处的东西和背景分不开),
亮度却趋于一个非零值。雾天里「看不见山」不是山变黑了,是山和天空同色了。
③ 而那个「雾的颜色」不是一个可以随便挑的常数:它是 入射光 × 单散射反照率 × 相函数 ——
而且入射光有两路:整个天穹给的那一路与「看哪儿」无关(雾因此整体是亮的),
太阳给的那一路走相函数、跟着太阳的方向变。逆光(光从远处朝你照)时前向瓣对着你,
雾发亮、有光晕;顺光时那一瓣掉下去,剩下的偏冷。常见那套
L = mix(L表面, 雾色, 1−e^(−τ)) 里的「雾色」就是这个量,只是被当成了常数。
保真度如实标「示意 · 等价重现」:体积散射在图形 API 层不可观测,本页按下面那条单散射算式重现;
并明确假设入射光在介质内是均匀的(不沿光路再衰减)—— 这是标准雾的理想化,写出来是为了每一步都能拿笔核。
v15 又按一手资料补了四处(起因是把上一版量出来看,见件二下方那张「量出来的四条」):
① 雾层以上接上真大气(气溶胶标高 1200 米 + 气体标高 8000 米,参数取自 Bruneton 与 Hillaire 的一手表),
天空由一张 64×64 的 Sky-View LUT 查出来,太阳盘按真角半径 0.2678° 单独解析地画;
② 太阳那一份拆成单次(e^(−τ_sun),厚雾里被吃干)与多次(宽瓣,衰减慢得多)两项 ——
「雾里那颗太阳」本来就是多次散射,上一版没有这一项;
③ 补一条 HDR 管线:曝光 → bloom → ACES → 编码,高光从此是滚降而不是一刀切;
④ 补抖动 + 颗粒,把色带与「过于干净」一起治掉。
e^(−τ) 一路削弱,
空气那一路却被沿途每一小段往里加一点、再按 (1−e^(−τ)) 累到饱和。
两路的和就是像素值 —— 远处之所以不是黑的,全在第二路。
散射角 θ 决定「加多少」:4π·p(θ) 就是相函数,θ = 0(逆光、光正朝你走来)时它最大。
下半框里那几行就是件二画每个像素时真正执行的代码(第三、四行是一句,为了放进框里折了行)——
只有一个 z 式的假设写在正文里:入射光在介质内均匀(不沿光路再衰减)。
先把 消光 β 从 0.002 拉到 0.06,盯住最远那两层山脊:它们不是变黑,
而是一层层褪进天空里 —— 最近那层还看得见轮廓,最远那层已经和天空同色;而脚边的地面几乎不动。
这条滑杆还顺带把「能见度」接上了:气象上用 Koschmieder 关系 V = 3.91/β,
β = 0.006 换算出来约 652 米(v15 把默认档从 0.018 放松到 0.006 —— 0.018 对应能见度只有 217 米,
六层山脊全糊在同一片雾里,看不出「一层层褪」这件事;能见度六百多米才能看见层次)。
再把 相函数 g 从 0.2 拉到 0.95、太阳方位在 0°–180° 之间转:同一片雾, 朝着太阳那一侧又亮又白(Mie 的前向瓣),背着太阳那一侧偏冷偏蓝 (前向瓣掉下去了,剩天穹那一路的冷白 —— Rayleigh 那点 ∝1/λ⁴ 的蓝就在这里)。 默认的 g = 0.85 是雾滴那一档(粒径 5 μm 上下),所以太阳周围会有一圈日晕: 它不是画上去的,是相函数自己长出来的;g 拉小,晕就散开、整个天变成一块平白。
然后按到「只吸收:ω = 0」:整幅画立刻塌下来,连最亮的天空也一起没了, 只剩脚边一点地面。这就是 6.1 那一版的画面:介质只会减法,雾的亮是散射还给你的。
最后把 高度衰减 k 拉大:雾会沉到地面附近,头顶先清出来、地平线那一条仍然糊着 —— 真实雾就是这样贴着地长的。
v15 新加的一对对照,请务必按一次:把「色调映射」按到右边(关)。 画面立刻变回上一版那个样子 —— 太阳周围糊成一大片 255 的纯白平顶、天空出现一圈圈色带。 再按回来(ACES + 曝光 + bloom + 颗粒),同一批 HDR 数字就长回了滚降、色带也没了。 这两个画面之间差的不是「特效」,是一条管线顺序:曝光 → 色调映射 → 编码, bloom 属于色调映射之前。顺便把 β 拉到 0.002(薄雾)看一次: 雾一薄,天空表就透出来了 —— 天顶偏蓝、地平线发白,太阳盘重新显出来(真角半径 0.2678°, 在这幅画里只有几个像素宽),周围那圈是 Mie 的前向瓣。 β 拉回 0.006,太阳盘又会被雾吃掉(τ_sun ≈ 3.4),只剩它旁边那圈多次散射的柔光 —— 这就是真实雾天里 「看不见太阳、但知道太阳在哪」的那件事。
三个开关都按到右边:像素里就只剩「沿途散射进来的那一路」,而且是直接截断的那一版 —— 就是研究里点名最假的那一张(255 的平顶 + 色带),拿它跟左边对照着看。
density = β·e^(−k·y),本页 k = 0.008 ⇒ 标高 125 米,比真实大气压低,好让 400 米内看出层次);
② 天空里有一颗低悬的太阳(高度角 14.5°,v15 从 12° 抬上来一点,好让它从山脊后面露出来),日晕由 Mie 相函数自己长出来(Hillaire EGSR 2020 指出日晕来自
Mie 相函数,且是低频的,所以低分辨率也够);③ 可褪色的层次:六层山脊(40–400 米,由近到远逐层褪进雾里)
+ 中景一排针叶树(30–100 米,同一排里最远的那几棵已经淡掉)+ 近处田野,这是雾景最典型的构图;
④ 相函数分成两路:Mie(雾滴粒径 ≈ 5 μm,几乎不挑波长 ⇒ 白色眩光,这是雾呈现白色的原因)
与 Rayleigh(气体分子,∝1/λ⁴,蓝光的散射概率约为红光的 3 倍 ⇒ 远山那点蓝),
两者都按 ∮(4πp/4π)dω = 1 归一化。
L = L_天空或物体 · e^(−τ) + L_air · (1 − e^(−τ)),
而 L_air 自己也是两路之和:
L_air = ω · { L_amb·C_amb(天穹·各向同性) + E_sun·[ (1−WR)·4π·p_Mie(cosθ) + WR·4π·p_Rayleigh(cosθ) ](太阳·带方向) }。
第一项与「看哪儿」无关 —— 整个天穹都在照亮雾体,雾因此整体是亮的;第二项才带方向,
θ 是视线与太阳方向的夹角 —— 看着太阳是前向、偏白,背着太阳只剩第一项那点冷蓝。
ω 是单散射反照率(开关给 0.9 / 0),WR = 0.28(雾滴以 Mie 为主,所以雾偏白),
默认 g = 0.85(雾滴那一档)。
(上一稿 L_air 只有带方向的那一项、没有天穹那一项,于是离太阳越远雾越暗 ——
画面左半边直接塌成一片淡黑的天空。那不是雾,那是黄昏。)
== 255 的纯白平顶占视口 1.21%
(研究给的线是 < 0.5%,根因正是「直接把 HDR 截断到 255」);② 最平滑区域的噪声
σ = 0 ~ 0.31 DN,而真实照片在 1–3 DN —— 画面「过于干净」,合成元素与素材噪声不匹配
就会被一眼看出;③ 一条 744 像素宽的横带上只有 42 个不同亮度值、相邻像素亮度差的
中位数是 0 —— 典型色带(8bit 只有 256 级,低对比渐变的量化台阶);④ 天空在天顶方向
死平 —— 原来的天顶/地平渐变被 e^(−τ) 乘成了 0,是一段死代码。
对着这四条改:接真大气表(修④)、太阳盘改真角半径并把单次散射按 e^(−τ_sun) 衰减
(修①)、补多次散射宽瓣、补 HDR 管线(修①)、补抖动与颗粒(修②③)。
另外把地面反照率从 0.46 压到 0.22、天穹给地面的那一份从 0.50 压到 0.33 —— 加了色调映射之后
线性域的比值会被压缩,近地亮度不压下来就会和天空一样亮,整幅画发平。
| 距离 d | τ | 透射 e(−τ) | 空气光那条路 | 合计(黑柱子) |
|---|
| 项 | 值 | |
|---|---|---|
τ = β·e^(−k·hcam)·(1 − e^(−k·dy·d)) / (k·dy)(高度雾的解析积分),
远处那一列是 L_air·(1−e^(−τ)),合计那一列是「一根全黑山脊」的像素值 ——
它不会掉到 0,而是停在 L_air 上(400 米处两者只差 0.1%,那点差就是 e^(−τ) 本身)。
② 同一层山脊,两种写法给的答案不一样:只吸收(ω = 0)时它是 0(黑的就是黑的);
吸收 + 散射时它是 L_air·(1−e^(−τ))。再往下两行是这一版按资料补上的:
能见度(Koschmieder V = 3.91/β,把这根滑杆接到气象上)与
相函数(前后向比 ((1+g)/(1−g))³,以及空气光的色偏 R÷B:
朝着太阳 > 1(前向瓣压过天穹那一路的冷白)、背着太阳 < 1(只剩天穹那一路)——
Mie 与 Rayleigh 的分工)。
② 表里后四行是 v15 对着「量出来的四条」新加的:单次散射那一份的衰减因子
(e^(−τ_sun),τ_sun 由雾层与大气两层解析给出)、多次散射那一份
(衰减指数只用 γ × τ_sun,所以它在厚雾里活下来)、太阳盘的真角半径与它在这幅画里的像素直径、
色调映射把天空放到哪一档、过曝占比是多少,最后一行是抖动与颗粒的噪声 σ ——
后三个数就是那三条「假」的量化对照。
本段只加了一条「介质自己也发光」,第 6 课的介质才真正闭环:
e^(−τ) 只说了一半:它说「还剩多少」,没说「挡掉的那部分去哪儿」。
加上沿途散射项之后,介质才既能变暗(吸收)又能变亮(散射)。e^(−τ) 掉,亮度趋于 L_air。
所以「远处看不清」有两种完全不同的成因 —— 一种是黑(吸收),一种是同色(散射);雾是后者。入射光 × ω × 相函数,而入射光还分「天穹那一份」与
「太阳那一份」。那个常数雾色只是它把所有方向都抹平(相函数退化成各向同性)之后的特例。上一段把账加全了(透射 + 沿途散射进来的那份),可它有一个没写出来的前提: 光是沿着你那条视线散射的 —— 每个像素只问「这条射线上进了多少」。所以 6.5 那条式子要成立, 介质必须够稀:一次散射就出去。
蜡、玉、牛奶、皮肤、蜡烛不是稀介质:光进去以后散了很多次,早就忘了自己从哪儿进来 ——
于是它会从你根本没看的地方出来。逆光时耳朵边上那圈红就是它:光从背后进来,在里面绕了一圈才出来。
这条一加上,前五段的整套方法就失效了:像素不再对应「一条射线」,而是对应
一群按概率走路的光子。
本段只增加一样东西:介质里散射很多次。换的也只有算法那一处 —— 把「沿视线积分」换成
「在介质里掷骰子走路」。三件反直觉的事:
① 散射不产生光,它只改变光从哪儿出来:把散射拉满,逃出去的总量几乎不动,
动的是那圈光晕的形状。而吸收一路上按走过来的
总路长扣钱(6.1 那条 e^(−σa·L) 现在按路长算,不再按直线距离);
② 散射越强,光晕反而越小:出射的横向尺度是扩散长度
δ = 1/√(3σaσtr),它随散射增大而缩小——
「撒得越开」是错的:撒得开的是方向,不是位置。各向异性也不单独成为一个自由度,
它与散射强度合成一个 σtr = σa + σs(1−g);
③ 颜色成了「走了多远」的函数:同一块介质,边薄的地方光走得短、厚的地方走得长,
于是薄处偏红、厚处偏暗 —— 皮肤、玉、蜡的「厚度感」全在这儿。把 σa 的三个通道差开,
一块白蜡立刻变成耳朵。
保真度如实标「示意 · 等价重现」:介质内部的多次散射在图形 API 层不可观测,本页按
蒙特卡洛随机行走重现(件一框里那六行就是件二真正在跑的)。两处理想化写在件二下方。
e^(−σa·L)。
先看上表面那一带:最亮的地方就在入射点正下方,然后按 δ(扩散长度)往外衰减 —— 这就是「一块蜡在灯下发光」的那个柔光晕,它不是模糊,是光子真的走到了那里才出来的。 默认这一档:逃出 78%、光晕中位半径 0.69 毫米(δ = 2.01 毫米)。
再把 σs 从 0.5 拉到 16(σtr 涨 26 倍):轨迹从「一条几乎直的线」 变成一团煮开的线团 —— 可逃出的总量几乎没动(83% → 81%),变的是那圈光晕的大小: 中位半径 1.92 → 0.29 毫米,缩了 6.6 倍。散射只搬家,不发光;而撒得开的是方向,不是位置。
然后把 σa 拉大:光晕同时变小变暗 —— 一路按路长扣钱,摊得最开的那一圈先没。 最后把 厚度 d 拉薄:下表面那一路由暗变亮(d = 2.4 毫米时逃出 78%,d = 0.4 毫米时 96%)—— 同一块材料,薄的地方"透"、厚的地方"实",这就是 6.1 那条指数在几何上翻出来的样子。
v16 新加的那一对对照,请务必按一次:把「界面」按到右边(界面开),η 停在 1.44(蜡那一档)。 同一片介质、同一批光子,逃出比例立刻掉下来(件三 ② 里两档并排写着)—— 盯两处: ① 撞界面的账:那一行会报出「撞到界面 N 次、其中全内反射 M 次」,M 是实打实的非零 —— 落在逃生锥(θ < θc = asin(1/η) = 44.0°)外的光被界面弹回去接着走,于是有些轨迹会贴着上下表面来回折; ② 中位出射距离:光在里面待得更久、走得更远,光晕因此比关界面时摊得更开 —— 而进来的那一步也少了 3.25%(F₀ = ((η−1)/(η+1))² = 0.0325)。 再把 η 往回拖到 1.00:逃生锥张成整个半球、F₀ 归零,画面立刻退回「界面关」那一档 —— 也就是说,前五段(以及本段前七条判据)赖以成立的,正是这个 η = 1 的理想化。 最后把 η 拉到 1.60(玻璃那一档):逃生锥只剩 38.7°、1/η² = 39.1%,光被关得更死。 这一条之所以值得单列,是因为它把「蜡不是半透明」这句话补全了:光出不来,一半的原因不在吸收,在界面。
v18 再加一对对照,请按一次:把「σs 也分色」按到右边(σa 分色开着)。
盯上表面那圈光晕 —— 同一个入射点,三个通道的中位出射距离分开了:
R 1.155 · G 0.976 · B 0.419 毫米(红光晕是蓝的 2.2 倍宽,而解析的 δR÷δB = 4.95)——
因为红光的散射自由程本来就长 4.3 倍(σs ∝ λ−4),它走得远,蓝光早早被散射与吸收留住。
再盯一件事:下表面的红蓝比几乎不再随厚度变(薄板 1.56、厚板 1.57)。
这与上一档正好相反 —— 只分 σa 时,厚下去 R÷B 从 1.48 涨到 2.16("越厚越红");
一旦 σs 也分通道,红光的"有效厚度"本来就小,色偏主要由"谁走得远"定,不再靠厚度累积。
两句话都对,但它们是两个机制在管同一件事 —— 这正是把开关分成两个的理由。
顺带说一句代价:分色档每通道的光子数掉到约 1/3,所以画面上会比单色档"麻"一点(读数里如实报出光子数)。
前四个杆与 6.5 是同一组:σs + σa 就是那一段的消光 β(这里拆开了), 相函数 g 是同一格。厚度 d 是 6.6 新增的 —— 有了多次散射,"多厚"才第一次成为参数。 η 与「界面」开关是 v16 补的那处真实实现:默认先关着(好让 6.1–6.5 的账与本段那几条判据逐位对得上), 按到右边就是真实介质 —— 看逃出比例会掉到多少。 「σs 也分色」是 v18 补的那处真实实现:按到右边就从"三通道共用一条路径" 变成"按波长分别行走"(红光的散射距离是蓝的 4.3 倍)—— 两处对照的账都写在件二下方。
e^(−r/δ)/r,用来核「扩散长度 δ 到底对不对」。
esc() 里就藏着 v16 的界面:它现在会返回"出没出去" ——
被 Fresnel 反射、或者落在逃生锥外(全内反射)时它返回 false,光留在介质里接着走。
| 横向距离 r | 出射(上表面) | 出射(下表面) | 衰减比(实测) | e(−Δr/δ)(解析) |
|---|
| 项 | 值 | |
|---|---|---|
τ = σtd、σtr = σa + σs(1−g)、
δ = 1/√(3σaσtr)。桶里的计数是真的掷出来的(同一颗种子可复现),
最后两列是逐段的衰减比(5 桶并一段)与解析的 e^(−Δr/δ):
最里面那一段两者几乎一样(实测 0.72 对解析 0.72),说明光晕的内圈就是 δ 定的;
再往外实测衰减得比解析慢(0.55 / 0.47 / 0.39)—— 板形边界的扩散解在远端比 e^(−r/δ) 更肥,
本页照实给出这一偏离(判据⑤用的是"δ 变、光晕跟着变"的相对标定,不受这个偏离影响)。
② 第一组是本页那一档的实测账;带「判据里那一组」的几行是固定参数下的一组对照
(开机算一次,拖动滑杆时不动):
「散射只搬家」—— σs 拉 32 倍,逃出总量只动几个百分点,而光晕缩了 6 倍多;
「1−g 是一个组合」—— 同 σtr 的两组 (σs, g) 光晕同尺度(实测差 ~20%,
那是扩散近似本身的精度,不是逐位相等),而只改 g 就差一倍多;
「δ 标定」—— σs 翻倍使 δ 缩 √2 倍,光晕半径跟着缩。
最后一行如实报出光子数与截断比例:光学厚度越大越不够用 —— 这就是实时渲染非要用
解析近似的那个原因。
本段只加了一条「散射很多次」,第 6 课的介质才真正走完:
σtr = σa + σs(1−g) 起作用。本段的活例由本页自己计算(示意 · 等价重现,非硬件产出):不依赖 WebGPU,任何设备都能跑。同一页的示意图与数值表照常可读。
页面版本 v18(本课 6 段全部上线 · 全课收束;v18 给 6.6 补上「真实实现」里剩下的那一处 —— σs 也分通道:新增「σs 也分色」开关,按波长分别行走(一个光子只服务一个通道), 分色比取瑞利那一路 σs ∝ λ−4 的 1 : 2.2 : 4.3;判据十二条扩到十四条; 单色档与「只 σ_a 分色」档的读数逐位不变(实测 R/G/B 逃出 0.780643 / 0.573693 / 0.377933、 薄厚色偏 1.4841 / 2.1627 与 v17 完全一致)。
v17 只修字的几何,算式与判据一行未动 ——
按新装的精确几何门禁(svg-bbox.mjs:用真浏览器的 getBBox() 量渲染后的盒子)查出四处的字画到了该在的地方之外:
6.1 件一的「吸收系数 σa(三个通道各一个)」越出格子 39.6 px 并压住代码行 13.6 px(收成「(分通道)」)、
6.2 件一的「一组波列(λ / 方向 / 振幅)」越出格子 28.9 px(分隔符的空格去掉)、
6.2 件一右列的「右列:这一像素最后长什么样」越出 720 的 viewBox 25.7 px(去掉"最后",被裁掉的字回来了)、
6.6 件一的「(薄片的"透光"就靠这一路)」越出 viewBox 56.2 px(收成「(薄片的"透光")」,图注里那句仍在)。
另把三通道读数的分隔符由 / 收到 /(0.350/0.050/0.020,144.3 → 116.8 px)以便塞进 150 宽的格子。
为什么以前没发现:check-svg.mjs 只看得见页内 module 用 createElementNS 建出来的 SVG(id 以 flow/fig 开头),
第 6、7 课的件一是直接写进 HTML 的 —— 那道门禁对它们一直报「0 个对象」,是假绿。
v16 给 6.6 补上「真实实现」里缺的那一处 —— 介质界面:Fresnel 进入 + 逃生锥 / 全内反射,判据七条扩到十二条;v15 是 6.5 的画质重做)
| 看不到新内容时请硬刷新(Ctrl+Shift+R),或给地址加 ?t=N —— 本站的 HTML 不带缓存指令。