GameTech/URP 渲染实战

案例 7.5从高度图实时求法线进阶

目标:只有一张高度图,在 Shader 里求出法线,并与离线烘焙的法线贴图对比。

高度场 h(u, v) 对应的表面法线(切线空间)是 n∝(−∂h∂u,−∂h∂v,1),问题归结为求两个偏导数。第一种办法是中心差分:在 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 的屏幕导数,两者通过链式法则联系:∂h∂x=∂h∂u∂u∂x+∂h∂v∂v∂x,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));
}
图 7-11近距离看砖墙。左:离线烘焙的法线贴图;中:中心差分,整体形状与左图一致,但倒角上出现零星的亮点;右:屏幕导数,只采样一次,砖缝边缘呈现出 2×2 像素的块状锯齿。

中图的亮点来自数据精度:配套的高度图按默认设置导入,被压缩成了有损格式,块压缩的误差本身很小,但差分运算会把相邻纹素之间的误差放大成斜率,再乘以强度系数,就变成了可见的噪点。离线烘焙时高度数据是全精度的,所以没有这个问题。实际使用中,用于求导的高度图应当选择单通道无损或高精度格式(例如 R8、R16,或专为单通道设计的 BC4),并关闭不必要的压缩。右图的块状感是屏幕导数的固有特性:同一个 2×2 块里的四个像素共享同一组导数。它在远处或低频高度场上表现不错,近看高频细节时就力不从心。

方法采样次数质量适用
烘焙法线贴图1最好(全精度离线计算)静态资源的默认选择
中心差分4(或 3:只取右、上两点做前向差分)好,受高度图精度影响程序化高度、运行时变化的高度(波纹、弹坑)
屏幕导数1一般,2×2 块状低频起伏、远景、对性能极度敏感的场合