GameTech/URP 渲染实战

案例 9.7白炉测试:BRDF 到底守不守恒高阶

目标:直接把方向反照率 R(𝐯) 画在球上,检验 BRDF 的能量是否守恒。

原理:设想把物体放进一个处处亮度都是 Lenv 的“白炉”里。由渲染方程,出射辐射率是:

Lo(𝐯)=Lenv∫Ωf(𝐥,𝐯)(𝐧·𝐥)dωl=LenvR(𝐯)

如果 BRDF 既不吸收也不丢失能量(R=1),物体就会和背景完全融为一体,看不见;如果它比背景暗,说明丢失了能量;如果比背景亮,说明凭空“造”出了能量——后一种是 BRDF 的硬伤。白炉测试是 PBR 实现者最常用的自检手段之一。

实现:不用灯光,每个像素直接用蒙特卡洛方法估算 R(𝐯)。Lambert 在半球上均匀采样;GGX 镜面则按 D(𝐡)(𝐧·𝐡) 做重要性采样(Importance Sampling),把大部分样本集中在高光波瓣里。把概率密度 p(𝐥)=D(𝐧·𝐡)/(4𝐯·𝐡) 代入估计量 f(𝐧·𝐥)/p,D 被约掉,只剩 GF(𝐯·𝐡)/((𝐧·𝐯)(𝐧·𝐡))。样本序列用 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 的影响:

图 9-20白炉测试。左一:反照率为 1 的 Lambert,完全“消失”在背景里;左二到右:GGX 镜面(F = 1),感知粗糙度 0.2、0.6、1.0。越粗糙越暗,粗糙度 1 时正对相机的中心只剩约 30% 的能量。

这个结果揭示了单次散射微表面模型的一个固有缺陷:G 项把被遮挡的光直接“扔掉”了,但现实中那些光会在微面元之间再弹几次,最终大部分仍然射出表面。粗糙度越高,丢得越多。表现在画面上,就是粗糙金属偏暗、饱和度偏高。解决办法是多次散射能量补偿(如 Kulla-Conty 方法),第 11 章会介绍思路。也可以用这个测试去检查任何一个 BRDF 实现——第 10 章就用它来检验 URP 的高光近似。

提示白炉测试里球心(𝐧·𝐯=1)反而比边缘暗,这不是 bug:粗糙度 1 时,正对着看的方向反照率约为 1−ln2≈0.31,可以用 α=1 时 D=1/π 的特殊情况手算验证;掠射时,镜面波瓣被“推”向地平线方向,反而更多地被积分到。