Unity URP水面Shader实战:从Shader Graph到HLSL实现波动、反射与深度效果
1. 项目概述为什么要在URP里折腾水面效果做游戏开发尤其是涉及到开放世界、RPG或者任何有自然场景的游戏水面效果几乎是一个绕不开的坎。它不仅仅是放一张蓝色的贴图那么简单一个灵动、真实的水面能瞬间提升场景的氛围感和玩家的沉浸感。我最近在做一个偏写实风格的独立项目场景里有一片不小的湖泊为了搞定这个水面在Unity的URP通用渲染管线里着实折腾了一番。你可能也发现了网上关于Unity水面效果的教程非常多但质量参差不齐。很多是基于旧版内置渲染管线的直接搬到URP里不是报错就是效果全无。还有一些虽然用了URP但要么只讲个大概要么代码和节点图混杂对于想快速上手的开发者来说理解成本很高。我这个教程的目标很明确从零开始在URP环境下手把手实现一个兼具基础波动、镜面反射、菲涅尔效应和深度颜色过渡的实用水面Shader。我们不追求物理绝对精确那是AAA大厂团队干的事而是要追求在移动端和PC端都有不错表现且性能可控的“风格化写实”效果。整个过程我会交替使用Shader Graph和手写HLSL代码两种方式来说明关键点。Shader Graph可视化强易于理解和调整而手写代码则更灵活能深入一些Graph不易实现的细节。无论你是Shader新手还是有一定基础想深入了解URP特性的开发者相信都能从中找到有用的东西。最终的效果会是一个能够随波荡漾、岸边透明见底、远处深邃反光的水面。2. 核心思路与方案选型Shader Graph还是手写代码在动手之前我们先得把设计思路理清楚。一个看起来不错的水面主要由以下几个视觉元素构成基础颜色与深度色水不是单一颜色。靠近岸边浅水区通常颜色较浅、偏绿或偏青能看到水底而深水区则颜色深邃偏蓝或黑色。这需要根据水面以下的深度Depth来插值。波动Waves静态的水面像果冻不自然。我们需要让水面动起来模拟波浪。通常使用正弦波Sine或格斯特纳波Gerstner Wave叠加一张噪声贴图Noise Texture来制造复杂的波面。法线Normal波动改变了水面的朝向我们需要计算新的法线向量这是实现光影高光和折射扭曲效果的基础。高光Specular阳光或灯光照在水面上形成的亮斑通常使用Blinn-Phong或更物理的BRDF模型来计算。菲涅尔效应Fresnel Effect这是水面看起来“真实”的关键。视线与水面夹角越小近乎平视反射越强夹角越大俯视折射透见水底越强。反射Reflection倒映天空和周围环境。在URP里我们通常使用反射探针Reflection Probe或平面反射Planar Reflection来实现。折射Refraction看到水下的物体时光线弯曲造成的扭曲效果。可以通过扰动屏幕深度图或颜色图来模拟。那么在URP里实现这些是用Shader Graph还是手写Unity Shader代码呢我的建议是核心算法用手写HLSL代码封装成自定义节点整体组装和参数调节用Shader Graph。为什么这么选Shader Graph的优势在于可视化和快速迭代。你可以实时拖动滑块看到颜色、波幅、菲涅尔强度的变化这对于美术调整参数非常友好。但是对于一些复杂的数学运算比如精确的格斯特纳波计算、自定义的深度处理逻辑用节点连起来会非常臃肿且难以阅读。而手写代码则简洁、高效且功能强大。在URP中我们可以将复杂的HLSL函数封装成Custom Function Node然后在Shader Graph中像使用普通节点一样调用它兼顾了灵活性与易用性。对于本教程波动计算、深度颜色计算、菲涅尔计算我都会封装成自定义函数节点。而整体的颜色混合、纹理采样、渲染状态设置则交给Shader Graph来管理。渲染路径选择URP的Forward Renderer因为我们需要精确的光照计算。水面材质我会设置为Transparent并开启深度写入ZWrite On但使用特定的渲染队列例如Transparent-100来处理与其他透明物体的渲染顺序。3. 环境准备与工程设置在开始编写Shader之前确保你的Unity工程已经正确配置。我使用的是Unity 2022.3 LTS版本URP版本为14.0.8。不同小版本间可能有细微差异但核心思路一致。3.1 创建URP资产与渲染器如果你的项目还不是URP项目需要先转换。在Package Manager中安装Universal RP。然后在Project窗口右键 - Create - Rendering - Universal Render Pipeline - Pipeline Asset (Forward Renderer)。我将其命名为URP-WaterPipelineAsset。接着需要创建一个渲染器数据资产同样右键 - Create - Rendering - URP Renderer (Forward Renderer)命名为WaterRenderer。最后在Project Settings - Graphics - Scriptable Render Pipeline Settings中拖入刚刚创建的URP-WaterPipelineAsset。在URP-WaterPipelineAsset的Inspector面板中将Renderer List里的默认渲染器替换成我们创建的WaterRenderer。3.2 创建水面的Mesh一个简单的Plane就可以作为起点。在场景中创建一个GameObject - 3D Object - Plane将其Scale调整到适合你场景的大小例如50x50。重命名为WaterSurface。我们将在这个Mesh上应用待会儿制作的水面材质。3.3 准备必要的纹理资源我们需要一张噪声贴图来驱动水波的复杂变化。你可以使用任何无缝的、灰度或RGBA的噪声图。我推荐使用Simplex Noise或Perlin Noise生成的纹理因为它们的连续性更好。在Unity中你可以通过一些免费的噪声生成工具如Texture Generator插件来创建或者从资源商店下载。我将一张512x512的RGBA噪声图导入工程并确保它的Wrap Mode设置为RepeatFilter Mode设置为Bilinear这样在采样时才能平滑无缝。另外为了丰富水面细节我们可能还需要一张法线贴图。但在这个教程里我们将通过计算动态生成法线所以这不是必须的。为了表现水底你可能还需要一张砾石或沙子的纹理用于在浅水区显示。3.4 设置反射探针为了得到环境反射我们在水面所在的位置放置一个反射探针Reflection Probe。选中WaterSurface物体在Inspector中点击Add Component搜索并添加Reflection Probe组件。将其Type设置为Realtime这样它能动态捕捉周围环境包括移动的物体。调整其大小Size使其能覆盖水面及周边区域。注意Realtime反射探针性能开销较大在大型场景中要谨慎使用或者烘焙Bake为静态。注意URP对透明物体的反射支持与内置管线不同。默认的URP渲染器可能不会为透明物体执行反射探针的混合。你需要检查你的WaterRenderer资产确保Transparent层级的Reflection Probes选项是启用的。有时为了更好的效果和性能开发者会选择使用Screen Space Reflection (SSR)后处理但这超出了本基础教程的范围。4. 构建Shader Graph搭建主体框架打开Shader Graph窗口Window - Rendering - Shader Graph新建一个Blank Shader Graph命名为URP_Water。我们将从输出节点开始反向构建整个网络。4.1 设置主节点Master Stack在URP的Shader Graph中我们主要关注PBR Master节点对于非金属的水面或Unlit Master节点如果我们想完全自己控制光照。为了获得URP完整的光照计算包括主方向光、附加逐物体光、环境光等我们选择PBR Master。Surface设置为Transparent。这是关键水是透明的。Blend预设选择Alpha即SrcAlpha OneMinusSrcAlpha。这是标准的透明混合模式。Depth Write设置为On。虽然水面是透明的但我们仍然希望它写入深度缓冲区这样位于水面下的物体才能被正确遮挡。深度测试ZTest通常保持默认的LEqual。Render Face设置为Both。因为水面可能从上方或下方被看到比如在潜水时。4.2 创建基础属性与输入我们需要在Blackboard中创建一系列属性方便在材质面板中调节_BaseColor(Color)深水区的基础颜色。_ShallowColor(Color)浅水区近岸的颜色。_DepthFactor(Vector1)控制颜色从浅到深过渡的强度因子。值越大浅色区域范围越小。_WaveSpeed(Vector1)波浪移动的速度。_WaveStrength(Vector1)波浪的强度振幅。_WaveFrequency(Vector1)波浪的频率波长倒数。_NoiseTex(Texture2D)用于扰动波浪的噪声纹理。_NoiseTiling(Vector1)噪声纹理的平铺系数。_Smoothness(Vector1)水面光滑度影响高光大小和强度。_FresnelPower(Vector1)菲涅尔效应的强度。将这些属性拖入Graph中它们就是我们整个Shader可调节的参数基石。5. 核心算法实现自定义函数节点这是整个Shader的“大脑”部分。我们将创建三个关键的Custom Function Node。5.1 波浪位移函数WaveDisplacement这个函数接收世界坐标、时间、波参数和噪声纹理返回经过波浪扰动后的新世界坐标和用于计算法线的偏导数。// 这是一个简化的多频正弦波叠加实际项目可以用更复杂的Gerstner波 void WaveDisplacement_float(float3 WorldPos, float Time, float Speed, float Strength, float Frequency, float NoiseTiling, Texture2D NoiseTex, SamplerState Sampler, out float3 DisplacedPos, out float2 DerivativeX, out float2 DerivativeY) { // 计算用于采样的UV使用世界XZ坐标并乘以平铺系数 float2 uv WorldPos.xz * 0.01 * NoiseTiling; uv Time * Speed * 0.1; // 让噪声也随时间流动 // 采样噪声图r通道和g通道代表两个不同方向的扰动 float4 noise SAMPLE_TEXTURE2D(NoiseTex, Sampler, uv); float2 noiseOffset (noise.rg * 2.0 - 1.0) * 0.1; // 将[0,1]映射到[-0.1, 0.1] // 计算主要波浪位移在X和Z方向分别使用相位差的正弦波 float wave1 sin((WorldPos.x noiseOffset.x Time * Speed) * Frequency) * Strength; float wave2 sin((WorldPos.z noiseOffset.y Time * Speed) * Frequency * 1.3) * Strength * 0.8; // 合并波浪Y轴高度产生位移 float totalDisplacement (wave1 wave2) * 0.5; DisplacedPos WorldPos; DisplacedPos.y totalDisplacement; // 计算偏导数粗略近似法线变化 // 对x方向的导数假设在x0.01位置再计算一次高度差 float wave1_dx sin(((WorldPos.x 0.01) noiseOffset.x Time * Speed) * Frequency) * Strength; float wave2_dx sin((WorldPos.z noiseOffset.y Time * Speed) * Frequency * 1.3) * Strength * 0.8; float height_dx (wave1_dx wave2_dx) * 0.5; DerivativeX.x 0.01; // Δx DerivativeX.y height_dx - totalDisplacement; // Δy // 对z方向的导数 float wave1_dz sin((WorldPos.x noiseOffset.x Time * Speed) * Frequency) * Strength; float wave2_dz sin(((WorldPos.z 0.01) noiseOffset.y Time * Speed) * Frequency * 1.3) * Strength * 0.8; float height_dz (wave1_dz wave2_dz) * 0.5; DerivativeY.x 0.01; // Δz DerivativeY.y height_dz - totalDisplacement; // Δy }在Shader Graph中创建Custom Function节点将上述代码粘贴进去设置好输入和输出端口。这个节点将是我们产生动态波面的核心。5.2 深度颜色混合函数DepthColorBlend这个函数根据水面以下的深度在浅水色和深水色之间进行混合。void DepthColorBlend_float(float3 WorldPos, float3 ShallowColor, float3 DeepColor, float DepthFactor, out float3 OutputColor) { // 获取当前片元的屏幕空间深度从深度缓冲区 float sceneDepth LinearEyeDepth(SHADERGRAPH_SAMPLE_SCENE_DEPTH(TransformWorldToHClip(WorldPos).xy), _ZBufferParams); // 获取当前水面片元的深度 float surfaceDepth TransformWorldToHClip(WorldPos).w; // 或者使用LinearEyeDepth计算水面自身的深度 // 计算水面以下的深度差 float waterDepth sceneDepth - surfaceDepth; // 防止负值当水面在物体上方时 waterDepth max(0, waterDepth); // 使用指数衰减函数混合颜色深度越大深水色占比越高 float blendFactor 1.0 - exp(-waterDepth * DepthFactor); OutputColor lerp(ShallowColor, DeepColor, blendFactor); }这里用到了一个关键函数SHADERGRAPH_SAMPLE_SCENE_DEPTH这是Shader Graph提供的用于采样场景深度图的节点。我们需要在Graph中先通过Scene Depth节点获取深度再传入自定义函数。指数衰减exp(-waterDepth * factor)能模拟出光线在水中的衰减效果比线性插值更真实。5.3 菲涅尔效应函数FresnelEffect菲涅尔效应计算视线方向与水面法线点乘的结果并进行幂运算。void FresnelEffect_float(float3 WorldNormal, float3 ViewDirection, float Power, out float Fresnel) { // 计算法线与视线夹角的余弦值 float cosTheta saturate(dot(normalize(WorldNormal), normalize(ViewDirection))); // 使用Schlick近似公式F F0 (1 - F0) * (1 - cosθ)^5这里简化为(1-cosθ)^Power Fresnel pow(1.0 - cosTheta, Power); }这个函数很简单但非常有效。Power参数控制着菲涅尔效应的边缘宽度。值越大反射高菲涅尔值区域越集中在视线与水面近乎平行的狭窄边缘。6. 在Shader Graph中组装与连接有了核心函数节点现在就像搭积木一样把它们连接起来。6.1 顶点位移与法线生成在Vertex Shader阶段使用Position节点设置为World空间和Time节点作为输入连接到我们创建的WaveDisplacement自定义函数。将函数的DisplacedPos输出连接到Position节点的Position输入端口这样就实现了顶点动画。函数的DerivativeX和DerivativeY输出是两个二维向量分别代表了在X和Z方向微小变化时Y方向的高度变化。我们可以用这两个向量构造出新的切空间向量进而计算出新的世界空间法线。计算新的切线Tangent和副切线BitangentNew Tangent float3(1.0, DerivativeX.y / DerivativeX.x, 0.0)假设原始切线是(1,0,0)New Bitangent float3(0.0, DerivativeY.y / DerivativeY.x, 1.0)假设原始副切线是(0,0,1)通过叉积计算新的法线New Normal normalize(cross(New Bitangent, New Tangent))。注意叉积顺序以确保法线朝上。将计算出的New Normal通过Custom Interpolator或者直接输出到片段着色器供后续光照和菲涅尔计算使用。实操心得直接在Shader Graph里用节点计算法线会非常繁琐连线混乱。这正是我推荐将WaveDisplacement封装成自定义函数的原因之一——它直接输出了偏导数。另一种更高效的做法是在自定义函数里直接完成法线计算并输出float3 WorldNormal。你可以根据喜好调整。6.2 片段着色器中的颜色与光照基础颜色将世界坐标、浅水色、深水色、深度因子传入DepthColorBlend函数得到基于深度的颜色BaseColor。菲涅尔因子将上一步计算出的WorldNormal、View Direction需要归一化和_FresnelPower传入FresnelEffect函数得到Fresnel值。反射颜色添加一个Scene Color节点如果需要平面反射则更复杂可能需要自己渲染反射RT或使用SSR。添加一个Reflection Probe采样节点将其与菲涅尔因子相乘得到反射贡献。Reflection Probe节点需要世界法线和视差视差可设为0作为输入。最终颜色混合水的最终颜色是BaseColor折射/透射部分和Reflection Color反射部分的混合。混合系数就是菲涅尔因子。Final Color lerp(BaseColor, ReflectionColor, Fresnel)同时透明度Alpha也可以由菲涅尔因子或深度来控制例如让深水区更不透明浅水区更透明Alpha lerp(0.3, 0.8, saturate(waterDepth * 0.5))。光照与高光将Final Color连接到PBR Master的Base Color。将_Smoothness属性连接到PBR Master的Smoothness。Metallic保持为0水是非金属。将我们计算出的WorldNormal连接到Normal输入。这样URP的光照系统就会基于我们提供的法线和平滑度自动计算高光和环境光遮蔽。6.3 渲染状态优化回到Graph的Master Stack设置或通过Graph Inspector添加Keyword和Render State。可以考虑启用_ALPHAPREMULTIPLY_ON关键字并使用Alpha预乘混合这对于半透明物体的颜色混合有时更准确。在Render State中可以强制设置Cull为Off因为我们渲染双面但前面已经在Master节点设置了Both。一个重要的设置是Queue。为了确保水面在所有不透明物体之后但在其他透明物体如粒子特效之前渲染我们可以设置一个自定义的渲染队列。例如在Graph Inspector的Graph Settings里将Active Material的Queue设置为Transparent-100数值越小越早渲染。这样能保证水面的折射/深度计算能正确获取到不透明物体的深度信息。7. 材质配置与场景调试Shader编译成功后在Project中会生成一个.shadergraph文件和一个.shader文件。右键.shadergraph文件选择Create - Material生成对应的材质球。将其命名为Mat_Water并拖拽给场景中的WaterSurface物体。7.1 参数调节心法打开材质Inspector面板你会看到所有在Blackboard中定义的属性。调节是一个艺术活但有些规律可循_WaveSpeed、_WaveStrength、_WaveFrequency从小值开始调。速度(0.1~0.5)强度(0.05~0.3)频率(0.5~2.0)。避免波浪看起来像沸腾的开水。_DepthFactor这个值很敏感。从0.1开始尝试值越大颜色从浅到深的变化越剧烈通常0.1~0.5之间比较自然。_ShallowColor和_BaseColor选择互补或相邻色。浅水色常用青绿色RGB约0.4, 0.8, 0.7深水色用深蓝色RGB约0.0, 0.1, 0.3。_FresnelPower控制反射范围。3~8之间比较常用。值太小则整个水面都反射像镜子值太大则只有非常边缘处反射。_Smoothness0.8~0.95让水面有明确的高光点。7.2 场景布置技巧光照一个强烈的主方向光模拟太阳是必须的它能产生漂亮的高光。可以考虑添加一个轻微的蓝色调的环境光Ambient Light来补充阴影处的颜色。水底在WaterSurface的Plane下方放置一个带有沙石纹理的Terrain或Mesh作为水底。调整_DepthFactor和浅水色让你能隐约看到它。岸边使用地形工具或Mesh创建有起伏的河岸/湖岸。岸边的斜坡与水面相交处是检验深度过渡是否自然的关键区域。反射探针确保其Box Size覆盖了水面及周边你会看到的景物。如果性能允许可以设置Refresh Mode为Every Frame但更推荐On Awake或通过脚本在需要时刷新。8. 性能优化与进阶方向一个基础的水面效果在PC上可能没问题但在移动端我们必须精打细算。8.1 性能优化点计算频率波浪计算放在顶点着色器还是片段着色器顶点着色器开销小但精度低水面网格细分不足时会显得块状。片段着色器精度高效果平滑但开销大。折中方案在顶点着色器做主要波浪位移在片段着色器用一张法线贴图可流动的补充高频细节。我们的自定义函数目前是在顶点阶段计算位移和法线是性能较好的方案。深度纹理Scene Depth节点的采样是有成本的。确保你的URP Asset设置中启用了Depth Texture。如果场景中只有水面需要深度可以考虑通过脚本控制相机的depthTextureMode。反射开销Realtime反射探针是性能杀手。对于静态环境务必烘焙Bake。对于动态水面可以考虑使用较低的分辨率或者使用Box Projection的烘焙探针结合屏幕空间反射SSR来模拟动态倒影。URP的SSR是一个后处理效果可以产生不错的镜面反射但对透明物体支持有限需要测试。网格复杂度水的Plane网格需要足够的细分三角形数量来表现波浪细节。但过多三角形会增加顶点着色器负担。使用LODLevel of Detail近距离使用高细分网格远距离替换为低细分网格甚至一个简单平面加法线贴图。Shader变体我们的Shader Graph可能会生成多个变体例如是否使用深度纹理、是否使用反射探针。在Graph Inspector中检查并剔除不需要的Keyword减少变体数量。8.2 效果进阶方向当基础效果满足后可以尝试添加以下特性让水面更加惊艳泡沫与浪花在岸边通过深度和坡度判断或波浪顶端通过法线陡峭度判断叠加泡沫纹理。可以使用顶点颜色或一张遮罩图来控制泡沫出现的位置。焦散Caustics模拟水底的光斑效果。通常使用一张动态的、投影到水底地面的焦散纹理动画来实现。这需要在另一个Pass中或通过脚本投影实现。交互涟漪当角色或物体进入水中时产生扩散的涟漪。这通常需要将交互位置和力度传递到Shader中通过贴图或全局参数并在波浪计算中叠加一个随时间衰减的圆形波。水下效果当摄像机进入水面以下时切换后处理效果颜色偏移、模糊、光轴God Rays。这需要检测摄像机深度并启用不同的Volume。8.3 常见问题排查水面闪烁Z-fighting水面和地面或水底模型太近。确保水面Mesh略高于水底几何体例如高0.01个单位。也可以尝试微调材质的Render Queue。反射错乱或缺失检查反射探针是否覆盖水面Type是否正确。在Shader Graph中检查Reflection Probe节点的输入法线是否正确以及菲涅尔因子是否合理可能值始终为0或1。确保URP渲染器设置中启用了透明物体的反射探针采样。边缘硬边或不透明检查深度计算是否正确。sceneDepth和surfaceDepth的计算可能因坐标系转换而出错。使用Visualize - Depth节点将深度值可视化输出为颜色检查过渡是否平滑。同时检查Alpha混合设置和颜色混合公式。波浪不自然或拉伸检查噪声纹理的Wrap Mode是否为RepeatTiling值是否合适。检查波浪计算中世界坐标的缩放因子。确保用于计算法线的偏导数Delta值代码中的0.01大小适中太小会精度不足太大会导致法线突变。移动端性能差首先使用Unity的Frame Debugger和Profiler定位瓶颈。如果问题在片段着色器尝试简化菲涅尔计算用pow(saturate(1.0 - NdotV), 5)代替更复杂的公式降低反射探针分辨率或者完全关闭实时反射使用静态立方体贴图。考虑将复杂的自定义函数节点中的部分计算移到顶点着色器阶段。实现一个令人满意的水面效果是一个不断迭代和权衡的过程。从最基础的波动开始逐步加入深度色、菲涅尔、反射每加一步都观察效果和性能。不要指望一次就调出“3A”级效果先把基础打扎实确保它运行稳定、性能可控然后再根据项目需求有选择地添加那些“锦上添花”的特性。这个在URP中构建的Shader Graph框架为你提供了继续探索和优化的坚实基础。

相关新闻