URP 渲染实战 / 第 7 章 法线贴图与视差 / 案例 7.5
案例 7.5从高度图实时求法线进阶
目标:只有一张高度图,在 Shader 里求出法线,并与离线烘焙的法线贴图对比。
高度场 h(u, v) 对应的表面法线(切线空间)是 ,问题归结为求两个偏导数。第一种办法是中心差分:在 U、V 方向各取左右两个相邻纹素,差值除以 2。_HeightMap_TexelSize 是 Unity 自动填充的纹素尺寸,正好用来算偏移量。这与离线工具从高度图生成法线贴图的算法一样(本书配套贴图就是这样生成的,强度系数为 6),所以结果应当与烘焙的法线贴图基本一致。
Assets/Cookbook/Ch07/HeightToNormal.shader(节选:central)
// 中心差分:在 U、V 方向各取左右两个邻居。结果与离线烘焙法线贴图的算法一致。 half3 NormalFromHeightCentral(float2 uv) { float2 t = _HeightMap_TexelSize.xy; float hL = SampleHeight(uv - float2(t.x, 0)); float hR = SampleHeight(uv + float2(t.x, 0)); float hD = SampleHeight(uv - float2(0, t.y)); float hU = SampleHeight(uv + float2(0, t.y)); // 每个纹素的高度差 × 强度 = 斜率;法线 = normalize(-dh/du, -dh/dv, 1) float dhdu = (hR - hL) * 0.5 * _HeightStrength; float dhdv = (hU - hD) * 0.5 * _HeightStrength; return normalize(half3(-dhdu, -dhdv, 1)); }
// 中心差分:在 U、V 方向各取左右两个邻居。结果与离线烘焙法线贴图的算法一致。 vec3 NormalFromHeightCentral(vec2 uv) { vec2 t = _HeightMap_TexelSize.xy; float hL = SampleHeight(uv - vec2(t.x, 0)); float hR = SampleHeight(uv + vec2(t.x, 0)); float hD = SampleHeight(uv - vec2(0, t.y)); float hU = SampleHeight(uv + vec2(0, t.y)); // 每个纹素的高度差 × 强度 = 斜率;法线 = normalize(-dh/du, -dh/dv, 1) float dhdu = (hR - hL) * 0.5 * _HeightStrength; float dhdv = (hU - hD) * 0.5 * _HeightStrength; return normalize(vec3(-dhdu, -dhdv, 1)); }
第二种办法是屏幕空间导数:GPU 以 2×2 像素块为单位执行片元着色器,ddx/ddy 返回任意变量在相邻像素之间的差。我们有 h 的屏幕导数,也有 uv 的屏幕导数,两者通过链式法则联系:,y 方向同理。两个方程、两个未知数,用克拉默法则解出 ∂h/∂u、∂h/∂v。它只需要采样一次高度图。
Assets/Cookbook/Ch07/HeightToNormal.shader(节选:derivative)
// 屏幕导数:ddx/ddy 给出“相邻像素之间”h 和 uv 的变化量,解一个 2×2 线性方程 // 就能把屏幕空间的变化换算成 dh/du、dh/dv。只采样一次,但精度受 2×2 像素块限制。 half3 NormalFromHeightDerivative(float2 uv) { float h = SampleHeight(uv); float2 duvdx = ddx(uv), duvdy = ddy(uv); float dhdx = ddx(h), dhdy = ddy(h); // [duvdx; duvdy] · (dh/du, dh/dv) = (dhdx, dhdy),用克拉默法则求解 float det = duvdx.x * duvdy.y - duvdx.y * duvdy.x; float2 grad = float2(dhdx * duvdy.y - dhdy * duvdx.y, duvdx.x * dhdy - duvdy.x * dhdx) / (abs(det) > 1e-12 ? det : 1e-12); grad *= _HeightMap_TexelSize.xy * _HeightStrength; // 每 UV 单位 → 每纹素 return normalize(half3(-grad, 1)); }
// 屏幕导数:dFdx/dFdy 给出“相邻像素之间”h 和 uv 的变化量,解一个 2×2 线性方程 // 就能把屏幕空间的变化换算成 dh/du、dh/dv。只采样一次,但精度受 2×2 像素块限制。 vec3 NormalFromHeightDerivative(vec2 uv) { float h = SampleHeight(uv); vec2 duvdx = dFdx(uv), duvdy = dFdy(uv); float dhdx = dFdx(h), dhdy = dFdy(h); // [duvdx; duvdy] · (dh/du, dh/dv) = (dhdx, dhdy),用克拉默法则求解 float det = duvdx.x * duvdy.y - duvdx.y * duvdy.x; vec2 grad = vec2(dhdx * duvdy.y - dhdy * duvdx.y, duvdx.x * dhdy - duvdy.x * dhdx) / (abs(det) > 1e-12 ? det : 1e-12); grad *= _HeightMap_TexelSize.xy * _HeightStrength; // 每 UV 单位 → 每纹素 return normalize(vec3(-grad, 1)); }

中图的亮点来自数据精度:配套的高度图按默认设置导入,被压缩成了有损格式,块压缩的误差本身很小,但差分运算会把相邻纹素之间的误差放大成斜率,再乘以强度系数,就变成了可见的噪点。离线烘焙时高度数据是全精度的,所以没有这个问题。实际使用中,用于求导的高度图应当选择单通道无损或高精度格式(例如 R8、R16,或专为单通道设计的 BC4),并关闭不必要的压缩。右图的块状感是屏幕导数的固有特性:同一个 2×2 块里的四个像素共享同一组导数。它在远处或低频高度场上表现不错,近看高频细节时就力不从心。
| 方法 | 采样次数 | 质量 | 适用 |
|---|---|---|---|
| 烘焙法线贴图 | 1 | 最好(全精度离线计算) | 静态资源的默认选择 |
| 中心差分 | 4(或 3:只取右、上两点做前向差分) | 好,受高度图精度影响 | 程序化高度、运行时变化的高度(波纹、弹坑) |
| 屏幕导数 | 1 | 一般,2×2 块状 | 低频起伏、远景、对性能极度敏感的场合 |