案例 19.4双法线流动进阶
目标:用法线贴图给水面加上细碎的波纹,并让波纹“流动”起来,同时避免一眼就能看出的重复平铺。
原理:两层不同尺度、不同方向的法线
一张法线贴图平铺在大面积水面上,重复感非常明显;让它沿一个方向滚动,看起来又像整片水面在平移,而不是在波动。经典做法是用同一张法线贴图采样两次:两次的平铺尺度不同(比如每米 0.18 次和 0.31 次,比例不是整数,两层的重复周期错开),流动方向和速度也不同。两层叠加后,任何一处的图案都在不断变化,重复与平移感都被打破了。
两层法线的混合用 Whiteout 方法:xy 相加、z 相乘再归一化。第 7 章对比过几种细节法线混合方法,Whiteout 比简单平均保留更多起伏,计算又比 RNM 便宜。
// 双层法线:同一张 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( 变换到世界空间。之所以不直接写死,是因为加入 Gerstner 波(案例 19.9)后,T、B、N 会随波面倾斜,到时只需要在顶点着色器里改它们。
法线贴图必须能无缝平铺
大面积平铺时,法线贴图左右、上下边界上任何一点不连续,都会在水面上形成一条条笔直的细缝,在高光和反射里格外刺眼。一个典型的坑是:用若干正弦波叠加高度场时,如果某个波在贴图周期内走的不是整数圈,边界处就会断开,求导后边缘一圈像素的法线明显错误。本章用 C# 程序生成一张可平铺的水面法线(配套的公共贴图 water_normal 也按同样的规则生成),顺便讲清楚“可平铺”的三个条件:
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; }
- 每个正弦波的波矢 (kx, ky) 都是整数,在一个贴图周期内正好走完整数个波长;
- 值噪声的格点索引对周期取模,u=0 与 u=1 取到同一组格点;
- 用中心差分求导时,坐标回绕(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 即可。
