URP 渲染实战 / 第 20 章 地形与植被 / 案例 20.3
案例 20.3法线贴图的 Triplanar:UDN、Whiteout 与 RNM进阶
目标:给三平面映射加上法线贴图,并且光照正确。
颜色可以随便按权重混合,法线不行。普通法线贴图存的是切线空间的方向,需要 TBN 矩阵转到世界空间;可 Triplanar 根本没有网格切线——三张投影各有各的“切线空间”:X 投影的 (u, v, n) 轴对应世界的 (z, y, x),Y 投影对应 (x, z, y),Z 投影对应 (x, y, z)。问题在于这三个“切线空间”的 n 轴都是世界轴,而真实表面的法线是倾斜的。直接把切线法线 swizzle 到世界轴上(Swizzle 法),等于假装表面就是轴对齐的平面——一个球会被照成一个方块。
正确的做法是把采样得到的细节法线“叠加”到几何法线上,这正是第 7 章细节法线混合要解决的同一个问题,只是这次“底层法线”是几何法线在该投影平面内的表示:对 X 投影,几何法线在其切线空间里的坐标就是 (N.z, N.y, N.x)。于是可以套用三种经典的法线混合公式:
| 方法 | 投影平面内的公式(n = 细节法线,g = 几何法线在该平面的表示) | 特点 |
|---|---|---|
| UDN | (n.xy + g.xy, g.z) | 最便宜;细节在斜面上会被“压扁”,凹凸感偏弱 |
| Whiteout | (n.xy + g.xy, n.z × g.z) | 多一次乘法,细节保留得更好,最常用的性价比方案 |
| RNM | 把 n 从 (0,0,1) “旋转”到 g 的方向 | 数学上最正确(Reoriented Normal Mapping),开销略高 |
RNM 在 core 库里有现成实现 BlendNormalRNM(n1, n2)(CommonMaterial.hlsl),它假设 n1 的 z 为正;所以先用 |N| 构造基底,混合后再把 z 的符号乘回去。
Assets/Cookbook/Ch20/TerrainCommon.hlsl(节选:triplanarNormal)
// 法线的 Triplanar:三张切线空间法线各自“转”到世界空间再按权重混合。 // mode 0 = 直接 swizzle(忽略几何法线,球会被照成方块) // mode 1 = UDN(xy 相加,z 用几何法线) // mode 2 = Whiteout(xy 相加,z 相乘) // mode 3 = RNM(Reoriented Normal Mapping,把细节法线“旋转”到几何法线上) half3 TriplanarNormalWS(TEXTURE2D_PARAM(bump, samp), TriplanarUV uv, float3 w, half3 normalWS, half scale, int mode) { half3 tx = UnpackNormalScale(SAMPLE_TEXTURE2D(bump, samp, uv.x), scale); half3 ty = UnpackNormalScale(SAMPLE_TEXTURE2D(bump, samp, uv.y), scale); half3 tz = UnpackNormalScale(SAMPLE_TEXTURE2D(bump, samp, uv.z), scale); half3 axisSign = normalWS < 0 ? -1.0 : 1.0; // 与 GetTriplanarUV 中翻转 U 对应:切线 x 分量也要跟着翻 tx.x *= axisSign.x; ty.x *= axisSign.y; tz.x *= -axisSign.z; half3 n = normalWS; half3 an = abs(normalWS); if (mode == 0) // Swizzle:假设表面就是轴对齐的平面 { tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z; } else if (mode == 1) // UDN { tx = half3(tx.xy + n.zy, n.x); ty = half3(ty.xy + n.xz, n.y); tz = half3(tz.xy + n.xy, n.z); } else if (mode == 2) // Whiteout { tx = half3(tx.xy + n.zy, abs(tx.z) * n.x); ty = half3(ty.xy + n.xz, abs(ty.z) * n.y); tz = half3(tz.xy + n.xy, abs(tz.z) * n.z); } else // RNM:基底法线取 (各平面内的几何法线, |N|),用 core 库的 BlendNormalRNM { tx = BlendNormalRNM(half3(n.zy, an.x), tx); ty = BlendNormalRNM(half3(n.xz, an.y), ty); tz = BlendNormalRNM(half3(n.xy, an.z), tz); tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z; } // swizzle 回世界轴:X 平面的 (u,v,n) = (z,y,x),Y 平面 = (x,z,y),Z 平面 = (x,y,z) return normalize(tx.zyx * w.x + ty.xzy * w.y + tz.xyz * w.z); }
// 法线的 Triplanar:三张切线空间法线各自“转”到世界空间再按权重混合。 // mode 0 = 直接 swizzle(忽略几何法线,球会被照成方块) // mode 1 = UDN(xy 相加,z 用几何法线) // mode 2 = Whiteout(xy 相加,z 相乘) // mode 3 = RNM(Reoriented Normal Mapping,把细节法线“旋转”到几何法线上) vec3 TriplanarNormalWS(sampler2D bump, TriplanarUV uv, vec3 w, vec3 normalWS, float scale, int mode) { vec3 tx = UnpackNormalmapRGorAG(texture(bump, uv.x), scale); vec3 ty = UnpackNormalmapRGorAG(texture(bump, uv.y), scale); vec3 tz = UnpackNormalmapRGorAG(texture(bump, uv.z), scale); vec3 axisSign = Ch20AxisSign(normalWS); // 与 GetTriplanarUV 中翻转 U 对应:切线 x 分量也要跟着翻 tx.x *= axisSign.x; ty.x *= axisSign.y; tz.x *= -axisSign.z; vec3 n = normalWS; vec3 an = abs(normalWS); if (mode == 0) // Swizzle:假设表面就是轴对齐的平面 { tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z; } else if (mode == 1) // UDN { tx = vec3(tx.xy + n.zy, n.x); ty = vec3(ty.xy + n.xz, n.y); tz = vec3(tz.xy + n.xy, n.z); } else if (mode == 2) // Whiteout { tx = vec3(tx.xy + n.zy, abs(tx.z) * n.x); ty = vec3(ty.xy + n.xz, abs(ty.z) * n.y); tz = vec3(tz.xy + n.xy, abs(tz.z) * n.z); } else // RNM:基底法线取 (各平面内的几何法线, |N|),用 core 库的 BlendNormalRNM { tx = BlendNormalRNM(vec3(n.zy, an.x), tx); ty = BlendNormalRNM(vec3(n.xz, an.y), ty); tz = BlendNormalRNM(vec3(n.xy, an.z), tz); tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z; } // swizzle 回世界轴:X 平面的 (u,v,n) = (z,y,x),Y 平面 = (x,z,y),Z 平面 = (x,y,z) return normalize(tx.zyx * w.x + ty.xzy * w.y + tz.xyz * w.z); }
注意前面翻转 U 时,切线空间的 x 轴也随之反向,所以采样出来的 tx.x 等也要乘上同样的符号,否则背面的凹凸方向是反的。最后一行把三个平面的结果 swizzle 回世界轴、按权重相加再归一化。

选型建议地形与大型静态物体用 Whiteout 足够;需要严格正确(例如法线贴图强度很大、或者要和烘焙结果对照)时用 RNM。移动端如果连 6 次采样都嫌贵,可以只对颜色做 Triplanar,法线只用 Y 投影一张,再按
|N.y| 把它淡出到几何法线。深入:为什么 Swizzle 不是完全错如果表面本身就是轴对齐的(建筑墙面、地板),几何法线 g 恰好等于投影轴,三种混合都退化成 Swizzle。所以在“盒子形”场景里 Swizzle 也能用,它的问题只出现在曲面和斜面上——而地形恰恰全是斜面。
本案例出自《URP 渲染实战》第 20 章「地形与植被」。在章节中阅读上下文 →