案例 5.6高光指数的能量归一化进阶
目标:理解为什么“调高高光指数,高光却变暗了”,推导 Phong 与 Blinn-Phong 的归一化系数,并在 URP 的 π 约定下正确使用它们。
问题
回头看图 5-11:n 从 2 增大到 512,高光变小了,但峰值亮度没变(始终是 ks),于是高光覆盖的总能量越来越少,最右边几乎看不到高光。可现实恰恰相反:越光滑的表面,反射越集中,高光应该越小、越亮——同样多的反射能量被挤进了更小的立体角里。美术只能一边调高光指数、一边手动调高 ks 来“补光”,两个参数纠缠在一起,很难调。
推导
解决办法是给波瓣乘上一个依赖 n 的系数 C(n),让波瓣在半球上的积分保持不变。以 Phong 为例,考虑光从正上方入射(此时 R = N,α 就是出射方向的极角 θ),要求反射的总能量等于 1:
积分里多出的那个 cos θ 是出射方向的投影因子(第 9 章讲渲染方程时会详细解释),sin θ dθ dφ 是立体角微元。对 Blinn-Phong,由于 N·H 与出射方向的关系更复杂,精确的归一化系数是一个较繁琐的表达式;实践中普遍使用一个误差很小的近似:
最后别忘了 5.1 节说的 π 约定:URP 的漫反射写作 albedo·N·L,没有除以 π,相当于整个 BRDF 被乘了 π。为了让高光与漫反射的比例保持正确,归一化系数也要乘 π,代码里实际使用的是 (n+2)/2 和 (n+8)/8。另外,归一化以后的高光被当作 BRDF 使用,它和漫反射一样,要乘上入射光的余弦 N·L——这一项在经典 Phong 里是没有的。
代码
// 返回“乘上 N·L 之后”的高光项,与 URP 的约定一致:漫反射写成 albedo * N·L(没有 1/π), // 所以这里的归一化系数也整体乘了 π:Blinn-Phong 用 (n+8)/8,Phong 用 (n+2)/2 float SpecularTerm(float3 N, float3 L, float3 V, float n) { float NdotL = saturate(dot(N, L)); #if defined(_MODEL_PHONG) float lobe = pow(saturate(dot(reflect(-L, N), V)), n); float norm = (n + 2.0) / 2.0; #else float lobe = pow(saturate(dot(N, SafeNormalize(L + V))), n); float norm = (n + 8.0) / 8.0; #endif #if !defined(_NORMALIZE_SPECULAR) norm = 1.0; #endif return norm * lobe * NdotL; }
// 返回“乘上 N·L 之后”的高光项,与 URP 的约定一致:漫反射写成 albedo * N·L(没有 1/π), // 所以这里的归一化系数也整体乘了 π:Blinn-Phong 用 (n+8)/8,Phong 用 (n+2)/2 float SpecularTerm(vec3 N, vec3 L, vec3 V, float n) { float NdotL = saturate(dot(N, L)); #if defined(_MODEL_PHONG) float lobe = pow(saturate(dot(reflect(-L, N), V)), n); float norm = (n + 2.0) / 2.0; #else float lobe = pow(saturate(dot(N, SafeNormalize(L + V))), n); float norm = (n + 8.0) / 8.0; #endif #if !defined(_NORMALIZE_SPECULAR) norm = 1.0; #endif return norm * lobe * NdotL; }
用 KeywordEnum 在 Phong 与 Blinn-Phong 之间切换,用 Toggle 控制是否归一化,四种组合都可以在材质上直接对比。

下排最右的两个球,高光中心已经超过 1 被截断成纯白,这在 HDR + 色调映射的管线里会表现为更自然的高亮,并可以触发 Bloom。归一化之后,美术只需要用 n(或光滑度)控制“表面有多光滑”,用 ks 控制“反射有多强”,两个参数各司其职。这正是 PBR 的核心思想之一,第 9 章的 GGX 分布同样是归一化的。
pow 的结果乘上这个系数容易超出 half 的范围(约 65504)产生 Inf。如果移动端出现高光处的黑点或白块,先检查这里的精度。