Unity卡通风格水着色器实战:从波浪造型到卡通高光的完整拆解

发布时间:2026/9/7 15:56:12
Unity卡通风格水着色器实战:从波浪造型到卡通高光的完整拆解 大家做Unity项目时只要遇到风格化场景水往往是绕不开的一道坎。写实水方案一大堆各种SSR、反射探针、法线贴图堆上去效果确实唬人。可一旦换成卡通渲染你会发现之前那套东西基本全部失效——卡通水对“形状感”和“颜色归纳”的要求远高于物理真实性。这篇文章就从一个实际项目里的高级着色器入手完整拆解在Unity里实现一套卡通风格水着色器的思路、代码、以及调试中踩过的坑。整个过程基于ShaderLab手写Vertex/Fragment Shader不依赖第三方资产适合已经能写基础Shader、想在风格化方向上深入一步的朋友参考。1. 内容整体设计与思路拆解1.1 卡通风格水和写实水的本质差异写实水追求的是“物理正确”入射光、折射、菲涅尔、体积散射、焦散……全都按真实规律来。卡通水恰恰相反它追求的是“图形归纳”和“信息控制”——把水的视觉特征抽象成有限的几层颜色把反射高光压成边缘清晰的色块把波浪漫画化成规则起伏。看似简单实际上要做的取舍非常多。我在刚开始做卡通水时犯过一个大忌对着写实水的参数表挨个调结果调出一锅四不像。后来才明白卡通水的核心逻辑不是“材质参数多”而是“视觉分层清晰”。通常来说一套能出片的卡通水至少需要这样几层基础水体颜色层受水深影响从浅蓝渐变到深蓝中间做过度的颜色插值。波浪造型层顶点位置要有明显可辨识的波动而不是只有法线扰动。卡通高光层高光必须是硬边缘或带柔和锯齿的“漫画式色块”不能是高斯模糊状的圆形光斑。边缘泡沫层水与物体、海岸线接触的地方要有白色泡沫但泡沫边缘同样要风格化不能是半透明雾状。这四个层次形成从底到上的叠加关系最终呈现出类似动漫原画里“一片水”的观感。1.2 技术选型为什么不用表面着色器Surface ShaderUnity有三种常见的Shader写法表面着色器、顶点/片元着色器、程序化着色器Compute Shader或Shader Graph。对于卡通水我更推荐直接手写_Vertex/Fragment Shader_。表面着色器是Unity封装好的光照模型它对卡通渲染来说有好几个麻烦一是光照模型被写死想做边缘硬高光得写自定义光照函数反而绕二是表面着色器对应的是物理光照管线直接在内部做深度纹理采样和透明度排序时会比较别扭三是它生成的代码量很大在移动平台性能控制上不够透明。Shader Graph当然也能做节点图拉一拉很容易出效果。但有一个隐性问题卡通水非常依赖“边缘深度检测”和“顶点波浪动画”这两个环节在节点图里实现前者需要大量的Depth节点和数学运算图会变得极难维护。后者虽然可以用Position节点改顶点但想叠加多层正弦波并控制不同频率相位时节点图的复杂度和手写HLSL完全不在一个量级。站在长期维护和灵活定制的角度Vertex/Fragment Shader是这几个方向里最直接、最少黑盒的选择。你能清楚看到每一个插值器如何传递每一个临时变量如何计算这在调试卡通水这类视觉特化很强的效果时会省下大量猜测时间。1.3 整体方案的分层架构我的实现方案分成四个阶段对应前面提到的四个视觉层在顶点阶段用多层正弦波叠加驱动网格顶点位置产生“卡通感”波浪造型。在片元阶段用场景深度纹理计算当前水面像素下方的水深对深浅两色做插值形成水的厚度感。用自定义的“卡通高光”函数基于视角方向和法线方向计算一个边缘分明的亮斑。用菲涅尔和深度渐变共同约束泡沫范围在靠近岸边和物体边缘的地方叠加白色泡沫。这四个阶段并不是完全独立的比如泡沫会同时受到深度和视角的影响高光又会在波浪顶点处因为法线变化而呈现出“沿着波峰散开”的效果。整体上它们共同构成一个相互制约、但又各司其职的着色流程。2. 核心细节解析与实操要点2.1 顶点波浪动画的核心算法卡通水的波浪绝不能是细微的贴图扰动而应该是肉眼能看出来的“大块起伏”。最常用的实现方式是叠加多个不同周期、不同方向的正弦波让水面看起来既有规律又不单调。我在项目中使用了三个正弦波叠加float3 waveOffset 0.0; waveOffset.y sin(position.x * _WaveA_Freq _Time.y * _WaveA_Speed) * _WaveA_Height; waveOffset.y sin(position.z * _WaveB_Freq _Time.y * _WaveB_Speed) * _WaveB_Height; waveOffset.y sin((position.x position.z) * _WaveC_Freq _Time.y * _WaveC_Speed) * _WaveC_Height;这里的核心参数有三个频率控制波长、速度控制水面流动感、高度控制起伏幅度。三个波的频率不要是整数倍关系否则会形成非常机械的重复样式。我常用的组合是主波频率低、幅度大负责水面整体起伏副波频率中高、幅度小负责表层碎波第三个波沿斜向传播用来打破前两个波的正交感。顶点规划的另一个关键是_波浪方向必须同步影响法线_。很多初学者只改了position却忽略了法线结果水面颜色和光照看起来完全没有任何起伏。这里可以先在顶点阶段通过解析方法计算波浪的梯度再重建法线。比如对于函数 y A * sin(x * f t)切线的斜率是 A * f * cos(x * f t)法线方向可以通过切线叉乘副切线得到。实际代码会比较繁琐也可以直接用浮点高度的相邻差值估算梯度。为了简单稳定我在项目中直接使用了一种变通方案正常计算扰动后的顶点坐标后用DDX/DDY在片元阶段求法线。这样代码更短而且不用做复杂的解析求导效果也不错。不过要注意DDX/DDY求出的法线是逐像素的会在三角形边缘产生一定的插值伪影如果波浪频率太高画面可能闪烁。所以在移动平台上我会把第三个波去掉只保留前两个波然后靠法线扰动贴图增强细节。2.2 深度纹理与水颜色分层卡通水的“深浅渐变”依赖场景深度和当前水面的深度差。Unity中将主相机的深度纹理模式设置为DepthTextureMode.Depth后可以在Shader中通过_CameraDepthTexture拿到场景深度。在片元着色器中我们通常这样取水深float rawDepth SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)); float sceneDepth LinearEyeDepth(rawDepth); float waterDepth sceneDepth - i.screenPos.w;LinearEyeDepth把深度缓冲中的非线性值转换成线性距离然后减去当前像素在裁剪空间中的w值得到的就是水面到场景底部物体的“真实距离”。这个waterDepth就是颜色分层的依据。我做颜色渐变时没有直接用线性插值而是用了smoothstep和一个深浅水交界阈值float depthFactor saturate(waterDepth / _DepthRange); float3 waterColor lerp(_ShallowColor, _DeepColor, smoothstep(0.3, 0.7, depthFactor));为什么不用简单的三步硬切因为卡通水虽然要色块分明但水体内部如果完全是阶梯状断层会显得非常“脏”。用一个带中间过渡的smoothstep可以让浅水到深水有一个柔和的弧度同时不至于像写实水那样细腻渐变。_DepthRange这个参数控制的是“多深才算深水”在不同场景中需要根据水面实际大小调整。我通常会在1.5到3.0之间调节场景尺寸越大这个值越大。这里还要考虑透明排序问题。水的Queue要设置为Transparent关闭ZWrite否则后面的深度纹理采不到水面下面的物体。这个坑在第4部分会详细展开。2.3 卡通高光的几何构造卡通高光是整片水最具风格化的点。我推荐使用“半兰伯特硬裁切”的思路先计算NdotH然后映射到0到1范围再用step函数裁切成清晰的高光区域。但单纯硬切会让高光边缘像锯齿一样生硬我在高光外围加了半阴影过渡带用来模拟一点“手绘笔触”的感觉。float ndotH dot(normalize(i.normal), normalize(halfDir)); float highLight smoothstep(_HighLightThreshold, _HighLightThreshold _HighLightSoftness, ndotH);_Threshold控制高光面积_Softness控制高光边缘的虚化程度。卡通水高光通常需要较高的阈值比如0.75甚至0.85这样只会在一小片波峰区域形成亮斑。如果把Softness调得非常小比如0.02就能得到利落的漫画式高光调大一些则更像赛璐璐动画中带一点笔触边缘的效果。另外我还会对高光进行“不等比缩放”只在主波的方向上保留高光让高光变成细长条状。这个可以通过在计算ndotH之前把法线的斜率投影到指定的波浪传播方向上来实现。更简单的做法是直接用噪声纹理对高光做mask让高光看起来更像被手绘打出来的碎块。2.4 边缘泡沫与菲涅尔结合泡沫层的核心是“告诉观众水面的边界在哪”。我会结合两种信号一是地理边界水深接近0的区域二是透视边界视线与水面夹角很小的区域也就是菲涅尔效应。两者结合后泡沫才会在岸边、礁石、湖心岛边缘都自然出现。float foamDepth 1.0 - saturate(waterDepth / _FoamWidth); float fresnel pow(1.0 - saturate(dot(normalize(viewDir), i.normal)), _FresnelPower); float foam saturate(foamDepth * 0.7 fresnel * 0.6); foam smoothstep(_FoamCutoff, _FoamCutoff 0.1, foam);这里的关键是_FoamWidth不能太大否则泡沫会蔓延到整个水面中心。我通常设置为0.3到0.8之间。而_FresnelPower则影响视线方向上的泡沫范围值越大泡沫越只在几乎贴在水面上时才出现。还需要把泡沫本身的纹理用UV滚动起来让它有流动感否则就会像个白边一样死板。float2 foamUV i.uv * _FoamScale; foamUV.x _Time.y * _FoamFlowSpeed; float foamNoise tex2D(_FoamTex, foamUV).r; foam * foamNoise;泡沫纹理建议用一张低分辨率噪点图甚至用古早的Perlin Noise图也行。关键是噪点的明暗对比要强让泡沫出现破碎感而不是一整条白带。3. 实操过程与核心环节实现3.1 Unity场景与着色器文件准备先准备好场景。我通常一个平面作为水面下方放一个圆柱体或者一个地形网格来挡住水深测试。地面不要是纯平面否则深度差太小看不出深浅变化。然后新建一个Unlit Shader或Standard Surface Shader的模板重命名为“Custom/CartoonWater”。注意Shader名称前面的层级路径要用“/”分隔。然后新建Material把Shader赋给它。如果你使用的是内置渲染管线记得把Material的Surface Type设置为Transparent、Blending Mode设置为Alpha或Alpha Blend。如果用URP或HDRP需要额外把Shader脚本匹配对应的Pass这里先以内置管线为例。场景中一定要有个相机把相机的Clear Flags设置为Solid Color或Skybox都可但别忘了开启深度纹理Camera.main.depthTextureMode DepthTextureMode.Depth;我建议直接把这句话放在一个独立的初始化脚本里或者放Start方法中避免每次切换相机后发现水的深度效果失效。不开启这个模式的话_CameraDepthTexture会出现采样结果为黑色或全为1.0的问题。3.2 完整Shader代码与参数详解下面给出我整理后的核心Shader代码它包含了上述所有要点并且已经在Unity 2021/2022的内置渲染管线中验证过。代码省略了阴影和光照处理聚焦在水本身。Shader Custom/CartoonWater { Properties { _ShallowColor (浅水颜色, Color) (0.2, 0.6, 0.8, 1) _DeepColor (深水颜色, Color) (0.0, 0.25, 0.5, 1) _DepthRange (水深渐变范围, Range(0.5, 10)) 2.0 _WaveA_Height (主波高度, Range(0.0, 1.0)) 0.35 _WaveA_Freq (主波频率, Range(0.5, 10)) 2.4 _WaveA_Speed (主波速度, Range(0.0, 5)) 1.2 _WaveB_Height (副波高度, Range(0.0, 0.5)) 0.1 _WaveB_Freq (副波频率, Range(0.5, 15)) 6.0 _WaveB_Speed (副波速度, Range(0.0, 5)) 2.0 _HighLightThreshold (高光阈值, Range(0.0, 1)) 0.8 _HighLightSoftness (高光柔边值, Range(0.0, 0.3)) 0.05 _HighLightIntensity (高光强度, Range(0.0, 2)) 1.2 _FoamColor (泡沫颜色, Color) (1, 1, 1, 1) _FoamWidth (泡沫宽度, Range(0.0, 2)) 0.5 _FoamCutoff (泡沫裁切, Range(0.0, 1)) 0.4 _FresnelPower (菲涅尔强度, Range(0.1, 6)) 1.5 _FoamScale (泡沫纹理缩放, Range(0.5, 5)) 2.0 _FoamFlowSpeed (泡沫流速, Range(0.0, 2)) 0.5 _FoamTex (泡沫噪点图, 2D) white {} } SubShader { Tags { RenderTypeTransparent QueueTransparent IgnoreProjectorTrue } Pass { ZWrite Off Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float4 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float4 screenPos : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; float2 uv : TEXCOORD3; }; sampler2D _CameraDepthTexture; sampler2D _FoamTex; float4 _ShallowColor; float4 _DeepColor; float4 _FoamColor; float _DepthRange; float _WaveA_Height, _WaveA_Freq, _WaveA_Speed; float _WaveB_Height, _WaveB_Freq, _WaveB_Speed; float _HighLightThreshold, _HighLightSoftness, _HighLightIntensity; float _FoamWidth, _FoamCutoff, _FresnelPower; float _FoamScale, _FoamFlowSpeed; v2f vert (appdata v) { v2f o; float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float3 pos worldPos; float waveA sin(pos.x * _WaveA_Freq _Time.y * _WaveA_Speed) * _WaveA_Height; waveA sin(pos.z * _WaveB_Freq _Time.y * _WaveB_Speed) * _WaveB_Height; waveA sin((pos.x pos.z) * (_WaveA_Freq * 1.7) _Time.y * (_WaveA_Speed * 1.3)) * (_WaveA_Height * 0.3); pos.y waveA; // 再把顶点移回对象空间 float3 localPos mul(unity_WorldToObject, float4(pos, 1.0)).xyz; v.vertex.xyz localPos; o.pos UnityObjectToClipPos(v.vertex); o.screenPos ComputeScreenPos(o.pos); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos pos; o.uv v.uv.xy; return o; } fixed4 frag (v2f i) : SV_Target { // 波浪法线估算用屏幕邻域高度的差值近似法线 float2 eps float2(0.01, 0.0); float3 posP i.worldPos; // 这里没有真正的邻域点只能用原来的法线做基再用噪声扰动模拟表面法线见下段代码 // 正常做法用噪声或贴图扰动法线 float3 worldNormal normalize(i.worldNormal); // 如果你希望波浪影响高光位置可以在这里重建法线本文用简化方式 float ndotV saturate(dot(worldNormal, normalize(_WorldSpaceCameraPos - i.worldPos))); // 深度 float rawDepth SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)); float sceneDepth LinearEyeDepth(rawDepth); float waterDepth sceneDepth - i.screenPos.w; // 深浅水颜色 float depthFactor saturate(waterDepth / _DepthRange); float3 waterColor lerp(_ShallowColor.rgb, _DeepColor.rgb, smoothstep(0.3, 0.7, depthFactor)); // 卡通高光 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float3 halfDir normalize(lightDir normalize(_WorldSpaceCameraPos - i.worldPos)); float ndotH saturate(dot(worldNormal, halfDir)); float highLight smoothstep(_HighLightThreshold, _HighLightThreshold _HighLightSoftness, ndotH); highLight * _HighLightIntensity; // 泡沫 float foamDepth 1.0 - saturate(waterDepth / _FoamWidth); float fresnel pow(1.0 - ndotV, _FresnelPower); float foam saturate(foamDepth * 0.7 fresnel * 0.6); foam smoothstep(_FoamCutoff, _FoamCutoff 0.1, foam); float2 foamUV i.uv * _FoamScale; foamUV.x _Time.y * _FoamFlowSpeed; foamUV.y - _Time.y * _FoamFlowSpeed * 0.7; float foamNoise tex2D(_FoamTex, foamUV).r; foam * foamNoise; float3 finalColor waterColor highLight * halfDir.y * _ShallowColor.rgb; finalColor lerp(finalColor, _FoamColor.rgb, foam); float alpha 0.85 foam * 0.15; return fixed4(finalColor, alpha); } ENDCG } } FallBack Off }注意上面的顶点波浪法线部分被简化了。如果你希望在片元阶段真正重建法线可以使用ddx/ddy下面这段代码可以作为真实项目中的替代方案float3 reconstructedNormal normalize(cross(ddx(i.worldPos), ddy(i.worldPos)));但ddx/ddy不能在顶点阶段使用必须在片元阶段。直接将这个法线赋值给世界法线就可以精确反映波浪的形状。不过它在交叠三角形边缘可能出现接缝必要时可以增加网格细分或减少波幅。3.3 参数调整与真实场景测试这套Shader虽然参数多但核心调节思路是“先水色后波动再高光最后泡沫”。我实际操作时会先在Unity中把平面放大到一个比较大的面积然后按下面顺序调浅水和深水颜色先锁死这两色确定整体色调印象。浅水建议偏蓝绿深水偏深蓝紫对比度不要太高否则整个水池会被颜色切得特别突兀。主波和副波把WaveA调到0.3左右WaveB调到0.05左右先看波峰形状。如果频率太快导致闪烁降低频率或者增加网格细分。高光阈值高光阈值一开始设0.75然后看波峰亮度位置。如果高光面积太大像爆闪就调高到0.85以上。软边值保持0.05等全部调完再美化。泡沫宽度和裁切泡沫宽度先设0.5看离岸边的泡沫范围是否自然。如果泡沫只在极近处有就把_FoamWidth增大如果泡沫太线性就减小_FoamCutoff让泡沫更碎。最终测试时建议把相机的Far Clip Plane调远一些否则深度纹理在远处会严重丢失精度浅水深水分层会被一整片深色吃掉。4. 常见问题与排查技巧实录4.1 深度纹理全黑或毫无作用这是使用深度纹理做水效果时最经典的问题。常见原因有相机没有开启depthTextureMode导致_CameraDepthTexture一直是无效数据。水Shader的Pass开启了ZWrite Off但同时也把相机DepthTextureMode给禁了。有些自定义的相机后处理脚本可能会覆盖深度纹理模式检查一下项目中是否有其他脚本在LateUpdate里改了depthTextureMode。使用了URP渲染管线后还需要在Universal Render Pipeline Asset里勾选Depth Texture选项否则深度纹理不会生成。排查技巧可以在Shader里临时输出深度纹理的值比如return rawDepth或者return sceneDepth看画面是灰阶变化还是全黑。如果全黑说明采样或者生成环节有问题不用继续检查其他代码。4.2 泡沫出现大片闪烁泡沫闪烁通常由两个原因导致一是深度纹理分辨率太低采样在远处会跳变二是泡沫噪声图太细且水体网格没有足够的细分导致逐像素噪声在低密度网格上起伏过大。我的处理方式很粗暴给泡沫区域加了一个软过渡把smoothstep的第二个参数从0.1改到0.2让泡沫边缘的宽度更宽闪烁就被压下去了。噪声纹理也用256分辨率而不是1024卡通风格本来就排斥过细的噪点。如果仍然闪烁可以在片元阶段对泡沫噪声做一次平滑滤波或者用UV的整数抖动让噪声呈像素块状反而更符合卡通风格。4.3 波浪顶点抖动导致边缘出现黑影当波浪高度比较大时水面边缘和场景物体接触处会出现频繁的面片交叉导致黑影或闪烁。这本质上是因为水面是独立网格和岸边物体没有做缝合。缓解办法是在靠近岸边的区域让波浪高度衰减到0。可以通过物体位置和场景边界距离计算一个衰减因子乘到波浪振幅上。最粗暴的做法是在Shader里传一个“岸边干扰距离”参数根据水面的局部坐标进行距离判断float edgeMask smoothstep(0, _ShoreFade, min(pos.x, pos.z)); waveA * edgeMask;这里的_ShoreFade表示离岸边多远的距离内波浪要衰减。不过如果是圆形水面需要改成径向距离计算。这个方法不完美但能在大多数场景下消除可感知的穿帮。4.4 高光呈点状分布而非条状很多人在调卡通水高光时发现高光永远是零星的点而不是原画中那种长条状光斑。原因在于网格的顶点密度不够导致每个波峰处的高光太小太碎。解决思路有两个一是在计算高光之前对法线做一定程度的平滑让相邻像素的法线差异变小二是在高光后处理中加入“膨胀”效果对高光纹理做一个模糊再叠加。我实际项目中直接在片元阶段对高光做了一次双通道模糊类似高斯模糊的思路只不过用硬阈值和软阈值交替做。最终效果就是高光从“碎点”变成“拉丝光斑”运动起来很有手绘感。4.5 水面透明排序混乱如果场景中有多个透明物体水很可能会被挡在错误的位置。确保水的Queue是Transparent并且让水的RenderQueue稍微靠后一些比如1990。同时建议关闭水的深度写入但要注意如果彻底关闭ZWrite水面后面的水内部层次比如多个水体交叠会出现混乱。这种情况下可以利用透明排序中的SeparatePass或者给内部水体增加一个单独的DepthPass用来写深度而颜色Pass不写深度。这是更进阶的用法普通单水体用不着。5. 个人经验总结与扩展建议做卡通水着色器这件事所有参数都是表象真正考验的是“视觉分层”的思维。如果在一开始就想着把水面调得像照片一样丰富最后往往会丢掉风格反过来死死咬住颜色、高光、泡沫三个层次反而能让画面变得非常整。我在实际项目里还发现一些很实用的扩展方向这里顺带分享给水增加法线贴图后不要把法线强度调大否则卡通边缘会立刻“塑料化”。保持0.2到0.3的强度只要让人感受到细致的波光即可。泡沫纹理可以用脚本动态生成一张扰动图这样不同水域的泡沫形态完全不一样避免所有水面看起来都是同一张贴图。如果想进一步风格化可以给水面叠一层“漫画速度线”——把高光区域沿运动方向拉长配合顶点波浪方向效果会非常惊艳。如果项目跑在移动端不建议开ddx/ddy求法线而应该在顶点阶段手工计算波浪的法线渐变。移动端的ddx/ddy有时会带来额外开销和抖动。我目前这套卡通水Shader已经在一款休闲游戏的多个关卡里跑了几个月除了偶尔需要根据场景光照微调参数外基本没有出现大的渲染问题。对于想继续深入的人我建议下一步可以尝试把水的边缘泡沫和场景中的物理碰撞体结合在一起做动态溅射效果那会是完全不同的挑战。但万变不离其宗先把这篇文章里这些基础层吃透再往上加特效就会顺手得多。