GameTech/URP 渲染实战

案例 10.3白炉测试:URP 的直接高光守恒吗高阶

目标:用第 9 章案例 9.7 的方法,对 URP 的 DirectBRDFSpecular 做白炉测试(F0 = 1),与标准 GGX 对比。

采样仍按 GGX 的 D(𝐡)(𝐧·𝐡) 进行,被积函数换成 URP 的公式。注意 DirectBRDFSpecular 按 Unity 约定已经乘了 π,积分前要除回去,还原成真正的 BRDF:

Assets/Cookbook/Ch10/SpecFurnace.shader(节选:estimate)
half4 frag(Varyings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float NoV = max(dot(N, V), 1e-4);

    half alpha = 1;
    BRDFData brdf;
    InitializeBRDFData(half3(1, 1, 1), 1.0, 0, _Smoothness, alpha, brdf);   // F0 = 1
    float a = brdf.roughness;

    float3 up = abs(N.y) < 0.999 ? float3(0, 1, 0) : float3(1, 0, 0);
    float3 T = normalize(cross(up, N)), B = cross(N, T);

    uint count = (uint)_SampleCount;
    float sum = 0;
    for (uint k = 0; k < count; k++)
    {
        float2 xi = float2((k + 0.5) / count, RadicalInverse(k));
        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 = T * (sinT * cos(phi)) + B * (sinT * sin(phi)) + N * cosT;
        float3 L = reflect(-V, H);
        float NoL = dot(N, L);
        if (NoL <= 0) continue;
        float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H));

        float pdf = D_GGX(NoH, a) * NoH / (4 * VoH);                       // 按 GGX 抽样的概率密度
        float f = (_Mode < 0.5)
            ? DirectBRDFSpecular(brdf, N, L, V) * INV_PI                   // 去掉 Unity 约定里的 π,还原成 BRDF
            : D_GGX(NoH, a) * V_SmithJointGGX(NoL, NoV, a);                // F = 1
        sum += f * NoL / max(pdf, 1e-6);
    }
    float E = sum / count;
    return half4(_EnvColor.rgb * E, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    float NoV = max(dot(N, V), 1e-4);

    float alpha = 1.0;
    BRDFData brdf;
    InitializeBRDFData(vec3(1.0), 1.0, vec3(0.0), _Smoothness, alpha, brdf);   // F0 = 1
    float a = brdf.roughness;

    vec3 up = abs(N.y) < 0.999 ? vec3(0, 1, 0) : vec3(1, 0, 0);
    vec3 T = normalize(cross(up, N)), B = cross(N, T);

    uint count = uint(_SampleCount);
    float sum = 0.0;
    for (uint k = 0u; k < count; k++)
    {
        vec2 xi = vec2((float(k) + 0.5) / float(count), RadicalInverse(k));
        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 = T * (sinT * cos(phi)) + B * (sinT * sin(phi)) + N * cosT;
        vec3 L = reflect(-V, H);
        float NoL = dot(N, L);
        if (NoL <= 0.0) continue;
        float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H));

        float pdf = D_GGX(NoH, a) * NoH / (4.0 * VoH);                     // 按 GGX 抽样的概率密度
        float f = (_Mode < 0.5)
            ? DirectBRDFSpecular(brdf, N, L, V) * INV_PI                   // 去掉 Unity 约定里的 π,还原成 BRDF
            : D_GGX(NoH, a) * V_SmithJointGGX(NoL, NoV, a);                // F = 1
        sum += f * NoL / max(pdf, 1e-6);
    }
    float E = sum / float(count);
    fragColor = vec4(_EnvColor.rgb * E, 1.0);
}
图 10-7白炉测试(F0 = 1,环境亮度与背景相同)。上排:URP 近似;下排:标准 GGX。感知粗糙度 0.1 → 0.9。下排光滑的球完全融入背景;上排光滑的球明显比背景亮,边缘却有一圈暗边。
0 0.5 1 1.5 2 0 0.2 0.4 0.6 0.8 1 观察方向余弦 μ = n·v 方向反照率 E(F0 = 1) 标准 GGX,粗糙度 0.2 URP 近似,粗糙度 0.2 标准 GGX,粗糙度 0.5 URP 近似,粗糙度 0.5 标准 GGX,粗糙度 0.8 URP 近似,粗糙度 0.8
图 10-8方向反照率 E 随观察角的变化(F0 = 1,数值积分)。实线:标准 GGX,始终 ≤ 1;虚线:URP 近似,感知粗糙度 0.2 时在大部分角度约为 1.8,掠射时迅速跌落。

结论很清楚:在 F0 = 1、粗糙度较低时,URP 的直接高光近似会反射出比入射更多的能量(约 1.8 倍),违反能量守恒;在粗糙度较高时又比标准公式更暗。实际材质的 F0 远小于 1(电介质 0.04),这个“多出来的能量”绝对值很小,加上直接高光只占画面的一小部分,所以日常使用中不易察觉。但如果你在做材质校准、或者把 URP Lit 的结果和离线渲染器、HDRP、第 11 章手写的标准 PBR 并排比较,就会发现光滑材质的直接高光亮度有系统性差异——原因就在这里。

提示如果项目确实需要更标准的直接高光,不必改 URP 源码:像案例 10.1 那样写自己的 Lit,在光照循环里把 DirectBRDFSpecular 替换成 DV_SmithJointGGX(NoH, NoL, NoV, roughness) * F_Schlick(...) * PI 即可(core 已提供 D 与 V 合并优化后的 DV_SmithJointGGX)。代价是每盏灯多一个平方根和若干乘法,移动端要权衡。