news 2026/10/14 9:04:32

Unity Shader水波效果实战:顶点波形、法线扰动与菲涅尔反射折射调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity Shader水波效果实战:顶点波形、法线扰动与菲涅尔反射折射调优

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或者粒子系统来实现。还有天气系统,下雨的时候水面增加雨滴涟漪,刮风的时候波形方向改变。

我个人觉得最有意思的扩展是水面和场景的交互。比如水面上的倒影会随着波纹扭曲,岸边的物体在水中的倒影会随着距离衰减。这些细节做出来之后,水面的真实感会提升一个档次。不过这些效果的性能开销也不小,需要根据项目情况取舍。

我在实际项目里做水波效果,最大的体会是:不要追求物理精确,要追求视觉可信。真实的水面物理极其复杂,实时渲染根本算不过来。玩家不会拿尺子量你的波长对不对,他们只会凭感觉判断"像不像水"。所以参数怎么调,最终还是要靠眼睛看。多对比、多调整,找到那个"看起来对"的点,比什么都重要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 9:03:36

AI Agent技能化嵌入SwiftUI:自然语言驱动App功能的架构实践

这两年我一直在折腾一个方向&#xff1a;把 AI Agent 塞进 SwiftUI 应用里&#xff0c;让它不只是当个聊天机器人&#xff0c;而是真正能用"技能"去操作 App 里的功能。这个组合我管它叫 SwiftUI-Agent-Skill&#xff0c;也就是用 SwiftUI 做交互外壳&#xff0c;用 …

作者头像 李华
网站建设 2026/10/14 9:03:19

Mac mini上跑GUI Agent:Mano-P虚拟机部署实战与调优

从第一次看到 Mano-P 这个项目的时候&#xff0c;我第一反应是&#xff1a;GUI Agent 这种听起来就必须得上百GB显存、一堆高端显卡才能折腾的玩法&#xff0c;跟一台巴掌大的 Mac mini 有什么关系&#xff1f;但后续仔细扒完项目架构和硬件需求&#xff0c;我改变了自己的判断…

作者头像 李华
网站建设 2026/10/14 9:03:13

N8N本地部署教程:从Docker Compose到生产环境避坑实践

简介&#xff1a;N8N是GitHub上备受瞩目的开源自动化工作流工具&#xff0c;通过可视化编排界面即可连接Webhook、定时任务、数据库操作、邮件服务等常见节点&#xff0c;适用于企业内部流程自动化、IT运维与数据处理等复杂场景。压缩包内提供可直接运行的N8N本地部署源码&…

作者头像 李华
网站建设 2026/10/14 9:02:43

医疗AI系统中的算法伦理与测试实践

1. 项目背景与问题起源去年参与某医疗AI系统的压力测试时&#xff0c;我在日志中发现一组异常数据&#xff1a;当患者信用评分低于阈值时&#xff0c;系统会自动降低其诊疗优先级。进一步排查发现&#xff0c;这并非技术故障&#xff0c;而是产品经理为"优化资源分配"…

作者头像 李华
网站建设 2026/10/14 9:01:26

前线部署工程师FDE:技术落地的最后一公里工程方法论

1. 前线部署工程师不是“去现场修电脑”的人——一个被严重误解的高价值技术角色FDE&#xff0c;Frontline Deployment Engineer&#xff0c;中文常译作“前线部署工程师”&#xff0c;但这个译名本身就像一扇半开的门&#xff0c;只透出一点光&#xff0c;却把里面的真实结构、…

作者头像 李华
网站建设 2026/10/14 9:00:45

AI时代存储方案:从百亿参数模型实战避坑指南

1. 项目概述那天凌晨三点&#xff0c;我被刺耳的磁盘警报声惊醒。监控系统显示&#xff0c;我们训练了整整一周的百亿参数大模型&#xff0c;把整个存储集群的剩余空间全部吃光了。这不是第一次遇到AI存储问题&#xff0c;但绝对是让我印象最深刻的一次——价值数百万的GPU算力…

作者头像 李华