GameTech/URP 渲染实战

案例 5.6高光指数的能量归一化进阶

目标:理解为什么“调高高光指数,高光却变暗了”,推导 Phong 与 Blinn-Phong 的归一化系数,并在 URP 的 π 约定下正确使用它们。

问题

回头看图 5-11:n 从 2 增大到 512,高光变小了,但峰值亮度没变(始终是 ks),于是高光覆盖的总能量越来越少,最右边几乎看不到高光。可现实恰恰相反:越光滑的表面,反射越集中,高光应该越小、越亮——同样多的反射能量被挤进了更小的立体角里。美术只能一边调高光指数、一边手动调高 ks 来“补光”,两个参数纠缠在一起,很难调。

推导

解决办法是给波瓣乘上一个依赖 n 的系数 C(n),让波瓣在半球上的积分保持不变。以 Phong 为例,考虑光从正上方入射(此时 R = N,α 就是出射方向的极角 θ),要求反射的总能量等于 1:

∫02π∫0π/2C⋅cosnθ⋅cosθ⋅sinθdθdφ=C⋅2πn+2=1⇒CPhong=n+22π

积分里多出的那个 cos θ 是出射方向的投影因子(第 9 章讲渲染方程时会详细解释),sin θ dθ dφ 是立体角微元。对 Blinn-Phong,由于 N·H 与出射方向的关系更复杂,精确的归一化系数是一个较繁琐的表达式;实践中普遍使用一个误差很小的近似:

CBlinn-Phong≈n+88π

最后别忘了 5.1 节说的 π 约定:URP 的漫反射写作 albedo·N·L,没有除以 π,相当于整个 BRDF 被乘了 π。为了让高光与漫反射的比例保持正确,归一化系数也要乘 π,代码里实际使用的是 (n+2)/2 和 (n+8)/8。另外,归一化以后的高光被当作 BRDF 使用,它和漫反射一样,要乘上入射光的余弦 N·L——这一项在经典 Phong 里是没有的。

未归一化:cosnα(峰值都是 1) 归一化:(n+2)/2 · cosnα 01 012 −60°α = 060° −60°α = 060° n=64 峰值 33 ↑ n = 4 n = 16 n = 64
图 5-14高光波瓣的剖面。左:未归一化时 n 越大,波瓣越窄,面积(能量)越小;右:归一化后波瓣变窄的同时变高,三条曲线在三维半球上的积分相同

代码

Assets/Cookbook/Ch05/SpecularNormalized.shader(节选:spec)
// 返回“乘上 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 控制是否归一化,四种组合都可以在材质上直接对比。

图 5-15Blinn-Phong 高光指数扫描,从左到右 n = 8、32、128、512、2048,高光颜色均为 0.25。上排:未归一化,高光越来越暗直至消失;下排:归一化,高光越来越小、越来越亮,“光滑度”和“亮度”被解耦了

下排最右的两个球,高光中心已经超过 1 被截断成纯白,这在 HDR + 色调映射的管线里会表现为更自然的高亮,并可以触发 Bloom。归一化之后,美术只需要用 n(或光滑度)控制“表面有多光滑”,用 ks 控制“反射有多强”,两个参数各司其职。这正是 PBR 的核心思想之一,第 9 章的 GGX 分布同样是归一化的。

坑归一化后的高光峰值可以非常大(n = 2048 时约 257·ks),在 half 精度下 pow 的结果乘上这个系数容易超出 half 的范围(约 65504)产生 Inf。如果移动端出现高光处的黑点或白块,先检查这里的精度。
深入严格来说,经典模型还违反了另一条能量守恒:漫反射和高光是直接相加的,kd + ks 可能大于 1。简单的修正是让漫反射乘以 (1 − ks)。更完整的做法要考虑菲涅尔效应——掠射角下反射更强、进入表面的光更少,这在 Blinn-Phong 里完全没有体现,是它看起来“塑料感”的主要原因之一。第 9 章的 Schlick 菲涅尔会补上这一块。