
1. 为什么我们绕不开Gerstner波接触UE5做水体、做海洋的开发者迟早会碰到一个名词Gerstner波。不管是官方Water插件里的Gerstner Waves组件还是各种海面材质教程里反复出现的“Gerstner Wave函数”它几乎成了游戏行业模拟海面的默认起点。我在刚上手UE5的那段时间想做一个风格化的小岛海面。第一反应是去Asset Store找现成材质但下载下来发现里面一大堆节点看不懂水面动起来也总有一种“果冻感”——浪峰不尖、波谷太平远看还行近看就露馅。后来查资料才知道问题出在用的是简单的正弦波叠加而不是Gerstner波。Gerstner波在真实感水面上做得最出名的一件事是让波形在水平方向产生位移波峰收窄、波谷拉平看起来像真正的海浪而不是一片上下抖动的半透明平面。这篇学习笔记是我自己入门UE5时整理的第一篇专门讲Gerstner波在UE5里的落地方式——包括数学原理、材质实现、C实现以及和UE内置Water系统的关系。适合刚接触UE5、想做水面又不想直接抄别人整套材质的人。看完这篇文章你至少能回答三个问题Gerstner波到底改了什么在UE5里怎么把它写出来为什么有人说内置Water插件也是Gerstner波却跟我写的不一样文章会从最基础的正弦波讲起所以没有图形学基础的人也不用担心顶多需要一点点向量和三角函数的概念。2. 先从最简单的正弦波说起为什么它不像水要理解Gerstner波先看传统正弦波是怎么模拟水面的。假设水面高度由这个函数决定y A * sin(w * x φ)其中A是振幅w是角频率φ是相位偏移。扩展到二维平面并加入时间就变成y A * sin(dot(D, P) * w time * speed φ)D是波传播方向P是平面上某点的坐标。把这个高度输出到材质的世界位置偏移或者顶点动画上你就得到了一块上下起伏的水面。很多入门教程里的“简易海面”都是这么做的。它简单、直观、可控但也有一个很明显的物理问题水分子只会垂直运动不会水平运动。真实海浪不是这样。观察海面你会发现波浪在推进过程中水面的质点是在做近似圆形的轨道运动——波峰处向前移动波谷处向后移动整体看起来才是破浪前进的样子。正弦波把每个点的运动全限制在垂直方向所以叠加再多层得到的依然是“一块揉皱的果冻”没有海浪向前卷动的感觉。Gerstner波解决的就是这个水平位移问题。它把垂直方向的正弦波和一个水平方向的余弦波耦合在一起相当于给每个水面质点加了一个水平方向的摆动。这个看似微小的改动效果却是质的飞跃波峰因为水平位移被压缩而显得尖锐波谷则被拉伸得平坦开阔。用图形学的话说波形的曲率分布更接近真实海浪。总结一下正弦波适合做平静湖面、用于低频环境扰动Gerstner波适合做开放海域的主波浪。想做一个真正有冲击力的海面Gerstner波几乎是入门首选。3. Gerstner波的数学原理一行公式背后的直觉3.1 从二维公式到三维公式Gerstner波的标准参数方程在网络上有各种写法我第一次看的时候被一堆希腊字母整懵了。后来自己推了一遍发现核心就三个式子x x - Σ( Q_i * A_i * D_i.x * cos(w_i * dot(D_i, P0) φ_i * t) ) y Σ( A_i * sin(w_i * dot(D_i, P0) φ_i * t) ) z z - Σ( Q_i * A_i * D_i.z * cos(w_i * dot(D_i, P0) φ_i * t) )P0是原始静止位置x和z是经过水平偏移后的新坐标y是高度。D_i是一个二维单位方向向量表示第i层波朝哪个方向传播w_i是角频率A_i是振幅Q_i是陡度系数φ_i是相位速度t当然就是时间。这个方程和你之前写的sin波有个巨大区别x和z不再是一成不变的水平坐标而是被cos项扰动。这个水平位移和我们习惯的“材质世界位置偏移”不太一样——Gerstner波的实现里顶点位置是整体被重算的包括水平坐标。当你要在UE5里实现它时常规做法是在材质中输出World Position Offset把x、y、z三个方向的偏移量都加到原位置上。但材质编辑器里拿到的“原位置”其实是像素所在的世界坐标所以往往还要配合顶点着色器或者自定义节点一起算才能在顶点级别完成这种非线性偏移。3.2 各个参数到底在控制什么初学者容易犯的一个错误是以为振幅越大越像海。实际上Gerstner波里最出效果的是陡度系数Q_i。Q_i的取值范围通常在0到1/(w_i * A_i)之间。严谨一点说为了保证波形不出现自交也就是波峰翻折过猛每个波的Q_i需要满足Q_i * w_i * A_i ≤ 1这个限制用大白话说就是波越陡振幅越大允许的水平位移就越大但一旦超过临界值波形就会翻转在渲染时会出现面片交叠和明显破面。我在调试时习惯先固定波长和振幅再单独拉Q_i。Q0时Gerstner波退化成普通正弦波Q越大波峰越尖锐波谷越平坦。实际游戏里大多数海面取Q在0.3到0.7之间已经能看出明显的“浪感”。取太高靠近岸边时容易出现交叉面取太低又回到了果冻状态。另一个容易忽视的参数是波数w也就是角频率。它和波长的关系是w 2π / λλ是波长。波长决定了波的空间尺度波长几十米的波适合做远景涌浪波长几米的波适合做近景碎浪。实际中建议至少叠加三层不同波长的Gerstner波长波负责大形变短波负责细节这样海面才会有层次不会像单层正弦波那样塑料。3.3 为什么说Gerstner波是“水粒子做圆周运动”的近似还记得我说过质点做圆周运动吗在Gerstner波的推导中每个水质点的运动轨迹其实是竖直平面上的一个椭圆或近似圆。当多个波叠加时多个椭圆运动耦合在一起就成了复杂的海浪形态。这也顺带解释了另一个现象大海里能量越大的波浪质点运动越剧烈水面的“破碎感”越强。所以在调试海面参数的时候你要有一个直觉——振幅大、波长短的波叠加多了海面必然会出现“急脾气”的小碎浪这在近景很真实但在远景会导致严重的闪烁混叠需要配合Mipmap和LOD策略处理。4. 在UE5里动手写一个Gerstner波4.1 材质节点的两种组装方式在UE5里用材质实现Gerstner波主流有两种做法。第一种是纯节点拼搭。把上面公式里的每一项拆成节点用Add、Multiply、Cosine、DotProduct等节点连起来。优点是每个变量都暴露在Details面板便于美术调参。缺点也很明显——一旦叠加三层以上节点数暴涨材质图变得像蜘蛛网一样排查问题很痛苦编译时间也直线上升。第二种是用Custom节点写HLSL。我个人强烈推荐这种方式哪怕你完全不熟悉HLSL也没关系因为你只需要写Gerstner波这一小段借Custom节点做算术计算再把结果通过输出引脚接给World Position Offset就行。下面这个示例是一个单层Gerstner波的Custom节点代码float3 GerstnerWave(float4 P, float w, float A, float Q, float2 D, float speed, float time) { float2 dir normalize(D); float dotResult dot(dir, P.xz); float phase w * dotResult time * speed; float offsetX -Q * A * dir.x * cos(phase); float offsetZ -Q * A * dir.y * cos(phase); float offsetY A * sin(phase); return float3(offsetX, offsetY, offsetZ); }如果你要叠加多层就循环调用这个函数。注意传入的P是原始顶点位置不要在里面直接累加P.xyz而是把每一层的偏移都累加到一个变量里最后一次性加到顶点位置上。4.2 在Custom节点里实现多层波叠加下面这段是我实际在UE5材质里用的多层Gerstner波控制参数全部做成Custom节点输入方便后续在材质实例里调整float3 result 0; float2 waves[3]; waves[0] float2(1.0, 0.2); waves[1] float2(0.4, 1.0); waves[2] float2(-0.3, 0.8); float w[3]; w[0] 0.5; w[1] 1.2; w[2] 2.5; float A[3]; A[0] 0.6; A[1] 0.25; A[2] 0.08; float Q[3]; Q[0] 0.6; Q[1] 0.5; Q[2] 0.2; for (int i 0; i 3; i) { result GerstnerWave(P, w[i], A[i], Q[i], waves[i], _Speed, Time); } return result;里面把波的方向写死在代码里。实际项目里我更建议把方向、振幅以数组形式传入或者在蓝图里组合好一个结构体数组再丢给材质。因为这个代码需要暴露的变量越少材质实例的调参效率越高。拼接好材质后记得在材质的“Material Domain”选择Surface“Shading Model”选择Subsurface或Lit“World Position Offset”引脚连上Custom节点输出。重点来了除了给顶点加偏移还要同步修改法线否则你的海面波浪形变是有了但光照高光依然是平的水看起来像涂了一层反光漆。4.3 法线该怎么算Gerstner波的法线其实可以直接从偏导数推出来。数学上需要求x、y、z关于原坐标的偏导数再叉乘得到法线。偏导数公式在多个教程里都有我直接给一个简化版本float3 dPdx float3( 1.0 - Q * A * dir.x * dir.x * sin(phase) * w, A * dir.x * cos(phase) * w, -Q * A * dir.x * dir.z * sin(phase) * w ); float3 dPdz float3( -Q * A * dir.x * dir.z * sin(phase) * w, A * dir.z * cos(phase) * w, 1.0 - Q * A * dir.z * dir.z * sin(phase) * w ); float3 normal normalize(cross(dPdz, dPdx));注意矩阵求导方向和叉乘顺序不能搞反否则法线会指向水面下方。我最初照着网上的公式抄出现“水像镜子倒影”的奇怪效果排查半天就是叉乘顺序问题。如果你的波浪高光位置和波峰对不上优先检查这里。4.4 用C写一个简单的Gerstner波组件材质版适合静态网格体和地形表面但要做动态漂浮物比如船、浮标在水面上起伏时CPU端也需要波浪高度数据。这时我会把Gerstner波再封装成C组件方便在游戏逻辑中采样。这里给出一个简化的C实现#pragma once #include CoreMinimal.h #include Components/ActorComponent.h #include GerstnerWaveComponent.generated.h USTRUCT(BlueprintType) struct FGerstnerWave { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) float Amplitude 0.5f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float WaveLength 10.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float Steepness 0.5f; UPROPERTY(EditAnywhere, BlueprintReadWrite) FVector2D Direction FVector2D(1.f, 0.f); }; UCLASS() class UGerstnerWaveComponent : public UActorComponent { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite) TArrayFGerstnerWave Waves; UFUNCTION(BlueprintCallable, Category Gerstner) float GetWaveHeight(FVector Location, float Time) const; };实现部分#include GerstnerWaveComponent.h float UGerstnerWaveComponent::GetWaveHeight(FVector Location, float Time) const { float Height 0.f; for (const FGerstnerWave Wave : Waves) { float Frequency 2.f * PI / Wave.WaveLength; float Speed FMath::Sqrt(9.8f / Frequency); float Phase Frequency * FVector2D::DotProduct(Wave.Direction, FVector2D(Location.X, Location.Y)) Time * Speed; Height Wave.Amplitude * FMath::Sin(Phase); } return Height; }这里的速度我采用了深水波的近似公式速度等于sqrt(g / w)。如果你做的是浅水区要考虑双曲正切修正不过入门阶段可以忽略。组件里我还额外写了一个GetWaveHeightAndNormal函数用来同时计算高度和法线方便让船体对齐水面先采样船头、船尾两个点的高度再计算俯仰和翻滚。这个我在后续的浮力系统篇会继续展开这里先提个思路。5. 和UE5内置Water插件的关系它到底是不是Gerstner波5.1 Water插件的Gerstner波在哪UE5内置的Water插件启用方式Edit - Plugins - Water自带水体Actor创建方式是在Place Actors面板搜索Water Body Ocean。选中WaterBodyOcean后细节面板里会有一个Gerstner Waves选项下面可以配置波数、振幅、方向等参数。它内部的核心模型就是Gerstner波。不过官方把它做成了一个Mesh的Waves系统支持多个WaveBand、自动处理与地形的交互边缘、生成渲染用的法线贴图还带了一套用于物理交互的WaterBody碰撞系统。所以当你看网上说“用UE5内置水体”时默认的Gerstner波配置往往只有一两层效果偏平静。你想做出大浪需要在Gerstner Waves组件里增加Wave Count并手动修改振幅、波长和方向。官方默认的波数不多单看默认值很难有惊艳效果手动加波是最常用的调优操作。5.2 选材质手写还是用Water插件我的建议是如果你只是要一个静态海面背景没有船只交互直接用Water插件最省事因为它连LOD、物理交互、水下后期都帮你做完了如果你要做一个风格化海面、想完全控制波形外观、或者想把波浪数据用于游戏逻辑比如浮力那我强烈建议你手写Gerstner波组件。手写并不意味着重新造轮子而是让你真正理解波浪参数和画面效果之间的关系。我见过不少项目直接用Water插件结果美术提的需求是“浪峰再卷一点”——美术根本不知道该去调哪个参数打开Gerstner Waves面板后里面一堆乱七八糟的数值最后只能到处乱试。反过来如果你自己实现过一遍Gerstner波再去用Water插件就非常顺手因为官方面板里的Amplitude、Wavelength、Steepness、Direction每一个参数对应什么、取值范围如何、互相之间怎么耦合你早就心里有数了。这也是写这类学习笔记最大的价值——不是为了替代官方工具而是为了更懂官方工具。5.3 用Water插件时踩过的参数坑默认的Wave Count只有1效果非常平静建议先加到4到6层再调。大振幅下Resolution网格细分如果不够波峰会明显走样出现锯齿。此时不要一味加振幅先提高水体的Mesh Density。官方Gerstner Waves里有一个Minimal Wave Amplitude不少项目会把这个设成默认0.001它会影响波浪的剔除范围值设太大时小波会被吃掉近看水面变得很平。交互浪和场景里粒子系统浮力用的波浪不一致时船会在水上“滑冰”。这个问题多半是因为物理系统用的波浪采样精度比渲染低需要单独调整交互采样分辨率。6. 实战中常见问题与排查技巧6.1 波浪方向与风向相反这是一个很玄学的问题。你在材质里把Direction设成(1,0)浪应该往正X方向推但渲染出来波浪却往负X方向跑。原因在于位移的方向向量和相位推进方向是反的Gerstner波的水平位移方向是D方向但波峰传播方向也是D方向位移本身却朝-D方向。排查经验如果你发现浪的“形状”移动方向反了把D改成相反方向如果浪的形状原地抖动但没有整体推进多半是相位项里方向向量没归一化或者World Position Offset的累积方式有问题。6.2 波峰出现折痕和自交自交往往是最难排查的问题。从侧视角看波浪表面像是“翻折”了一样。根本原因是陡度系数Q超过了临界值。我习惯做一个小工具在材质里循环检查每个波的Q * w * A是否超过1。但这里有个隐藏细节多层波叠加之后即使每个波单独都小于1叠加后的波峰也可能出现微小自交。这种情况下最简单的处理是全局降低Q或者减少波层数。另一种方式是给Q增加一个随波长衰减的系数让长波更陡、短波更缓这样既保留了大的浪型又避免了高频细节破面。6.3 水面法线异常高光乱跳法线错误通常分三种法线方向反了表现为水面向下的高光。解法调整叉乘方向。法线包含了水平位移信息但没归一化表现为高光过曝或过暗。解法最终normal要normalize且在Custom节点里返回法线时记得乘上World tangent空间转换。法线精度不够表现为波浪在摄像机距离变化时闪光闪烁。解法把法线计算切到VertexInterpolator或在材质里为远距离采样使用简化的法线计算。6.4 性能开销几十个Gerstner波的取舍Gerstner波最大的性能代价来自两层一是在材质里计算大量sin/cos尤其在移动平台三角函数开销很大二是顶点采样不足时为了抗锯齿而增加的过采样。我实测过在PC上跑四层Gerstner波材质指令数还能接受在移动端四层以上就会明显发热尤其还叠加了反射和折射。这时要采用分频策略近景用三到四层完整Gerstner波远景只保留一两层长波再用一个单独的细节法线贴图补充高频碎波。别小看这个方法很多手游的海面都是这么干的。7. 这只是一个起点Gerstner波之后可以学什么掌握了Gerstner波的实现你的UE5水体系万里长征其实才走完三分之一。后续还有几个方向值得深入我简单列一下方便你自己规划学习路线。第一FFT海洋模拟。Gerstner波适合做有限的几层大浪而FFT方法能从频谱生成非常精细的海面同时叠加上百种频率的波。UE5里有官方插件和开源项目可以参考但计算开销更大通常需要ComputeShader或WaveWorks这类中间层。第二水与物体的交互。只做静态海面远远不够船要有吃水线、漂浮物要被浪推动还要有船头浪花和尾流。这些需要把Gerstner波的高度函数暴露给物理系统结合浮力算法做每一个浮力采样点。我之前用C组件写了一个浮力Actor把船的每个浮力筒浸入水下时计算浮力和阻力效果比单纯调物理约束要真实得多这部分后面写了再单独发文。第三近岸浅水模拟。海浪接近沙滩时波长会变短、波高会变化还会发生折射。UE5内置Water系统对近岸有简单处理但要做到真实需要引入浅水方程或者至少加入深度衰减系数。这个阶段建议先把Gerstner波的参数方程弄熟再去看Shallow Water Equation会轻松很多。最后分享一个调试心得。Gerstner波这种东西参数之间耦合很强改一个叠了三层波的方向可能只影响某一处的波峰形状肉眼很难看出规律。所以我建议你用“数据化调参”先在CPU端或者编辑器的Details面板里把每个波的波长、振幅、方向都列出来再用第三方插件比如GPU Visualizer实时采样波浪高度和法线对比效果图来调。我在做第一个海面场景时就是反复切换“纯正弦波”和“Gerstner波”对照最终才算对波形有了手感。用眼睛去看用手去调比背一百条公式都管用。希望这篇笔记也能帮你省下一些瞎调参数的时间。