Unity3D手写简单水面Shader:从颜色分层到波光粼粼的实现
1. 先搞清楚这个水面shader到底要解决什么问题1.1 简单水面不等于简陋水面先列需求清单前阵子给一个水下捕鱼题材的小Demo调水面效果翻了半天Asset Store里的水面资源要么贵得离谱要么拖进来一堆后处理依赖改个颜色都得鼓捣半天。最后干脆决定绕开所有商品资源自己用Unity3D写一个简单水面shader。做之前我给自己列了一张需求清单这张清单决定了后面所有的技术取舍视觉上要像水不需要物理级真实但要有颜色分层、波动、波光反射这几个核心特征不能太吃性能要能在普通笔记本和移动端流畅跑不能每帧都在GPU上做一堆大循环工程上要干净不依赖第三方插件不引入复杂的后处理栈参数要好调美术同学看到面板能自己拖动度条改色而不是每次跑到代码里改数字能和场景里的鱼、海底模型自然融合尤其是捕鱼题材里常见的那种浅滩浅水、深水区海水变暗的过渡。如果你不算太较真这个需求其实用Shader Graph也能拼出来但我一直不建议一上来就拖节点。原因很简单当你不知道每个节点背后在算什么水面稍微有点bug比如光照方向不对、边缘发黑、波纹“漂移”得不像水波你根本不知道该调哪里。手写shader虽然慢一点但每一个变量、每一段代码你都心里有数出问题能顺着管线一步步排查。1.2 渲染管线选择Built-in、URP还是HDRPUnity3D现在的渲染管线选择对shader写法影响非常大。同样是写水面Built-in管线、URP、HDRP的代码接口和渲染语义都不一样。如果你的项目还停留在Built-in那下面这套基于CGPROGRAM的写法可以直接用如果是URP项目建议把CGPROGRAM换成HLSLPROGRAM并且把UnityCG.cginc里的辅助函数替换成URP对应的库函数HDRP就更复杂一般不建议为了一个简单水面去碰。我做这个水面shader时默认选择Built-in管线不是因为老旧而是因为它最简单直接学习成本最低。ShaderLab的框架就是这样Shader Custom/SimpleWater { Properties { _MainTex (Main Texture, 2D) white {} } SubShader { Tags { RenderTypeTransparent QueueTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 这里写顶点函数和片元函数 ENDCG } } }我相信很多初学者第一次看到这段代码时最大的困惑不是语法而是这些Tag到底有什么用。RenderType是用来告诉Unity这物体属于哪一类方便后期效果区分Queue是渲染队列Transparent表示它会在不透明物体之后渲染Blend决定半透明的颜色混合方式ZWrite Off表示不写深度让后面的半透明物体还能继续叠加。这些都是水面必须的不然透明效果会乱套。2. 水体的颜色从哪来浅水、深水与岸边过渡2.1 最简单的颜色方案高度差混合水面的第一个视觉特征是颜色。真实的海水不是单一蓝色而是从岸边浅滩的透明青绿色逐渐过渡到深海区的墨蓝色。在捕鱼水域这种游戏场景里这种过渡尤其重要因为玩家需要从水面上隐约看到下面的鱼群和地形如果水面颜色死板整体氛围直接垮掉。最简单的实现思路是用顶点世界高度差来混合两种颜色。假如水面网格是平面水面高度基本固定那么可以拿每个像素的世界Y坐标和某个浅水区高度阈值做比较fixed3 finalColor lerp(_DeepColor, _ShallowColor, saturate((waterY - _ShallowStart) / (_ShallowEnd - _ShallowStart)));这里waterY可以是世界坐标Y也可以采样高度图。因为水面本身就是一个平面世界Y变化不大用这个方法做出来的渐变会比较生硬只有在网格做了大幅起伏时才明显。更实用的做法是用水下深度来做混合也就是让相机能看到的水下不透明物体越深颜色越深。2.2 用深度纹理做岸边渐变用深度纹理是Unity3D里做水体颜色的主流方案。核心思想是采样相机深度纹理得到当前位置后面遮挡物的深度再用这个深度减去水面的深度差值就是这个像素处的水深。水深越浅颜色越偏向浅水色水深越深颜色越接近深水色。先启用深度纹理。在Camera组件上勾选Depth Texture即可或者在脚本里设置camera.depthTextureMode DepthTextureMode.Depth。然后在shader里声明sampler2D_float _CameraDepthTexture; float _WaterDepthMax; // 最大可视水深 fixed4 _ShallowColor; fixed4 _DeepColor; #define UNITY_PROJ_COORD(a) a fixed4 frag(v2f i) : SV_Target { // 还原屏幕空间深度 float rawDepth SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)); float sceneZ LinearEyeDepth(rawDepth); // i.screenPos.w 近似等于水面到相机的距离 float waterDepth sceneZ - i.screenPos.w; // 深度范围映射到0-1越大越深 float depthFactor saturate(waterDepth / _WaterDepthMax); // 根据深度混合颜色 fixed3 waterColor lerp(_ShallowColor, _DeepColor, depthFactor); return fixed4(waterColor, _Opacity); }这一段代码是我实际项目里最核心的颜色逻辑。SAMPLE_DEPTH_TEXTURE_PROJ是Unity提供的宏负责对深度纹理做投影采样LinearEyeDepth把非线性深度缓冲转成线性距离。waterDepth这个值越大说明水越深。lerp在浅水色和深水色之间插值。这样即便水面网格是一个完全平的Plane岸边和深水区也能出现明显色差视觉层次一下就出来了。这里有一个细节i.screenPos.w的来源必须确保顶点着色器里使用的是ComputeScreenPos否则这个差值会算错。我刚开始折腾时就是漏了这一步结果浅水区全变成深蓝色。排查了半天最后在Unity官方文档里看到ComputeScreenPos的w分量就是摄像机空间深度这才反应过来。3. 让水动起来顶点扰动和UV滚动的分工3.1 为什么我推荐先用顶点动画铺底水面如果只有颜色不动一眼看去就是个半透明色块。让水面活起来最常见的手段有两种一种是让顶点位置在GPU上随波形变化另一种是让UV纹理和法线贴图持续滚动。前者让水面几何形状真正起伏后者从视觉上模拟波光流动。捕鱼游戏这类场景水面不需要浪涛汹涌但也不能平静得跟死水一样。我的经验是先用顶点正弦波叠加铺一个基础起伏让水面的轮廓有真实的高低变化再用UV滚动法线贴图去补波纹高光。两种手法分工不同缺一不可。顶点波形最基础的做法是几个不同方向的正弦波叠加。公式如下finalY originalY A1 * sin(dot(float2(x, z), _WaveDir1) * _WaveFreq1 _Time.y * _WaveSpeed1) A2 * sin(dot(float2(x, z), _WaveDir2) * _WaveFreq2 _Time.y * _WaveSpeed2);在顶点着色器里写出来就是float _WaveAmp1, _WaveFreq1, _WaveSpeed1; float _WaveAmp2, _WaveFreq2, _WaveSpeed2; float2 _WaveDir1, _WaveDir2; float4 vert(float4 vertex : POSITION, float2 uv : TEXCOORD0) : SV_POSITION { float3 worldPos mul(unity_ObjectToWorld, vertex).xyz; float wave1 sin(dot(worldPos.xz, normalize(_WaveDir1)) * _WaveFreq1 _Time.y * _WaveSpeed1); float wave2 cos(dot(worldPos.xz, normalize(_WaveDir2)) * _WaveFreq2 _Time.y * _WaveSpeed2); worldPos.y wave1 * _WaveAmp1 wave2 * _WaveAmp2; return UnityObjectToClipPos( mul(unity_WorldToObject, float4(worldPos, 1.0))); }这里要注意几个参数的实际含义。_WaveDir波的传播方向两个波的方向千万不要平行否则叠加效果看起来像一道直线在推没有交叉感。_WaveFreq是波数可以理解成单位距离内有多少个波峰频率越高水纹越密。_WaveSpeed是时间系数取值太小水面像慢放太大会产生高频闪烁。我常用的初始值是Amp10.15Freq12.0Speed11.2Amp20.06Freq25.0Speed20.8看起来自然又不会太闹。3.2 顶点扰动容易踩的三个坑顶点着色器写波形时最常见的错误是在模型空间和世界空间之间来回转换时把坐标搞混。一定要想清楚sin函数的输入是世界坐标还是模型坐标。如果你的水面网格只有一个且没有位移、缩放模型坐标和世界坐标差不多但一旦场景里做了摆放、旋转就可能导致波形朝向错误、振幅变化不对。稳妥的做法是一律转成世界坐标计算最后再转回裁剪空间。第二个坑是离散度问题。水面网格越稀疏顶点正弦波看起来越像折线而不是平滑的波浪。如果你的Plane只有少数几个分段建议把分辨率调高或者直接使用一个专门的细分平面模型。但也不能无脑细分移动端上一个顶点数几十万的水面帧率会非常难看。我一般用512顶点左右的平面铺完波形再加法线扰动视觉上基本够用。第三个坑是法线没有重新计算。顶点位置变了但是传给片元着色器的法线如果还是原始法线光照方向就是错的水面高光会跟着地形歪掉。正确做法是把波形公式对x、z求偏导近似得到扰动后的切线空间法线或者事后在片元里用ddx/ddy求屏幕空间导数重建法线。对简单水面来说用两张法线贴图滚动的方式更省事这就是下一章要说的内容。4. 波光粼粼的关键法线贴图滚动与高光4.1 为什么一张法线贴图不够单靠顶点正弦波做不出细腻的波光。因为波峰之间的大面积区域太平滑少了那种阳光洒在水面上、一小块一小块闪烁的细节。解决办法是在片元着色器里叠加法线贴图。法线贴图是一张记录微观凹凸方向的纹理通常看起来整体呈蓝紫色因为法线向量的z分量正常接近1映射到RGB后B通道很大。Shader中通过UnpackNormal把贴图的颜色还原成真正的法线向量。为什么要用两张法线贴图而不是一张因为单张贴图如果单纯做UV平移波纹朝向会一致动起来有一种固定的拉伸感不像水波倒像一块有条纹的塑料布在滑动。用两张方向、速度、缩放都不同的法线贴图叠加波纹朝向会互相干扰形成类似水面毛细血管般错落的起伏感这才是水面的质感来源。sampler2D _NormalMap1; sampler2D _NormalMap2; float _NormalStrength; float _WaveSpeedN1, _WaveSpeedN2; fixed3 frag(v2f i) : SV_Target { float2 uv1 i.uv * _NormalScale1 float2(_Time.x * _WaveSpeedN1, _Time.x * _WaveSpeedN1 * 0.3); float2 uv2 i.uv * _NormalScale2 float2(_Time.x * _WaveSpeedN2 * -0.5, _Time.x * _WaveSpeedN2 * 0.2); fixed3 normal1 UnpackNormal(tex2D(_NormalMap1, uv1)); fixed3 normal2 UnpackNormal(tex2D(_NormalMap2, uv2)); // 两张法线合成并用法线强度控制扰动幅度 fixed3 normal normalize(float3(normal1.xy normal2.xy, normal1.z * normal2.z)); normal.xy * _NormalStrength; normal normalize(normal); }这里有个常被误解的点两张法线贴图直接相加并不是在数学上严谨的“叠加两个表面”但效果上足够好性能也划算。normalize很重要否则法线长度不为1高光亮度会发飘。_NormalStrength建议范围0-1我常用的初始值是0.4太强水面像开了电风扇太弱又看不出波光。4.2 高光和菲涅尔水面向远处看为什么会发白水面的高光和水面反射本质上是两个光照现象。高光是直接光源在微观起伏表面上的镜面反射属于Blinn-Phong或者GGX高光模型而菲涅尔效应则是指视线与水面夹角越接近掠射角也就是从侧面看水面反射越强烈透射越弱。坐过飞机往下看湖泊的人会有直观体会从正上方看水是透明的能看到河床但从远处斜着看水面亮得几乎像镜子。在shader里菲涅尔可以不用查复杂的物理公式用pow(1 - dot(N, V), _FresnelPow)来近似。N是水面法线V是视线方向两者越接近垂直dot越大菲涅尔值越小此时透射明显水下颜色占据主导从侧面看dot越小菲涅尔值越接近1反射增强水面发白。公式虽然简化但视觉上足够。再配合一个CubeMap反射采样就能做出一层环境反射的质感。如果没有CubeMap可以用unity_SpecCube0获取天空盒反射。实现片段大致如下float3 viewDir normalize(i.viewDir); float3 halfDir normalize(_WorldSpaceLightPos0.xyz viewDir); float fresnel pow(1 - saturate(dot(normal, viewDir)), _FresnelPow); // 反射采样 float3 reflectDir reflect(-viewDir, normal); float3 reflCol texCUBE(_EnvCube, reflectDir).rgb; // 高光 float spec pow(saturate(dot(normal, halfDir)), _Gloss); float3 specCol _LightColor0.rgb * _SpecColor.rgb * spec; // 漫反射水色 反射 高光混合 float3 finalColor waterColor * (1 - fresnel) reflCol * fresnel specCol;在实际调参过程中_Gloss控制高光的锐度。取值8-16时高光斑块干净利落适合晴天波光取值小于8会让高光糊成大范围白斑适合阴天或雾天。_FresnelPow通常取1-3数值越大反射出现越晚也就是只有很侧的角度才会发白。这个参数对天空在水面上发光的感受影响极大建议每次调整幅度不超过0.5逐步逼近想要的效果。5. 把水面放进场景透明排序与地下物体融合5.1 透明队列和深度写入的博弈写水面shader时有一个比较隐蔽的问题半透明物体的渲染顺序。Unity的渲染队列QueueTransparent保证不透明物体先画然后所有半透明物体按距离从远到近排序。水面这种大面积半透明物体会让排序变得很有压力因为它的每个像素可能都会和后面的鱼、海底、天空盒发生混合。对于简单水面建议的Tag组合是Tag / 参数推荐值原因QueueTransparent延迟到不透明物体后渲染RenderTypeTransparent配合透明渲染语义ZWriteOff避免水面遮挡水下透明物体BlendSrcAlpha OneMinusSrcAlpha标准alpha混合但ZWrite Off也有副作用水面本身不写入深度如果场景里还有第二个半透明平面它就可能跟第一层水面混合得乱七八糟。大多数简单场景中只有一层水面这个组合够用。如果要做多层水面或水面下有大量半透明粒子那就需要考虑把水面放在单独相机渲染或者用高精度深度技巧不过这就超出简单水面的范围了。5.2 和水下鱼模型、海底地形的配合捕鱼题材场景里玩家看到的是水面以下的鱼群和珊瑚。为了让这些模型在清澈浅水区清晰可见在深水区则朦胧变暗我只靠颜色和透明度是不够的。这里有两个额外技巧值得记录第一个是采样深度纹理做“水下雾效”。前面已经通过waterDepth得到了水深那就可以直接利用它给水下物体叠加一层雾。比如水越深碎片颜色越偏向深蓝色透明度越低。这是一个纯数学的lerp运算不增加纹理采样成本几乎为零。float fogFactor saturate(waterDepth / _FogDepthMax); finalColor lerp(finalColor, _DeepFogColor, fogFactor);这个做法特别适合捕鱼、海洋馆这一类场景。鱼在浅水区域时颜色饱和、轮廓清晰游到深水区后慢慢融入背景蓝整个画面的纵深感和真实感会提升一大截。第二个是注意水面网格与海底模型的“穿插”。因为水面顶点有正弦波当波形振幅太大而水下地形又很高时海底可能从波谷处露出水面看起来很假。解决方法是控制_WaveAmp不要超过水下地形到水面平均高度的最小距离或者给水下地形做一个稍微低于水面最低点的安全高度。这个坑我在测试时遇到很多次水面波谷和海底山尖同时出现的效果非常出戏。6. 性能优化不是所有水都值得烧GPU6.1 我控制Shader开销的几个土办法很多新手写水面第一个版本能跑就急着加效果最后发现电脑都烫手。其实简单水面的性能优化可以从几个方向控制先看纹理采样次数。刚才那段shader已经有颜色深度纹理、法线贴图A、法线贴图B、环境反射CubeMap一共四次采样。如果每帧每个像素都执行移动端压力不小。可以做的优化是把环境反射采样从每个像素都算改成低分辨率采样或者在部分低端设备上用单张法线贴图加快速滚动省一次采样。再看指令数。pow、exp、sqrt这类数学函数在GPU上开销比较高虽然单个不贵但shader里大量使用会拖慢填充率。比如菲涅尔公式可以不用pow用1 - saturate(dot(N,V))的线性近似效果差别不大但省了一次指令。我这里不是叫你什么都省到丑而是说在对效果影响小的地方做减法。最后是顶点数量。移动端的水面顶点数建议控制在2000以内毕竟水面上通常没有太多需要精细形变的物体。如果波形是低频大浪500个顶点都够。要是发现顶点锯齿严重先用噪声法线贴图弥补不要直接加网格分段。下面是不同方案的开销对比方便做决策方案顶点采样成本片元采样成本适用机型仅顶点正弦波 纯色低低低端手机、低配电脑顶点正弦波 单法线贴图中中大多移动设备、笔记本顶点正弦波 双法线贴图 反射中高中高端手机、桌面全屏后处理反射折射高高仅PC高端刚开始调shader的兄弟容易犯一个毛病在自己RTX显卡上把双法线贴图和反射拉满感觉很流畅结果拿到集显机器上直接卡成PPT。性能优化一定要提前定好目标机型再决定上多少特性。6.2 一个移动端可跑的参数参考我在捕鱼Demo里最后采用的是一套比较保守的参数兼顾效果和性能直接参考没问题水面网格50×50顶点半透明Plane接受方向光顶点正弦波Amp10.12Freq11.8Speed11.0Amp20.04Freq24.0Speed20.6法线贴图两张512分辨率的水纹法线图_NormalStrength0.35滚动速度0.06和0.08不采样CubeMap反射改用一个纯色高光近似天空反光透明度0.85左右保证水下鱼可见。这套设定在骁龙和天玑中端芯片上能稳定60帧画质不算顶级但作为捕鱼题材的展示绰绰有余。如果你做的是PC端独立游戏可以再打开CubeMap反射视觉提升相当明显。7. 调试水面shader时的几个实用土技巧最后一个部分分享一些我调这个shader时用到的实战技巧。每一个都是踩过一次坑后才总结出来的对刚接触Unity3D shader的朋友应该很有帮助。第一个技巧是学会使用_Time和自定义速度值调试。看到水不动先别急着怀疑shader代码而要在材质面板上手动调高_WaveSpeed如果还是完全静止大概率是_Time.y没有传入顶点/片元函数或者时间变量写错了。_Time是Unity内置的时间变量格式为float4(t/20, t, t*2, t*3)最常用的是_Time.y表示以秒为单位的时间。一定要看清楚。第二个技巧是用法线可视化调试。法线贴图或者计算后的法线是否正确可以直接在片元着色器临时返回fixed4(normal * 0.5 0.5, 1.0)用颜色去观察法线方向。如果法线正确画面应该呈现蓝紫色为主且波纹的方向清晰可见如果出现大块红色、绿色说明某一路法线方向错了。这个技巧简单粗暴比一遍遍猜光照问题高效得多。第三个技巧是区分水面在动和水面看起来在动。如果顶点正弦波和法线滚动方向、速度不一致视觉效果会非常奇怪要么像在抖动要么像两块不同频率的布料叠在一起。解决办法是让法线滚动的主方向和顶点波动的传播方向尽量区分开比如主波方向朝东北法线滚动朝东西方向会产生自然的涟漪交叉感。如果两者完全同向水波会失去层次像一块平移的贴图。第四个技巧是渲染状态检查。我发现很多水面shader出问题不是算法错了而是ShaderLab的Tag和Blend写错了。比如忘了写Blend SrcAlpha OneMinusSrcAlpha透明效果根本出不来ZWrite Off漏了水下物体被水面遮挡一片都看不见。遇到这种莫名其妙的视觉bug先回去检查渲染状态比在片元算法里翻来翻去有效十倍。第五个技巧是利用Unity的Frame Debugger查Pass顺序。Window Analysis Frame Debugger可以查看每一帧的渲染顺序确认水面的Pass是不是在自己预期的队列位置。如果水面Pass居然出现在不透明物体之前那说明Queue标签没写对或者是SubShader前有空格/大小写错误导致标签失效。这个问题很奇怪有时编译器不报错但实际渲染顺序就是不对。老实说这个水面shader前前后后我改了三版才真正达到简单、好看、够用的目标。第一版全是顶点正弦波颜色单薄第二版加了双法线贴图波光很漂亮但透明度控制不好第三版加了深度纹理和菲涅尔混合才终于有了那种从高处看湖泊、从侧面看镜子一样的立体感。如果你也在做Unity3D里的水面效果建议不要直接复制一大段代码就完事而是把我的需求清单拿过来一行行理解再按自己的场景调整参数。这样调出来的水面才不会千篇一律也会真正变成你自己的东西。