GameTech/URP 渲染实战

案例 5.3Wrap Lighting 与交界线染色进阶

目标:用一个参数在 Lambert 和 Half-Lambert 之间连续过渡,并给出“能量守恒”版本;再利用 Wrap 多出来的那一圈,给交界线染上颜色,做出廉价的次表面散射感。

原理

Wrap Lighting 让光“绕过”交界线,照亮背光面的一部分。参数 w(0~1)表示绕过去多远:

Dwrap=saturate(N⋅L+w1+w)

w = 0 时就是 Lambert;w = 1 时就是不平方的 Half-Lambert;中间的值让归零点落在 θ = arccos(−w) 处,比如 w = 0.5 时,光一直照到 120°。

这个式子有一个不太显眼的问题:它让表面变亮了。被照亮的区域变大,而峰值(正对光源处)仍然是 1,球体反射出去的总能量就比 Lambert 多。可以算一下:设 μ = cos θ,一个被平行光照射的球,反射的总能量正比于 D(μ) 在 μ ∈ [−1, 1] 上的积分。Lambert 是 ∫01 μ dμ = 1/2;Wrap 是 ∫−w1 (μ + w)/(1 + w) dμ = (1 + w)/2,多出了 (1 + w) 倍。所以只要把分母再乘一个 (1 + w),总能量就和 Lambert 相同了:

Dwrap, 守恒=saturate(N⋅L+w(1+w)2)

能量守恒版本的效果是:光“铺开”了,但峰值被压低,整体亮度不变。图 5-6 里橙色的曲线是不守恒的 w = 0.5 版本。

代码

Assets/Cookbook/Ch05/WrapLighting.shader(节选:wrap)
half WrapDiffuse(half NdotL, half w)
{
#if defined(_WRAP_ENERGY_CONSERVING)
    // 分母 (1+w)^2:被照亮的范围变大了,峰值相应压低,总能量与 Lambert 相同
    return saturate((NdotL + w) / ((1 + w) * (1 + w)));
#else
    // 经典写法:w = 0 时退化为 Lambert,w = 1 时等价于未平方的 Half-Lambert
    return saturate((NdotL + w) / (1 + w));
#endif
}
float WrapDiffuse(float NdotL, float w)
{
#if defined(_WRAP_ENERGY_CONSERVING)
    // 分母 (1+w)^2:被照亮的范围变大了,峰值相应压低,总能量与 Lambert 相同
    return saturate((NdotL + w) / ((1.0 + w) * (1.0 + w)));
#else
    // 经典写法:w = 0 时退化为 Lambert,w = 1 时等价于未平方的 Half-Lambert
    return saturate((NdotL + w) / (1.0 + w));
#endif
}
Assets/Cookbook/Ch05/WrapLighting.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    half3 N = normalize(input.normalWS);
    Light mainLight = GetMainLight();
    half3 radiance = mainLight.color * mainLight.distanceAttenuation;

    half NdotL = dot(N, mainLight.direction);
    half wrap = WrapDiffuse(NdotL, _Wrap);

    // 交界线染色:只在“Wrap 有、Lambert 没有”的那一圈加颜色,模拟光在皮下散射后带出的红色
    half scatter = saturate(wrap - saturate(NdotL));
    half3 diffuse = _BaseColor.rgb * radiance * wrap
                  + _TerminatorTint.rgb * radiance * scatter;

    half3 ambient = _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
    return half4(diffuse + ambient, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    Light mainLight = GetMainLight();
    vec3 radiance = mainLight.color * mainLight.distanceAttenuation;

    float NdotL = dot(N, mainLight.direction);
    float wrap = WrapDiffuse(NdotL, _Wrap);

    // 交界线染色:只在“Wrap 有、Lambert 没有”的那一圈加颜色,模拟光在皮下散射后带出的红色
    float scatter = saturate(wrap - saturate(NdotL));
    vec3 diffuse = _BaseColor.rgb * radiance * wrap
                 + _TerminatorTint.rgb * radiance * scatter;

    vec3 ambient = _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(diffuse + ambient, 1.0);
}

能量守恒用 shader_feature_local_fragment 做成材质开关,两种写法编译成两个变体,运行时没有分支开销。交界线染色的思路是:wrap − saturate(NdotL) 恰好只在“Wrap 照亮了、Lambert 没照亮”的那一圈大于 0,用它乘上一个偏红的颜色叠加上去,就能模拟光在皮肤、蜡、玉石内部散射后从交界线附近透出的红晕。

图 5-8从左到右 w = 0、0.25、0.5、0.75、1,光从右侧照来。上排:经典 Wrap,越往右越亮;下排:能量守恒 Wrap,光照范围扩大的同时峰值降低,整体亮度基本不变
图 5-9左:Lambert(w = 0);右:能量守恒 Wrap(w = 0.5)+ 暗红色交界线染色,明暗交界处出现柔和的红晕,有了“皮肤感”
深入Wrap 加染色只是次表面散射(Subsurface Scattering)最粗糙的近似:它不考虑物体厚度,也不区分红绿蓝光在皮肤里的散射距离差异。第 12 章会介绍预积分皮肤 LUT 和薄片透射,它们是 Wrap 思路的“正规化”版本。