GameTech/URP 渲染实战

案例 5.5Blinn-Phong 与半程向量入门

目标:用半程向量 H 改写 Phong 高光,理解 Blinn-Phong 与 Phong 的形状差异,以及它为什么成了游戏引擎里的“标准高光”。

原理

Jim Blinn 在 1977 年提出:与其比较 R 和 V,不如比较 N 和“L 与 V 的角平分线”。这条角平分线就是半程向量(Half Vector):

H=L+V‖L+V‖SBlinn=ks⋅max(0,N⋅H)n′

当视线恰好在镜面反射方向上(V = R)时,L 和 V 关于 N 对称,它们的角平分线正好就是 N,N·H = 1,高光最亮——与 Phong 一致。偏离反射方向时,N 与 H 的夹角 β 大约只有 R 与 V 夹角 α 的一半,所以要得到差不多大小的高光,Blinn-Phong 的指数 n′ 要比 Phong 大,经验上 n′ ≈ 4n。

N L V H β • H 是 L 与 V 的角平分线 • V 在镜面方向上时 H = N • β ≈ α / 2,所以 n′ ≈ 4n • H 可理解为“能把 L 反射 到 V 的微小镜面”的朝向
图 5-12半程向量 H。Blinn-Phong 比较的是 N 与 H 的夹角 β,而不是 R 与 V 的夹角 α

Blinn 当年的动机是性能:对于方向光和“无限远”的观察者,L 和 V 处处相同,H 也就是常数,每个像素只需要一次点积。今天这个优势在透视相机下已不存在,但 Blinn-Phong 依然被广泛使用,原因更深一层:H 有清晰的物理含义。把粗糙表面看成无数朝向各异的微小镜面,只有朝向恰好等于 H 的那些微镜面,才能把 L 方向来的光反射到 V 方向上。(N·H)n 描述的其实就是“朝向为 H 的微镜面有多少”,这正是第 9 章微表面理论里“法线分布函数 D”的雏形,Blinn-Phong NDF 至今仍是 PBR 文献里的一种 D 项。

代码

Assets/Cookbook/Ch05/BlinnPhong.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    float3 N = normalize(input.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS);
    Light mainLight = GetMainLight();
    float3 L = mainLight.direction;
    half3 radiance = mainLight.color * mainLight.distanceAttenuation;

    float NdotL = dot(N, L);
    half3 diffuse = _BaseColor.rgb * saturate(NdotL);

    // 半程向量:L 与 V 的“角平分线”。L、V 几乎相反时 L+V 接近零向量,用 SafeNormalize 防 NaN
    float3 H = SafeNormalize(L + V);
    float NdotH = saturate(dot(N, H));
    float spec = pow(NdotH, _Shininess) * step(0.0, NdotL);

    half3 color = (diffuse + _SpecColor.rgb * spec) * radiance;
    color += _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
    return half4(color, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light mainLight = GetMainLight();
    vec3 L = mainLight.direction;
    vec3 radiance = mainLight.color * mainLight.distanceAttenuation;

    float NdotL = dot(N, L);
    vec3 diffuse = _BaseColor.rgb * saturate(NdotL);

    // 半程向量:L 与 V 的“角平分线”。L、V 几乎相反时 L+V 接近零向量,用 SafeNormalize 防 NaN
    vec3 H = SafeNormalize(L + V);
    float NdotH = saturate(dot(N, H));
    float spec = pow(NdotH, _Shininess) * step(0.0, NdotL);

    vec3 color = (diffuse + _SpecColor.rgb * spec) * radiance;
    color += _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(color, 1.0);
}

和 Phong 相比只改了两行:用 SafeNormalize(L + V) 求 H,用 N·H 代替 R·V。SafeNormalize 在向量长度接近 0 时返回零向量而不是 NaN——当光从正背后照来、L 与 V 几乎相反时,L + V 会接近零向量,普通的 normalize 会除以 0。

Phong 与 Blinn-Phong 的形状差异

两者在正面观察时几乎一样,差别出现在掠射角:当光和视线都贴着表面时,Phong 的高光仍然是圆的,而 Blinn-Phong 的高光会沿着视线方向拉长成一条。现实中,夕阳照在湖面或湿马路上时,倒影总是拉成一条竖直的长条,这正是 Blinn-Phong 的样子——它更接近真实。

图 5-13一块大平面,光源在远处、贴近地平线,迎着相机照来。左:Phong,n = 32,高光是一大团圆斑;中:Blinn-Phong,n = 32,高光沿视线方向拉长;右:Blinn-Phong,n = 128,宽度与左图接近,但依然是竖直的长条

URP 自带的 Simple Lit 用的就是 Blinn-Phong,核心函数在 Lighting.hlsl 里:

摘自 URP 14 ShaderLibrary/Lighting.hlsl
half3 LightingSpecular(half3 lightColor, half3 lightDir, half3 normal, half3 viewDir, half4 specular, half smoothness)
{
    float3 halfVec = SafeNormalize(float3(lightDir) + float3(viewDir));
    half NdotH = half(saturate(dot(normal, halfVec)));
    half modifier = pow(NdotH, smoothness);
    float3 specularReflection = specular.rgb * modifier;
    return lightColor * specularReflection;
}

// CalculateBlinnPhong() 中:
half smoothness = exp2(10 * surfaceData.smoothness + 1);

可以看到两点:第一,名为 smoothness 的参数其实是高光指数,由材质面板上 0~1 的光滑度经过 exp2(10·s + 1) 映射而来,范围是 2 到 2048;这种指数映射让滑块在“粗糙”一侧变化平缓、在“光滑”一侧变化剧烈,手感更均匀。第二,它没有做归一化,也没有乘 N·L——这两点正是下一个案例要讨论的。