内容工厂. / 演示空间 / PBR 高级效果 · 第 6 课 · 介质与透射 段 1 / 5
PBR 高级效果 | 第 6 课 | 上一课:材质与形状

介质与透射

读时 约 5 分钟 本课 6 段 · 已上线 6 段 每段 三件套 保真度 示意 · 等价重现(全课) 页面版本 v18
课题骨架(本课题只声明一次)

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

本课把积分搬进了介质内部:前面四课的光都在表面上交代完了 —— 反射掉、或者透进去之后就不再管它。 可水、血、叶子、布、雾不是这样:光进去以后还要走一段路,路上一直在被吃掉; 而雾更狠 —— 被吃掉的那部分还会从别的方向散射回来;蜡和玉再狠一层 —— 那部分还要在里面散很多次,从别的地方出来。于是「颜色」这个词第一次离开了表面: 它变成沿途剩下多少、再加上沿途又加进来多少。

6.1
水:颜色来自吸收
6.2
水:让它动起来
6.3
叶:光穿过叶片
6.4
布:掠射的边缘光晕
6.5
雾:远处变成雾的颜色
6.6
蜡:光在里面拐很多次弯
6.1

水的颜色不是蓝的,是剩下的

示意 · 等价重现

本节点在骨架的换域环上 —— 上一课的收束句是「参数表是艺术接口,不是物理」,可那句话有个前提:光在表面就交代完了。这一课把这个前提拿掉。

到上一课为止,光只有两种结局:在表面被反射(那就走了),或者透进去 —— 而「透进去」之后的那个颜色,前面几课一直当作一件已经定了的事(就是底色 baseColor)。 可你只要看过一杯浓茶就明白这件事是错的:同一杯茶,薄薄一层是琥珀色,厚厚一杯近乎黑 —— 颜色在路上就变了。 本段只增加一样东西:一条穿过介质的路径(厚度 d)与「每单位长度吃掉多少」(吸收系数 σa)。 光走完这条路之后剩下的比例只有一条算式 —— Beer–Lambert:T = exp(−σa·d)。
三个要点,件三都能拿笔核:① 三个通道各吃各的(σa 是按波长给的),谁先吃完谁先退场 —— 所以「水的蓝」不是水有一层蓝漆,而是红光走不了几米就没了、剩下来的是蓝; ② 衰减是指数,不是线性:厚度翻倍不是亮度减半(件三 ③ 把这两条并排摆着,差多少一目了然); ③ 有一条只由 σ 决定的特征长度 —— 走 1/σa 那么远,只剩 1/e ≈ 0.368。它就是「这杯茶有多浓」的物理刻度。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段算式
左列:这一步吃什么 每单位长度吃掉多少 · 走多远 吸收系数 σa(分通道) — 路径长度 d — 右列:三个通道各剩多少 与件三、与画面同源 TR = exp(−σR·d) — TG / TB — 剩下的那个颜色 — 吸收 · 件二真正在跑的那几行 const d = D * (x / w); // 走过的路 const T = sig.map((s) => Math.exp(-s * d)); // 出射 = 入射 × T —— 三个通道各乘各的 c = 255 * T[i]; // 画成这一列 // d = 0 时 T ≡ 1(逐位):还原性判据 // d = 1/σ 时 T = e⁻¹ ≈ 0.36788:特征长度 // 线性对照写的是 1 − d/D,与指数并排看
左列 → 中间真算式 → 右列。框里那几行就是件二画每一列时真正执行的代码(件二是本页自己算的, 所以这里放的是 JS,不是 WGSL —— 保真度角标也如实标了「示意 · 等价重现」)。 逐行的账: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 之后的样子 | 下:三通道各自的衰减
启动中…
本段新增 · 一条路径与每单位长度的吸收
介质(就是 σa 的三个数)
对照:把指数换成线性
三个通道的吸收系数(本页单位下的示意值,量级照水体衰减系数的样子取): 清水 0.35 / 0.05 / 0.02 · 浓茶 1.20 / 0.60 / 0.25 · 血 0.80 / 3.50 / 4.00 · 叶绿素 2.20 / 0.45 / 2.60(顺序都是 R / G / B)。 它们不是「颜色」,是三个衰减速率 —— 本段唯一的新东西就在这里。
件三 中间量数值表 · 与件二同源
① 三个通道各剩多少(手算可核:exp(−σ·d))
dTRTGTB剩下的颜色
② 特征长度与两档对照 · 本段的正题
项值
① 每一行都能拿笔算:T = exp(−σ·d),最后一列是三通道一起剩下的那个颜色。 注意 d = 0 那一行必须是 1.0000 / 1.0000 / 1.0000(逐位) —— 这是本段的还原性判据。 ② 里第一行是各自的特征长度 1/σ(走那么远只剩 1/e ≈ 0.36788), 它把「这个介质有多浓」压成了三个数;最后两行是同一档下指数与线性的并排读数。
本节获得

本段只加了一条路径,就把「颜色」这个词从表面上搬了下来:

  • 颜色可以来自「没被吸收的部分」:水的蓝、血的绿(厚了变黑)、茶的琥珀,都是三个衰减速率之差。
  • 它是指数:厚度翻倍不是亮度减半,而是平方;1/σ 那个特征长度才是「浓淡」的刻度。
  • 两个可验算的点:d = 0 时 T ≡ 1(还原性);d = 1/σ 时 T = e⁻¹ ≈ 0.36788(特征长度)。
6.2

让它动起来:波、界面、与折叠处的泡沫

示意 · 等价重现

上一段把颜色从表面上搬了下来(光走过一段路被吸收)。可直到现在,那个「表面」还是一块不动的板 —— 本段给它装上几何。

真实水面由许多列方向、波长不同的波叠加而成,本段按海洋学里最常用的做法取这一组:每列的波长自己决定自己的速度, 由深水色散关系 ω = √(g·k) 给出 —— 这不是装饰,它才是「长浪跑得快、细浪拖着走」的来源。 波面用 Gerstner 摆线:把「水平坐标也一起推一下」,于是波峰变尖、波谷变平(chop 就是这一下的量)。 本段只增加一样东西:一个会动的高度场,以及随之而来的三件事 —— ① 法线由高度场的解析导数直接给出(不用差分,也不多算一次高度); ② 界面按 Schlick Fresnel 分账(空气/水 F₀ ≈ 0.02:俯看几乎全透、掠看几乎全反); ③ 光进水之后仍按上一段的 Beer–Lambert 衰减,而路径长度就是视线在水里走的那一段。 另外本段给出泡沫的物理判据:波面被推得自交时(Jacobian ≤ 0)才起白沫 —— 而不是「高度超过某个值就白」。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段算式
左列:这一步吃什么 一组波列(λ/方向/振幅) — 深水色散 ω = √(g·k) — 视线与波面求交 — 在水里走的路径长 d — 中列:件二每像素真正在跑的那几行 H = Σ aᵢ·sin(kᵢ·(cosθᵢ·x + sinθᵢ·z) − ωᵢ·t) ∂H/∂x = Σ aᵢkᵢcosθᵢ·cos(…) // 解析导数给法线 J = (1+∂Dx/∂x)(1+∂Dz/∂z) − … // 折叠判据 F = F₀ + (1−F₀)(1−cosθ)⁵ // F₀ = 0.02 d = (yHit − yBottom) / |dʸ| // 水里那一段 T = exp(−σt · d) // 上一段的式子 c = F·c_reflect + (1−F)·T·c_bottom // 泡沫:只在 J ≤ 0(波面自交)的地方加白 右列:这一像素长什么样 反射里有什么 — 透下去剩下什么 — 泡沫覆盖率 W — 合成后的像素 —
中列就是件二每像素执行的真实代码(本页自算,保真度如实标「示意 · 等价重现」)。 三处可拿笔核:色散 ω²=gk(件三 ①)、Fresnel 端点 F(0°)=F₀=0.02(件三 ②)、 吸收 T(1/σ)=e⁻¹(接上一段)。泡沫那一行是本段的判据:只有折叠(J ≤ 0)才起白, 拖大 chop 能看着它从「个别浪尖」涨成「成片白冠」。

先把风速拖到中档,看三件事:① 远处的浪比近处「慢」—— 因为它们波长更长(ω=√(gk),件三 ① 每行的相速列); ② 俯看时水面透、掠看时水面反 —— 把视高拖低,整片水会突然变成一面镜子(Fresnel 在 80° 之后陡涨,件三 ② 有数); ③ 水色随浊度变 —— 同一片波、同样的天,只是光在水里被吃得快些。

再把 chop 一直拖到底(3.0 —— 它是让波面「自交」的那个量,折叠判据就在这一档触发),盯住浪尖:泡沫是从自交处冒出来的(不是从「高」处冒出来的)—— 可以先关掉泡沫开关看一眼尖浪,再打开,看白沫怎么沿着那些尖峰铺开。件三 ③ 里报的是本帧的折叠像素比例。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 拖动看水怎么动
每像素:求交 → 法线 → Fresnel → 反射 / 吸收 → 泡沫
启动中…
海况(决定那一组波列)
相机与水
开关(用来做对照)
本段的水是每像素一条视线与波面求交(先与 y=0 平面求交,再用「水面高度」代回做 5 次限幅阻尼迭代 —— 掠射时不定步长会发散);远处的短波按屏幕足迹逐列收掉(一个像素覆盖好几米时,短波的法线会把太阳光斑打成白噪点 —— 收掉后镜面由「锐」转「宽」,能量不凭空消失,只是摊成粗糙度;这一收是连续过渡,一刀切会在画面上留下一条横贯的硬边),法线取高度场的解析导数; 反射采一颗程序化天空(地平线渐变 + 云层 + 太阳:太阳得按真实量级给足亮度 —— 它是天空的 10³ 倍以上,水面反出来的那一串闪烁(glitter)就是它;只按「比天空亮 0.5 倍」写,水面既没有闪烁也没有明暗对比,读起来像塑料),折射侧按视线在水里的实际路径长做 Beer–Lambert。 碎波是一张 128² 小瓦片(5 级 mip 按像素足迹自动选级 + 三层滚动,每层速度按色散关系 √(g·λ/2π):长波快、短波慢;波矢量取整数分频 ⇒ 严格可平铺 —— 否则接缝会在水面上织成一张会动的裂纹网)。写 8 位像素前加 ±0.5/255 的抖动:平滑区每像素差不到半个色阶,不抖就会把量化误差连成等值线(色带)。 活例有两条通路:本机支持 WebGPU 时走 GPU(全屏片元 pass + 2× 超采样,实时;角标「真机可测」),否则落回本页 CPU 自算(角标「示意 · 等价重现」,默认只画静态一帧、点「播放」才动;为护住手感,帧时超过 34 ms 自动下调内部分辨率、超过 90 ms 且已降到下限时自动停手)。两条通路同一组算式、同一组判据;GPU 那条本机无从验证,故只说它「真机可测」,不说它「已验证」。
件三 中间量数值表 · 与件二同源
① 一组波列:波长自己决定速度(手算可核:ω = √(g·k))
列λ (m)k (1/m)ω (rad/s)相速 c=ω/k
② Fresnel 与吸收 · 本段的正题
项值
① 每行可按 ω=√(9.81·k) 手核;相速那一列就是「长浪跑得快」的数值证据。 ② 上半是 Schlick Fresnel 在四个入射角的值(F(0°) 必须逐位等于 F₀=0.02、F(89°)→1); 下半是本帧的水面读数:折叠像素比例(泡沫只在它非零处出现)、平均路径长、以及帧时与内部分辨率。
本节获得

本段只加了一个会动的高度场,于是「表面」这个词终于立起来了:

  • 波长决定速度(ω=√(gk)):长浪快、细浪慢,海面的「层次」来自这一条,不是来自振幅大小。
  • 界面分账:Fresnel 让同一片水在俯看与掠看时判若两物(0.02 → 1),这是水最容易被忽略的一半。
  • 泡沫是几何事件:起白的位置由折叠判据 J ≤ 0 决定,与「哪里高」无关 —— 这一条把泡沫从贴图拉回了物理。
6.3

叶子不是不透明的:光穿过去,从背面出来

示意 · 等价重现

本节点在骨架的换域环末端 —— 上一段给介质装上了会动的表面,可那个表面背后的世界仍然一片空白:光进去、被吃掉,然后就没有下文了。

到现在为止,本课的介质全都是半无限的:水底下还有好几米,光走进去就再也回不来。 所以上一段那块「水底」,其实只是一件用来看折射的摆设 —— 它自己不发光,也没有任何光从它那一侧穿过来。 可叶子不是这样:它只有零点几毫米厚,光从背面进去、穿过、再从正面出来。 逆光下的叶子之所以是亮的,就是因为它比水少了「半无限」这个前提。 本段只增加一样东西:一个出口界面(把「半无限的水」换成「一张薄片」)。 随之而来三笔账,件三全部摊开: ① 光是进来扣一次、出去扣一次 —— 两道界面各按 Fresnel 扣(叶面蜡质 n = 1.5 ⇒ F0 = 0.04,正入射扣 4%); ② 中间那段路照旧按 6.1 的 Beer–Lambert 衰减,只是路径换成了薄片里的那一条(正对叶片时它就等于厚度 d); ③ 还有一笔最容易漏的:光在片内会来回折,每折一趟多扣一次 F² 和一段 2d 的吸收 —— 把这些趟数全加起来是个几何级数。 件三 ② 会告诉你这一笔在叶子上值多少:三个通道分别只有 0.031% / 0.083% / 0.008% —— 几乎为零,因为叶绿素把每一趟都吃掉一遍。 它只在透明薄片上才显著,而把角度推到掠射就能把它放大 —— 那是下一段的事。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段算式
本段只多一个界面:出口 背光 → 入口 → 内部(吸收)→ 出口 → 观者侧 叶片:厚度 d ① 入口 (1−F) ② 内部 e^(−τ) ③ 出口 (1−F) ④ 来回 F²e^(−2τ) 左列:这一步吃什么 厚度 × 吸收系数 · 两个开关 τ = σ·d(三个通道) — 出口界面 — 右列:出来多少 与件三、与画面同源 TR(红光剩下多少) — 叶肉处剩下的颜色 — 薄片 · 件二真正在跑的那几行 const e = Math.exp(-K * th); // 6.1 那一趟 const g = 1 / (1 - F*F*e*e); // 来回等比 const T = TWO ? one * e * one * g : 0; // 出射 = F·前界面反射 + (1-F)·T·背光;one = 1 - F // 正对叶片 ⇒ 内部路程就是厚度 d
上半是几何,下半是账。框里那几行就是件二画每个像素时真正执行的代码(件二是本页自己算的, 所以放的是 JS,不是 WGSL —— 保真度角标也如实标了「示意 · 等价重现」)。 上半那张图值得多看两眼:光从背面进来(① 扣一次 1−F),在内部走一段被吸收(② e−τ), 从正面出去时再扣一次(③ 1−F);而在每一次打到界面时,都有一部分被反射回片内, 于是有了 ④ 那趟折返 —— 它再走一遍 2d、再吃两遍吸收。把这一串趟数加起来就是件三里的那个 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.1 叶绿素档同一组比例)
开关(用来做对照)

两个开关都按到右边,就是 6.2 的模型(半无限):光进去就没了。

本段正对叶片(入射角 0),所以 Schlick 给的 F 恒等于 F0 = 0.04, 内部路程就是厚度 d —— 每像素只剩三次 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)。
件三 中间量数值表 · 与件二同源
① 逐档厚度的账(手算可核:T = (1−F)·e−τ·(1−F)·g,τ = σd)
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% 的反光)。
本节获得

本段只加了一个出口界面,却把「介质」这个词补完了:

  • 透射光是另一项,不是反射的一部分:半无限介质里它根本不存在;叶子逆光发亮 100% 来自它。
  • 两道界面各扣一次,且这个「扣」有个下限:(1−F)/(1+F) —— 正入射下再薄的叶片也只透 92.3%。
  • 薄片的账仍然是 6.1 那一条指数:变的只是路径(d 而不是水深),所以 6.1 的判据(e−1 的特征长度)一字不改地接着用。
  • 知道哪一笔可以省:内部来回在叶子上最大只值 0.083%(三个通道 0.031% / 0.083% / 0.008%)—— 该省的时候省,本身就是算清楚之后才有的判断。
6.4

绒布不是反光强:边缘亮,是因为那一圈的纤维正对着光

示意 · 等价重现

上一段把介质做成了一张薄片,但它有一个前提在段首就钉死了:镜头正对叶片(入射角 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 的那个角,这一段放开;放开之后才知道, 同一个角落在两种几何上,答案是反的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那段算式
本段只增加一样东西:入射角 放开 6.3 里钉死在 0 的那个角 —— 同一个角落在两种几何上,答案相反 ① 平板(6.3 的叶片) θ θt ≤ 41.81° d F = 0.92 路径 ≤ 1.3416 d · F → 0.92 ⇒ 变镜子 ② 绒毛层(布) θ h 这一段 = h / cosθ 纤维之间是空气 路径 = h / cosθ(没有顶) · 掠射才满足 T·H = 0 左列:这一步吃什么 厚度 · 密度 · 角度 掠射处的路径倍数 — 绒毛层的光学厚度 τ — 右列:出来多少 与件三、与画面同源 最陡坡面的绒毛项 — 光晕比(边缘 ÷ 中心) — 绒布 · 件二真正在跑的那几行 const tv = fz ? bh * fuzzPath64(av) : 0; const c2 = Math.max(0, 1 - tH * tH); const lobe = Math.pow(c2, o.p / 2); const one = KS64 * o.rho * amt * F64(fcos) * lobe; const fib = fz ? one * oL * oV : 0;
上半是同一个入射角落在两种几何上:左边一块平板,光在介质里的角度被 Snell 封在 41.81° 以内, 所以那条折线短,而掠射时 F = 0.92 把光几乎全反回去 —— 变镜子; 右边一层绒毛,纤维之间是空气,光线不折射、直着穿过去,所以那一段的长度就是 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。这一步是本节的正题:掠射本身不会让东西变亮, 是掠射那圈上的纤维正对着光;而「高光处褪色」是调研补上的那一条 —— 真绒布在那里褪成灰白, 靠的就是这一层不带底色的表面反射。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 拖动看那条亮带怎么走
上:一幅垂褶绒布(纤维竖着) | 下:三项账 + 褶皱到底扫到多少度
启动中…
本段新增 · 入射角
同一个介质,换一段旋钮
开关(用来做对照)

两个开关都按到右边,绒布就被拆成了「一块布」加「一层纤维」两项。

这一版为什么不是球(这是查证之后改的):球在材质预览里通行,是因为它包含所有法线—— 一张图就把整个 BRDF 扫了一遍;但 Khronos 的 glTF sheen 官方参考资产用的是垂褶布、椅与天鹅绒沙发, 球被留给 metal-rough 那类参数测试。也就是说:球适合展示机制,不适合展示「这是布」。 换成垂褶正好两样都占:每条褶的坡面从正对一路斜到掠射,自己就把视角扫了一遍,看上去又是一块挂起来的布。 这块布的结构也是量出来的,不是凭感觉调的:整幅只有 2.4 个深褶(褶深 0.62–1.04 个 R,坡面能到 65–72°), 所以亮带是宽的 —— 7 条、每条约 40px、占画面宽度 37.5%;法线里有 46% 落在 60–80° 这一档。 上一版给的是 4 个褶、坡面只到 53°,绒毛项永远点不到满值,量出来是「10 条亮带、每条 26px」—— 那不是垂褶,是细密压褶。顶上那一截是抽褶的褶头(褶更密、更浅,所以是一排细密的明暗), 底下有摆边与地面、上面有帘杆 —— 有了这两条边,画面才是「一块挂着的布」,而不是「一片无限延伸的波纹」。 褶线还会往下散开、并沿长度呼吸(窄一段宽一段):没有这两样,整幅画就是一排等距竖条纹,那是瓦楞板。 代价如实写在件三 ② 里:一块真帘子的坡面斜不到 90°(本页实测最陡 87.8°、不到一半的法线在 60° 以内), 所以光晕是软的亮带,不是球上那条贴着轮廓的硬边 —— 而这恰好就是真绒布的样子。 底布那一条路要走两趟(进来一次、出去一次),各按 6.1 的 Beer–Lambert 扣; 绒毛那一项是一条圆柱的高光 —— 圆柱的轴是法线(绒毛竖着),所以条件写成 T·H = 0。 纤维表面自己的 Fresnel 只有 0.04(掠射那一圈上,光正好落在纤维的侧面,局部接近正入射)—— 这正是它不像镜子的原因:单根只反 4%,只是被照到、又被看见的纤维有成千上万条。 KS = 1/F₀ = 25 就是「有效纤维条数 × 那 4%」的合计,是本页唯一的模型常数: 它取成 1/F₀,意思是「这一层绒毛恰好在最亮的那一圈把 4% 凑成满值」,所以它是天花板而不是测量值。 底下那条浅色直方条是这块布的法线倾角分布——它告诉你这条曲线被"走"到了哪一段。 底布是深酒红、绒毛项几乎不带色(换色是调研补上的第三条:Disney 说表面反射不带底色), 于是亮带在坡面上褪成灰白、褶脊上的深酒红一直不动 —— 那正是绒布最容易被认出来的地方。 叶片那一侧的数(41.81° / 1.3416 / 0.9191)由 Snell 与 Schlick 逐位给出,可以拿笔核。
件三 中间量数值表 · 与件二同源
① 逐档视角的账(手算可核:路径 = h/cosθ)
θvcosθ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),两个数不是各写一份。 最后两行是这一版按查证结果补上的:参考形状换成垂褶(球的用处与局限),以及 高光带的颜色(表面反射不带底色,所以坡面褪成灰白)。
本节获得

本段只放开了一个角,就把「介质」这一课的最后一层补上了:

  • 掠射换了一个问题:不再是「反光有多强」,而是「哪里才反得出来」—— 平板的答案是「全部」(变镜子),绒布的答案是「最陡的那几条坡面」(亮带)。
  • 纤维把切向和法线合到了一起:5.1 讲各向异性时,高光被拉成一条线;把切向推到与法线重合, 这条线就退化成一条边 —— 天鹅绒那条亮带就是各向异性的极端。
  • 5.3 表里那个 sheen 旋钮的出处就在这里:它不是一层涂层,是一层几何—— 所以它只在掠射处出现,且与粗糙度无关。
  • 「画得像」这件事本身要查证:这一版的形状与颜色是按一手资料改的 —— 球是所有材质的预览默认 (它包含所有法线),但布料的官方参考形状是垂褶、椅、沙发;而绒布的高光不带底色, 所以它在坡面上褪成灰白。三件事:换垂褶、给底布上色、让 sheen 不带色。
6.5

雾不是让远处变暗,是让远处变成雾的颜色

示意 · 等价重现

前面四段的光,账都记在表面上:弹回来(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^(−τ)) 相机 远处的山脊 L 表面 太阳 θ 沿途每一小段都往你的方向散一点 视线越远,累加的这一摞越厚;但每一段自己也被雾挡掉一部分 左列:这一步吃什么 距离 · 消光 · 散射角 透射 e^(−τ) — Mie 相函数 4π·p(θ) — 右列:出来多少 与件三、与画面同源 空气光 L_air — 最远那层的黑剪影 — 雾 · 件二真正在跑的那几行 const ts = tauSun65(o.beta, o.kf); const g1 = ESC65 * Math.exp(-ts); const g2 = EMS65 * KMS65 * (1 - Math.exp(-ts)) * Math.exp(-GMS65 * ts); const aR = amb[0] + (aSc + aMs) * CM65[0]; _F65.L[0] = LsR * u + w * aR * v;
上半那条线是同一段路上的两条账:从相机到柱子这一段,表面那一路被雾按 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),只剩它旁边那圈多次散射的柔光 —— 这就是真实雾天里 「看不见太阳、但知道太阳在哪」的那件事。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 一条有太阳的山谷
上:雾里的山脊与太阳 | 下:两条账 vs 距离
启动中…
本段新增 · 介质自己也发光
同一个介质,换一段旋钮
开关(用来做对照)

三个开关都按到右边:像素里就只剩「沿途散射进来的那一路」,而且是直接截断的那一版 —— 就是研究里点名最假的那一张(255 的平顶 + 色带),拿它跟左边对照着看。

这一版的场景是按一手资料重做的。上一版是一条封闭走廊:没有天空、没有太阳、 没有可以逐层褪色的东西,密度还是均匀的 —— 那正好是渲染文档里点名的失败模式(「Flat, gray image」)。 现在补齐了资料里说的四样:① 密度随高度指数衰减(Unreal / Filament 的 exponential height fog, 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 归一化。
构图又返工了一次(v13),起因是把上一版量出来看:下半幅的局部起伏只有 5.9/255 —— 那就是"一片灰",再对的物理也读不出是雾景。三处按量出来的数改:相机从 87° 广角收到 68° (广角把山压扁了)、六层山脊按「高出地平线的视张角」重新排开(约 1° → 14.5°,越远越高; 原来的近层只有 4 米,压在 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 只有带方向的那一项、没有天穹那一项,于是离太阳越远雾越暗 —— 画面左半边直接塌成一片淡黑的天空。那不是雾,那是黄昏。)
v15:这一版的画质是「先量后改」改出来的。把上一版原样抓下来量了四条,条条都是 图形行业总结过的「假」:① 太阳附近 == 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 —— 加了色调映射之后 线性域的比值会被压缩,近地亮度不压下来就会和天空一样亮,整幅画发平。
如实标的六处:① 入射光在介质内按均匀处理(不沿光路再衰减),这是标准雾的理想化; ② 密度起伏写成视线方向的一个低频因子(真实雾是三维场,本页用二维近似); ③ 「太阳那一份」的刻度、多次散射的注入强度 κ_ms 与它自己的衰减指数 γ 都是折出来的示意值, 不是测量值;④ 大气表的绝对刻度是按「天穹给雾体的那一份」归一的(只用一个比例, 各通道的比值仍由物理给),所以它给的是形状不是绝对测光;⑤ 中景那排树是解析的锥形剪影 (不是建模出来的树),只用来给雾景一个尺度与一层中景;⑥ 太阳盘在默认的浓雾里根本透不出来 (τ_sun ≈ 11 ⇒ e^(−τ) ≈ 1e−5),能看见的是它旁边那圈多次散射的柔光 —— 把 β 拉到 0.002 圆盘才重新显出来,这是物理上对的,不是画丢了。 下半那条虚线是「只吸收」时的合计,它会一路掉到 0;实线会停在 L_air 上 —— 两条线的差就是本段加的那一项。
件三 中间量数值表 · 与件二同源
① 沿视线的两条账(手算可核:τ = β·d)
距离 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 课的介质才真正闭环:

  • 6.1 那条 e^(−τ) 只说了一半:它说「还剩多少」,没说「挡掉的那部分去哪儿」。 加上沿途散射项之后,介质才既能变暗(吸收)又能变亮(散射)。
  • 对比度与亮度是两条账:对比度按 e^(−τ) 掉,亮度趋于 L_air。 所以「远处看不清」有两种完全不同的成因 —— 一种是黑(吸收),一种是同色(散射);雾是后者。
  • 「雾色」是个算出来的量:入射光 × ω × 相函数,而入射光还分「天穹那一份」与 「太阳那一份」。那个常数雾色只是它把所有方向都抹平(相函数退化成各向同性)之后的特例。
  • 相函数把「方向」带进了介质:前面四段的方向只出现在表面上(Fresnel、法线); 这一段的散射角 θ 是光路与视线之间的夹角 —— 同一片雾,换个光源方向就是另一种颜色。 这也是课题三里第一次出现「光从哪来」由沿途而不是由表面决定。
6.6

蜡不是半透明:光进去以后拐了很多弯才出来

示意 · 等价重现

上一段把账加全了(透射 + 沿途散射进来的那份),可它有一个没写出来的前提: 光是沿着你那条视线散射的 —— 每个像素只问「这条射线上进了多少」。所以 6.5 那条式子要成立, 介质必须够稀:一次散射就出去。

蜡、玉、牛奶、皮肤、蜡烛不是稀介质:光进去以后散了很多次,早就忘了自己从哪儿进来 —— 于是它会从你根本没看的地方出来。逆光时耳朵边上那圈红就是它:光从背后进来,在里面绕了一圈才出来。 这条一加上,前五段的整套方法就失效了:像素不再对应「一条射线」,而是对应 一群按概率走路的光子。 本段只增加一样东西:介质里散射很多次。换的也只有算法那一处 —— 把「沿视线积分」换成 「在介质里掷骰子走路」。三件反直觉的事: ① 散射不产生光,它只改变光从哪儿出来:把散射拉满,逃出去的总量几乎不动, 动的是那圈光晕的形状。而吸收一路上按走过来的 总路长扣钱(6.1 那条 e^(−σa·L) 现在按路长算,不再按直线距离); ② 散射越强,光晕反而越小:出射的横向尺度是扩散长度 δ = 1/√(3σaσtr),它随散射增大而缩小—— 「撒得越开」是错的:撒得开的是方向,不是位置。各向异性也不单独成为一个自由度, 它与散射强度合成一个 σtr = σa + σs(1−g); ③ 颜色成了「走了多远」的函数:同一块介质,边薄的地方光走得短、厚的地方走得长, 于是薄处偏红、厚处偏暗 —— 皮肤、玉、蜡的「厚度感」全在这儿。把 σa 的三个通道差开, 一块白蜡立刻变成耳朵。 保真度如实标「示意 · 等价重现」:介质内部的多次散射在图形 API 层不可观测,本页按 蒙特卡洛随机行走重现(件一框里那六行就是件二真正在跑的)。两处理想化写在件二下方。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那个循环体
本段只增加一样东西:介质里散射很多次 同一条入射光,光子在里面拐很多次弯 —— 于是它从别的地方出来 入射 介质(蜡 / 玉 / 皮肤):σ_s · σ_a · 厚度 d 上表面出射 下表面穿出 (薄片的"透光") 吸收掉 每走一步:先掷一个自由程,再掷一个方向;撞到界面就结束 左列:这一步吃什么 σ_s · σ_a · d 逃出比例(上 + 下) — 中位出射距离 — 右列:出来多少 与件三、与画面同源 输运自由程 1/σ_tr — 扩散长度 δ — 多次散射 · 件二真正在跑的循环体 const step = -Math.log(1 - r1()) / st; fly(step); w *= Math.exp(-sa * W.last); if (W.wall && esc(W.L, W.z)) break; const th = hgTh(g, r2()); turn(th, PH);
上半那条折线是真走出来的轨迹(从件二那一次模拟里抽出来的样本):进去之后每一步都先掷一个自由程 (指数分布,均值 1/σt),再按相函数掷一个方向,撞到上下任一界面就结束。 于是「出来」有两条路:从上表面反射出来(这就是一块蜡在灯下的那圈光晕)与 从下表面穿出去(薄片之所以"透光")。 下半框里那六行是件二画每一帧时真正执行的循环体 —— 注意它和 6.1 是接上的: 吸收不是"随机杀掉光子",而是按走过的路长解析地折一笔 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,所以画面上会比单色档"麻"一点(读数里如实报出光子数)。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 一块蜡里的一群光子
上:介质剖面(光子密度 + 抽样出来的行走轨迹) | 下:出射点分布 vs 横向距离
启动中…
本段新增 · 介质里多散几次
开关(用来做对照)

前四个杆与 6.5 是同一组:σs + σa 就是那一段的消光 β(这里拆开了), 相函数 g 是同一格。厚度 d 是 6.6 新增的 —— 有了多次散射,"多厚"才第一次成为参数。 η 与「界面」开关是 v16 补的那处真实实现:默认先关着(好让 6.1–6.5 的账与本段那几条判据逐位对得上), 按到右边就是真实介质 —— 看逃出比例会掉到多少。 「σs 也分色」是 v18 补的那处真实实现:按到右边就从"三通道共用一条路径" 变成"按波长分别行走"(红光的散射距离是蓝的 4.3 倍)—— 两处对照的账都写在件二下方。

看什么:上半是一块蜡的剖面(横轴 = 沿表面走多远、纵轴 = 进到多深),亮斑是光子密度 (每走一步就落一笔权重),上面叠着 30 条抽样出来的轨迹;下半是出射点分布:横轴是出射点离入射点多远, 纵轴取对数 —— 虚直线是解析的扩散尾巴 e^(−r/δ)/r,用来核「扩散长度 δ 到底对不对」。
两个开关:「只散一次」把循环压到一次散射就收工 —— 那就是 6.5 那条式子成立的世界: 光只散一次就出界面,没有"从别处绕回来"的那一层(件三 ② 里有它与多次散射的对照); 「吸收分色」(σa 的 R : G : B = 1 : 2.6 : 5.6) 给的是皮肤那一组,看下表面透出来的颜色:薄的时候偏红、厚下去先红后暗。
框里第 4 行那个 esc() 里就藏着 v16 的界面:它现在会返回"出没出去" —— 被 Fresnel 反射、或者落在逃生锥外(全内反射)时它返回 false,光留在介质里接着走。
v16 补上的那处「真实实现」:界面。把 η 拖到 1.44(蜡),再把「界面」按到右边 —— 同一片介质、同一批光子,逃出比例立刻掉下来:进来的光只有 (1 − F₀) = 96.8% 进得去 (F₀ = ((η−1)/(η+1))² = 3.25%),而撞到界面时只有落在逃生锥(θ < θc = asin(1/η) = 43.9°)内才谈得上出去, 锥外一律被全内反射弹回。一个内部各向同性的场里,撞到界面的通量只有 1/η² = 48.2% 落在锥内 (这条能拿笔算:∫₀^θc cosθ sinθ dθ ÷ ∫₀^{π/2} cosθ sinθ dθ = sin²θc = 1/η²)—— 剩下的 51.8% 被界面关在里面接着走。这才是「蜡不是半透明」的字面意思:光不是出不来,是被界面拦住了一半。 把 η 往回拖到 1:逃生锥张成整个半球,界面那一档就退化成它右边那一档。
如实标的两处理想化(v16 各降了一级):① 界面默认是关的 —— 这不是省事,是让它可对照: 6.1–6.5 的账与本段前七条判据都在「无界面」这一档上标定过,开着一个新增机制会把那些逐位关系弄脏; ② 三通道共用同一条路径(用 σs + σa 采样自由程,各自的吸收按走过的总路长折算)—— 这一步在 σa ≪ σs 时是标准近似,也就是只 σa 分色那一档。

v18 补上的那一处「真实实现」:σs 也分通道。真实材料连散射系数都分通道 —— 瑞利那一路 σs ∝ λ−4,取 650 / 550 / 450 nm ⇒ R : G : B = 1 : 2.2 : 4.3, 也就是红光的散射距离约是蓝的 4.3 倍(正文一直写"约 5 倍",同一档)。 把「σs 也分色」按到右边,一个光子就只服务一个通道: σs 与 σa 都取它自己的值,自由程也跟着各走各的 —— 学界管这叫「按波长分别行走」。
代价与分账(如实说):蓝光子的期望步数比红光子大十几倍,若还按原来那条公式发 N 颗光子, 总步数(也就是帧时)会涨近一倍 ⇒ 分色档改用三通道期望步数的平均定 N, 步数预算与单色档持平(实测帧时没有增加,反而略快 —— 因为最贵的蓝光子少发了), 代价是每通道摊到的光子数从 N 掉到约 N/3(读数里的方差大一些)。 滑块 σs 给的是三通道的几何平均,所以按这个开关只改变"分不分",不顺手改整体尺度。
于是本段只剩一处如实标着的理想化:界面默认是关的(可由开关对照)。 另记一条不省掉的细节:折射时辐亮度按 (n_出/n_入)² 变(从介质里出来是 1/η² = 0.482), 但它与出射立体角的扩张配对、功率守恒,本页累加的是功率,所以不乘它 —— 判据⑩就是拿白炉核这件事。
光子数是自适应的:光学厚度越大,一个光子要走的步越多,所以画面上报出的光子数会自己降档 (并报出被截断的比例)—— 这不是偷懒,就是真实 SSS 渲染器非要用扩散/偶极子那类近似的原因。
件三 中间量数值表 · 与件一、件二同源
① 出射点分布(手算可核:桶内计数与逐桶衰减比)
横向距离 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 倍,光晕半径跟着缩。 最后一行如实报出光子数与截断比例:光学厚度越大越不够用 —— 这就是实时渲染非要用 解析近似的那个原因。
v18 新增的两行紧跟在「色偏」下面:σs 分色那行把上表面中位出射距离按通道摊开 (不开 σs 分色时三个数逐位相同 —— 那正是"三通道共用一条路径"的样子); 逐通道白炉那一行是固定参数下的对照:σa = 0 且界面开着时, 每个通道各自都得满足"进多少出多少"= 1 − F₀ —— 它钉的是"新走法没有引入偏置、一颗都不丢"。
本节获得

本段只加了一条「散射很多次」,第 6 课的介质才真正走完:

  • 「沿视线积分」到此为止:6.1–6.5 都在一条射线上做账;光一旦在里面拐很多次弯, 像素就不再对应一条射线,只能换统计的做法(掷骰子走得够多,分布自己会浮出来)。
  • 散射只搬家,不发光:能量账摆在那里 —— 加大散射改变的是光从哪儿出来, 以及它路上被扣掉多少。蜡"发光"是前者,玉"发闷"是后者。
  • 1−g 是多散射里最省事的一条:各向异性不单独成为一个自由度, 它和散射强度合起来只有一个 σtr = σa + σs(1−g) 起作用。
  • 为什么引擎不硬跑蒙特卡洛:厚度一大,光子数就不够(页面上如实报出光子数与截断比例)—— 所以实时渲染用的是扩散长度 δ、偶极子那类解析近似:它们省掉的正是本段这几行循环。
本课其余段落 · 未上线
6.7 (本课已收束) 第 6 课六段全部上线:6.1 吸收 · 6.2 界面 · 6.3 薄片 · 6.4 纤维 · 6.5 单散射 · 6.6 多散射。下一页是第 7 课「环境与输出」。 已收束
运行时自检 等待脚本…

本段的活例由本页自己计算(示意 · 等价重现,非硬件产出):不依赖 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 不带缓存指令。