案例 5.5Blinn-Phong 与半程向量入门
目标:用半程向量 H 改写 Phong 高光,理解 Blinn-Phong 与 Phong 的形状差异,以及它为什么成了游戏引擎里的“标准高光”。
原理
Jim Blinn 在 1977 年提出:与其比较 R 和 V,不如比较 N 和“L 与 V 的角平分线”。这条角平分线就是半程向量(Half Vector):
当视线恰好在镜面反射方向上(V = R)时,L 和 V 关于 N 对称,它们的角平分线正好就是 N,N·H = 1,高光最亮——与 Phong 一致。偏离反射方向时,N 与 H 的夹角 β 大约只有 R 与 V 夹角 α 的一半,所以要得到差不多大小的高光,Blinn-Phong 的指数 n′ 要比 Phong 大,经验上 n′ ≈ 4n。
Blinn 当年的动机是性能:对于方向光和“无限远”的观察者,L 和 V 处处相同,H 也就是常数,每个像素只需要一次点积。今天这个优势在透视相机下已不存在,但 Blinn-Phong 依然被广泛使用,原因更深一层:H 有清晰的物理含义。把粗糙表面看成无数朝向各异的微小镜面,只有朝向恰好等于 H 的那些微镜面,才能把 L 方向来的光反射到 V 方向上。(N·H)n 描述的其实就是“朝向为 H 的微镜面有多少”,这正是第 9 章微表面理论里“法线分布函数 D”的雏形,Blinn-Phong NDF 至今仍是 PBR 文献里的一种 D 项。
代码
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 的样子——它更接近真实。

URP 自带的 Simple Lit 用的就是 Blinn-Phong,核心函数在 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——这两点正是下一个案例要讨论的。