公司动态

Unity ShaderGraph实战:打造真实水面倒影与动态波纹效果

📅 2026/8/8 2:01:52
Unity ShaderGraph实战:打造真实水面倒影与动态波纹效果
1. 项目概述为什么水面倒影是检验Shader功力的试金石朋友如果你在Unity里做过场景尤其是那种带点自然风光的肯定遇到过这个坎怎么让那片水看起来“像真的”不是贴一张带点波纹的PNG图片就完事了那种静态的、毫无交互的“死水”一眼假。真正的挑战在于让水面有动态的涟漪能反射出岸边的景物并且随着视角变化反射内容还能有正确的透视和扭曲。这背后就是Shader的舞台。而今天要聊的就是用Unity的ShaderGraph这个可视化工具来打造一个既真实又高效的水面倒影Shader。这不仅是提升场景氛围的利器更是深入理解实时渲染中光照、反射、折射等核心概念的绝佳实践。过去要实现这样的效果你得埋头写一大堆HLSL或CG代码跟各种矩阵、采样器、噪声函数搏斗门槛不低。但现在ShaderGraph把节点拖拽变成了我们的画笔让艺术家和技美也能直观地构建复杂的着色逻辑。这个“水面倒影Shader”项目目标就是利用ShaderGraph不写一行代码构建一个包含动态波纹、菲涅尔反射、基于深度图的岸边泡沫、可交互扰动等特性的完整水面材质。它解决的不仅仅是“看起来像水”的问题更是“如何在移动端和PC端都保持性能与效果的平衡”的工程问题。无论你是刚接触ShaderGraph的新手想找个有成就感的综合案例练手还是已经有一定基础想优化自己项目中水面效果的老手这套思路和具体实现节点图都能给你带来直接的参考价值。2. 核心效果拆解一个真实水面包含哪些视觉要素在动手连接节点之前我们必须先想清楚眼睛看到一片真实的水面大脑是如何解读这些视觉信号的。拆解开来主要是以下几个核心要素的叠加2.1 基础反射捕捉世界的倒影这是水面之所以为“镜”的第一要素。在实时渲染中我们通常不会像光线追踪那样去真实计算每一条反射光线性能开销巨大而是采用一种叫做“屏幕空间反射”或更简单的“平面反射”技术。对于ShaderGraph我们最常用且高效的方法是使用一个“场景颜色”节点。这个节点的本质是在渲染当前物体水面时去采样一张包含了当前帧所有不透明物体渲染结果的纹理即屏幕图像。但直接采样会导致反射是静态的我们需要根据水面法线、视角方向对它进行扭曲模拟水波带来的反射畸变。这里的关键在于采样UV的扰动。我们不会直接用模型本身的UV而是会基于屏幕空间坐标叠加一个由噪声或正弦波生成的偏移量。这个偏移量由我们后续要讲的水波法线贴图来驱动。这样平静的水面反射清晰有波纹的地方反射图像就会随之扭曲动态感立刻就出来了。2.2 动态波纹赋予水面生命一潭死水是没有灵魂的。动态波纹是水面“活过来”的关键。在ShaderGraph里我们通常通过两种方式叠加生成复杂的波纹法线平铺噪声图使用一张或两张预先制作好的、无缝平铺的法线贴图。它们通常包含各种尺度的波纹细节。我们让这两张图以不同速度和方向滚动然后进行混合就能产生永不重复的、复杂的波光粼粼的效果。这是性能最好、效果最可控的方法。程序化噪声使用Gradient Noise等节点实时生成噪声再通过一系列计算如DDX/DDY或自定义节点转换为法线信息。这种方式灵活性极高可以动态调整波纹的尺度、强度甚至实现更风格化的效果但计算量相对大一些。在实际项目中我强烈推荐第一种方法。找两张高质量的水面法线贴图资源通过一个“Tiling And Offset”节点控制其滚动速度用“Time”节点驱动再用“Blend”节点混合就能快速得到一个基础很好的动态法线。这个法线将同时用于扭曲反射贴图上述和影响高光反射。2.3 菲涅尔效应控制反射与透明的边界这是一个非常重要的物理现象视线与水面法线夹角越小掠射角观察反射越强夹角越大垂直向下看反射越弱透射看到水底越强。这个效应让水面边缘靠近岸边或视角平行处反射强烈而中心区域则更透明。 在ShaderGraph中实现菲涅尔效应非常简单。用一个“Fresnel Effect”节点输入水面法线和视角方向向量它会输出一个0到1的系数。这个系数完美描述了上述规律。我们通常用这个系数来混合“反射颜色”和“水底颜色/折射颜色”。参数“Power”用来控制菲涅尔效应的过渡锐利程度值越大反射与透明的分界线越清晰。2.4 深度与岸边泡沫解决“硬边”难题这是让水面自然融入场景避免看起来像飘在空中塑料布的关键。我们需要知道水面与水下物体如河床、岩石或水面与岸边交界处的深度信息。深度图Unity的URP/HDRP管线提供了“Scene Depth”节点。它可以获取当前像素位置到最近场景物体的深度值。我们同样获取水面自身的深度通常通过变换到相机空间计算两者相减就得到了水面淹没物体的深度差。岸边泡沫当深度差很小时即水面刚好接触岸边或浅滩我们利用这个差值来生成泡沫效果。通常是用一个“Remap”节点将很小的深度差范围比如0到0.1米重新映射到0-1然后用这个值去采样一张泡沫噪声贴图并叠加到最终颜色上。同时这个深度差也可以用来淡化水体的透明度让浅水区更清澈深水区更幽暗层次感就出来了。2.5 高光与镜面反射波光粼粼的点睛之笔阳光照在水面上那些闪烁的、移动的亮斑就是高光。在PBR工作流中这通常由“Specular”通道控制。我们有了动态的法线贴图高光计算就是水到渠成。使用“Specular Glossiness”或“Metallic”工作流将我们混合好的动态法线连接到Master Node的“Normal”输入调整合适的平滑度和金属度水面金属度通常很低约0.1-0.3但高光反射很强主要通过平滑度控制ShaderGraph就会自动根据场景灯光计算出动态的高光亮点。 为了增强效果我们还可以额外添加一个简单的“镜面反射”强度乘数并用菲涅尔系数或一个噪声图来调制它让高光在水面某些区域如波峰更强烈模拟阳光直射的效果。3. ShaderGraph节点网络构建全流程理论清晰后我们进入实战环节在ShaderGraph中一步步搭建这个水面材质。我假设你使用的是Unity URP管线并已创建了一个Lit Shader Graph。以下是我经过多个项目验证后总结出的高效、清晰的节点组织逻辑。3.1 创建与组织基础节点框架首先规划好你的节点图空间。我习惯从左到右、从上到下分为几个功能区输入参数区、法线生成区、深度与泡沫计算区、反射折射颜色混合区、最终输出区。 在“Blackboard”中创建以下参数这是控制水面外观的“遥控器”WaterColor(Color): 水体基础色影响透射颜色。Smoothness(Slider, 0-1): 控制高光锐利度建议0.85-0.95。Metallic(Slider, 0-1): 控制金属感水面建议0.1-0.3。WaveNormalMap1/2(Texture2D): 两张滚动法线贴图。WaveTiling1/2(Vector2): 分别控制两张法线贴图的平铺密度。WaveSpeed1/2(Vector2): 分别控制两张法线贴图的滚动速度。ReflectionStrength(Slider, 0-1): 反射整体强度。FresnelPower(Slider, 0-10): 菲涅尔效应过渡功率。FoamColor(Color): 岸边泡沫颜色。FoamThreshold(Slider, 0-1): 生成泡沫的深度阈值。FoamNoiseMap(Texture2D): 泡沫噪声贴图。3.2 动态法线生成的经典双图混合策略这是整个Shader的核心驱动源。步骤如下采样第一张法线图创建Sample Texture 2D节点纹理输入连接到WaveNormalMap1参数。UV输入连接到一个Tiling And Offset节点。Tiling输入WaveTiling1Offset输入Time*WaveSpeed1。这样法线贴图就会按照设定速度和密度滚动起来。从采样节点输出的RG通道就是法线的XY分量我们需要用Unpack Normal节点将其转换为完整的法线向量通常输出到Normal Map插槽的贴图需要勾选“Bump Map”ShaderGraph会自动解包但显式使用该节点更清晰。采样并混合第二张法线图完全重复步骤1但使用WaveNormalMap2、WaveTiling2和WaveSpeed2。为了让两套波纹叠加更自然建议第二套的平铺密度是第一套的1.5-2倍滚动方向相反或呈一定角度。混合法线使用Blend Normals节点。将第一张图的法线连接到Base第二张图的法线连接到Blend。这个节点会以正确的方式混合两个法线向量产生更复杂的细节。混合后的法线我们称之为FinalWaveNormal。与世界空间对齐FinalWaveNormal目前是在切线空间下的。为了用于后续的反射计算通常在视图或世界空间我们需要将其转换。使用Transform节点选择从Tangent空间到World空间。输入FinalWaveNormal并确保Normal输入连接了顶点的世界空间法线通过Normal Vector节点获取并设置为World空间。输出即为WorldWaveNormal。实操心得选择法线贴图时尽量选择灰度对比柔和、细节丰富的无缝贴图。过于尖锐的波纹会显得不自然。两张图的滚动速度差不要太大否则混合效果会显得混乱。可以暴露一个Blend Factor参数来控制第二张图的影响强度方便微调。3.3 实现屏幕空间反射与扭曲获取屏幕UV使用Screen Position节点模式选择Default输出一个四维向量。取其xy分量并除以w分量使用Divide节点进行透视除法得到标准的屏幕空间UV范围0-1。我们称其为ScreenUV。用世界法线扰动UV我们不需要用完整的法线去扰动通常取其xy分量并乘以一个很小的系数如0.01-0.05这个系数可以作为一个DistortionStrength参数暴露出来。计算DistortedScreenUV ScreenUV (WorldWaveNormal.xy * DistortionStrength)。采样场景颜色使用Scene Color节点。将DistortedScreenUV连接到其UV输入。这个节点就会在我们扭曲后的屏幕位置采样当前帧已经渲染好的场景图像作为反射贴图。输出为ReflectionColor。应用反射强度将ReflectionColor与一个白色用Lerp节点混合混合因子就是ReflectionStrength参数。这样我们可以全局控制反射的明显程度。3.4 集成菲涅尔效应与颜色混合计算菲涅尔系数使用Fresnel Effect节点。Normal输入连接WorldWaveNormalView Dir输入连接View Direction节点设置为World空间。调整Power参数即我们创建的FresnelPower。输出一个Fresnel系数值在0垂直视角到1掠射视角之间。准备水底/透射颜色简单的实现可以直接使用WaterColor参数。更真实的做法可以尝试用ScreenUV未扭曲的去采样一次Scene Color模拟简单的折射然后再与WaterColor混合。这里为了清晰我们先使用WaterColor作为WaterBaseColor。混合反射与水色使用Lerp节点。A端输入WaterBaseColorB端输入上一步得到的ReflectionColorT因子输入Fresnel系数。这样根据视角不同水面颜色会在基础水色和反射景象之间平滑过渡。输出为BaseWaterWithReflection。3.5 深度检测与岸边泡沫生成这是提升真实感最关键的一步。获取场景与水面深度场景深度使用Scene Depth节点它会输出当前像素位置对应的场景物体的深度值。水面深度这需要一点技巧。一种通用方法是使用Position节点设置为World空间获取水面顶点的世界坐标。然后使用Transform节点将其转换到View空间相机空间。在视图空间中点的Z坐标的绝对值或取其负值大致代表了该点到相机的深度。我们称之为WaterDepth。计算深度差DepthDifference SceneDepth - WaterDepth。这个差值在水面与物体相交处为0在水面以上物体处为负通常不考虑在水面以下物体处为正。我们只关心正值部分即水面淹没物体的部分。使用Max节点将DepthDifference与0比较确保非负。生成泡沫遮罩将DepthDifference通过一个Remap节点将原范围[0, FoamThreshold]映射到[1, 0]。这样深度差为0岸边交界时输出1深度差达到FoamThreshold比如0.1米时输出0之间平滑过渡。输出为FoamDepthMask。同时采样泡沫噪声贴图FoamNoiseMap使用世界位置的XZ坐标通过Position节点获取乘以一个较小的平铺系数作为UV模拟泡沫的细节。将FoamDepthMask与噪声采样值相乘然后通过一个Saturate节点或Clamp和Power节点控制其对比度得到最终的FoamMask。应用泡沫使用Lerp节点。A端输入上一步混合好的BaseWaterWithReflectionB端输入FoamColorT因子输入FoamMask。这样就在水岸交界处叠加了泡沫颜色。输出为WaterWithFoam。3.6 最终输出与性能优化连接将最终计算好的颜色和属性连接到Master Stack或PBR Master NodeBase Color: 连接WaterWithFoam。Normal: 连接WorldWaveNormal注意PBR Master Node通常需要切线空间法线所以你可能需要将WorldWaveNormal再转换回Tangent空间或者从一开始就保留切线空间的FinalWaveNormal直接连接到这里。URP Lit Master Node的Normal输入默认是切线空间。Metallic: 连接Metallic参数。Smoothness: 连接Smoothness参数。Emission: 可以留空或者将高光特别强的部分通过计算连接到此处实现自发光般的波光效果需谨慎容易过曝。Alpha: 连接一个由Fresnel系数和深度差共同控制的透明度。例如Alpha 1 - (Fresnel * 0.3 saturate(DepthDifference * 2))。这样垂直看更透明岸边浅水区也更透明。至此一个功能完整的水面倒影Shader节点网络就搭建完毕了。你可以将其保存为一个Shader Graph资源然后创建一个材质球应用它拖给场景中的水面平面通常是一个细分较多的Plane或自定义Mesh。4. 参数调试心法与视觉微调技巧节点连好了只是第一步调出感觉才是艺术。下面分享一些我调试水面时的“心法”。4.1 法线贴图滚动创造自然的韵律感不要简单地把WaveSpeed设为常数。尝试使用Time乘以一个速度系数后再加上一个由世界XZ坐标通过Sine函数生成的微小变量。例如Offset Time * Speed sin(WorldPos.x * 0.1) * 0.01。这样能让不同区域的水波有细微的速度差打破完全均匀的机械感显得更自然。两张法线图的滚动速度比可以尝试接近黄金分割比例如1:1.618这样混合后的波纹周期更长更不容易出现重复的视觉模式。4.2 菲涅尔与反射强度平衡真实与艺术FresnelPower参数非常敏感。值太小如1-2水面整体反射都很强看起来像镀铬金属。值太大如8-10则只有几乎平行的视角才有反射垂直看下去就是一片纯色不真实。对于大多数自然水体4-6是一个不错的起点。同时ReflectionStrength不要设为1通常0.6-0.8为宜让反射颜色与水体基础色有一定混合避免反射过于生硬刺眼。你可以将Fresnel系数再乘一次ReflectionStrength这样掠射角的反射最强垂直视角的反射最弱符合物理直觉。4.3 深度泡沫避免“肥皂水”和“硬边”泡沫最容易出现两个问题一是像一圈肥皂泡浮在水面不自然二是与水体的过渡太生硬。解决“肥皂水”确保你的泡沫噪声贴图是黑白的、细节丰富的灰度图而不是有明确黑白边界的图案。在将FoamMask与颜色混合时不要直接用FoamColor而是用FoamColor乘以一个基于噪声的随机因子比如0.8-1.2让泡沫颜色有深浅变化。更高级的做法可以用DepthDifference来影响泡沫的厚度浅水区泡沫厚深水区迅速消失。解决“硬边”除了用Remap做平滑过渡还可以在生成FoamMask后用一个Smoothstep节点或者简单的Power节点小于1的值来柔化边缘。例如FoamMask pow(FoamMask, 0.5)。同时将FoamThreshold调小让泡沫只出现在极窄的深度范围内。4.4 性能考量与移动端适配这个Shader效果虽好但节点较多需关注性能减少纹理采样Scene Color和Scene Depth是开销大户。确保你的项目设置中启用了Opaque Texture和Depth Texture且只在需要水面的相机上开启。如果移动端压力大可以考虑简化反射只用立方体贴图或简化版的屏幕采样。简化计算对于移动端可以去掉第二张法线贴图或者降低其平铺密度。菲涅尔计算和深度计算是必需的但可以检查精度使用半精度浮点数如果ShaderGraph和GPU支持。LOD策略为水面材质制作简化版本。远距离水面可以禁用泡沫计算、使用静态法线贴图不滚动、降低反射扭曲强度、甚至降低反射采样分辨率通过一个Sample Texture 2D LOD节点。批处理与合批确保水面Mesh的材质实例尽可能少。通过脚本动态修改参数如风速影响波速而不是为每个水面创建独特的材质实例。5. 常见问题排查与实战解决方案在实际应用过程中你肯定会遇到一些“坑”。这里记录了几个最常见的问题及其解决方法。5.1 反射内容错乱或拉伸问题描述水面上反射的建筑物或天空盒出现奇怪的拉伸、错位或重复。原因分析这几乎总是Scene Color采样UV计算错误导致的。可能是屏幕UV计算未做透视除法或者法线扰动强度过大导致采样点跑到了屏幕范围之外。解决方案检查屏幕UV计算务必确认使用了Screen Position的Default模式并且用其xy除以了w。钳制UV在DistortedScreenUV连接到Scene Color之前用一个Clamp节点将其xy分量限制在[0.001, 0.999]的范围内避免采样到屏幕边缘可能存在的无效数据。调整扰动强度将DistortionStrength参数调小从0.01开始尝试。过强的扰动会让反射完全失真。检查法线方向确保WorldWaveNormal的扰动方向是正确的。可以临时将WorldWaveNormal.xy直接输出到Base Color在场景中查看法线扰动图它应该是平滑变化的没有剧烈的跳变。5.2 水面与岸边交界处有尖锐的Z-fighting闪烁问题描述水面网格与地面网格在岸边交界处像素级深度值过于接近导致渲染顺序冲突产生闪烁。原因分析这是经典的Z-fighting问题。两个表面在深度缓冲中的值相差极小由于精度限制谁在前谁在后随机决定。解决方案轻微偏移水面在Shader中在顶点着色器阶段将水面顶点沿法线方向通常是向上轻微偏移一个极小值如0.001个单位。在ShaderGraph中可以在顶点阶段添加一个Position节点设置为Object空间加上一个Normal VectorObject空间乘以一个极小的参数再输出到Position。这能确保水面始终略高于地面。调整渲染队列确保水面材质的渲染队列在背景地形之后。在材质的Inspector面板中可以尝试将Render Queue设置为Transparent3000或者比地形更高的值。使用深度偏移在URP管线的Lit Shader Graph中Master Stack提供了Depth Offset输入。你可以连接一个很小的负值如-0.001这会在深度测试时让水面“退后”一点。这是最直接的方法。5.3 移动设备上帧率下降明显问题描述在PC上运行流畅但在Android或iOS设备上帧率骤降。原因分析Shader复杂度超标特别是Scene Color和Scene Depth的全屏采样以及多张纹理采样和复杂的向量运算。解决方案启用GPU Instancing如果场景中有多个相同的水面材质确保材质球上勾选了Enable GPU Instancing。简化Shader变体检查Shader的编译变体数量。避免使用过多的Keyword开关。在ShaderGraph的Graph Inspector中审视图上使用的所有Keyword节点移除不必要的。降级效果为移动端创建简化版Shader。可以按以下优先级简化第一步去掉泡沫效果关闭Foam相关节点。第二步将双法线图混合改为单张法线图。第三步降低反射质量例如不使用扭曲或者使用一个模糊的立方体贴图代替Scene Color。第四步简化菲涅尔计算使用一个基于视角点乘法线的简单计算代替Fresnel Effect节点。控制绘制范围确保水面Mesh的面数在合理范围对于手机一个10x10细分度的Plane通常足够。使用LOD远距离水面替换为更简单的Mesh和材质。5.4 水下部分看不到或渲染异常问题描述当相机进入水面以下时要么一片漆黑要么渲染出错。原因分析默认情况下Scene Depth节点采样的是不透明物体的深度。当相机在水下时水面本身位于相机和场景物体之间深度关系反转常规计算会失效。此外水下通常需要不同的颜色调和后处理效果如色偏、模糊。解决方案 这是一个进阶话题需要判断相机与水面的相对位置。检测相机高度在Shader中获取相机世界坐标_WorldSpaceCameraPos与水面高度可以是一个全局参数或通过脚本传入比较。使用双面渲染将材质的Surface Type设置为Transparent并开启Two Sided。这样水面的背面从水下看也会被渲染。水下着色当检测到相机在水下时应切换一套完全不同的着色逻辑禁用反射或使用水下专用的模糊反射增强透射叠加一个淡蓝色的色调并可能使用Scene Depth来模拟水下距离雾。这通常需要通过自定义节点或分支Branch来实现会增加Shader复杂度。对于许多游戏直接使用一个单独的水下后处理体积Post-Processing Volume来模拟整体环境变化可能是更高效且效果更好的方案。水面Shader的调试是一个感性与理性结合的过程。多观察现实中的水体——池塘、河流、大海注意不同天气、不同光照下反射、颜色和波纹的变化。将这些观察转化为你Shader中的参数反复微调才能得到最令人信服的效果。记住没有一套参数是放之四海而皆准的针对你的场景光照、美术风格进行定制化调整才是最终出彩的关键。这个用ShaderGraph构建的水面系统为你提供了一个强大且可扩展的基础框架剩下的就交给你的艺术直觉和耐心调试了。