案例 11.1BRDF 四件套进阶
目标:把 D、V、F 和 Lambert 写成四个独立的小函数,名字统一加 My 前缀,避免和 core 包 BSDF.hlsl 里的同名函数(如 D_GGX、F_Schlick)冲突。
// 法线分布函数 D:GGX / Trowbridge-Reitz // D(h) = a² / ( π ( (n·h)² (a² - 1) + 1 )² ) float MyD_GGX(float NoH, float a2) { float d = (NoH * a2 - NoH) * NoH + 1.0; // = NoH²(a²-1)+1,写成两次 MAD return a2 / (PI * d * d); } // 可见性项 V = G2 / (4 NoL NoV),G2 用高度相关(Height-Correlated)Smith-GGX // V = 0.5 / ( NoL * sqrt(NoV²(1-a²) + a²) + NoV * sqrt(NoL²(1-a²) + a²) ) float MyV_SmithGGXCorrelated(float NoV, float NoL, float a2) { float lambdaV = NoL * sqrt((NoV - NoV * a2) * NoV + a2); float lambdaL = NoV * sqrt((NoL - NoL * a2) * NoL + a2); return 0.5 / max(lambdaV + lambdaL, 1e-5); } // 菲涅尔 F:Schlick 近似 F = F0 + (F90 - F0)(1 - cosθ)^5 float3 MyF_Schlick(float3 f0, float f90, float cosTheta) { float x = 1.0 - cosTheta; float x2 = x * x; float x5 = x2 * x2 * x; // 手写 pow5,比 pow() 便宜且没有 pow(0,5) 的边界问题 return f0 + (f90 - f0) * x5; } // 漫反射:Lambert。能量守恒要求 ∫ f_d cosθ dω = albedo,所以要除以 π float MyFd_Lambert() { return INV_PI; }
// 法线分布函数 D:GGX / Trowbridge-Reitz // D(h) = a² / ( π ( (n·h)² (a² - 1) + 1 )² ) float MyD_GGX(float NoH, float a2) { float d = (NoH * a2 - NoH) * NoH + 1.0; // = NoH²(a²-1)+1,写成两次 MAD return a2 / (PI * d * d); } // 可见性项 V = G2 / (4 NoL NoV),G2 用高度相关(Height-Correlated)Smith-GGX // V = 0.5 / ( NoL * sqrt(NoV²(1-a²) + a²) + NoV * sqrt(NoL²(1-a²) + a²) ) float MyV_SmithGGXCorrelated(float NoV, float NoL, float a2) { float lambdaV = NoL * sqrt((NoV - NoV * a2) * NoV + a2); float lambdaL = NoV * sqrt((NoL - NoL * a2) * NoL + a2); return 0.5 / max(lambdaV + lambdaL, 1e-5); } // 菲涅尔 F:Schlick 近似 F = F0 + (F90 - F0)(1 - cosθ)^5 vec3 MyF_Schlick(vec3 f0, float f90, float cosTheta) { float x = 1.0 - cosTheta; float x2 = x * x; float x5 = x2 * x2 * x; // 手写 pow5,比 pow() 便宜且没有 pow(0,5) 的边界问题 return f0 + (f90 - f0) * x5; } // 漫反射:Lambert。能量守恒要求 ∫ f_d cosθ dω = albedo,所以要除以 π float MyFd_Lambert() { return INV_PI; }
D:GGX 法线分布
代码里分母的写法 (NoH * a2 - NoH) * NoH + 1.0 看起来绕,其实是把 拆成两次乘加(MAD),GPU 上一条指令完成一次乘加。这个式子在 时取峰值 :越光滑峰越高越窄,但积分 始终成立——光滑表面的高光“小而亮”、粗糙表面“大而暗”,总能量不变。GGX 相比 Beckmann 的长尾巴(高光周围一圈柔和的光晕)就来自分母的平方形式衰减得慢。
V:高度相关的 Smith 可见性
几何项 描述的是微表面之间的相互遮挡:从视线方向看过去,一部分微平面被前面的凸起挡住(Masking);从光源方向看过去,一部分微平面处在别人的阴影里(Shadowing)。Smith 模型用一个辅助函数 描述单个方向上被遮挡的程度,对 GGX 有解析解:
右边这个 叫“高度相关(Height-Correlated)”形式。它和常见的“可分离”形式 的区别在于:可分离形式假设“被视线遮挡”和“被光线遮挡”是独立事件,而实际上高处的微平面两样都不容易被挡、低处的两样都容易被挡,两者正相关。高度相关形式考虑了这种相关性,遮挡估计更准,也更不容易把能量“挡多了”。
BRDF 里 总是和分母 一起出现,把 用余弦展开后,二者合并成可见性项 V,分母里的 NoL、NoV 会和 里的项约掉:
这就是 MyV_SmithGGXCorrelated。这种写法最大的好处是:没有单独的 ,掠射角时不会出现“0 除以 0”。分母仍可能在 NoL 与 NoV 同时为 0 时为 0,所以加了 max(…, 1e-5)。
F:Schlick 菲涅尔
注意菲涅尔用的角度是视线与半角向量的夹角 (等于 ),因为真正发生镜面反射的是朝向 h 的那些微平面;而不是 。后者只在环境光(11.4 节)里作为近似出现。 我们取 1:任何材质在完全掠射时都接近全反射。五次方手写成 x2 * x2 * x,既省掉 pow 的 exp/log 展开,又避免某些平台上 pow(0, 5) 的未定义行为。
Lambert 漫反射
Lambert 是常数 BRDF 。为什么除以 π?因为余弦加权的半球积分 ,除以 π 后整块表面反射出去的总能量才恰好是反照率 c。第 9 章提到的 Disney Diffuse 在粗糙表面掠射角有回射增亮,但它本身不严格能量守恒,开销也更大;移动端友好的 URP Lit 也用 Lambert,这里保持一致,便于对比。
把参数准备好:MyMaterial
// 由表面参数推出 BRDF 需要的量(与光源无关,每像素算一次) struct MyMaterial { float3 diffuseColor; // 进入 Lambert 的颜色 float3 f0; // 正入射镜面反射率 float perceptualRoughness; float a2; // α² ,α = perceptualRoughness² float3 energyCompensation; // 多次散射补偿系数,由 DFG 算出,默认 1 }; MyMaterial MyInitMaterial(half3 albedo, half metallic, half perceptualRoughness) { MyMaterial m; // 金属度工作流:金属没有漫反射,基础色就是 F0;电介质 F0 固定 0.04,基础色进漫反射。 // 电介质表面先被镜面反射走约 4%,剩下的才进入次表面形成漫反射,所以乘 (1 - 0.04) m.diffuseColor = albedo * (1.0 - metallic) * (1.0 - MY_DIELECTRIC_F0); m.f0 = lerp(MY_DIELECTRIC_F0.xxx, albedo, metallic); m.perceptualRoughness = max(perceptualRoughness, MY_MIN_PERCEPTUAL_ROUGHNESS); float a = m.perceptualRoughness * m.perceptualRoughness; // 感知粗糙度平方 = GGX 的 α m.a2 = a * a; m.energyCompensation = 1.0; return m; }
// 由表面参数推出 BRDF 需要的量(与光源无关,每像素算一次) struct MyMaterial { vec3 diffuseColor; // 进入 Lambert 的颜色 vec3 f0; // 正入射镜面反射率 float perceptualRoughness; float a2; // α² ,α = perceptualRoughness² vec3 energyCompensation; // 多次散射补偿系数,由 DFG 算出,默认 1 }; MyMaterial MyInitMaterial(vec3 albedo, float metallic, float perceptualRoughness) { MyMaterial m; // 金属度工作流:金属没有漫反射,基础色就是 F0;电介质 F0 固定 0.04,基础色进漫反射。 // 电介质表面先被镜面反射走约 4%,剩下的才进入次表面形成漫反射,所以乘 (1 - 0.04) m.diffuseColor = albedo * (1.0 - metallic) * (1.0 - MY_DIELECTRIC_F0); m.f0 = mix(vec3(MY_DIELECTRIC_F0), albedo, metallic); m.perceptualRoughness = max(perceptualRoughness, MY_MIN_PERCEPTUAL_ROUGHNESS); float a = m.perceptualRoughness * m.perceptualRoughness; // 感知粗糙度平方 = GGX 的 α m.a2 = a * a; m.energyCompensation = vec3(1.0); return m; }
金属度工作流的核心就两行:diffuseColor 与 f0。金属没有次表面散射,进入表面的光很快被吸收,因此漫反射为 0,基础色全部用来当 F0(金属的颜色体现在反射里);电介质(塑料、木头、石头……)的 F0 很低且几乎不带颜色,常用 0.04 近似,基础色进入漫反射。乘 是一个最简单的能量分配:表面先镜面反射走约 4%,剩下的才进入次表面。更精细的做法是乘 或用“漫反射能量补偿”,但收益很小,URP Lit 也是这样的常数近似。
组合:直接光 BRDF
// 单个光源方向 L 上的 BRDF:分别返回漫反射项 fd 与镜面项 fs(单位 1/sr), // 还没乘入射光和 NoL —— 那是“光照”的事,不是“材质”的事。 void MyDirectBRDF(MyMaterial m, float3 N, float3 V, float3 L, out float3 fd, out float3 fs, out float NoL) { float3 H = SafeNormalize(L + V); float NoV = abs(dot(N, V)) + 1e-5; // 法线贴图会让 N 背向视线,取绝对值避免除 0 NoL = saturate(dot(N, L)); float NoH = saturate(dot(N, H)); float LoH = saturate(dot(L, H)); // = VoH,半角向量两侧对称 float D = MyD_GGX(NoH, m.a2); float Vis = MyV_SmithGGXCorrelated(NoV, NoL, m.a2); float3 F = MyF_Schlick(m.f0, 1.0, LoH); fs = (D * Vis) * F * m.energyCompensation; fd = m.diffuseColor * MyFd_Lambert(); }
// 单个光源方向 L 上的 BRDF:分别返回漫反射项 fd 与镜面项 fs(单位 1/sr), // 还没乘入射光和 NoL —— 那是“光照”的事,不是“材质”的事。 void MyDirectBRDF(MyMaterial m, vec3 N, vec3 V, vec3 L, out vec3 fd, out vec3 fs, out float NoL) { vec3 H = SafeNormalize(L + V); float NoV = abs(dot(N, V)) + 1e-5; // 法线贴图会让 N 背向视线,取绝对值避免除 0 NoL = saturate(dot(N, L)); float NoH = saturate(dot(N, H)); float LoH = saturate(dot(L, H)); // = VoH,半角向量两侧对称 float D = MyD_GGX(NoH, m.a2); float Vis = MyV_SmithGGXCorrelated(NoV, NoL, m.a2); vec3 F = MyF_Schlick(m.f0, 1.0, LoH); fs = (D * Vis) * F * m.energyCompensation; fd = m.diffuseColor * MyFd_Lambert(); }
MyDirectBRDF 只返回 BRDF 本身(单位是 1/sr),刻意不乘灯光与 NoL:BRDF 属于“材质”,乘灯光属于“光照”,两者分开后,调试视图可以单独显示每一项,c12 的清漆、各向异性也能在同一框架下替换镜面项。NoV 用了 abs(…) + 1e-5:法线贴图常会让像素法线略微背向视线,直接 saturate 会让 NoV 变成 0,V 项和环境 BRDF 都在这里出问题,表现为模型边缘出现黑点或亮点;取绝对值是 Filament 等引擎的通行做法。
