GameTech/URP 渲染实战

案例 7.4细节法线:四种混合方法对比进阶

目标:同一组基础法线(砖墙)和细节法线(圆点),用四种方法混合,并分别看最终光照和切线空间结果。四个函数写在一个独立的 include 里,方便在别的 Shader 中复用:

Assets/Cookbook/Ch07/NormalBlend.hlsl(节选:blend)
// ① 线性平均:把两个方向直接相加再归一化。最直觉、也最不对——两张图会互相“抹平”。
half3 BlendNormalLinear(half3 n1, half3 n2)
{
    return normalize(n1 + n2);
}

// ② UDN(Unreal Developer Network 流传的写法):只叠加 xy,z 用基础法线的。
//    便宜,但细节在陡坡上会被压扁。
half3 BlendNormalUDN(half3 n1, half3 n2)
{
    return normalize(half3(n1.xy + n2.xy, n1.z));
}

// ③ Whiteout:xy 相加,z 相乘。相当于把两个“斜率”相加,平地和斜坡上的细节都比较可信。
half3 BlendNormalWhiteout(half3 n1, half3 n2)
{
    return normalize(half3(n1.xy + n2.xy, n1.z * n2.z));
}

// ④ RNM(Reoriented Normal Mapping):把细节法线当成“相对 +Z 的旋转”,
//    再把这个旋转施加到基础法线上。URP 的 Lit 细节法线用的就是它:
//    BlendNormalRNM(n1, n2),定义在 core 的 CommonMaterial.hlsl,要求两个输入都已归一化。
half3 BlendNormalReoriented(half3 n1, half3 n2)
{
    return normalize(BlendNormalRNM(normalize(n1), normalize(n2)));
}
// ① 线性平均:把两个方向直接相加再归一化。最直觉、也最不对——两张图会互相“抹平”。
vec3 BlendNormalLinear(vec3 n1, vec3 n2)
{
    return normalize(n1 + n2);
}

// ② UDN(Unreal Developer Network 流传的写法):只叠加 xy,z 用基础法线的。
//    便宜,但细节在陡坡上会被压扁。
vec3 BlendNormalUDN(vec3 n1, vec3 n2)
{
    return normalize(vec3(n1.xy + n2.xy, n1.z));
}

// ③ Whiteout:xy 相加,z 相乘。相当于把两个“斜率”相加,平地和斜坡上的细节都比较可信。
vec3 BlendNormalWhiteout(vec3 n1, vec3 n2)
{
    return normalize(vec3(n1.xy + n2.xy, n1.z * n2.z));
}

// ④ RNM(Reoriented Normal Mapping):把细节法线当成“相对 +Z 的旋转”,
//    再把这个旋转施加到基础法线上。URP 的 Lit 细节法线用的就是它:
//    BlendNormalRNM(n1, n2),要求两个输入都已归一化。
vec3 BlendNormalReoriented(vec3 n1, vec3 n2)
{
    return normalize(BlendNormalRNM(normalize(n1), normalize(n2)));
}

Shader 的片元部分用 _BlendMode 切换。注意两点:一是混合前先 normalize(UnpackNormalScale 返回的向量在强度 ≠ 1 时并非单位长度,而 RNM 要求单位输入);二是 _DetailMask 的处理与 URP Lit 一致,在“仅基础法线”和“混合结果”之间插值。

Assets/Cookbook/Ch07/DetailNormal.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 n1 = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv.xy), _BumpScale);
    half3 n2 = UnpackNormalScale(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, i.uv.zw), _DetailNormalScale);
    n1 = normalize(n1);
    n2 = normalize(n2);

    half3 blended;
    if (_BlendMode < 0.5)      blended = BlendNormalLinear(n1, n2);
    else if (_BlendMode < 1.5) blended = BlendNormalUDN(n1, n2);
    else if (_BlendMode < 2.5) blended = BlendNormalWhiteout(n1, n2);
    else if (_BlendMode < 3.5) blended = BlendNormalReoriented(n1, n2);
    else if (_BlendMode < 4.5) blended = n1;
    else                       blended = n2;
    // 遮罩:与 URP Lit 一样在“只有基础法线”和“混合结果”之间插值
    half3 normalTS = normalize(lerp(n1, blended, _DetailMask));

    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3 normalWS = NormalizeNormalPerPixel(
        TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS)));

    if (_ShowNormal > 0.5) return half4(normalTS * 0.5 + 0.5, 1);   // 直接看切线空间结果
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv.xy).rgb;
    return half4(Ch07Shade(i.positionWS, normalWS, albedo, 0.5, 0.2, 1), 1);
}
#ifdef FRAGMENT
void main()
{
    vec3 n1 = UnpackNormalScale(texture(_BumpMap, uv.xy), _BumpScale);
    vec3 n2 = UnpackNormalScale(texture(_DetailNormalMap, uv.zw), _DetailNormalScale);
    n1 = normalize(n1);
    n2 = normalize(n2);

    vec3 blended;
    if (_BlendMode < 0.5)      blended = BlendNormalLinear(n1, n2);
    else if (_BlendMode < 1.5) blended = BlendNormalUDN(n1, n2);
    else if (_BlendMode < 2.5) blended = BlendNormalWhiteout(n1, n2);
    else if (_BlendMode < 3.5) blended = BlendNormalReoriented(n1, n2);
    else if (_BlendMode < 4.5) blended = n1;
    else                       blended = n2;
    // 遮罩:与 URP Lit 一样在“只有基础法线”和“混合结果”之间插值
    vec3 normalTS = normalize(mix(n1, blended, _DetailMask));

    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    vec3 nWS = NormalizeNormalPerPixel(
        TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS)));

    if (_ShowNormal > 0.5) { fragColor = vec4(normalTS * 0.5 + 0.5, 1.0); return; }   // 直接看切线空间结果
    vec3 albedo = texture(_BaseMap, uv.xy).rgb;
    fragColor = vec4(Ch07Shade(positionWS, nWS, albedo, 0.5, 0.2, 1.0), 1.0);
}
#endif
图 7-9砖墙法线 + 圆点细节法线,从左到右:线性平均、UDN、Whiteout、RNM。线性平均明显把砖块倒角和圆点都削弱了;后三种在平坦的砖面上几乎一样。
图 7-10换成“大圆包 + 小圆点”并直接输出切线空间法线颜色,顺序同上。在大圆包的坡面上,线性平均让整体发白(法线被拉回 +Z);UDN 的小圆点在坡面上偏淡;Whiteout 与 RNM 保留了更多细节,RNM 在坡面最陡处的颜色最饱和。

从两张图可以得出很实用的结论:基础法线比较平缓时,UDN、Whiteout、RNM 几乎没有区别;基础法线很陡时,差别才显现。线性平均则在任何情况下都应该避免。性能上,UDN 最便宜,Whiteout 多一次乘法,RNM 多几次乘加和一次除法,在今天的 GPU 上差别都很小,所以默认用 RNM(与 URP Lit 一致)或 Whiteout 即可;移动端极端情况下 UDN 也是可以接受的折中。

提示URP Lit 的 ApplyDetailNormal 在注释里承认了一个简化:遮罩插值的是“混合前后的法线”,严格来说应该插值旋转角度。遮罩边缘过渡区稍有偏差,肉眼几乎不可见。我们的实现沿用了同样的做法。