Unity3D水面Shader制作指南:从顶点波动到深度融合的移动端实现
2026/9/15 12:04:26 网站建设 项目流程

水面shader在Unity3D项目里属于那种“看着不难,一写就废”的东西。两年前我第一次尝试,直接把水面做成一个半透明蓝片片,放在场景里不仅没有“波光粼粼”,反而像个蓝色大果冻,倒影、浪花、深浅变化全都没有,越看越尴尬。后来静下心把水面效果拆解成“顶点起伏、法线扰动、颜色渐变、边缘融合、反射高光”五个层面,一个个解决,才做出能放进游戏里用、帧率也不太难看的水面。这篇文章就是基于我当时踩坑的过程整理出来的完整实现方案,全部用ShaderLab写,可以直接拿去做一个能上移动端的水面材质。

作者只需要一个Unity3D项目、一块基本的法线贴图,就能跟着把代码搭起来。我也会把每个关键参数的选择理由说清楚,比如波形为什么要叠加两个方向、深水区和浅水区的颜色为什么要用深度纹理去过渡,以及为什么不管多着急都先开深度纹理,否则边缘必穿帮。整体方案不是那种吃性能的FFT海洋,而是走“常规技巧组合拳”的路线:顶点正弦波+双法线贴图扰动+深度融合+反射探针+高光,渲染量几乎可以忽略不计,在低端安卓机上也能跑,适合大多数俯视角和第三人称场景。

1. 内容整体设计与思路拆解

1.1 水面效果本质:不是“画”出来,而是“骗”出来

想清楚水面怎么做,先得明白人眼觉得“这是水”到底靠哪几个信号。单纯给一个平面贴蓝色,再调成半透明,出来的东西谁也不认为是水面,因为没有“动态”和“深度”。一片合格的水面至少要同时具备这四类视觉特征:

  • 表面有随时间变化的起伏,即使是远处看起来相对平静的水面,放大看也是在动;
  • 颜色随着水的深浅变化,浅滩发青、发透,深水区发蓝、发暗;
  • 与岸边、物体的交界处有融合感,不能是一刀切的边缘;
  • 高光和反射要跟着视角动,太阳打在波纹上的那条“光路”是水面身份的重要标识。

这些特征加在一起,才是“水感”。单独拆开每一项都不难,难的是组合在一起还得高效。所以我的方案是:顶点Shader负责几何层面的波动,片元Shader负责颜色、光照和边缘融合,再叠加噪声或双法线贴图增加表面细节。这样分配最符合GPU的工作方式——顶点阶段运算少但改变几何结构,片元阶段逐像素处理能创造丰富细节,各干各的、互不拖累。

1.2 为什么不用复杂的FFT海洋扰动方案

网上一搜水面shader,很容易看到一堆FFT(快速傅里叶变换)海面、Gerstner波大规模模拟的教程。那些方案确实效果震撼,但是它们的目标场景是《盗贼之海》那种大型海面,需要的是海量顶点密度和GPU Tessellation,甚至要配合compute shader做频谱合成。普通项目的水面,可能只是一个池塘、一条河流,或者战斗场景里一片浅滩,直接把FFT搬过来,性能先崩一半,调参又能调一个礼拜。

我这篇文章用的是“正弦波叠加+法线贴图”的经典组合,本质上是让一个平面在顶点层做连续的波浪状偏移,再用法线贴图去“欺骗”光照方向。水面网格用不到太多顶点,一块20x20的平面,顶点密度在合适范围内就够用,移动端跑起来几乎没压力。这也是UnityAsset Store里一大堆风格化水面插件的基本原理,不是偷懒,而是这个方案在“性价比”上确实最优。

1.3 整体架构:五个模块各司其职

从0到1写这个shader,我的思路是分模块实现,不搞一口气糊一大段代码。每个模块效果可单独开关,方便调试定位问题。模块划分如下:

模块作用实现层级
波形起伏平面顶点随正弦波偏移,产生几何波动顶点着色器
法线扰动让光照计算使用扰动后的法线,形成波纹高光细节片元着色器
深度融合采深度纹理,让水面颜色随深度渐变,边缘与岸边融合片元着色器
反射与高光反射探针采样+Blinn-Phong高光,补足立体感和光感片元着色器
透明与排序半透明渲染队列,让水能透出下面的物体颜色SubShader配置

每个模块单独写成一个小函数,最后在片元着色器里汇总。这样如果最终颜色发黑或者波纹不动,我能立刻知道是哪个模块出了问题,而不是对着三百行代码猜。

2. 基础水面Shader实现:从零到能用

2.1 Shader框架与渲染队列的坑

代码先从ShaderLab的框架开始。水面是半透明的,水底物体透过水层必须被看到,所以渲染队列要放在Transparent,混合模式用SrcAlpha OneMinusSrcAlpha。这里有个初学者常踩的坑:如果仅仅是半透明,其实用Queue=TransparentQueue=AlphaTest都行,但水面往往涉及深度纹理对比,为了防止水面自己挡住自己、又得能正常写入深度给后续物体参考,建议加上ZWrite Off。单纯把队列设置成Transparent而不关深度写入,物体会出现半透明排序错乱,远处的桥墩可能会被莫名其妙地挡住。

Shader "Custom/SimpleWater" { Properties { _MainTex("水面法线贴图", 2D) = "bump" {} _WaveStrength("波纹强度", Range(0, 1)) = 0.3 _WaveSpeed("波纹流动速度", Range(0, 2)) = 0.5 _ColorDeep("深水颜色", Color) = (0.1, 0.3, 0.5, 1) _ColorShallow("浅水颜色", Color) = (0.3, 0.7, 0.8, 1) _Gloss("高光锐度", Range(8, 256)) = 64 _SpecColor("高光颜色", Color) = (1, 1, 1, 1) _DepthFactor("深度渐变色强度", Range(0, 2)) = 1 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" // ... 具体函数 ... ENDCG } } }

Cull Off这个细节容易被忽略,水面俯视角看没问题,但如果从侧面、底下观察,开了背面剔除就会看到水面背面是空的,特别假。直接关掉,一劳永逸。

2.2 顶点波动:最简单的正弦波叠加

顶点波动是水面动态的基础。最原始的想法是给顶点Y轴加一个sin(_Time.y)高度,但这样整片水面同一个频率升降,效果跟升降平台一样,一点波纹感都没有。正确做法是叠加多个不同方向、不同频率的正弦波。

在顶点着色器中这样写:

float3 vertexWave(float3 pos, float2 uv) { float wave1 = sin(uv.x * 6.28 + _Time.y * 1.2) * 0.1; float wave2 = sin(uv.y * 8.0 + _Time.y * 0.9) * 0.08; float wave3 = sin((uv.x + uv.y) * 4.0 + _Time.y * 0.6) * 0.05; pos.y += wave1 + wave2 + wave3; return pos; }

三个波叠加的理由很简单:单个波是“规律感很强”的重复波动,人眼会迅速识别成机械运动;三个频率、方向不同的波叠加上去,就能得到一个看似无规律、但又流畅自然的水面起伏。实际调整的时候,我习惯让一个波幅度大但频率低,负责“大浪”;一个波幅度小但频率高,负责“波纹细节”;第三个波斜着穿过平面,破坏前两个波形成的规则方格感。

注意,这里为了简化演示,直接用了uv作为波的位置参考,它的缺点是当物体scale变化时波纹大小会跟着变。如果希望水面波纹跟世界坐标挂钩,可以把uv替换成worldPos.xz,这样所有水面拼在一起时波纹尺度统一,接缝处不会有尺度突变的问题。

2.3 片元函数:颜色基础与时间动画

片元着色的第一步是把深水色和浅水色按区域混合。选颜色是门学问,纯蓝色并不是首选,因为自然界的水体颜色受底部和天空影响很大。我常用的两套配色:

  • 清澈热带海水:深水色(0.05, 0.35, 0.45),浅水色(0.25, 0.75, 0.85),适合海岛、泳池周边;
  • 阴沉湖泊水:深水色(0.05, 0.1, 0.15),浅水色(0.25, 0.35, 0.4),适合暗色调恐怖、求生类场景。

颜色混合不能直接拿_ColorDeep_ColorShallow做线性lerp,那样会造成明显的硬边。标准做法是拿深度纹理的结果,做一个非线性映射,让浅水区范围收窄,深水区快速过渡到深色。下面这个函数先预留,第3节详解:

float4 frag(v2f i) : SV_Target { float3 normal = UnpackNormal(tex2D(_MainTex, i.uv + _Time.x * _WaveSpeed)); // 后续会在这里叠加深度、反射、高光等 float4 col = lerp(_ColorShallow, _ColorDeep, depthFactor); return col; }

法线贴图的uv加上时间偏移,是为了让波纹产生“流动感”。单纯让法线贴图静止不动的话,波纹虽然存在,却像塑料压花;给UV加上随时间变化的偏置,法线就像有了生命。

3. 进阶效果:让水面真正“活”起来

3.1 从深度纹理拿到水底深度差

深度融合是水面效果从“塑料玻璃”升级到“真水”的关键一步。菜鸟做法是给水面一个固定透明度,但真实浅水区应该能看到底,深水区则越来越不透明。这两者的分界,必须依赖“深度纹理”——也就是场景中每个像素距离相机的远近。

拍摄深度纹理前,需要在相机上允许深度纹理。如果是主相机,直接设置Camera.main.depthTextureMode = DepthTextureMode.Depth;,或者在相机的Inspector面板里把Depth Texture选项设为On。Shader里通过_CameraDepthTexture访问。拿到当前水面像素的深度和它后面物体深度的差值,记作depthDelta。深度差值大,水就深;差值小,水就浅。

float depth = LinearEyeDepth(tex2Dproj(_CameraDepthTexture, i.screenPos).r); float depthDelta = depth - i.screenPos.w; float depthFactor = saturate(depthDelta * _DepthFactor);

这段代码的解释:i.screenPos是水面顶点投影后的屏幕坐标,i.screenPos.w是水面本身在视图空间里的深度。背景物体的深度减去水面的深度,得到的就是“从水面往下看有多深”。

这里有一个直接决定效果好坏的经验:_DepthFactor不是越大越好。设成0.5到1.5之间比较稳妥。数值太大会导致水面颜色瞬间过渡到深水色,浅滩效果完全消失;数值太小又会千篇一律全是浅水色,深水区不显“深”。

3.2 双法线贴图流动:打破重复感

单张法线贴图无论怎么偏移,仔细观察还是能看出波纹循环的规律,因为图案在移动,但图案本身不变。要打破这种规律性,“双法线贴图不同速度、不同方向叠加”是目前综合性价比最高的方案。

这是我的核心做法:

float2 uv1 = i.uv * _MainTex_ST.xy + float2(_Time.x * 0.3, _Time.y * 0.2); float2 uv2 = i.uv * _MainTex_ST.xy + float2(_Time.x * -0.2, _Time.y * 0.3); float3 normal1 = UnpackNormal(tex2D(_MainTex, uv1)); float3 normal2 = UnpackNormal(tex2D(_MainTex, uv2)); float3 normal = normalize(float3(normal1.xy + normal2.xy, normal1.z * normal2.z));

两张法线贴图用同一张图,但是让它们一个往右上移、一个往左下移、速度还不一样,叠加之后,原本规则的重复纹理就变得很自然。如果用两张不同的法线贴图,比如一张细小波纹、一张大波浪,效果更好,但哪怕只有一张,只要用双UV的方法,观感也能提升一大截。

使用法线贴图时的导入设置也很关键:必须把Texture Type设为Normal Map,否则取样出来的法线方向是错的,水面会出现一种“凹凹凸凸”的怪光感,而且无法通过调强度修正。

3.3 反射探针:不用实时反射的Plan B

实时反射相机(Reflection Probe的Realtime模式)在移动端是性能杀手,一个反射相机等于把场景又渲染一遍,遇到复杂场景帧率直接腰斩。更聪明的做法是使用烘焙反射探针(Baked Reflection Probe)。

具体操作:在场景里创建Reflection Probe,选择Baked模式,调整好影响范围,保证水面所在区域能被探针捕捉到。Unity会预计算一个Cubemap,包含环境颜色信息。Shader里这样采样:

float3 reflDir = reflect(-viewDir, normal); float3 reflColor = texCUBE(unity_SpecCube0, reflDir).rgb;

unity_SpecCube0不需要额外声明变量,这是Unity ShaderLab内置的反射探针采样接口,光照探针组会自动给它赋值。这样水面就会反射出天空、周围的山和建筑,性能开销几乎可以忽略。

如果想让反射更“糊”一点,像真实水面那样模糊反射,可以用texCUBElod(unity_SpecCube0, float4(reflDir, roughness)),越大越模糊。这是一个免费模糊,不用做单独的模糊渲染,靠mipmap的lod就能实现。

3.4 高光:Blinn-Phong的魔改

水面的高光不是普通镜面高光,而是“碎碎”的、被波纹切碎的光斑。直接把Blinn-Phong的高光套上去,会看到一大块闪亮的亮斑,像塑料球。我的做法是把高光项跟法线扰动挂钩,因为法线已经被双法线贴图打乱了,高光自然就会碎掉:

float3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfDir), 0), _Gloss); float specMask = saturate(noiseFactor * 2); float3 specColor = _SpecColor.rgb * spec * specMask;

specMask可以用法线贴图里的R通道或者另一个噪声值做,目的是给高光加一个不规则强度蒙版,让有些位置亮、有些位置暗,而不是一刀切整片亮。_Gloss设置在32到128之间最有水面感,太低是粗糙水面,太高就成了镜子。

3.5 岸边泡沫和边缘淡化:细节决定档次

一个人物站在岸边,或者小船划过水面,水面边缘跟岸接触的地方如果只有一条硬边,立刻显得廉价。这里有两个思路:

思路一:用深度差做泡沫Mask,深度差小于一定阈值的区域,认为是浅滩/岸边,混入白色泡沫颜色。这样水面接近岸边的位置会自然形成一圈白边:

float foam = smoothstep(0.05, 0.25, depthDelta); float3 foamColor = float3(1, 1, 1); col.rgb = lerp(foamColor, col.rgb, foam);

思路二:用深度差渐变控制Alpha,越浅的水越透明,越深的水越不透明:

col.a = saturate(1 - depthDelta * 2);

这两个效果共用一份深度差数据,没有任何额外成本,但观感提升是肉眼可见的。如果水面在大面积湖泊中用,泡沫阈值建议调到0.2到0.4之间,太大会出现一条很宽的白边,很怪;如果做小溪或浅滩,可以适当加大。

4. 材质面板、参数调优与性能考量

4.1 一张可直接抄作业的完整Shader

把前面所有模块串起来,生成一个完整、能直接应用于Unity3D项目的Shader。这段代码我实测过,是本文的核心资产。把它复制进工程内新建的Shader文件,新建材质、指定这个Shader,再把法线贴图拖进_MainTex槽位即可运行。

Shader "Custom/SimpleWater" { Properties { _MainTex("法线贴图", 2D) = "bump" {} _WaveStrength("顶点波浪强度", Range(0, 0.5)) = 0.15 _WaveSpeed("波浪速度", Range(0, 2)) = 0.6 _ColorDeep("深水颜色", Color) = (0.05, 0.2, 0.4, 1) _ColorShallow("浅水颜色", Color) = (0.3, 0.75, 0.8, 1) _Gloss("高光锐度", Range(8, 256)) = 64 _SpecColor("高光颜色", Color) = (1, 1, 1, 1) _DepthFactor("深度渐变强度", Range(0, 2)) = 0.8 _NormalScale("法线强度", Range(0, 2)) = 1 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" sampler2D _MainTex; float4 _MainTex_ST; float _WaveStrength; float _WaveSpeed; float4 _ColorDeep; float4 _ColorShallow; float _Gloss; float4 _SpecColor; float _DepthFactor; float _NormalScale; sampler2D _CameraDepthTexture; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float4 screenPos : TEXCOORD1; float3 worldPos : TEXCOORD2; }; v2f vert(appdata v) { v2f o; float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float wave1 = sin(worldPos.x * 0.3 + _Time.y * 1.2) * _WaveStrength; float wave2 = sin(worldPos.z * 0.5 + _Time.y * 0.8) * _WaveStrength * 0.6; float wave3 = sin((worldPos.x + worldPos.z) * 0.4 + _Time.y * 0.7) * _WaveStrength * 0.3; v.vertex.y += wave1 + wave2 + wave3; o.uv = v.uv; o.vertex = UnityObjectToClipPos(v.vertex); o.screenPos = ComputeScreenPos(o.vertex); o.worldPos = worldPos; return o; } fixed4 frag(v2f i) : SV_Target { float2 uv1 = i.uv * _MainTex_ST.xy + float2(_Time.x * _WaveSpeed * 0.8, _Time.y * _WaveSpeed * 0.4); float2 uv2 = i.uv * _MainTex_ST.xy + float2(_Time.x * _WaveSpeed * -0.6, _Time.y * _WaveSpeed * 0.7); float3 normal1 = UnpackNormal(tex2D(_MainTex, uv1)); float3 normal2 = UnpackNormal(tex2D(_MainTex, uv2)); float3 normal = normalize(float3((normal1.xy + normal2.xy) * _NormalScale, normal1.z * normal2.z)); normal = normalize(normal); float depth = LinearEyeDepth(tex2Dproj(_CameraDepthTexture, i.screenPos).r); float depthDelta = saturate(depth - i.screenPos.w); float depthFactor = saturate(depthDelta * _DepthFactor); float4 col = lerp(_ColorShallow, _ColorDeep, depthFactor); float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos); float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfDir), 0), _Gloss); float specMask = saturate(normal1.r * 2 + normal2.r * 1.5); col.rgb += _SpecColor.rgb * spec * specMask; float3 reflDir = reflect(-viewDir, normal); float3 reflCol = texCUBElod(unity_SpecCube0, float4(reflDir, 2)).rgb; col.rgb += reflCol * 0.3; float foam = smoothstep(0.05, 0.25, depthDelta); col.rgb = lerp(float3(0.9, 0.95, 1) * foam * 0.8, col.rgb, saturate(foam + 0.2)); col.a = saturate(0.6 + depthFactor * 0.4); return col; } ENDCG } } }

4.2 材质面板参数调优经验

参数调优的基准顺序:先调颜色,再调深度,再调法线,最后调高光。

颜色先确定大基调,深水色和浅水色的色相不要相差太远,否则会有一条明显的变色分界线。深度渐变强度决定这个变色带的位置,把它调到水面刚好能看出深浅过渡、但又不会突然跳色为止。法线强度决定波浪的“锐利感”,数值超过1后波纹高光会过曝,产生闪烁白噪点。高光锐度控制反射光斑的大小,64附近是安全区,过小像油面,过大像镜面。

如果是夜晚场景,深水色和浅水色都要压暗,且高光颜色改成冷色调(比如淡蓝色),不要用纯白,否则水面在月光下会显得荧光。

4.3 移动端与PC端的性能取舍

性能方面主要看三块:顶点数、法线贴图采样次数、反射探针。

顶点数上,20x20平面网格在PC上没什么感觉,但移动端建议用10x10或者8x8的网格密度。水面波浪的细节由法线贴图补充,顶点密度低一点无妨。法线贴图采样我控制在2次,再加上一个反射探针采样,片元Shader压力很小。反射探针的Cubemap分辨率建议顶到256就够,设成512的话在游戏中占据内存和带宽明显增加。

值得提醒的是,如果水面面积很大,千万不要用一个大平面挂这个Shader,然后用Realtime反射。替代方案是:水面分为近处和远处两块,近处用高精度参数,远处用简化版(没有顶点波动,只做颜色渐变)。很多成熟项目都是这么处理的。

5. 常见问题与排查实录

5.1 快速对照表:问题、原因、解决

问题现象大概率原因解决方案
水面全黑相机没有开启深度纹理Camera.main.depthTextureMode = DepthTextureMode.Depth;
水面没有波动顶点密度太低增加平面网格分段数,或检查_WaveStrength是否接近0
法线贴图有奇怪凹凸贴图没有设置成Normal Map在导入设置里Texture Type选Normal Map
水面排序错乱、挡物体没有ZWrite Off加回ZWrite Off
高光过曝闪烁法线强度_NormalScale太大降到0.5~1之间
反射内容不正确反射探针没覆盖水面区域调节Reflection Probe的Bounds,确保障景覆盖
水面半透明看不到水底背景物体不在Transparent前面大概率深度纹理未开,或背景物体用Unlit不写入深度

5.2 深度纹理不生效的完整排查过程

曾经遇到过一个问题:水下物体能看见,但水面深浅过渡完全失效,整片水面都是一种颜色。排查后发现不是Shader的问题,而是相机的Depth Texture没有成功生成。DetectionTexutre在部分后处理管线里自带,但在纯Shader实现时,必须手动开启相机深度纹理模式。

排查时可以这样验证:用一个临时脚本在Start里输出Camera.main.depthTextureMode,看看有没有包含Depth标志。如果没有,就执行Camera.main.depthTextureMode |= DepthTextureMode.Depth;。另一个隐蔽的坑是URP管线下的_CameraDepthTexture命名和内置管线不同。URP里要用_CameraDepthTexture没问题,但Shader要改成HLSL语法,整体结构不一样,这篇文章的ShaderLab版本主要针对内置渲染管线(Built-in RP)。如果用URP,需要把代码迁移到HLSL,LinearEyeDepth的调用参数也要根据URP版本调整。

5.3 法线贴图不动,波纹像静止贴图

法线纹理不动的原因通常是Shader里没有给_MainTex的UV加时间偏置,或者_Time被禁用了。_Time.x_Time.y在Unity内置Shader变量里都有定义,只要Shader没有被特殊优化掉,默认都能用。还有一个常见错误是直接在采样函数里写_Time.x但没有使用_Time.y,导致速度极慢,肉眼几乎看不到变化。我实践下来,水面流动速度设置在每秒偏移0.2到0.5个UV单位比较合适,低于0.1基本肉眼无法察觉,高于1则像瀑布一样剧烈。

5.4 透明物体排序的终极方案

半透明渲染顺序一直是个老大难。水面之间互相重叠、物体一半在水里一半在水上,排序很容易乱。Shader层面的常规解法是为模型摆正渲染顺序,而不是依赖distance to camera排序。水面可以用Queue=Transparent+1或更高,确保在水下物体之后渲染。如果一个物体需要在水面之上被看到,它的材质队列应小于水面;如果物体是在水下被看到,则要保证它先绘制到深度纹理里再被水面覆盖。这个顺序一旦搞混,就会出现明明物体在水下,却被水面完全遮挡的效果。

6. 从“能用”到“好用”的方向补充

6.1 双面波纹的奇偶网格优化

大平面网格的顶点波在计算时,如果所有顶点用同样的公式,会出现对称感很强的重复波浪。优化方法是让每个顶点的波长和相位跟它的坐标挂钩,比如:

float wave4 = sin(worldPos.x * 0.7 + worldPos.z * 1.1 + _Time.y * 0.5);

这样平面不同区域相位错开,波形更自然。还有一种做法是用顶点色R/G/B通道存储三种相位偏移,美术可以在模型上自由刷出波动的强弱,这种方法在较大水面时很好用。

6.2 用Gerstner波替代正弦波做浪尖

如果想做更真实的海浪,可以让顶点横纵坐标也跟时间发生偏移,这就是Gerstner波的核心思路。Gerstner波能做出“波峰尖锐、波谷平缓”的立体感,而正弦波只能做出对称的起伏。这段内容是为那些觉得纯正弦波不够味的朋友准备的,实现方式是在顶点函数里加入水平方向偏移:

float xOff = sin(worldPos.x * 0.5 + _Time.y * 1.0) * _WaveStrength * 0.5; float zOff = cos(worldPos.z * 0.4 + _Time.y * 0.8) * _WaveStrength * 0.5; worldPos.x += xOff; worldPos.z += zOff;

加入水平偏移后,水面会从上下起伏变成真正的“涌动”,尤其在岸边的礁石碰撞处,这种运动会更有冲击力。代价是水面网格的密度需要更高,否则顶点位移会造成穿插。

6.3 风格化水面的极简思路

如果项目走低多边形/卡通风格,可以放弃真实感路线,把所有物理参数替换成颜色分区。做法如下:去掉法线贴图,用噪声图让颜色沿世界坐标xz流动;深度纹理保留,但只用来区分两个固定颜色,不渐变;高光直接做成圆形亮斑,跟随视角移动。效果很风格化,性能也最好。

6.4 后续扩展方向

目前实现的Shader是基于内置管线的,如果要转URP(尤其Unity6的URP 17以上版本),需要把CGPROGRAM改成HLSLPROGRAM,采样深度纹理的函数从LinearEyeDepth改到LinearEyeDepth(URP有自定义实现),光照函数也要从Lighting.cginc换成URP的Lighting.hlsl。转换不难,但细节很多。

更进一步,可以加入模拟浪花泡沫贴图、泡沫随时间消散、岸边湿沙效果、以及水面下的焦散投影(Caustics)。焦散投影的做法是给水下地面单独加一层扰动贴图,用水的波形去驱动这个图案,可以实现水下光影摇曳的梦幻效果。这篇文章先写到这,后面我会专门出一篇URP迁移和焦散实现的进阶教程,到时候可以对照着一起改。

其实水面shader最有趣的一点,就是它把所有基础图形学技巧都串起来用了。顶点变换、纹理采样、法线映射、反射、深度缓冲——全是基础功,组合起来却能有很惊艳的结果。做完这个真的能体会到graphics编程的乐趣,每次调个参数,水面都会给你完全不一样的表情。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询