跳到主要内容
实时渲染 约 5 分钟

Fresnel 效应的推导与实时近似

从电磁场的边界条件出发推导 Fresnel 反射率,说明 Schlick 近似为什么够用,给出 F0 与折射率的换算关系和常见介质的数值,并解释边缘光、GGX 中的 Fresnel 项与常见实现错误。

本文目录

从现象说起

站在湖边垂直往下看,水几乎是透明的;把视线压到接近水平,水面就变成了一面镜子。这个「掠射角反射变强」的现象就是 Fresnel 效应,它是实时渲染里边缘光(rim light)的物理来源,也是 PBR 中 F 项的全部内容。

严格形式

设入射介质的折射率为 n1n_1,折射介质为 n2n_2,入射角 θi\theta_i、折射角 θt\theta_t 满足 Snell 定律:

n1sin⁡θi=n2sin⁡θtn_1 \sin\theta_i = n_2 \sin\theta_t

由电磁场在界面上的连续性条件可以解出两个正交偏振分量的振幅反射系数。对s 偏振(电场垂直于入射面):

rs=n1cos⁡θi−n2cos⁡θtn1cos⁡θi+n2cos⁡θtr_s = \frac{n_1\cos\theta_i - n_2\cos\theta_t}{n_1\cos\theta_i + n_2\cos\theta_t}

对p 偏振(电场平行于入射面):

rp=n1cos⁡θt−n2cos⁡θin1cos⁡θt+n2cos⁡θir_p = \frac{n_1\cos\theta_t - n_2\cos\theta_i}{n_1\cos\theta_t + n_2\cos\theta_i}

反射率是振幅系数的模平方。自然光是非偏振的,取两者的平均:

F(θi)=∣rs∣2+∣rp∣22F(\theta_i) = \frac{|r_s|^2 + |r_p|^2}{2}

两个极限值得记住:垂直入射时 θi=0\theta_i = 0,rs=rp=n1−n2n1+n2r_s = r_p = \frac{n_1 - n_2}{n_1 + n_2},所以

F(0)=(n1−n2n1+n2)2F(0) = \left(\frac{n_1 - n_2}{n_1 + n_2}\right)^2

而掠射角 θi→90∘\theta_i \to 90^\circ 时 F→1F \to 1,任何材质都变成全反射。

Schlick 近似

上面的公式每像素要算两次三角函数和一次开方,实时渲染里几乎没人直接用。Schlick 提出的经验近似是:

F(θ)≈F0+(1−F0)(1−cos⁡θ)5F(\theta) \approx F_0 + (1 - F_0)(1 - \cos\theta)^{5}

其中 F0=F(0)F_0 = F(0),cos⁡θ=max⁡(N⋅V, 0)\cos\theta = \max(\mathbf{N}\cdot\mathbf{V},\ 0)。这个式子在电介质上的最大相对误差大约在百分之几,视觉上完全无法区分,而且只需要一次点积、一次 pow。

指数 5 不是魔法数字,它来自对真实曲线做多项式拟合的结果。很多风格化渲染会把指数改成可调参数:

float cosTheta = clamp(dot(N, V), 0.0, 1.0);
float fresnel = pow(1.0 - cosTheta, uRimPower);
vec3 rim = uRimColor * fresnel * uRimIntensity;

本站的 Fresnel 边缘光 示例就是这个形式,uRimPower 默认值 3.0 比物理值 5 略小,因此亮边更宽一些——风格化效果通常宁愿「多一点光」也不要严格正确。

F0F_0 从哪里来

对电介质(绝缘体),F0F_0 直接由折射率决定。取 n1=1n_1 = 1(空气):

F0=(n−1n+1)2F_0 = \left(\frac{n - 1}{n + 1}\right)^2
材质折射率 nnF0F_0
水1.330.020
塑料 / 玻璃1.500.040
蓝宝石1.770.075
钻石2.420.173

大多数非金属的 F0F_0 都落在 0.02 ~ 0.08 之间,这也是为什么 PBR 工作流里非金属的「高光颜色」通常被固定为一个 0.04 左右的灰白值。

对导体(金属),折射率是复数 n~=n+ik\tilde{n} = n + ik,kk 是消光系数。此时

F0=(n−1)2+k2(n+1)2+k2F_0 = \frac{(n-1)^2 + k^2}{(n+1)^2 + k^2}

nn 和 kk 都随波长变化,所以金属的 F0F_0 是有颜色的——黄金偏黄、铜偏红。这解释了为什么在 metalness 工作流里,金属度为 1 时漫反射被置零、而 baseColor 被当作 F0F_0 使用。

在 Cook-Torrance BRDF 里的位置

微表面模型的镜面项是

fr=D⋅F⋅G4 (N⋅L) (N⋅V)f_r = \frac{D \cdot F \cdot G}{4\,(\mathbf{N}\cdot\mathbf{L})\,(\mathbf{N}\cdot\mathbf{V})}

其中 DD 是法线分布函数(常用 GGX/Trowbridge-Reitz),GG 是几何遮蔽项(常用 Smith),FF 就是上面的 Fresnel 项。差别只在于角度用半程向量 H\mathbf{H} 而不是 V\mathbf{V}:

F=F0+(1−F0)(1−H⋅V)5F = F_0 + (1 - F_0)(1 - \mathbf{H}\cdot\mathbf{V})^{5}

粗糙表面还需要考虑多次散射带来的能量补偿,常见做法是把 (1−F0)(1-F_0) 乘上一个和粗糙度相关的因子:

F=F0+(max⁡(1−roughness, F0)−F0)(1−H⋅V)5F = F_0 + (\max(1 - \text{roughness},\ F_0) - F_0)(1 - \mathbf{H}\cdot\mathbf{V})^{5}

这正是 UE4 里 EnvironmentBRDF 使用的形式。

常见实现错误

1. 忘记 clamp 点积。 背面或数值误差会让 N⋅V\mathbf{N}\cdot\mathbf{V} 变成负数,(1−负数)5(1-\text{负数})^5 会大于 1,边缘反而爆掉。永远写 clamp(dot(N, V), 0.0, 1.0)。

2. 法线与视线不在同一空间。 three.js 的 normalMatrix 给出的是视图空间法线,而 cameraPosition 是世界空间的。混用之后边缘光会随相机移动而漂移,看起来像贴图错了。要么统一到世界空间(自己算 mat3(modelMatrix) * normal),要么统一到视图空间(V=normalize⁡(−viewPosition)\mathbf{V} = \operatorname{normalize}(-\text{viewPosition}))。

3. 把 F0F_0 当成颜色强度调。 F0F_0 是垂直入射的反射率,电介质下应该很小。想要「边缘发光的科幻材质」,正确做法是加一个自发的 rim 项,而不是把 F0F_0 拉到 0.9——那样整个材质的能量关系都会失真。

4. 在 sRGB 空间里算 Fresnel。 光照计算必须在线性空间完成,否则 (1−cos⁡θ)5(1-\cos\theta)^5 这种高次幂会把误差放大得非常明显。

5. pow(0.0, 0.0)。 当 cos⁡θ=1\cos\theta = 1 且指数为 0 时,部分驱动会返回未定义值。给指数加一个下限(例如 max(uRimPower, 0.0001))是廉价且必要的防御。

参考实现

本站的两个示例都用了同一套 Fresnel 代码,可以直接在浏览器里调参数看变化:

  • Fresnel 边缘光:环面结上的世界空间实现,可以改指数、强度、光方向;
  • URP RimLit 移植示例:同样的数学,但光源数据来自 Unity URP GetMainLight() 的映射,附完整的参数对照表。