GameTech/URP 渲染实战

案例 19.4双法线流动进阶

目标:用法线贴图给水面加上细碎的波纹,并让波纹“流动”起来,同时避免一眼就能看出的重复平铺。

原理:两层不同尺度、不同方向的法线

一张法线贴图平铺在大面积水面上,重复感非常明显;让它沿一个方向滚动,看起来又像整片水面在平移,而不是在波动。经典做法是用同一张法线贴图采样两次:两次的平铺尺度不同(比如每米 0.18 次和 0.31 次,比例不是整数,两层的重复周期错开),流动方向和速度也不同。两层叠加后,任何一处的图案都在不断变化,重复与平移感都被打破了。

两层法线的混合用 Whiteout 方法:xy 相加、z 相乘再归一化。第 7 章对比过几种细节法线混合方法,Whiteout 比简单平均保留更多起伏,计算又比 RNM 便宜。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:normals)
// 双层法线:同一张 water_normal 以不同尺度、不同方向流动,两层叠加打破重复感
half3 DualNormalTS(float2 xz, float t)
{
    float2 uvA = xz * _NormalTiling.x + _FlowA.xy * t;
    float2 uvB = xz * _NormalTiling.y + _FlowB.xy * t;
    half3 nA = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvA), _FlowA.z);
    half3 nB = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvB), _FlowB.z);
    // Whiteout 混合:xy 相加、z 相乘,比简单平均保留更多细节(第 7 章有对比)
    return normalize(half3(nA.xy + nB.xy, nA.z * nB.z));
}
// 双层法线:同一张 water_normal 以不同尺度、不同方向流动,两层叠加打破重复感
vec3 DualNormalTS(vec2 xz, float t)
{
    vec2 uvA = xz * _NormalTiling.x + _FlowA.xy * t;
    vec2 uvB = xz * _NormalTiling.y + _FlowB.xy * t;
    vec3 nA = UnpackNormalScale(texture(_NormalMap, uvA), _FlowA.z);
    vec3 nB = UnpackNormalScale(texture(_NormalMap, uvB), _FlowB.z);
    // Whiteout 混合:xy 相加、z 相乘,比简单平均保留更多细节(第 7 章有对比)
    return normalize(vec3(nA.xy + nB.xy, nA.z * nB.z));
}

采样坐标直接用世界坐标 xz,而不是网格 UV:这样水面的波纹尺度以“米”为单位,水面网格放大缩小都不会改变波纹大小,多块水面拼在一起也天然连续。

切线空间

平静水面的切线空间很简单:切线 +X、副切线 +Z、法线 +Y。顶点着色器把这三个向量传给片元,片元用 mul(normalTS, half3x3(T, B, N)) 变换到世界空间。之所以不直接写死,是因为加入 Gerstner 波(案例 19.9)后,T、B、N 会随波面倾斜,到时只需要在顶点着色器里改它们。

法线贴图必须能无缝平铺

大面积平铺时,法线贴图左右、上下边界上任何一点不连续,都会在水面上形成一条条笔直的细缝,在高光和反射里格外刺眼。一个典型的坑是:用若干正弦波叠加高度场时,如果某个波在贴图周期内走的不是整数圈,边界处就会断开,求导后边缘一圈像素的法线明显错误。本章用 C# 程序生成一张可平铺的水面法线(配套的公共贴图 water_normal 也按同样的规则生成),顺便讲清楚“可平铺”的三个条件:

Assets/Cookbook/Ch19/Editor/WaterNormalGenerator.cs(节选:gen)
public static Texture2D Create(int size = 256, float strength = 3.5f)
{
    if (s_Cached != null) return s_Cached;
    var h = new float[size, size];
    // 整数波矢 (kx, ky):在一个贴图周期里正好走完整数个波长,左右/上下边界自然衔接。
    // 方向大致集中在“风向”附近,看起来才像被风吹出来的水纹
    int[,] k = { { 3, 1 }, { 2, 3 }, { 4, -1 }, { 5, 2 }, { 3, 5 }, { 6, -3 }, { 7, 4 }, { -2, 6 },
                 { 9, 2 }, { 8, 7 }, { 11, -4 }, { 5, 10 }, { 13, 3 }, { -7, 11 } };
    var rnd = new System.Random(19);
    var phase = new float[k.GetLength(0)];
    for (int i = 0; i < phase.Length; i++) phase[i] = (float)rnd.NextDouble() * Mathf.PI * 2;

    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            float u = (float)x / size, v = (float)y / size, sum = 0;
            // 域扭曲:用周期噪声扰动相位,打破正弦叠加的“格子布”感(噪声是周期的,所以仍然无缝)
            float warp = PeriodicNoise(u, v, 4, 7) * 2.2f + PeriodicNoise(u, v, 8, 8) * 1.1f;
            for (int i = 0; i < phase.Length; i++)
            {
                float kx = k[i, 0], ky = k[i, 1];
                float amp = 1f / Mathf.Sqrt(kx * kx + ky * ky);
                sum += amp * Mathf.Sin(2 * Mathf.PI * (kx * u + ky * v) + phase[i] + warp);
            }
            // 再叠几层周期性值噪声(fBm)
            sum += 0.3f * PeriodicNoise(u, v, 8, 1) + 0.15f * PeriodicNoise(u, v, 16, 2) + 0.08f * PeriodicNoise(u, v, 32, 3);
            h[x, y] = sum;
        }

    var px = new Color[size * size];
    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            // 中心差分,坐标按周期回绕
            float dx = (h[(x + 1) % size, y] - h[(x - 1 + size) % size, y]) * 0.5f * strength;
            float dy = (h[x, (y + 1) % size] - h[x, (y - 1 + size) % size]) * 0.5f * strength;
            var n = new Vector3(-dx, -dy, 1).normalized;
            // R=x、G=y、B=z、A=1。URP 的 UnpackNormalScale 在桌面平台走 UnpackNormalmapRGorAG:
            // 先 a *= r 再取 (a, g),A=1 时得到的正好是 (r, g),所以这种未压缩的 RGBA 也能正确解码
            px[y * size + x] = new Color(n.x * 0.5f + 0.5f, n.y * 0.5f + 0.5f, n.z * 0.5f + 0.5f, 1);
        }

    s_Cached = new Texture2D(size, size, TextureFormat.RGBA32, true, true)
    {
        name = "WaterNormal_Tileable", wrapMode = TextureWrapMode.Repeat,
        filterMode = FilterMode.Trilinear, anisoLevel = 4, hideFlags = HideFlags.DontSave
    };
    s_Cached.SetPixels(px);
    s_Cached.Apply(true);
    return s_Cached;
}
  1. 每个正弦波的波矢 (kx, ky) 都是整数,在一个贴图周期内正好走完整数个波长;
  2. 值噪声的格点索引对周期取模,u=0 与 u=1 取到同一组格点;
  3. 用中心差分求导时,坐标回绕(x+1 超出边界就回到 0)。

此外还用了一个技巧:用周期噪声扰动正弦的相位(域扭曲),否则十几个正弦叠加会呈现规则的“格子布”纹理。生成的贴图是未压缩的 RGBA,R、G、B 存法线的 xyz,A 存 1。URP 的 UnpackNormalScale 在桌面平台走 UnpackNormalmapRGorAG:先 a *= r,再取 (a, g) 作为 xy——A=1 时得到的正好是 (r, g);在定义了 UNITY_NO_DXT5nm 的平台上则走 UnpackNormalRGB 直接读 RGB,同样正确。所以这种贴图不用标记为 Normal Map 也能正确解码。如果你用的是导入的法线贴图,正常勾选 Texture Type = Normal Map 即可。

图 19-7左:调试视图,单层法线(切线空间 xy 放大显示);中:调试视图,双层法线叠加,图案更细碎、更不规则;右:双层法线 + 折射 + 天空反射的效果。