
1. 项目背景与整体思路想做卡通风格水着色器这件事很多Unity开发者会下意识觉得不就是把水的颜色改成蓝的嘛真上手之后才发现完全不是这么回事。写实水渲染追求的是物理正确的反射折射、次表面散射、光线吸收衰减而卡通水恰恰相反它要做的是提炼——把水的形态特征抽象成几个高度风格化的视觉元素干净分层的颜色、节奏感明确的波浪、边界清晰的泡沫、硬边高光。这套逻辑本质上是平面设计思维在3D渲染里的延伸跟写实渲染是两套完全不同的话语体系。这篇文章里我会用Unity的Shader Graph为主线配合必要的HLSL代码补充完整拆解一个卡通风格水着色器的实现过程。内容覆盖颜色分层、波浪顶点动画、卡通化高光、岸边泡沫、深度交互这五个核心模块同时把我在实际项目中踩过的坑和排查思路一并写出来。适合已经能用Unity做基础场景、想往渲染方向深入的开发者阅读Shader Graph的节点操作会让你快速上手手写Shader的朋友也能从代码片段里找到参考。先说清楚一个观念问题卡通水的核心不是节点连得多复杂而是每一个视觉元素都有明确的形状边界。写实水可以靠噪声纹理把颜色糊在一起因为物理上水就是连续介质但卡通水一旦糊了风格感就没了。所以整个实现里我会反复用step函数和smoothstep的sharpness参数来压边界这是贯穿所有模块的核心手法。2. 核心渲染模块拆解2.1 深水与浅水的颜色过渡逻辑卡通水的第一层视觉是颜色。跟写实水用深度纹理做连续衰减不同卡通水通常会把水面区域划分为浅水色和深水色两个色块之间用一条清晰的过渡带分隔。这里的关键在于过渡带的宽度和位置由什么决定。我习惯用两个要素来控制一是水面网格高度与场景水体底部的高度差这对应水深二是相机视角与水面法线的夹角这对应菲涅尔效应。两者混合之后颜色过渡会同时具备地形深浅和视角变化两个维度的动态响应而不是一个死板的平面渐变。Shader Graph里实现的话用Scene Depth Difference节点算出水深经过smoothstep做区间映射输出作为两个颜色之间的Lerp系数。smoothstep的两个边界值就是风格化的控制点浅水区的深度上限和深水区的最小深度。我把边界值的差压得越小颜色过渡越锐利风格感越强反之过渡越柔和。这个参数建议直接暴露到材质属性面板上方便美术在项目里反复调。注意Scene Depth采样依赖相机的深度纹理。如果在URP里没开DepthTexture这个节点拿到的值是0水的颜色会直接变成浅水色且毫无过渡。这个问题在后面排查章节会详细讲。2.2 波浪顶点动画的两种思路顶点波浪的经典方案是Gerstner波它能产生尖锐的波峰和宽缓的波谷适合海洋场景。但Gerstner波有个特点波峰处顶点会横向挤压产生水面的卷曲感这在写实海里很漂亮在卡通风格里却容易显得乱。卡通水面我反而推荐用多层正弦波叠加或者退一步用Simple Noise配合时间偏移来做顶点位移。原因是卡通水的波浪要的是韵律感——波浪应该像动画帧一样清晰可读而不是一串喧宾夺主的随机凹凸。实际项目里我常用的参数组合是两层正弦波一层频率低、振幅大负责水面整体起伏另一层频率高、振幅小负责表面细碎波动。两层的方向可以取不同值产生近似十字交叉的涟漪效果。顶点位移在Shader Graph里的实现路径是Position节点 →Add→Vertex Position输入。注意要在Vertex分支里去改坐标而不是在Fragment分支。很多初学者会把波形计算放在片元阶段结果波浪只影响了颜色和法线网格本身完全没有形变水面看起来像画上去的而不是动起来的。如果要写HLSL核心代码就几行float waveHeight sin(positionWS.x * _WaveA freq _Time.y * _WaveSpeed) * _WaveAmpA cos(positionWS.z * _WaveB freq _Time.y * _WaveSpeed) * _WaveAmpB; positionWS.y waveHeight;这里有个经验值波浪振幅不要超过水面网格尺寸的1/10否则网格拉伸严重法线会崩在高光区域会出现肉眼可见的撕裂感。2.3 卡通高光的硬边处理卡通风格的高光跟写实最大的区别是形状归整。写实高光用Blinn-Phong或GGX高光点有一个平滑的能量衰减过渡卡通高光要的是把这块衰减用step压成硬边有时还会故意做成几何形——比如一条带状高光或者一个圆角菱形。最省事的做法是在半程向量和法线点乘的结果上做step点乘大于阈值的区域显示高光小于阈值的区域完全不显示。阈值这一步就把原本连续的高光范围切成了边界分明的两段。但直接step会带来严重的锯齿尤其是低分辨率下高光边缘会闪。我一般会在step之前先做一次smoothstep保留一个很小的过渡带过渡带宽度给0.02到0.05。这个值肉眼几乎看不出渐变感但能消除大部分锯齿。如果项目对锯齿极度敏感可以考虑在高光边缘用fwidth做抗锯齿修正float ndh dot(normalWS, halfDir); float specMask smoothstep(_SpecThreshold - fwidth(ndh) * 0.5, _SpecThreshold fwidth(ndh) * 0.5, ndh);这一招在移动端很实用比开MSAA便宜得多。另外卡通水高光还有个常见需求是高光跟随视角移动但形状不变。这要靠把点乘结果做归一化处理或者直接用法线扰动后的固定方向代替半程向量。具体做法是在高光计算里只采样固定的反射方向而不是每一帧都实时计算视角向量。这个小技巧能让高光在水面上移动更稳风格上更接近三渲二的效果。2.4 菲涅尔边缘与岸边泡沫卡通水的边缘通常会用一条比较亮或者比较白的描边来强化轮廓这跟日系赛璐璐角色描边的思路一脉相承。物理上光线在大角度入射时反射增强这个现象在卡通渲染里被抽象成了一根明确的边缘光带。实现上还是菲涅尔公式只是把连续的衰减曲线改成硬边界。标准的Fresnel节点输出0到1的连续值我先用Power节点把曲线集中到边缘区域再用smoothstep压出清晰的光带。光带的宽度和位置各自暴露参数边缘色一般用浅白色或浅蓝色跟深水色形成强烈对比。岸边泡沫的实现思路有两种实际项目中我会叠加使用第一基于水面高度和场景深度差做泡沫带。深度差小的区域岸边浅水区浮现泡沫深度差大的区域没有泡沫。用smoothstep在深度上做一个窗口区间得到岸边泡沫的分布掩码。第二在这个掩码上叠加噪声扰动让泡沫边缘变得不规则因为完全光滑的泡沫线看起来很假像AI生成的示意图。泡沫本身的颜色一般是纯白带一点不透明度配合材质透明度做半透叠加。这里有一个要点泡沫区域的颜色混合模式需要在Blend One One和Blend SrcAlpha OneMinusSrcAlpha之间选择。前者是加法混合适合发光泡沫后者是传统透明度混合适合实心泡沫。卡通风格推荐后者因为泡沫需要遮挡底部的水色才能有浮在水面上的层次感。3. 实操搭建卡通水Shader Graph材质3.1 基础设置与节点图结构在动手连节点之前先把Shader Graph配置好。我建议用URP的Lit Shader Graph作为起点但要做两个关键修改Surface Type改成TransparentRender Face改成Both。前者让水面支持透明度混合后者保证你在水面下方看的时候也能看到颜色而不是一片黑。如果项目不需要水下视角Render Face保持Front就行性能会稍好。URP里的Lit Shader默认会套用一堆基于物理的光照计算这在卡通水里面反而是干扰。我自己更常用Unlit模板然后手动接光照参数这看起来工作量大了实际上可控性高得多。Unlit模板加一个Sample Cubemap节点或者手动画一个Blinn-Phong高光也就三四个节点的事但最终的风格化空间是物理渲染给不了的。节点图画出来大概是这个结构左边入口是Vertex阶段的Position和Normal中间走波浪和法线扰动右边出口是Base Color、Alpha、Metallic设为0、Smoothness设为0.6到0.8。法线部分我会用一个Tiling And Offset节点配合Noise Sampler生成扰动法线贴到Normal通道上。卡通风格的法线扰动不要用太细密的高频噪声否则高光会碎建议噪声Tiling控制在2到4之间频率低一点。3.2 颜色、波浪、高光与泡沫的节点组合颜色分层部分我建议这样连Scene Depth节点 →Difference减去物体自身深度→Divide除以一个可调参数把深度值映射到0到1区间→SmoothStep得到浅深水混合系数→Lerp连接浅水色和深水色。这串节点基本是淡水体的标配。波浪要放在Vertex阶段与颜色通道互不干扰。用一个Add节点把两个方向的正弦波叠加结果接到Vertex Position的Y轴分量上。注意这里要修改的是Object Space还是World Space坐标我推荐直接在Object Space下操作这样水面网格可以随便挪位置波浪相位保持一致。法线扰动放在Fragment阶段在Normal Vector输出前用一个Normal From Height节点配合噪声贴图生成。做这一步是为了让高光看起来像碎在水面上而不是一整块平面反射。高光结构简单View Direction与Normal Vector在Dot Product里算反射强度再用SmoothStep压边界。如果你想要高光跟随波浪移动就把法线扰动后的向量接进去想要稳定的图层感高光就用不扰动的法线。泡沫节点组是最后拼图Scene Depth Difference再次被取出来用SmoothStep生成窗口掩码乘以Noise Sampler得到的噪波值结果作为泡沫颜色和透明度的权重最后跟前面算好的水体颜色做Lerp。这里泡沫的透明度我习惯不完全压到1保留大概0.85到0.95这样泡沫边缘跟水色之间会有一点透光视觉上更自然。3.3 法线扰动与URP渲染管线的适配细节URP里如果你选了Lit作为Shader Graph的Master Stack它默认已经帮你处理了雾、阴影、光照贴图这些。但卡通水一般不进阴影所以在Surface Options里把Cast Shadows关掉同时把Receive Shadows也关掉。否则水面上会出现树的阴影投射观感上会立刻从卡通滑向半写实很破坏整体风格。用的是Unlit模板的话这些默认都不存在你需要自己处理跟URP的协调。推荐在URP里把水面的Renderer Priority设置得比较靠后确保水面画在所有不透明物体之后。不然水面和地形互相穿插时半透明物体的深度写入会造成很奇怪的穿模。特别是在有几层瀑布、溪流的场景里水面绘制顺序错了泡沫会莫名其妙地浮到石头上面。另外要注意URP版本差异。URP 12Unity 2021以后的Shader Graph里Scene Depth节点的输出值分辨率跟相机深度纹理一致如果你的相机开了Opaque Texture但没有开Depth Texture采样的深度是不准确的。我在项目里会同时把两个选项都打开反正URP里这两个开关基本不占额外性能还能避免后面调试时被这种低级配置问题坑半天。3.4 用HLSL补充Shader Graph做不到的部分Shader Graph覆盖90%需求没问题但有几个硬伤一是Custom Function节点频繁使用容易让节点图变得混乱二是一些需要循环或分支的算法比如多层Gerstner波求和用节点连会非常痛苦。遇到这种场景我建议直接在Custom Function里写一段HLSL代码。下面是多层Gerstner波的HLSL片段可以直接当Custom Function用void GerstnerWaves_float(float3 worldPos, float time, float waveCount, float amplitude, float steepness, float wavelength, out float3 result) { float3 pos float3(0, 0, 0); for (int i 0; i waveCount; i) { float2 dir normalize(float2(sin(i * 1.3), cos(i * 0.7))); float speed sqrt(9.8 / wavelength) * 2.0; float freq 2.0 * 3.14159 / wavelength; float phase dot(dir, worldPos.xz) * freq time * speed; float displacement amplitude * sin(phase); pos.x dir.x * displacement * steepness; pos.z dir.y * displacement * steepness; pos.y amplitude * cos(phase); } result pos; }这里要注意Gerstner波参数steepness这个值不要超过1.0否则波的尖峰会过度俯冲下来产生自交。amplitude和wavelength的比值建议控制在1/20到1/40之间跟之前说的振幅不超过网格尺寸1/10类似这是经验上保持网格稳定性的安全区。另外一个需要HLSL介入的场景是泡沫的交互响应——比如角色踩入水面时脚边的泡沫要溅开。这个如果用Shader Graph连要传递位置、比较距离、衰减散成一堆节点之后完全没法读。用HLSL写一个基于世界空间的球形判定就简单了每帧传入角色位置和一个半径参数让泡沫权重在一个半径范围内逐渐衰减到0。4. 常见问题与排查技巧4.1 水面透明排序与渲染队列异常最常见的问题大概是水面跟地形穿插时出现奇怪的遮挡。排查思路第一步是看Surface Type和Render Queue。Unity默认的Transparent队列是3000比Geometry2000靠后正常应该后画。但如果的水面材质里混用了别的Shader比如某些粒子Shader改的渲染队列就可能变成2000甚至2500导致水面先画、地形后画结果地形把水面盖住了。解决方法是手动指定渲染队列在URP的Shader Graph里可以直接在Surface Options里设置Render Queue为Transparent或者通过代码在运行时给材质设置renderQueue (int)UnityEngine.Rendering.RenderQueue.Transparent。如果需要几层瀑布叠在一起还要进一步细分——比如把最上层的瀑布设成3000中间的水潭设成3001泡沫设成3002。同队列内部Unity会按距离再排序但半透明物体之间没法保证顺序所以Renderer Priority要手动调。还有一个跟透明排序强相关的问题是自遮挡:水面上叠加的泡沫层跟水底完全处在同一平面时因为深度精度不足会出现闪烁。解决办法很简单——泡沫片元的深度值拉偏一点点或者干脆让泡沫的渲染队列跟水错开一位。预算允许的情况下我推荐泡沫单独一个SubShader配合Offset指令做深度偏移SubShader { Tags { QueueTransparent1 } Offset -1, -1 // ... }这样泡沫永远画在水面之上不会有闪面问题。4.2 深度纹理未开启导致泡沫失效前面提过Scene Depth依赖深度纹理。实际排查泡沫不显示的问题时我几乎每次都能在项目设置里找到原因——URP Asset的Depth Texture选项是关的。开启路径Project Settings → Graphics → URP Asset → Depth Texture勾选。这里有个容易忽略的点如果你在场景里用了多个URP Asset比如不同画质档位各一个每一个都要勾选运行时会按当前激活的那个Asset走。还有一种情况是深度纹理开着但泡沫还是不对。这时候要检查用的是Scene Depth还是Scene Color。一些教程里会把两个节点搞混Scene Color是相机看到的颜色缓冲不是深度缓冲采样出来是一片乱码。判断方法简单粗暴把泡沫权重直接设成1看颜色对不对再用Scene Depth节点做可视化看水面上呈现的是不是黑白深度灰阶图。如果灰阶图看起来正常那基本说明问题在后续的smoothstep参数上。4.3 不同渲染管线下的兼容性问题如果你的项目跑的是Built-in管线Shader Graph的节点导出成Shader后代码差异非常大。主要问题一个是API路径不一样(SystemInfo的宏定义不同)另一个是URP里有的关键字如_RECEIVE_SHADOWS_OFF在Built-in里不存在编译会报一系列未定义错误。我的处理方式是给项目统一用URP或者在写Shader时做一次宏判断#if defined(_RECEIVE_SHADOWS_OFF) // URP shadow handling #else // Built-in shadow handling #endif不过这属于下策因为不同管线的光照模型本身差异就很大。如果目标是同时兼容多管线建议Shader Graph里全部用Unlit模板把光照计算完全交给手动编码这样至少在跨管线上不会有奇怪的编译错误。移动端还有一个单独的坑OpenGL ES 3.0以下的设备不支持某些高级纹理格式比如BC7如果美术给的噪声贴图是BC7压缩在部分Android真机上会显示成黑块或者花屏。卡通水的噪声贴图通常很小我干脆不压缩用RGBA32格式换来的是绝对稳妥的兼容性。512x512的噪声贴图在内存里占1MB对移动游戏来说完全可以接受。4.4 移动端性能优化经验谈卡通水在移动端的性能瓶颈主要出在三个地方节点图规模、过度绘制和动态分支。先说节点图一个完整的卡通水Shader Graph大概40到60个节点这在桌面平台上连眼皮都不眨但在部分手机GPU上Shader编译时间可能直接翻倍运行时也会因为寄存器压力大而掉帧。优化思路是合并计算把两个smoothstep共用一组参数把泡沫的噪声采样和法线扰动采样合并到同一张贴图用RG通道存法线B通道存泡沫。这种技巧在贴图资源管理里叫通道打包在Shader里就能省掉一次采样器的开销。移动端采样器数量和金贵能省就省。过度绘制方面水面本身是一张覆盖大范围区域的网格半透明混合意味着每个像素至少要执行一次额外混合。如果你的水面网格是100x100的平面像素覆盖率又高建议把水面拆成几个小块只在可见范围内保留高精度密度。或者用LOD组远距离切换到一个没有泡沫、只有一个颜色和一层高光的简化版本。我在实际项目里就做过远距离无泡沫版本肉眼基本看不出区别能节省大概20%的GPU开销。动态分支比如在Shader里用if判断平台尽量少用GPU的uniform分支不便宜而且不同GPU的处理方式差异很大。宁可多写两个Pass或者用同一套代码配合关键字也不要让一件简单的事靠分支跳来跳去。5. 扩展方向从静态水到交互水体实现到这里已经有一个能看能用的卡通水了。但如果想让水面真正跟场景互动起来有两条延展路径值得尝试。第一条路径是角色与水面的交互。角色涉水时水面应该在接触点周围泛起涟漪和泡沫。这个不是靠Shader单方面能解决的需要C#脚本把碰撞点位置传给材质。做法是脚本在Update里检测角色的脚部位置如果低于水面高度就把位置写入一个全局Vector数组同时把时间戳也传进去Shader这边维护一个固定数量的波纹数组每帧加上基于距离的时间衰减函数在顶点位移里叠加一圈高度偏移在泡沫权重里叠加一圈泡沫溅射。这套方案在移动端也能跑关键在于控制波纹数量我一般控制在16个以内。第二条路径是改变波浪风格从平静水潭切换到汹涌海洋。核心变化是三处一是把正弦波换成多层Gerstner让波形有锋利的浪尖二是把颜色分层从两段改成三段增加一个浪花白中间层三是把高光的形状从圆点改成沿波峰方向的狭长形。这三处改动在Shader层面都是参数级别的调整不改架构但视觉输出是两滴水。如果想把参数暴露给非程序的同学做场景调节记得把所有关键数值水深阈值、泡沫宽度、波浪振幅、高光锐度等做成材质属性并起好规范命名的属性名比如_ShallowColor、_DeepColor、_FoamThreshold这些。这在团队协作里能省掉大量来回沟通的成本。动手试的时候建议从一个非常小的测试场景开始——一张平面网格一个方向光一台相机。把Shader Graph每加一个模块就运行一次反复调参数感受每个节点对最终画面的影响。这个流程看起来基础但恰恰是理解卡通水渲染最快的路径。我在最初学习这套方案的时候就是靠这种方式慢慢把节点图调成自己的肌肉记忆的之后再放进正式项目遇到问题心里基本就有数了。