1. 水波效果的核心思路与方案选型
水波效果在实时渲染里算是一个经典到不能再经典的案例,几乎每一本图形学入门书都会拿它当综合练习。但经典归经典,真要把一个水波效果调到自己满意,中间踩的坑一点都不少。我当初跟着书上的思路走了一遍,发现代码能跑起来只是第一步,水面看起来像不像水、有没有通透感、波纹会不会太假,这些才是真正花时间的地方。
水波效果本质上要解决三个问题:水面的形状怎么动、水面的颜色怎么算、水面和环境的交互怎么表现。这三个问题对应到Shader里,就是顶点位移、法线扰动、反射折射采样。很多人一开始会把注意力全放在波纹动画上,觉得波纹动起来就像水了,结果做出来一看,像一块会抖动的塑料布。原因就在于只做了顶点动画,没有处理法线和光照,水面缺少高光和环境反射,自然就没有水的质感。
从方案选型上来说,水波效果大致有三条路线可以走。第一条是纯数学波形叠加,用多个正弦波或者Gerstner波在顶点着色器里做位移,优点是可控性强、性能好,缺点是波形规律性太明显,容易看出重复。第二条是用噪声纹理驱动顶点位移,配合法线贴图的UV动画,优点是细节丰富、看起来更自然,缺点是需要额外的纹理资源,而且噪声参数不好调。第三条是用RenderTexture做实时反射折射,把水面当成一面扭曲的镜子,优点是效果最真实,缺点是需要额外的相机和渲染目标,性能开销大。
我在实际项目里最常用的组合是:顶点用数学波形做基础起伏,法线用两层法线贴图做UV流动,颜色用菲涅尔混合反射和折射。这个组合在效果和性能之间取得了比较好的平衡,移动端也能跑得动。下面我会把这个方案的每个环节拆开来讲,包括参数怎么定、代码怎么写、坑在哪里。
提示:如果你的项目对性能极其敏感,比如要在低端手机上跑满帧,可以砍掉实时反射,只保留法线扰动和菲涅尔近似,效果也能接受。
2. 水面网格与顶点波形的实现细节
2.1 网格密度与顶点数量的取舍
水面的网格密度直接决定了波形的平滑程度。网格太稀,波形会变成折线,看起来像锯齿;网格太密,顶点计算量上去了,性能吃不消。我的经验是,一个屏幕大小的水面,用100x100到200x200的网格比较合适。如果是开放世界的大面积水域,可以用LOD分级,近处密、远处稀,或者干脆远处用一张法线贴图糊弄过去。
这里有个容易忽略的点:水面的网格最好是均匀分布的平面网格,而不是用Unity默认的Plane。默认Plane只有10x10个顶点,做波形根本不够用。你可以在建模软件里自己拉一个高分辨率的网格,或者在代码里动态生成。我一般会在代码里生成,因为这样可以方便地控制顶点数量和UV分布。
// 动态生成水面网格的简化示例 Mesh GenerateWaterMesh(int resolution, float size) { Mesh mesh = new Mesh(); Vector3[] vertices = new Vector3[(resolution + 1) * (resolution + 1)]; Vector2[] uvs = new Vector2[vertices.Length]; int[] triangles = new int[resolution * resolution * 6]; for (int y = 0; y <= resolution; y++) { for (int x = 0; x <= resolution; x++) { int index = y * (resolution + 1) + x; vertices[index] = new Vector3( (x / (float)resolution - 0.5f) * size, 0, (y / (float)resolution - 0.5f) * size ); uvs[index] = new Vector2(x / (float)resolution, y / (float)resolution); } } // 三角形索引生成略 mesh.vertices = vertices; mesh.uv = uvs; mesh.triangles = triangles; mesh.RecalculateNormals(); return mesh; }2.2 正弦波叠加的参数计算
顶点位移最常用的就是正弦波叠加。单个正弦波的公式是y = A * sin(k * x - ω * t),其中A是振幅,k是波数,ω是角频率。多个波叠加就是把几个不同参数的正弦波加起来。
这里的关键是波数k和角频率ω的关系。在真实的物理世界里,水波的传播速度v和波长λ、周期T有关系:v = λ / T。而k = 2π / λ,ω = 2π / T,所以ω = k * v。如果你随便给k和ω赋值,波看起来就会很怪,要么跑得飞快,要么慢得像蜗牛。我的做法是先定波长和速度,然后反推k和ω。
举个例子,我想要一个波长2米、速度1米每秒的波,那么k = 2π / 2 = π ≈ 3.14,ω = 3.14 * 1 = 3.14。这样算出来的波,视觉上就比较自然。
// 顶点着色器中的波形计算 float3 CalculateWave(float3 worldPos, float time) { float3 offset = float3(0, 0, 0); // 波1:主波,波长长,振幅大 float k1 = 3.14; float omega1 = 3.14; float A1 = 0.15; offset.y += A1 * sin(k1 * worldPos.x - omega1 * time); // 波2:次波,方向不同,增加细节 float k2 = 6.28; float omega2 = 5.0; float A2 = 0.08; offset.y += A2 * sin(k2 * (worldPos.x * 0.7 + worldPos.z * 0.7) - omega2 * time); // 波3:高频小波,增加表面细节 float k3 = 12.56; float omega3 = 8.0; float A3 = 0.03; offset.y += A3 * sin(k3 * worldPos.z - omega3 * time); return offset; }2.3 波形方向与水面朝向的匹配
还有一个细节是波的方向。如果你只用x方向的波,水面看起来就像一排排平行的波浪,很假。我的做法是让每个波的方向向量不同,用点积来计算相位。比如波的方向是float2(1, 0),那么相位就是dot(direction, worldPos.xz)。这样多个方向的波叠加起来,水面就会呈现出比较自然的交错波纹。
注意:波的方向向量要归一化,否则相位计算会出错。另外,波的方向最好和场景里的风向大致一致,这样看起来更协调。
3. 法线扰动的核心技巧与参数调优
3.1 为什么法线比顶点更重要
很多人做水波效果,把大量精力花在顶点位移上,结果发现水面看起来还是不像水。问题出在法线上。顶点位移改变的是水面的几何形状,但水面的大部分视觉细节其实来自于法线的变化。你想想看,真实的水面在远处看,几何起伏几乎看不出来,但你能看到水面上闪烁的光斑和扭曲的倒影,这些都是法线在起作用。
所以我的建议是:顶点位移可以简单一点,法线扰动一定要做足。顶点位移负责大尺度的波浪起伏,法线扰动负责小尺度的细节和光照变化。两者配合,水面才有层次感。
3.2 双层法线贴图UV流动
法线扰动的标准做法是用两层法线贴图,以不同的速度和方向流动,然后叠加。为什么要两层?因为一层法线贴图的流动方向太单一,看起来像一块滑动的塑料布。两层不同方向、不同速度的流动叠加在一起,才能模拟出水面上那种杂乱又自然的波纹。
// 法线采样与叠加 float2 uv1 = worldPos.xz * _NormalScale1 + _Time.y * _FlowSpeed1; float2 uv2 = worldPos.xz * _NormalScale2 + _Time.y * _FlowSpeed2; float3 normal1 = UnpackNormal(tex2D(_NormalMap, uv1)); float3 normal2 = UnpackNormal(tex2D(_NormalMap, uv2)); // 叠加两层法线 float3 combinedNormal = normalize(float3( normal1.xy + normal2.xy, normal1.z * normal2.z ));这里的参数有几个关键点。_NormalScale控制法线贴图的平铺密度,值越大,波纹越细密。_FlowSpeed控制流动速度,两个层的速度最好不一样,而且方向最好有夹角。我一般会让第一层往x方向流,第二层往z方向流,速度比大概是1:0.7。
3.3 法线强度的控制
法线叠加之后,强度需要控制。如果直接把两层法线的xy加起来,强度会翻倍,水面看起来会过于凹凸不平。我的做法是给每层法线一个权重,然后归一化。
float3 combinedNormal = normalize(float3( normal1.xy * _NormalStrength1 + normal2.xy * _NormalStrength2, normal1.z * normal2.z ));_NormalStrength一般设置在0.5到1.0之间。如果水面看起来太"油",就降低强度;如果看起来太平,就提高强度。这个参数需要根据场景的光照环境来调,没有固定值。
实操心得:法线贴图最好用无缝的,否则水面会出现明显的接缝。如果找不到合适的无缝法线贴图,可以用噪声生成一张,或者用两张法线贴图交叉混合来掩盖接缝。
4. 菲涅尔效应与反射折射的混合
4.1 菲涅尔效应的物理原理
菲涅尔效应说的是:当视线和表面法线的夹角越大(也就是越掠射),表面的反射越强;夹角越小(越垂直),折射越强。你站在湖边看水面,脚下的水是透明的,能看到水底;远处的水面像镜子一样,反射着天空和山。这就是菲涅尔效应。
在Shader里,菲涅尔通常用这个公式近似:
float fresnel = pow(1.0 - saturate(dot(viewDir, normal)), _FresnelPower);_FresnelPower控制菲涅尔的衰减速度。值越大,反射区域越集中在掠射角附近;值越小,反射区域越广。我一般设置在3到5之间。
4.2 反射与折射的采样
反射采样需要一张反射纹理(通常是用Reflection Probe或者RenderTexture生成的),折射采样需要一张折射纹理(通常是用GrabPass或者RenderTexture抓取的场景图像)。然后把两者按菲涅尔系数混合。
// 反射采样 float3 reflectDir = reflect(-viewDir, normal); float3 reflection = texCUBE(_ReflectionTex, reflectDir).rgb; // 折射采样 float2 refractUV = screenPos.xy / screenPos.w; refractUV += normal.xy * _RefractStrength; float3 refraction = tex2D(_RefractionTex, refractUV).rgb; // 菲涅尔混合 float3 finalColor = lerp(refraction, reflection, fresnel);折射采样的时候,UV需要加上法线的偏移,这样才能模拟出水面的扭曲效果。_RefractStrength控制扭曲的强度,一般设置在0.02到0.05之间。太大了会看起来像哈哈镜,太小了又看不出扭曲。
4.3 水色的叠加
纯反射折射混合出来的水面,看起来像一块玻璃,缺少水的颜色。真实的水是有颜色的,而且颜色和水的深度有关。浅水区偏透明,深水区偏蓝绿。我的做法是叠加一个水体颜色,用深度或者菲涅尔来控制混合比例。
float3 waterColor = _ShallowColor * (1 - depthFactor) + _DeepColor * depthFactor; finalColor = lerp(finalColor, waterColor, _WaterColorStrength);_ShallowColor一般是浅青色,_DeepColor是深蓝色。depthFactor可以用场景深度和当前像素深度的差值来算,也可以用菲涅尔来近似。如果不想做深度计算,直接用菲涅尔也行,效果差不太多。
注意:反射纹理和折射纹理的采样坐标要处理好。反射用反射向量采样Cubemap,折射用屏幕坐标采样。如果折射纹理是全屏抓取的,记得处理UV的边界,否则水面边缘会出现拉伸。
5. 高光与水面细节的打磨
5.1 高光的计算
水面的高光是非常关键的视觉元素。太阳或者光源在水面上形成的那一条闪烁的光带,是水面质感的重要来源。高光用Blinn-Phong或者GGX都可以,关键是高光要锐利,而且要高强度。
float3 halfDir = normalize(lightDir + viewDir); float spec = pow(saturate(dot(normal, halfDir)), _SpecularPower); float3 specular = _LightColor * spec * _SpecularIntensity;_SpecularPower一般设置在100到500之间,值越大高光越集中。_SpecularIntensity可以设置得比较高,让高光在水面上跳出来。
5.2 泡沫与岸边效果
如果水面和岸边有接触,可以加一层泡沫效果。泡沫的做法通常是用深度差来判断水面和场景的距离,距离近的地方叠加泡沫纹理。这个效果在海岸线或者水池边缘特别有用,能大大提升真实感。
float depthDiff = sceneDepth - waterDepth; float foam = saturate(1 - depthDiff / _FoamDistance); foam *= tex2D(_FoamTex, worldPos.xz * _FoamScale + _Time.y * _FoamSpeed).r; finalColor = lerp(finalColor, _FoamColor, foam * _FoamIntensity);_FoamDistance控制泡沫的宽度,一般设置在0.5到2米之间。泡沫纹理的流动速度可以和水面法线的流动速度不一样,这样看起来更自然。
5.3 水面细节的层次感
水面效果最怕的就是"平"。整个水面一个颜色、一个亮度,看起来就像一块塑料板。要打破这种平的感觉,需要引入层次。我的做法是:大波用顶点位移,中波用法线贴图,小波用高频法线叠加,再加上高光和泡沫。这样从大到小、从几何到光照,各个尺度上都有细节,水面就活了。
还有一个技巧是给水面加一点颜色变化。可以用噪声纹理控制水色的深浅,或者用顶点颜色控制不同区域的水色。这样水面就不会是一整块纯色,而是有微妙的变化。
6. 常见问题与排查技巧实录
6.1 水面看起来像塑料布
这是最常见的问题。原因通常是法线扰动不够,或者高光太弱。排查步骤:先检查法线贴图有没有正确采样,再检查法线强度参数是不是太低,最后检查高光参数。如果法线贴图是平的(比如用了默认的bump),那水面肯定像塑料布。
6.2 水面波纹太规律
如果波形看起来像一排排平行的波浪,说明波的方向太单一。解决办法是增加波的数量,每个波的方向不同,波长和速度也不同。另外,法线贴图的流动方向也要有变化,两层法线的方向最好有夹角。
6.3 水面边缘有接缝
如果水面网格的边缘和岸边有缝隙,或者水面和场景的交接处有硬边,通常是深度计算的问题。检查深度纹理的采样是否正确,泡沫的深度差计算是否合理。另外,水面网格的边缘可以稍微往下弯一点,让它和岸边有重叠,这样接缝就不明显了。
6.4 性能问题
水波效果的性能开销主要来自三个方面:顶点计算、法线采样、反射折射。如果性能不够,可以按以下优先级砍:先砍反射折射,用菲涅尔近似代替;再砍法线层数,从两层减到一层;最后砍顶点网格密度。顶点计算其实开销不大,除非网格特别密。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 水面像塑料布 | 法线扰动不足 | 检查法线贴图采样和强度 | 提高法线强度,增加法线层数 |
| 波纹太规律 | 波方向单一 | 检查波形参数 | 增加波的数量和方向变化 |
| 边缘有接缝 | 深度计算错误 | 检查深度纹理采样 | 修正深度差计算,边缘重叠 |
| 性能不足 | 反射折射开销大 | 用Profiler定位 | 砍反射折射,降网格密度 |
| 水面太油 | 法线强度过高 | 检查法线强度参数 | 降低法线强度,调整高光 |
| 水面太暗 | 水色叠加过多 | 检查水色混合参数 | 降低水色强度,提高反射比例 |
实操心得:调水波效果的时候,最好找一个真实的水面视频放在旁边对比。人眼对水的记忆其实很模糊,你觉得"差不多像水了",一对比就会发现差得远。另外,不同的光照环境下水面的表现差别很大,白天、黄昏、夜晚的水面参数需要分别调。
7. 参数调优的实战经验
7.1 波形参数的调试顺序
调波形参数的时候,不要一上来就调所有参数。我的顺序是:先调主波的波长和振幅,让水面有大尺度的起伏;然后调次波,增加波形的复杂度;最后调高频小波,增加表面细节。每调一步,都要在场景里转一圈看看,确保各个角度都过得去。
7.2 法线参数的调试顺序
法线参数先调平铺密度,让波纹的尺度合适;再调流动速度,让波纹动起来自然;最后调强度,让水面的凹凸感适中。流动速度不要太快,否则看起来像快进的视频。我一般让法线贴图在10秒左右完成一个循环。
7.3 菲涅尔和高光的配合
菲涅尔和高光是互相影响的。菲涅尔强的时候,反射多,高光会被反射盖住;菲涅尔弱的时候,折射多,高光更明显。我的做法是先调菲涅尔,确定反射和折射的比例,然后再调高光,让高光在反射区域里跳出来。
7.4 不同场景的参数预设
我一般会准备几套参数预设:平静的湖面、微波荡漾的池塘、波涛汹涌的海面。平静湖面的波形振幅小、法线强度低、菲涅尔强;海面的波形振幅大、法线强度高、泡沫多。这样切换场景的时候直接换预设,不用重新调。
// 参数预设的示例结构 struct WaterPreset { float waveAmplitude; float waveFrequency; float normalStrength; float fresnelPower; float specularPower; float foamIntensity; }; WaterPreset calmLake = { 0.05, 2.0, 0.5, 5.0, 300, 0.0 }; WaterPreset roughSea = { 0.3, 8.0, 1.2, 3.0, 150, 0.8 };8. 水面效果的扩展方向
水波效果做完之后,还有很多可以扩展的方向。比如水下效果,当相机进入水面以下时,切换一套水下渲染参数,加上水体的雾效和颜色偏移。再比如交互效果,当物体进入水面时,在水面上生成一圈涟漪,这个可以用RenderTexture或者粒子系统来实现。还有天气系统,下雨的时候水面增加雨滴涟漪,刮风的时候波形方向改变。
我个人觉得最有意思的扩展是水面和场景的交互。比如水面上的倒影会随着波纹扭曲,岸边的物体在水中的倒影会随着距离衰减。这些细节做出来之后,水面的真实感会提升一个档次。不过这些效果的性能开销也不小,需要根据项目情况取舍。
我在实际项目里做水波效果,最大的体会是:不要追求物理精确,要追求视觉可信。真实的水面物理极其复杂,实时渲染根本算不过来。玩家不会拿尺子量你的波长对不对,他们只会凭感觉判断"像不像水"。所以参数怎么调,最终还是要靠眼睛看。多对比、多调整,找到那个"看起来对"的点,比什么都重要。