URP 渲染实战 / 第 7 章 法线贴图与视差 / 案例 7.4
案例 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


从两张图可以得出很实用的结论:基础法线比较平缓时,UDN、Whiteout、RNM 几乎没有区别;基础法线很陡时,差别才显现。线性平均则在任何情况下都应该避免。性能上,UDN 最便宜,Whiteout 多一次乘法,RNM 多几次乘加和一次除法,在今天的 GPU 上差别都很小,所以默认用 RNM(与 URP Lit 一致)或 Whiteout 即可;移动端极端情况下 UDN 也是可以接受的折中。
提示URP Lit 的
ApplyDetailNormal 在注释里承认了一个简化:遮罩插值的是“混合前后的法线”,严格来说应该插值旋转角度。遮罩边缘过渡区稍有偏差,肉眼几乎不可见。我们的实现沿用了同样的做法。