URP 渲染实战 / 第 9 章 PBR 理论 / 案例 9.7
案例 9.7白炉测试:BRDF 到底守不守恒高阶
目标:直接把方向反照率 画在球上,检验 BRDF 的能量是否守恒。
原理:设想把物体放进一个处处亮度都是 的“白炉”里。由渲染方程,出射辐射率是:
如果 BRDF 既不吸收也不丢失能量(),物体就会和背景完全融为一体,看不见;如果它比背景暗,说明丢失了能量;如果比背景亮,说明凭空“造”出了能量——后一种是 BRDF 的硬伤。白炉测试是 PBR 实现者最常用的自检手段之一。
实现:不用灯光,每个像素直接用蒙特卡洛方法估算 。Lambert 在半球上均匀采样;GGX 镜面则按 做重要性采样(Importance Sampling),把大部分样本集中在高光波瓣里。把概率密度 代入估计量 , 被约掉,只剩 。样本序列用 Hammersley 低差异序列,每个像素都相同,画面没有噪点。
Assets/Cookbook/Ch09/WhiteFurnace.shader(节选:estimate)
float DirectionalAlbedo(float3 N, float3 V) { uint count = (uint)_SampleCount; float NoV = max(dot(N, V), 1e-4); float a = _Roughness * _Roughness; float sum = 0; for (uint i = 0; i < count; i++) { float2 xi = float2((i + 0.5) / count, RadicalInverse(i)); if (_Model < 0.5) { // Lambert:在半球上均匀采样,pdf = 1/(2π) // 估计量 = f·cosθ / pdf = (ρ/π)·cosθ·2π float cosT = xi.x, sinT = sqrt(1 - cosT * cosT), phi = TWO_PI * xi.y; float3 L = TangentToWorld(float3(sinT * cos(phi), sinT * sin(phi), cosT), N); sum += C9_Lambert(_Albedo.xxx).x * saturate(dot(N, L)) * TWO_PI; } else { // GGX 重要性采样:按 D(h)(n·h) 的分布抽半程向量 h,再反射得到 l // pdf(l) = D·(n·h) / (4 (v·h)),估计量化简为 G·F·(v·h) / ((n·v)(n·h)),这里 F = 1 float cosT = sqrt((1 - xi.x) / (1 + (a * a - 1) * xi.x)); float sinT = sqrt(1 - cosT * cosT), phi = TWO_PI * xi.y; float3 H = TangentToWorld(float3(sinT * cos(phi), sinT * sin(phi), cosT), N); float3 L = reflect(-V, H); float NoL = dot(N, L); if (NoL > 0) { float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H)); float G = C9_G_SmithHeightCorrelated(NoL, NoV, a); sum += G * VoH / (NoV * max(NoH, 1e-4)); } } } return sum / count; }
float DirectionalAlbedo(vec3 N, vec3 V) { uint count = uint(_SampleCount); float NoV = max(dot(N, V), 1e-4); float a = _Roughness * _Roughness; float sum = 0.0; for (uint i = 0u; i < count; i++) { vec2 xi = vec2((float(i) + 0.5) / float(count), RadicalInverse(i)); if (_Model < 0.5) { // Lambert:在半球上均匀采样,pdf = 1/(2π) // 估计量 = f·cosθ / pdf = (ρ/π)·cosθ·2π float cosT = xi.x, sinT = sqrt(1.0 - cosT * cosT), phi = TWO_PI * xi.y; vec3 L = TangentToWorld(vec3(sinT * cos(phi), sinT * sin(phi), cosT), N); sum += C9_Lambert(vec3(_Albedo)).x * saturate(dot(N, L)) * TWO_PI; } else { // GGX 重要性采样:按 D(h)(n·h) 的分布抽半程向量 h,再反射得到 l // pdf(l) = D·(n·h) / (4 (v·h)),估计量化简为 G·F·(v·h) / ((n·v)(n·h)),这里 F = 1 float cosT = sqrt((1.0 - xi.x) / (1.0 + (a * a - 1.0) * xi.x)); float sinT = sqrt(1.0 - cosT * cosT), phi = TWO_PI * xi.y; vec3 H = TangentToWorld(vec3(sinT * cos(phi), sinT * sin(phi), cosT), N); vec3 L = reflect(-V, H); float NoL = dot(N, L); if (NoL > 0.0) { float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H)); float G = C9_G_SmithHeightCorrelated(NoL, NoV, a); sum += G * VoH / (NoV * max(NoH, 1e-4)); } } } return sum / float(count); }
配图时背景色与 _EnvColor 用同一个灰色(两者都是 Color,Unity 会做同样的 sRGB→线性换算,所以数值严格一致),F 设为 1,只看 D 和 G 的影响:

这个结果揭示了单次散射微表面模型的一个固有缺陷:G 项把被遮挡的光直接“扔掉”了,但现实中那些光会在微面元之间再弹几次,最终大部分仍然射出表面。粗糙度越高,丢得越多。表现在画面上,就是粗糙金属偏暗、饱和度偏高。解决办法是多次散射能量补偿(如 Kulla-Conty 方法),第 11 章会介绍思路。也可以用这个测试去检查任何一个 BRDF 实现——第 10 章就用它来检验 URP 的高光近似。
提示白炉测试里球心()反而比边缘暗,这不是 bug:粗糙度 1 时,正对着看的方向反照率约为 ,可以用 时 的特殊情况手算验证;掠射时,镜面波瓣被“推”向地平线方向,反而更多地被积分到。