GameTech/URP 渲染实战

案例 5.4Phong 高光入门

目标:实现 Bui Tuong Phong 在 1975 年提出的高光模型,理解反射向量、高光指数和它们的几何意义。

原理

镜子会把光沿着“镜面反射方向”反射出去:入射角等于反射角,入射光、法线和反射光在同一平面内。完美镜面只在恰好对准反射方向时才看得到光源,而真实表面不是完美光滑的,反射光会在反射方向周围散开一点。Phong 用一个经验公式描述这个“散开”:视线 V 越接近反射方向 R,高光越亮,亮度按 (R·V)n 衰减:

SPhong=ks⋅max(0,R⋅V)nR=2(N⋅L)N−L

ks 是高光颜色(强度),n 是高光指数(Shininess)。n 越大,(R·V)n 从 1 掉到 0 的速度越快,高光就越小、越“硬”,表面看起来越光滑。反射向量的公式可以从下图直接读出来:把 L 投影到 N 上得到 (N·L)N,L 关于法线的镜像就是两倍投影减去 L 本身。

N L R V α (N·L)N θ θ R = 2(N·L)N − L  HLSL:R = reflect(−L, N)  高光 ∝ cosn α = (R·V)n
图 5-10Phong 模型的几何:R 是 L 关于法线 N 的镜像,高光强度只取决于 R 与视线 V 的夹角 α

代码

Assets/Cookbook/Ch05/Phong.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);

    // reflect(i, n) 要求 i 是“射入”方向,所以传 -L
    float3 R = reflect(-L, N);
    float RdotV = saturate(dot(R, V));
    // 高光指数常常上百,用 float 计算,避免 half 精度下出现色带
    float spec = pow(RdotV, _Shininess);
    // 背光面不应该出现高光(R 可能恰好朝向相机)
    spec *= 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);

    // reflect(i, n) 要求 i 是“射入”方向,所以传 -L
    vec3 R = reflect(-L, N);
    float RdotV = saturate(dot(R, V));
    // 高光指数常常上百,用 float 计算,避免 half 精度下出现色带
    float spec = pow(RdotV, _Shininess);
    // 背光面不应该出现高光(R 可能恰好朝向相机)
    spec *= step(0.0, NdotL);

    vec3 color = (diffuse + _SpecColor.rgb * spec) * radiance;
    color += _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(color, 1.0);
}
  1. reflect 的第一个参数是“入射方向”。HLSL 的 reflect(i, n) 计算的是 i − 2·dot(i, n)·n,i 指向表面。我们的 L 指向光源,所以要传 -L。这是新手最常见的错误之一,写反了高光会出现在完全错误的位置。
  2. 背光面屏蔽。当 N·L < 0 时,光其实照不到这个点,但 R 依然可以算出来,而且可能恰好朝向相机,于是背光面会冒出一个“幽灵高光”。乘以 step(0, NdotL) 把它关掉。
  3. 精度。高光指数动辄上百,pow 在 half 精度下很容易在高光边缘产生色带甚至溢出。本书在高光计算上统一使用 float,移动端如果要省寄存器,至少保证 pow 的底数是 float。
  4. 颜色组合。漫反射乘反照率,高光乘高光颜色,两者相加后再整体乘以入射光。高光颜色对电介质(塑料、漆、木头)应该是灰白色,因为它们的反射不“染色”。
图 5-11Phong 高光指数扫描,从左到右 n = 2、8、32、128、512,高光颜色相同。n 越大高光越小,同时高光的总亮度也在下降——案例 5.6 会解释并修正这个问题

注意 Phong 模型在 V 与 R 夹角超过 90° 时 R·V 变为负数,被截断成 0,所以高光的“外沿”在 α = 90° 处被一刀切掉。在高光指数很小(n < 4)时,这条切边会在低角度的大平面上显现出来;n 稍大时 cosnα 早已接近 0,看不出来。