案例 19.9Gerstner 波进阶
目标:让水面真正起伏,有波峰波谷,并且波峰比波谷尖——这是 Gerstner 波和简单正弦波最明显的区别。
第 17 章已经推导过 Gerstner 波:水面上的每个点不只是上下运动,还会沿传播方向前后运动,做圆周运动。一个波由方向 d、陡度 Q、波长 L 决定:
相速度 c 来自深水波的色散关系 ω² = gk:长波跑得快、短波跑得慢,多个波叠加时自然会“错开”,不会整齐地同步。Q = 1 时波峰恰好变成尖角,再大就会自交打结,所以多个波叠加时它们的陡度之和最好不超过 1。
移植到水面 Shader 时要注意两件事。一是法线必须解析地求:位移函数对 x、z 求偏导得到切线 T 和副切线 B,法线 = B × T。函数里用 inout 把每个波的偏导累加进去,三个波叠加完一次性得到正确的切线空间。二是切线空间要同步给法线贴图:波面倾斜后,细节法线也要跟着倾斜,所以顶点着色器把 T、B、N 一起传给片元,片元用它们构造 TBN 矩阵(案例 19.4)。
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:gerstner)
// 单个 Gerstner 波。wave = (方向x, 方向z, 陡度 Q, 波长 L)。累加位移,同时累加切线/副切线用于求法线。 // 推导见第 17 章:k = 2π/L,相速度 c = sqrt(g/k),振幅 a = Q/k(Q=1 时波峰刚好变尖) float3 GerstnerWave(float4 wave, float3 p, float t, inout float3 tangent, inout float3 binormal) { float steep = wave.z; float k = 2.0 * PI / wave.w; float c = sqrt(9.8 / k); float2 d = normalize(wave.xy); float f = k * (dot(d, p.xz) - c * t); float a = steep / k; float s = sin(f), co = cos(f); tangent += float3(-d.x * d.x * steep * s, d.x * steep * co, -d.x * d.y * steep * s); binormal += float3(-d.x * d.y * steep * s, d.y * steep * co, -d.y * d.y * steep * s); return float3(d.x * a * co, a * s, d.y * a * co); }
// 单个 Gerstner 波。wave = (方向x, 方向z, 陡度 Q, 波长 L)。累加位移,同时累加切线/副切线用于求法线。 // 推导见第 17 章:k = 2π/L,相速度 c = sqrt(g/k),振幅 a = Q/k(Q=1 时波峰刚好变尖) vec3 GerstnerWave(vec4 wave, vec3 p, float t, inout vec3 tangent, inout vec3 binormal) { float steep = wave.z; float k = 2.0 * PI / wave.w; float c = sqrt(9.8 / k); vec2 d = normalize(wave.xy); float f = k * (dot(d, p.xz) - c * t); float a = steep / k; float s = sin(f), co = cos(f); tangent += vec3(-d.x * d.x * steep * s, d.x * steep * co, -d.x * d.y * steep * s); binormal += vec3(-d.x * d.y * steep * s, d.y * steep * co, -d.y * d.y * steep * s); return vec3(d.x * a * co, a * s, d.y * a * co); }
Assets/Cookbook/Ch19/Water.shader(节选:vert)
Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // 平静水面:切线 +X、副切线 +Z、法线 +Y float3 tangent = float3(1, 0, 0); float3 binormal = float3(0, 0, 1); #if defined(_GERSTNER) float t = WaterTime(); float3 p = positionWS; positionWS += GerstnerWave(_WaveA, p, t, tangent, binormal); positionWS += GerstnerWave(_WaveB, p, t, tangent, binormal); positionWS += GerstnerWave(_WaveC, p, t, tangent, binormal); #endif o.positionWS = positionWS; o.positionCS = TransformWorldToHClip(positionWS); o.tangentWS = normalize(tangent); o.bitangentWS = normalize(binormal); o.normalWS = normalize(cross(binormal, tangent)); o.fogFactor = ComputeFogFactor(o.positionCS.z); return o; }
void main() { vec3 pWS = TransformObjectToWorld(positionOS.xyz); // 平静水面:切线 +X、副切线 +Z、法线 +Y vec3 tangent = vec3(1, 0, 0); vec3 binormal = vec3(0, 0, 1); #if defined(_GERSTNER) float t = WaterTime(); vec3 p = pWS; pWS += GerstnerWave(_WaveA, p, t, tangent, binormal); pWS += GerstnerWave(_WaveB, p, t, tangent, binormal); pWS += GerstnerWave(_WaveC, p, t, tangent, binormal); #endif positionWS = pWS; gl_Position = TransformWorldToHClip(pWS); tangentWS = normalize(tangent); bitangentWS = normalize(binormal); normalWS = normalize(cross(binormal, tangent)); fogFactor = ComputeFogFactor(gl_Position.z); }
还有一个容易忽略的影响:顶点被 Gerstner 波抬高或压低之后,片元里的 positionWS 是位移后的位置,所以水深、泡沫、焦散都会随波起伏自动变化——波谷处水变浅,泡沫带向外扩;波峰处水变深。这种联动是“免费”的,因为后续计算全部基于实际的水面位置。

网格密度与常见坑
- 网格要够密:顶点位移只能表现比网格间距大得多的波。本例 60 米的水面用了 400×400 的网格(每格 15 厘米)。大面积水面通常用以相机为中心的同心网格或曲面细分来分配顶点。
- 包围盒:顶点被抬高后可能超出网格原本的包围盒,相机边缘的水面被视锥剔除。本书的
Meshes.Grid已经把包围盒的高度设得和宽度一样;自己建网格时记得扩大mesh.bounds。 - 岸边:波浪会把水面抬到沙滩上方,或者在浅水处露出水底。小水池里浪不宜太大;真正的海岸线需要按水深衰减波高(用一张水深图或离岸距离图驱动振幅)。
- 物理交互:浮在水上的物体需要知道某一点的水面高度。Gerstner 波是解析函数,可以在 C# 里用同样的公式计算——注意它是水平位移过的,查询点 (x, z) 处的高度需要迭代求解,或者近似地忽略水平位移。