1. 为什么一个“卡通水波”要专门写一篇长文?——从美术需求倒推技术选型
“卡通样式的水波效果”,这八个字在Unity项目里出现的频率,远比你想象中高得多。它不是出现在某个独立游戏Demo的角落,而是频繁出现在某高校数字媒体实验室的毕业设计、某儿童教育类App的交互反馈、某轻量级AR导览系统的水面标识,甚至某跨平台休闲游戏的UI动效中。我接触过不下二十个类似需求,绝大多数最初都只有一句话:“水面要像动画片里那样,有清晰的轮廓线、平滑的起伏、带点弹性感,但别太真实。”——这句话背后藏着三重矛盾:美术风格要简练,物理表现要可信,运行性能要扛住低端设备。很多人第一反应是去搜“Unity 水面Shader”,结果一头扎进Standard Shader的法线贴图+菲涅尔+折射通道里,调了三天发现边缘发灰、波纹糊成一片、手机上直接掉帧。问题不在Shader本身,而在于起点就错了:卡通水波不是“简化版真实水”,而是“用几何与颜色重新定义水的视觉语法”。它不模拟水分子运动,而是用几条可控的正弦曲线画出波峰波谷;它不计算光线折射路径,而是用预设的色阶带替代明暗过渡;它不依赖高精度法线贴图,而是靠顶点位移+轮廓描边双管齐下。关键词里没写“卡通渲染”“Toon Shading”“顶点动画”,但这些才是真正的底层支撑。我试过用URP内置的2D Sprite Shader硬改,结果发现UV偏移根本压不住波形跳跃;也试过直接套用社区里的“Cartoon Water”Asset Store插件,跑通后才发现它默认绑定Camera Depth Texture,在无深度渲染路径的UI Canvas里直接失效。最后落地的方案,是把整个效果拆成三个可独立调试的模块:基础波形生成器(数学层)、卡通化着色器(视觉层)、动态轮廓控制器(风格层)。每个模块都用Shader Graph可视化搭建,但核心逻辑必须手写Custom Function节点——因为Graph的内置节点在处理高频正弦叠加时会产生精度漂移,导致波纹在远处突然断裂。这不是炫技,而是当美术同学拿着iPad指着屏幕说“这里波浪断掉了,像被剪刀剪过”时,你唯一能快速定位并修复的路径。所以这篇内容不讲“怎么拖几个节点出来”,而是带你从一张空白Graph开始,亲手构建这个效果的每一根神经,包括那些官方文档里绝不会提的、在真机上让波纹从“有点像”变成“就是它”的临界参数。
2. 波形生成的核心:不是“加噪声”,而是“控制谐波相位”
所有失败的卡通水波,第一步就栽在波形生成上。美术给的参考图里,水波是几道干净、有弹性的弧线,而新手做的效果却是密密麻麻、毫无节奏的碎纹。根源在于混淆了“噪声”和“谐波”的用途。Perlin Noise适合做真实水面的混沌感,但卡通水需要的是可预测、可编辑的周期性起伏。我们真正需要的,是三组不同频率、不同振幅、不同相位偏移的正弦波叠加——就像钢琴同时按下Do、Mi、Sol三个键,合成的不是杂音,而是和谐的和弦。
2.1 三频谐波的数学结构与美术意义
在Shader Graph里,正弦波由Sine节点实现,但关键参数不是振幅或频率,而是相位(Phase)。一个基础正弦波公式是:y = A * sin(2π * f * x + φ),其中A是振幅,f是频率,φ是相位。美术同学说“波浪要有弹性”,其实是在要求φ随时间非线性变化。我们拆解三组波:
- 主波(Base Wave):频率0.8,振幅0.03,相位=Time*1.2。这是水波的骨架,决定整体起伏节奏。振幅0.03意味着顶点Y轴最大偏移3%单位长度,足够明显又不破坏模型轮廓。
- 次波(Detail Wave):频率3.5,振幅0.008,相位=Time*2.7+0.5。高频小波叠加在主波上,制造“水花跳动感”。相位偏移0.5确保它与主波错开,避免波峰完全重合导致线条僵硬。
- 微波(Micro Wave):频率12.0,振幅0.002,相位=Time*5.3+1.2。这是最容易被忽略的细节层,负责消除波形边缘的“锯齿感”。它的振幅极小,但在放大观察时,能让波峰过渡如手绘线条般柔顺。
提示:频率值不是随便定的。0.8/3.5/12.0构成近似1:4:15的比例关系,这是经过实测验证的“视觉舒适比”。若把次波频率改成4.0,波纹会显得机械;改成3.0,则与主波产生拍频干扰,远处看会出现游移的暗纹。
2.2 在Shader Graph中构建相位驱动链
Graph里没有“时间变量”节点,必须用Time节点配合Split提取Time.x(秒级时间)。但直接连到Sine的Phase端口会出问题:Time.x每秒增长1,而正弦函数周期是2π≈6.28,相位值过大导致浮点精度丢失。解决方案是归一化相位:用Fraction节点取小数部分,再乘以2π。具体链路:
Time → Split → Time.x → Multiply (×1.2) → Fraction → Multiply (×6.2832) → 主波Phase次波和微波同理,但乘数分别改为2.7和5.3。注意Fraction节点必须放在Multiply之后——如果先取小数再乘,高频波的相位变化会被截断,导致微波“卡顿”。
2.3 顶点位移的坐标系陷阱与修复
波形生成后,需应用到模型顶点。常见错误是直接用世界坐标X/Z计算正弦值,导致水体移动时波纹跟着平移。正确做法是使用对象空间(Object Space)坐标。在URP管线中,Position节点选择Object模式,再用Split提取Position.x和Position.z。但这里有个致命细节:卡通水体通常是平面(Plane),其顶点Y值初始为0,位移后需更新Y坐标。然而Position节点输出的是输入坐标,不是可修改的顶点位置。必须用Vertex Position节点(在Advanced选项中开启),再通过Transform Position转回对象空间。最终位移公式:
NewY = OriginalY + BaseWave + DetailWave + MicroWave其中OriginalY来自Vertex Position的Y分量。我踩过的坑是忘了在Vertex Position后接Transform Position,导致波纹在模型旋转后方向错乱——明明X轴波动,却在Z轴方向起伏。
3. 卡通着色的核心:抛弃“光照模型”,拥抱“色阶映射”
真实水面的明暗由Blinn-Phong光照模型计算,但卡通水波的明暗是美术指定的。它不需要镜面高光,只需要三段式色阶:波谷深蓝、波中浅蓝、波峰亮白。强行套用PBR流程,只会让颜色被环境光污染,失去手绘感。我们的策略是:用波形高度值(即位移后的Y值)作为索引,直接查表映射颜色。
3.1 高度到颜色的映射逻辑与美术协作
波形生成后,每个顶点有一个HeightOffset值(范围约-0.03~+0.03)。但直接把这个值当颜色索引会出问题:范围太小,映射到0~1区间后几乎全黑。必须先做归一化拉伸。我们定义三个关键高度点:
TroughLevel:波谷基准线,设为-0.025CrestLevel:波峰基准线,设为+0.025MidLevel:中间过渡点,设为0.0
然后构建分段线性映射:
- 当
HeightOffset ≤ TroughLevel:输出深蓝色(#0A2E5C) - 当
TroughLevel < HeightOffset < MidLevel:线性插值深蓝→浅蓝(#4A7EBB) - 当
MidLevel ≤ HeightOffset < CrestLevel:线性插值浅蓝→亮白(#FFFFFF) - 当
HeightOffset ≥ CrestLevel:输出亮白
这个逻辑不能用Graph的Lerp节点硬凑,因为四段判断会嵌套三层,导致编译失败。正确解法是用Custom Function写HLSL代码:
float3 GetCartoonColor(float height, float3 troughCol, float3 midCol, float3 crestCol) { float t = saturate((height - _TroughLevel) / (_CrestLevel - _TroughLevel)); if (t <= 0.33) { return lerp(troughCol, midCol, t * 3); } else if (t <= 0.66) { return lerp(midCol, crestCol, (t - 0.33) * 3); } else { return crestCol; } }在Graph中,Custom Function节点输入height、troughCol等参数,输出Color。_TroughLevel和_CrestLevel作为Property暴露,方便美术实时调整。
3.2 菲涅尔效应的卡通化改造
真实水面的菲涅尔效应(边缘变亮)在卡通风格里要简化。标准做法是用View Direction和Normal点积,但卡通水波的Normal是平面法线(0,1,0),点积结果恒为View.y,导致整个水面亮度随视角单调变化。这违背“固定风格”原则。我们的改造是:用波形高度的绝对值替代Normal。因为波峰处曲率大,天然该更亮。公式:
FresnelFactor = 1 - saturate(abs(HeightOffset) / _CrestLevel)再用Power节点提升对比度(指数1.8),最后与基础颜色混合。这样,波峰永远最亮,波谷永远最暗,不受相机角度影响。实测发现,当_CrestLevel从0.025调到0.03时,菲涅尔过渡带变窄,波峰“锐利感”增强,更接近赛璐璐动画效果。
3.3 轮廓线的双重保障机制
卡通水波的灵魂是轮廓线。但只靠后处理描边(Outline Pass)会出问题:当水体与其他物体重叠时,描边会把两者一起包住,破坏层次。必须在着色器内实现基于深度的局部描边。原理是:检测当前像素与邻近像素的深度差,超过阈值则绘制黑线。Graph中用Sample Texture 2D LOD采样深度纹理,但URP默认不输出深度到纹理。需在Renderer Feature中添加RenderObjects,设置Render Queue为After Rendering,Target Texture为_CameraDepthTexture。然后在Shader Graph中:
- 用
Screen Position节点获取屏幕坐标 Screen Position→Split→XY→Add(±0.005像素偏移)→Sample Texture 2D LOD(采样深度)- 两方向深度差取
Max,过阈值(0.05)则输出黑色
注意:这个深度采样必须在Fragment阶段,且
Sample Texture 2D LOD的LOD设为0。若设为Auto,移动端GPU会因mipmap缺失报错。
4. 动态控制层:让美术师真正掌控“水的性格”
技术实现只是基础,真正的难点是让非程序员的美术同事能直观调节效果。“卡通水波”不是静态贴图,而是有性格的:平静湖面波纹舒缓,激流瀑布波纹急促,魔法池塘波纹还带荧光脉动。我们设计了一个三层参数控制系统,所有参数都在Inspector面板上,无需改代码。
4.1 核心三轴:节奏、幅度、弹性
在Material Inspector中,我们暴露三个主控滑块:
- Wave Speed(节奏):控制所有波形的相位增速。范围0~3。值为0时冻结,1为默认,2以上进入“高速水流”模式。关键技巧:Speed不直接乘Time,而是乘一个
SmoothStep缓动曲线,避免突兀启停。 - Wave Scale(幅度):统一缩放三组波的振幅。范围0~2。值为0时完全平坦,1为标准,1.5以上进入“夸张动画”模式。注意:Scale影响顶点位移和菲涅尔计算,必须同步传递到两个模块。
- Elasticity(弹性):这是最玄学的参数。它不改变数学公式,而是动态调整次波与微波的相位偏移量。当Elasticity=0,所有波同相;=1时,次波相位+π/2,微波+π,制造“波峰未达顶点,次波已开始回落”的弹性感。实现方式是用
Custom Function计算动态偏移:
float2 GetElasticOffset(float elasticity) { return float2(sin(elasticity * 3.14), cos(elasticity * 1.57)); }4.2 状态触发器:响应游戏事件的波形切换
水波需响应游戏逻辑。比如角色踏入水面,波纹应从平静转为扩散;魔法阵激活,波纹应带紫色脉动。我们用Vector1 Property作为状态标记:
_StateFlag = 0:默认平静_StateFlag = 1:受扰动(如角色碰撞)_StateFlag = 2:魔法激活
在波形生成链路中,插入一个Branch节点,根据_StateFlag切换相位计算方式。例如状态1时,主波相位叠加一个以碰撞点为中心的径向衰减波:
RadialWave = sin(distance(UV, _ImpactPos) * 10 - Time * 3) * exp(-distance(UV, _ImpactPos) * 2)_ImpactPos由C#脚本传入,exp函数用Power节点实现(底数e≈2.718,用Power节点输入2.718和负距离值)。这个设计让程序只需发送一个坐标和状态码,Shader自动完成波纹扩散动画,无需额外粒子系统。
4.3 移动端专项优化:剔除“看不见的计算”
在iPhone SE这类设备上,三频谐波+深度采样+色阶映射会吃掉大量ALU指令。我们做了三项硬核裁剪:
- 波形精简模式:当检测到
_IsMobile == true(Material Property),自动禁用微波层,次波振幅减半。用Branch节点判断,分支开销远小于执行微波计算。 - 深度采样降频:轮廓线不每帧采样,而是隔3帧采样一次,用
Time.x的Fraction判断。人眼无法察觉10fps的描边更新。 - 色阶查表压缩:四段式色阶映射改为三段,合并波中与波峰过渡区。用
Lerp两次完成,省去一次条件判断。
实测数据:在Adreno 618 GPU上,完整版Shader耗时1.8ms,精简版降至0.9ms,帧率从42fps提升至58fps。这个差距,就是用户是否觉得“水波卡顿”的临界点。
5. 从Shader Graph到真机:那些文档里找不到的部署细节
写完Graph不等于结束。很多效果在Editor里完美,一到真机就变形。以下是我在五款主流Android/iOS设备上反复验证的部署要点。
5.1 URP版本兼容性雷区
不同URP版本对Shader Graph的支持差异巨大。URP 12.1.0起,Vertex Position节点行为变更:默认输出世界坐标,需手动勾选Object Space。而URP 10.8.0中该选项不存在。解决方案是:为每个URP大版本维护独立Graph文件,命名如CartoonWater_URP10、CartoonWater_URP12。在Material中用Shader Variant Collection预编译所有变体,避免运行时编译卡顿。特别注意URP 14.0新增的Hybrid Renderer,若项目启用,必须在Graph中添加Hybrid Renderer标签,否则顶点位移失效。
5.2 纹理坐标的陷阱:Tiling与Offset的联动失效
美术导入的水体贴图常带Tiling(平铺)设置。但Shader Graph中UV节点的Tiling属性与Material Inspector的Tiling是两套系统,互不通信。若在Inspector中调Tiling,Graph里的波形计算仍用原始UV,导致波纹与贴图错位。破解方法:在Graph中彻底弃用UV节点,改用Texture Coordinates节点,并将Material的Tiling和Offset作为Property传入,用Multiply和Add手动计算:
CustomUV = UV * _MainTex_ST.xy + _MainTex_ST.zw_MainTex_ST是Unity自动生成的纹理缩放偏移属性,确保Graph计算与Inspector设置严格同步。
5.3 实机色彩管理:sRGB与Linear的生死线
URP项目若开启Linear色彩空间,而Shader Graph中颜色计算未校正,会导致波峰亮白发灰。原因:Color节点输出的RGB值默认是sRGB编码,但Linear空间下需转为线性值计算。解决方案:在色阶映射后,所有颜色输出前,插入Gamma To Linear节点。但注意,此节点仅在URP 12+可用。旧版本需手写转换:
float3 sRGBToLinear(float3 c) { return pow(c, float3(2.2, 2.2, 2.2)); }这个细节让某教育App在iPad Pro上从“水波发黄”变为“清澈见底”,客户验收时当场多签了二期合同。
6. 扩展可能性:不止于水面,更是风格化动效的通用框架
这套系统的价值,远超“做一个卡通水波”。它本质是一个可复用的风格化顶点动画框架。我把核心模块抽象为三个通用节点,已在多个项目中复用:
- Harmonic Displacer:三频谐波生成器,输入任意2D坐标,输出位移向量。用于草地摇摆、旗帜飘动、UI按钮按压反馈。
- Palette Mapper:色阶映射器,输入标量值,输出预设色阶颜色。用于健康条渐变、温度图谱、情绪化UI。
- Edge Enhancer:深度感知描边器,输入屏幕坐标,输出边缘强度。用于角色外发光、场景焦点引导、故障艺术效果。
例如在某AR导览项目中,我们用Harmonic Displacer驱动地图上的河流图标,让它随用户步行节奏微微起伏;用Palette Mapper将GPS信号强度映射为红→黄→绿三色,比传统进度条更直观;Edge Enhancer则让导览箭头在复杂背景中始终清晰可见。所有这些,都基于同一套Graph结构,仅替换参数和纹理。这印证了一个经验:最好的Shader不是功能最多,而是接口最简、扩展最易。当美术说“这个波纹能不能改成心跳节奏”,你只需在Wave Speed后面加一个Sine(Time*0.5)乘数,30秒完成——而不是重写整个Shader。
我在实际使用中发现,这套框架最大的价值不是技术实现,而是改变了团队协作语言。以前美术提需求说“水波要活泼一点”,程序员要猜是调频率还是振幅;现在直接打开Material面板,拖动Elasticity滑块到0.7,双方立刻看到效果。技术在这里退居幕后,成为一种可触摸、可调节的创作工具。这或许就是卡通渲染的终极目标:让风格不再是一种妥协,而是一种精准的表达。