Unity ShaderGraph 2D水面特效:从物理建模到美术可控实现

Unity ShaderGraph 2D水面特效:从物理建模到美术可控实现 1. 为什么水面不是“加个波纹贴图”就完事了——从美术直觉到物理建模的认知跃迁你有没有在Unity里拖进一张带波纹的PNG调高Tiling再加个Scroll动画就以为做出了“动态水面”我试过而且不止一次。第一次是在做校园模拟器时想让池塘有点生气结果水面像一块被风吹皱的锡纸边缘生硬、反射呆板、连角色走近都激不起半点涟漪。第二次是给一个儿童教育App做海洋场景美术同事交来三张不同强度的法线贴图轮播运行起来像老式投影仪换片——咔哒、咔哒、咔哒节奏感倒是有了可那根本不是水是会动的墙纸。问题出在哪不在工具而在认知起点。ShaderGraph本身不生产“水”它只忠实地执行你定义的数学逻辑。而真实水面的本质是表面张力、重力、风力、物体扰动、光折射与反射共同作用下的非线性波动系统。我们日常看到的粼粼波光其实是数万个小镜面在随机朝向中对环境的瞬时采样远处的波浪轮廓是多个正弦波叠加后形成的包络线近处水花飞溅的破碎感则源于高频噪声对基础波形的局部扰动。这些无法靠一张贴图滚动解决必须拆解为可计算、可调控、可分层叠加的数学表达。这也是为什么标题强调“艺术与科学”——艺术决定你想要什么效果是卡通风格的夸张涌浪还是写实向的晨雾海面科学则决定你如何可靠地实现它用哪种波函数建模主波形噪声如何采样才不显重复法线如何从高度场导出才符合微分几何原理反射采样如何避免穿模又保持性能。ShaderGraph的价值恰恰在于把这套原本需要手写HLSL的复杂流程可视化为节点连接让美术能直观调整“风速”“粘度”“扰动强度”等语义化参数而程序员则能随时切入底层公式验证物理合理性。所以这篇内容不是“ShaderGraph入门教程”而是一次针对2D水面特效的专项攻坚记录从最简正弦波开始逐层叠加噪声、扰动、反射、折射、边缘消融每一步都解释“为什么选这个节点”“参数变化对应什么物理量”“美术同学调参时最容易踩的坑在哪里”。它面向两类人一是能画出惊艳原画但被Shader劝退的2D美术二是熟悉C#却对图形学公式的实际落地感到模糊的程序。我们不讲傅里叶变换推导但会告诉你“Simplex Noise节点输出值域是[-1,1]而水面高度偏移若直接用它会导致波峰波谷不对称必须先做0.5偏移再缩放”——这种细节才是项目真正跑通的关键。2. 核心四层结构高度场→法线→反射→边缘缺一不可的2D水面构建逻辑很多初学者在ShaderGraph里堆了一堆Noise节点连出几条波纹就以为完成了水面。结果运行起来要么像地震后的沥青路要么像被搅浑的豆浆根本看不出“水”的质感。问题根源在于缺失了分层建模的系统性思维。真实水面视觉由四个物理上严格耦合、但计算上必须分层处理的模块构成动态高度场Height Field→ 表面法线Normal Map→ 环境反射Reflection→ 边缘交互Edge Interaction。漏掉任何一层效果都会垮掉。下面我用自己实测过的结构图说明每一层的作用、依赖关系和常见错误。2.1 高度场水面波动的数学骨架不是“随便加点噪声”高度场是整个水面效果的地基。它定义了每个像素点在垂直方向Z轴上的位移量后续所有光学效果都基于此计算。很多人误以为“加个Tiling大的Noise就是高度”这是最大误区。Noise只是工具高度场必须满足三个硬性约束连续性相邻像素的高度值不能突变否则法线计算会爆炸出现刺眼白点。Simplex Noise比Perlin Noise更优因其梯度连续性更好尺度分层大海的涌浪低频大振幅和水面上的细碎涟漪高频小振幅必须分开建模再叠加。单一层Noise无法同时表现两种尺度方向性控制真实水面波纹有主传播方向如风向不能是纯各向同性噪声。我的实操方案是三层叠加主波Low Frequency用Sine Wave节点频率0.3振幅0.08方向由Vector2参数控制如(1,0)表示水平风次波Medium FrequencySimplex NoiseScale8Amplitude0.03UV用主波UVTime*0.5偏移制造相位差微涟漪High FrequencySimplex NoiseScale40Amplitude0.008UV用Time*2.0快速滚动模拟风拂表面。提示所有振幅值单位是“世界单位”需根据你的摄像机正交尺寸校准。例如若正交Size5则0.08振幅约等于水面整体起伏4%的视口高度肉眼刚好可辨又不夸张。2.2 法线从“起伏”到“反光”的关键跃迁导数计算不能偷懒有了高度场下一步是生成法线。这里90%的失败案例出在“法线怎么算”。新手常犯两个致命错误一是直接用Noise纹理当法线贴图完全错误Noise是标量法线是三维向量二是用“高度差近似法线”但忽略UV方向与屏幕坐标的映射关系。正确做法是从高度场解析导数。ShaderGraph提供了Derivative Vector节点但更稳定可控的是手动差分法在当前UV点分别采样右方UV (0.005, 0)和上方UV (0, 0.005)的高度值计算X/Z和Y/Z斜率再构造成切线空间法线。公式为Normal normalize(float3(-dx, -dy, 1))其中dx height(UVdx) - height(UV)dy同理。我在项目中封装了一个自定义Sub Graph“HeightToNormal”输入高度值、UV步长0.005、法线强度1.2输出标准化法线。关键经验法线强度不是“调亮调暗”而是控制表面“陡峭感”。值过大2.0会让水面像碎玻璃过小0.5则失去立体感1.0~1.5是安全区间。2.3 反射水面的灵魂没有反射就没有“液态感”高度场和法线只解决了“形状”反射才赋予它“液态灵魂”。2D水面反射有两大难点采样源选择和菲涅尔效应模拟。采样源不能直接采样主相机Render Texture性能爆炸且2D下易穿模。正确方案是用Grab Pass抓取当前帧背景再用法线偏移UV进行采样。ShaderGraph中通过“Scene Color”节点实现但必须在Sub Graph中勾选“Use Scene Color”并设置Render Type为OpaqueTransparent。菲涅尔效应即“看水面正上方时反射弱透出水下看边缘时反射强像镜子”。用pow(1.0 - dot(viewDir, normal), 5.0)计算指数5.0是经验值太小3.0边缘反射不足太大8.0中心区域发黑。注意Grab Pass在URP中需额外配置。在Shader的Render Pipeline Settings里将“Render Queue”设为Transparent并在Pass中添加Tags { QueueTransparent }否则Grab可能抓不到UI或粒子。2.4 边缘交互水面与岸线/物体的物理对话决定真实感上限水面最“假”的时刻往往发生在它接触其他物体时比如角色脚踏入水水面应向上涌起并产生同心圆波纹石头落入应有向外扩散的环形波。这需要动态扰动系统而非静态高度场。我的方案是引入“扰动源Disturbance Source”概念每个可交互物体Player、Stone在Shader中传入一个World Position和Strength。在高度场计算前用distance(uv, sourceUV)计算当前像素到扰动源的距离再用smoothstep(0.5, 0.0, dist)生成衰减权重最后将该权重乘以一个脉冲函数如sin(Time * 10) * exp(-dist * 5)叠加到主高度上。这样扰动随距离自然衰减且有时间维度的震荡感。关键技巧扰动源UV必须用World Position转换而非Screen UV。因为Screen UV随摄像机移动而变会导致扰动“粘”在屏幕上不动。正确做法是在C#脚本中用Camera.WorldToViewportPoint(sourcePos)转为0~1视口坐标再传入Shader。这四层结构不是线性流程而是环环相扣的反馈系统高度场驱动法线法线影响反射采样偏移反射强度受菲涅尔调制而边缘扰动又实时修改高度场。理解这个闭环才能真正掌控水面。3. ShaderGraph实战从空白Graph到可调水面Shader的完整搭建链路现在我们把前面的理论全部落地为ShaderGraph操作。这不是“照着节点截图连线”的保姆教程而是每一步都解释“为什么连这里”“参数为何设这个值”“不这么连会怎样”的深度实践。我用的是Unity 2021.3.30f1 URP 12.1.10节点版本兼容性已验证。3.1 创建基础框架命名规范与渲染管线适配第一步永远不是拉节点而是明确Shader类型和渲染管线。在Project窗口右键 → Create → Shader → Universal Render Pipeline → Unlit Shader。命名为“Water2D_Unlit”。为什么选Unlit因为水面效果核心是反射/折射不需要光照模型参与Unlit性能更高、逻辑更干净。若后续要加水下体积光再升级为Lit Shader。打开ShaderGraph第一件事是重命名Master Stack。双击默认的“Unlit Master”节点改为“Water2D Master”。然后在Inspector面板中将“Surface Type”设为“Transparent”“Blend Mode”设为“Alpha”“Z Write”设为“Off”。这是2D水面的铁律必须透明否则遮挡背后场景必须关闭ZWrite否则水面自身前后像素深度打架。警告若忘记关ZWrite会出现水面“闪烁”或“部分消失”的现象尤其在摄像机移动时。这是URP中透明物体的常见陷阱务必检查。3.2 构建动态高度场三层波形叠加的节点链从左上角Add Node →搜索“Sine”拖入一个Sine Wave节点。这是主波骨架。设置其属性Frequency:0.3低频控制大波浪周期Amplitude:0.08振幅单位世界坐标Time Parameter: 勾选Name填“_Time”自动接入全局时间接着Add Node → “Simplex Noise”。这是次波。设置Scale:8中等尺度噪声Offset:Vector2(0,0)初始无偏移Time Parameter: 勾选Name填“_Time_Sec”独立时间变量避免与主波同频再拖一个Simplex Noise这是微涟漪Scale:40高频制造细腻纹理Time Parameter: 勾选Name填“_Time_Fast”现在把三个节点的输出都是标量连到一个Add节点。但注意不能直接相加因为Sine输出范围是[-1,1]Simplex Noise是[-1,1]直接加会导致总高度超出合理范围。必须先归一化。我在每个噪声节点后加一个“Multiply”节点主波SineMultiply ×0.08已含振幅无需再调次波NoiseMultiply ×0.03微涟漪NoiseMultiply ×0.008然后Add三者输出即为最终高度值。将其命名为“Height_Field”。3.3 生成法线手动差分法的精确实现法线生成是精度敏感区。Add Node → “Sample Texture 2D”Texture选一张1×1纯白纹理用于占位实际不用采样。将其UV输入改为“UV”节点但我们要的是差分UV。Add Node → “Split”分离Vector2将UV节点连入。Split输出R/G通道即U/V。Add Node → “Add”加法第一个输入连Split的R第二个输入填0.005X方向步长另一个Add连Split的G和0.005Y方向步长。再Add两个“Combine”节点一个Combine(R0.005, G)另一个Combine(R, G0.005)。将这两个新UV连回同一个“Sample Texture 2D”节点复用但这次Texture选我们刚做的“Height_Field”Sub Graph稍后创建。用两个“Subtract”节点分别计算(Height_Udx) - Height_U和(Height_Vdy) - Height_V得到dx/dy。最后Add Node → “Combine” → 输入-dx,-dy,1→ 连入“Normalize”节点 → 输出即为法线。经验步长0.005是经验值。太大0.01法线粗糙太小0.001在低分辨率屏上失效。建议在目标设备上实测。3.4 实现反射与菲涅尔Grab Pass的稳定接入Add Node → “Scene Color”。这是Grab Pass的核心。但URP中需确保它能正确抓取。在ShaderGraph顶部菜单点击“Graph Settings” → “Additional Shader Includes”添加一行#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl。将“Scene Color”节点的UV输入连入“Transform”节点Type选World to Screen再连入“Screen Position”节点Component选XY。这是标准的Grab UV构造法。然后用之前生成的法线通过“Append”节点拼接成float3再用“Transform”Type选World to Tangent转到切线空间。接着用“Multiply”将法线XY分量乘以一个“Reflection Strength”参数Slider范围0~2作为UV偏移量加到Scene Color的UV上。最后菲涅尔Add Node → “View Direction”连入“Normalize”与法线点乘得dot用“Power”节点Exponent5计算pow(1-dot,5)再用“Lerp”节点A连Scene Color采样结果B连一个浅蓝色float3(0.7,0.85,1)模拟水下色T连菲涅尔结果。输出即为最终颜色。4. 美术友好化如何把物理参数翻译成美术师能懂的“风速”“粘度”“浪高”技术实现只是起点真正的落地卡点在于美术与程序的语言鸿沟。程序员说“调整Simplex Noise Scale”美术听不懂美术说“我要更粘稠的水”程序员不知道该改哪个数学参数。我的解决方案是在Shader中建立一套语义化参数映射表并配套C#脚本提供直观调节面板。4.1 Shader内参数语义化从数学符号到美术语言打开ShaderGraph的Blackboard右上角图标删除所有默认参数只保留以下6个参数名类型默认值美术含义物理映射逻辑_WindSpeedSlider (0~5)2.0风吹得多快控制次波Noise的Time Speed值越大波纹滚动越快_WaveHeightSlider (0~1)0.6浪有多高主波Sine Amplitude × 0.1微调整体起伏幅度_WaterViscositySlider (0.1~5)1.0水有多“粘”控制扰动衰减系数exp(-dist * _WaterViscosity)值越大波纹扩散越慢_ReflectIntensitySlider (0~2)1.2反光有多强菲涅尔计算后的Lerp T值缩放值越大边缘越镜面_EdgeFoamColor(0.9,0.9,0.95,1)水边泡沫色用于边缘消融的Color Mask非物理参数但强相关_DisturbancePowerSlider (0~10)3.0扰动有多猛扰动源脉冲函数的振幅倍率关键设计所有参数都有明确的美术语义且范围限制在直觉区间内。例如_WaterViscosity不叫_DisturbanceDecay因为美术不知道“decay”是什么范围设为0.1~5而非0~100避免滑块微调失灵。4.2 C#脚本桥接让美术在Inspector里直接调参创建C#脚本“Water2DController.cs”挂载到水面Sprite Renderer上using UnityEngine; using UnityEngine.Rendering.Universal; public class Water2DController : MonoBehaviour { public Material waterMaterial; // 引用ShaderGraph生成的Material public float windSpeed 2f; public float waveHeight 0.6f; public float waterViscosity 1f; public float reflectIntensity 1.2f; public Color edgeFoamColor new Color(0.9f, 0.9f, 0.95f, 1f); public float disturbancePower 3f; void Update() { if (waterMaterial null) return; // 将美术参数实时传入Shader waterMaterial.SetFloat(_WindSpeed, windSpeed); waterMaterial.SetFloat(_WaveHeight, waveHeight); waterMaterial.SetFloat(_WaterViscosity, waterViscosity); waterMaterial.SetFloat(_ReflectIntensity, reflectIntensity); waterMaterial.SetColor(_EdgeFoam, edgeFoamColor); waterMaterial.SetFloat(_DisturbancePower, disturbancePower); } }在Inspector中美术师看到的不再是冰冷的_WindSpeed而是带中文标签的滑块拖动时实时预览效果。这比打开ShaderGraph调节点高效十倍。4.3 真实项目中的参数调试案例校园池塘 vs 海洋风暴用同一套Shader仅调参数就能产出截然不同的效果。以下是我在两个项目中的实测配置校园池塘宁静、清澈、低扰动_WindSpeed: 0.8微风拂面波纹缓慢_WaveHeight: 0.3浅水浪不高_WaterViscosity: 3.0水体“厚重”扰动扩散慢适合小范围涟漪_ReflectIntensity: 0.8降低反射突出水下鹅卵石_EdgeFoam: (0.95,0.95,0.98,1)极淡的灰白模拟水边湿润感_DisturbancePower: 1.0角色走动仅产生微小波纹海洋风暴狂暴、深邃、高动态_WindSpeed: 4.5强风波纹高速滚动_WaveHeight: 0.9巨浪起伏剧烈_WaterViscosity: 0.3水体“稀薄”扰动瞬间扩散模拟开阔海域_ReflectIntensity: 1.8强反射突出浪尖白沫_EdgeFoam: (0.9,0.9,0.95,1)稍浓模拟浪花飞溅_DisturbancePower: 8.0落石产生巨大同心圆波踩坑心得曾为海洋风暴把_WaterViscosity设为0.1结果波纹扩散过快在远处形成一片模糊噪点。后来发现URP的Grab Pass采样有固有模糊_WaterViscosity低于0.2时这种模糊会被放大。最终定稿0.3是平衡点。5. 性能优化与跨平台适配在手机上跑出60帧的水面秘诀ShaderGraph很酷但一个没优化的水面Shader在低端安卓机上可能直接拖垮帧率。我经历过在红米Note 8上未优化的水面让UI从60帧掉到22帧。以下是经过真机压测的优化策略按优先级排序。5.1 关键性能瓶颈定位不是“节点多”而是“采样次数”很多人以为“节点越多越卡”其实URP中最大的性能杀手是纹理采样Texture Sample次数。每个Sample Texture 2D节点在GPU上都是一次内存读取代价远高于数学运算。我们的水面Shader中潜在采样点有Grab PassScene Color1次高度场Sub Graph中的Noise采样3次三层波形边缘消融的Mask采样1次如果启用总计5次采样。而URP移动端推荐上限是3次。优化核心合并采样减少冗余。方案是将三层Noise烘焙到一张256×256的Texture中。用C#脚本在Editor模式下生成主波用R通道次波用G微涟漪用B。Shader中只需1次采样再用Split节点分离RGB。虽然牺牲了运行时动态调整Noise Scale的自由度但换来3次采样节省帧率提升40%。对于2D项目这是值得的妥协。5.2 移动端专属精简模式关闭非必要特性在Quality Settings中为移动端创建专用Shader Variant。在ShaderGraph中用“Branch”节点配合Keyword如MOBILE_OPTIMIZED控制分支关闭菲涅尔计算Branch → False分支直接用1.0代替菲涅尔结果省去ViewDir采样和Power运算简化法线Branch → False分支用预烘焙的法线贴图替代实时差分计算降噪Branch → False分支将微涟漪振幅设为0。在C#脚本中根据SystemInfo.deviceType自动开启Keywordif (SystemInfo.deviceType DeviceType.Handheld) waterMaterial.EnableKeyword(MOBILE_OPTIMIZED);5.3 屏幕空间优化只为“可见区域”计算水面最狠的优化是让水面Shader只在摄像机视口内生效。创建一个Render Feature在URP Asset中添加。其核心逻辑在BeforeRenderingTransparents阶段用camera.ViewportToWorldPoint(new Vector3(0,0,0))获取视口四角世界坐标计算包围盒再用GeometryUtility.CalculateFrustumPlanes(camera)获取裁剪平面。最终只对包围盒内的Sprite Renderer执行水面Shader。实测数据在1080p屏幕上一个铺满全屏的水面Sprite优化后GPU耗时从8.2ms降至1.7ms。原理很简单你永远看不到屏幕外的水面何必计算最后提醒所有优化必须在真机上验证。编辑器里的Profiler是骗人的它跑的是PC GPU。我曾在一个“优化后”的Shader上编辑器显示1.2ms刷到小米11上直接52ms——因为编辑器没走移动端精简路径。每次打包前必用Android Profiler抓帧分析。6. 超越水面这个Shader架构如何扩展为2D流体模拟系统做到动态水面只是起点。这套分层架构的真正价值在于它的可扩展性。我已在三个项目中基于同一套ShaderGraph衍生出不同流体效果证明其底层逻辑的普适性。6.1 从“水”到“熔岩”替换物理参数改变材质本质熔岩和水的核心差异在于粘度极高、表面张力大、热辐射发光。只需修改几个参数_WaterViscosity→ 设为8.0熔岩流动极慢_WaveHeight→ 0.1几乎无波纹只有缓慢隆起_ReflectIntensity→ 0.3熔岩表面哑光反射弱新增参数_GlowIntensity用高度场乘以sin(Time*3)再叠加到最终颜色的RGB上模拟内部热辐射。关键洞察流体的“类型”由少数几个核心物理参数定义而非重写整个Shader。这正是分层建模的优势——换皮肤不换骨架。6.2 从“静态水面”到“交互式河流”加入流速场Flow Map河流需要定向流动。在原有架构上增加一个“Flow Map”纹理RG通道存2D流速矢量。在高度场计算前用Flow Map对UV进行偏移UV flowVector * Time * _FlowSpeed。这样波纹会沿着河流方向被“拉长”形成顺流而下的动态感。美术只需画一张Flow Map程序零代码改动。6.3 从“2D”到“伪3D水体”结合Depth Texture的体积感真正的3D水体需深度测试但2D项目可用Depth Texture模拟。在URP中启用Depth TextureShader中用Sample Depth采样当前像素深度再与水面预设深度如_WaterDepth 2.0比较。若采样深度 _WaterDepth说明水下有物体增强折射偏移反之按常规反射处理。这样角色走入水中时腿部会自然“沉入”产生深度错觉。我的体会ShaderGraph不是万能的但它把图形学的门槛从“掌握HLSL语法”降到了“理解物理逻辑”。当你能把“风速”“粘度”“浪高”这些美术语言精准映射到数学公式和节点参数时你就真正掌握了2D流体的钥匙。后面所有的扩展不过是转动这把钥匙打开更多门而已。