Unity URP动态水面Shader实现:从法线扰动到深度折射全解析

发布时间:2026/7/24 1:36:52
Unity URP动态水面Shader实现:从法线扰动到深度折射全解析 1. 项目概述为什么要在URP里折腾水面做游戏或者数字孪生这类项目场景里要是没片像样的水总觉得差点意思。静态的水面贴图早就过时了玩家和用户现在要的是那种波光粼粼、能倒映天空、甚至能互动起浪的真实感。Unity作为主流引擎实现水面效果的路子很多但自从Universal Render PipelineURP通用渲染管线成为官方主推的新标准后很多老方法就得跟着变一变。我最近刚在一个数字孪生项目里用URP重做了一遍水面踩了不少坑也总结了一套比较稳当的实现流程。你会发现URP虽然简化了渲染设置但在实现一些高级效果时它的“脾气”和内置渲染管线不太一样尤其是涉及到自定义Shader和后期处理的时候。网上很多教程是基于老版内置管线写的直接照搬可能会遇到各种光照不正常、深度采样失败的问题。所以这篇内容我就围绕“在URP下实现一套完整的动态水面效果”这个目标从核心思路拆解到Shader代码细节再到性能优化和常见坑点给你完整捋一遍。无论你是想做个湖面、海面还是一个小水池这套方法都能给你一个扎实的起点。2. 核心思路与方案选型从“看起来像”到“动起来真”实现动态水面本质上是在模拟光与水的复杂交互。我们不需要完全复现物理公式而是用一系列“视觉把戏”来欺骗眼睛。在URP框架下我们需要组合使用几种关键技术2.1 核心视觉组件拆解一个合格的水面通常由以下几个视觉层叠加而成基础颜色与透明度水的颜色不是单一的通常岸边浅、深处蓝并且具有半透明特性。法线扰动波浪这是动态感的来源。通过一张或叠加多张法线贴图Normal Map并让它们随时间滚动来模拟水面的波光粼粼。这是最关键的一步。高光反射水面对光源如太阳、月亮的镜面反射。在URP中我们需要自己计算高光因为URP的Lit Shader流程和内置管线的Standard Shader不同。环境反射反射天空盒或周围环境。可以用反射探针Reflection Probe但对于开阔水面更常用的方法是使用屏幕空间反射SSR或简单的立方体贴图Cubemap采样。折射透过水面看到水底物体的扭曲效果。这需要用到相机的深度纹理和颜色纹理进行扰动采样。边缘泡沫与焦散岸边或物体周围的白色泡沫以及水底的光斑效果。这些属于提升细节的“加分项”。2.2 URP下的方案决策在URP中实现上述效果你有几个选择每个选择都意味着不同的工作量、效果上限和性能开销方案A使用URP Lit Shader为基础修改思路从URP提供的Simple Lit或LitShader模板开始在其基础上添加法线滚动、折射等计算。优点可以最大程度复用URP的光照模型确保与场景中其他URP物体的光照一致性如阴影接收。上手相对快。缺点URP的Shader结构复杂定制深度效果如复杂的折射、边缘泡沫时需要深入理解其代码框架可能会束手束脚。适用场景对光照一致性要求高且水面效果需求中等有波浪、基础反射折射即可的项目。方案B编写自定义Unlit Shader思路完全自己控制输出颜色抛开URP的PBR光照计算。自己实现菲涅尔效应Fresnel、法线、反射、折射。优点绝对的控制权可以实现任何天马行空的效果代码结构清晰便于调试和优化。缺点需要自己处理与场景光源的交互比如让水面能对方向光产生高光失去了URP内置的阴影接收等便利功能。适用场景追求独特风格化效果或需要极致性能控制可以砍掉很多计算的场景。方案C使用Asset Store插件思路直接购买如AQUAS、Water System等成熟插件。优点效果华丽功能全面通常包含水下效果、流动贴图等节省大量开发时间。缺点成本高插件可能过于庞大定制化修改需要研究插件代码可能与项目特定的URP版本存在兼容性问题。适用场景商业项目赶工期或对水面效果有极高要求且预算充足。对于大多数希望学习和掌控技术的中小型项目我推荐从方案A开始。它平衡了效果、性能和可学习性。本次分享也将以“基于URP Lit Shader模板进行扩展”作为主线。当你完全吃透后转向方案B也会非常轻松。注意无论选择哪种方案在URP中获取深度和颜色纹理用于折射都需要在URP Asset中显式开启Opaque Texture和Depth Texture选项。这是第一个也是最重要的一个坑点后面会详细说。3. 环境准备与核心资源制作工欲善其事必先利其器。在动手写代码前先把环境和素材准备好。3.1 URP项目配置检查创建或升级项目确保你使用的是Unity 2021 LTS或更新版本创建项目时选择“Universal Render Pipeline”模板。如果是老项目需要通过Package Manager安装Universal RP包并创建URP Asset替换掉原有的渲染管线资产。关键设置在Project窗口中找到你的URP Asset文件通常叫UniversalRP-HighQuality或类似选中它在Inspector面板中勾选Opaque Texture这将生成一张包含所有不透明物体颜色的屏幕纹理是我们做折射的基础。勾选Depth Texture生成深度纹理用于计算水面像素的深度差实现正确的折射和深度边缘泡沫。调整Opaque Downsampling为了性能可以设为2x Bilinear或4x Box这会在渲染折射时使用降采样的纹理轻微牺牲质量换取性能对于移动端尤其重要。3.2 法线贴图资源准备水面的动态波浪几乎完全依赖法线贴图。你需要至少一张高质量的水面法线贴图。可以去纹理资源网站搜索“Water Normal Map”选择那种带有细腻波纹的。理想情况下准备两张不同缩放和滚动速度的法线贴图叠加使用可以打破重复感让波浪看起来更自然。技巧一自己生成法线。如果你有类似Substance Designer的工具可以自己制作。一个简单的方法是在Photoshop中创建灰度图用云彩、纤维等滤镜模拟波浪高度然后使用“滤镜-3D-生成法线图”功能来转换。技巧二使用噪声叠加。在Shader中除了采样法线贴图还可以混合一些程序化噪声如Value Noise进一步增加细节和随机性避免明显的贴图重复平铺。3.3 创建自定义Shader与材质在Project窗口中右键选择Create - Shader - Universal Render Pipeline - Lit Shader。给它起个名字比如WaterURP.shader。这个模板就是我们改造的起点。创建一个新材质球将Shader选择为你刚刚创建的WaterURP。后续所有的属性调节都将在这个材质球上进行。4. Shader核心逻辑解析与实现这是最硬核的部分。我们将打开WaterURP.shader文件一步步添加功能。我会先解释原理再给出代码片段。请注意以下代码需要插入到Shader的适当位置主要是SurfaceInput.hlsl输入结构、Lighting.hlsl光照计算和片元着色器fragment shader部分。为了清晰我会说明插入点。4.1 扩展输入属性与变量首先在Shader文件的Properties块和对应的HLSL变量声明块中添加我们需要的参数。// 在 Properties 块中添加 _BaseColor(Water Color, Color) (0.1, 0.3, 0.5, 0.8) _ShallowColor(Shallow Water Color, Color) (0.2, 0.6, 0.8, 0.5) // 浅水区颜色 _DepthFactor(Depth Color Factor, Range(0, 5)) 1.0 // 深度颜色过渡系数 _WaveNormalMap(Wave Normal Map, 2D) bump {} _WaveNormalMap2(Wave Normal Map 2, 2D) bump {} _NormalTiling(Normal Tiling, Vector) (1,1,0,0) // 两张法线贴图的平铺 _NormalSpeed(Normal Speed, Vector) (0.05, 0.03, 0.04, 0.02) // xy: 第一张速度 zw: 第二张速度 _NormalStrength(Normal Strength, Range(0, 2)) 0.5 // 法线强度 _RefractionStrength(Refraction Strength, Range(0, 0.1)) 0.02 // 折射强度 _FresnelPower(Fresnel Power, Range(1, 10)) 5.0 // 菲涅尔效应强度 _Specular(Specular, Range(0, 1)) 0.5 // 高光强度 _Smoothness(Smoothness, Range(0, 1)) 0.9 // 光滑度 _FoamColor(Foam Color, Color) (1,1,1,1) _FoamDistance(Foam Distance, Range(0, 5)) 1.0 // 泡沫出现距离 _FoamNoise(Foam Noise, 2D) white {} // 泡沫噪声图4.2 实现动态法线采样这是产生波光粼粼效果的核心。思路是采样两张法线贴图让它们的UV随时间偏移然后混合两张法线向量。在片元着色器中通常在SurfaceData填充之后光照计算之前// 计算随时间滚动的UV float2 uv1 input.uv * _NormalTiling.xy _Time.y * _NormalSpeed.xy; float2 uv2 input.uv * _NormalTiling.zw _Time.y * _NormalSpeed.zw; // 从法线贴图解码法线向量记得将贴图设置为Normal Map格式 float3 normal1 UnpackNormalScale(SAMPLE_TEXTURE2D(_WaveNormalMap, sampler_WaveNormalMap, uv1), _NormalStrength); float3 normal2 UnpackNormalScale(SAMPLE_TEXTURE2D(_WaveNormalMap2, sampler_WaveNormalMap2, uv2), _NormalStrength); // 混合两张法线。简单的加法后归一化或者使用更复杂的混合方式 float3 waveNormal normalize(float3(normal1.xy normal2.xy, normal1.z * normal2.z)); // 保留z分量的乘积有助于保持法线“向上” // 将扰动后的法线应用到表面数据中供URP光照计算使用 surfaceData.normalTS waveNormal; // 注意这里是在切线空间下的法线实操心得_NormalSpeed的取值很关键。让两张法线图以不同速度、不同方向比如一个x正方向一个y负方向滚动能有效避免产生明显的定向流动图案让波浪看起来更自然、无序。数值通常很小比如(0.03, 0.02, -0.02, 0.01)。4.3 实现基于深度的颜色与折射水的颜色应该随深度变化并且能看到水底物体的折射扭曲。// 1. 获取屏幕空间位置和深度 float2 screenUV input.screenPos.xy / input.screenPos.w; // 得到0-1范围的屏幕UV float sceneRawDepth SampleSceneDepth(screenUV); // 采样场景深度 float waterRawDepth input.screenPos.w; // 水面本身的深度线性深度需要转换这里简写 // 将深度值转换为线性深度近似。URP提供了LinearEyeDepth函数但需要正确引入。 // 假设我们已经正确引入了Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl float sceneLinearDepth LinearEyeDepth(sceneRawDepth, _ZBufferParams); float waterLinearDepth LinearEyeDepth(waterRawDepth, _ZBufferParams); // 计算深度差 float depthDifference sceneLinearDepth - waterLinearDepth; depthDifference max(0, depthDifference); // 确保非负 // 2. 基于深度差混合深浅水颜色 float depthLerpFactor saturate(depthDifference * _DepthFactor); float4 waterColor lerp(_ShallowColor, _BaseColor, depthLerpFactor); // 3. 折射效果用法线扰动屏幕UV再去采样场景颜色 float2 refractionOffset waveNormal.xy * _RefractionStrength * saturate(depthDifference); // 深度越大折射越强 float2 refractedScreenUV screenUV refractionOffset; float3 sceneColor SampleSceneColor(refractedScreenUV); // 采样扰动后的场景颜色 // 4. 将折射颜色与水的本色混合。简单做法是直接用水的透明度进行混合 float3 finalBaseColor lerp(sceneColor, waterColor.rgb, waterColor.a);关键点解析这里用到了SampleSceneDepth和SampleSceneColor。在URP中你需要先在Shader顶部声明这些纹理。通常在HLSLINCLUDE块或Pass之前添加TEXTURE2D_X_FLOAT(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); TEXTURE2D_X(_CameraOpaqueTexture); SAMPLER(sampler_CameraOpaqueTexture);并且确保你的URP Asset中开启了Depth Texture和Opaque Texture。这是最容易出错的地方如果采样结果是黑的首先检查这里。4.4 实现菲涅尔反射与高光菲涅尔效应是指视线与水面法线夹角越大即看向水面边缘反射越强夹角越小看向水面正下方折射越强。// 计算菲涅尔系数 (Fresnel) float3 viewDirWS normalize(input.viewDirectionWS); // 世界空间视线方向 float3 normalWS TransformTangentToWorld(waveNormal, input.tangentToWorld); // 将法线转到世界空间 float fresnel pow(1.0 - saturate(dot(viewDirWS, normalWS)), _FresnelPower); // 采样环境反射这里使用最简单的方法采样天空盒 float3 reflection GlossyEnvironmentReflection(reflect(-viewDirWS, normalWS), 0.0, _Smoothness); // 可以使用URP内置函数 // 混合反射颜色到最终颜色 float3 finalColor lerp(finalBaseColor, reflection, fresnel); // URP Lit Shader的高光计算通常已经在光照函数中完成我们只需要确保surfaceData.specular和smoothness设置正确。 // 我们在前面Properties中定义的_Specular和_Smoothness需要传递给surfaceData。 surfaceData.specular _Specular; surfaceData.smoothness _Smoothness;4.5 实现边缘泡沫泡沫通常出现在水面与其他物体如岸边、船体交界处也就是深度发生突变的地方。// 计算深度差的变化率梯度模拟泡沫 float depthGradient fwidth(depthDifference); // 使用fwidth求近似梯度 float foamMask saturate(depthGradient * _FoamDistance * 10); // 放大并钳制 // 可以叠加一张噪声图来打破泡沫的均匀感 float2 foamUV input.worldPos.xz * 0.1 _Time.y * 0.1; // 世界坐标缩放后滚动 float foamNoise SAMPLE_TEXTURE2D(_FoamNoise, sampler_FoamNoise, foamUV).r; // 结合梯度遮罩和噪声 foamMask * foamNoise; // 将泡沫颜色混合到最终颜色 finalColor lerp(finalColor, _FoamColor.rgb, foamMask * _FoamColor.a);5. 材质调试与场景整合Shader写完后回到Unity编辑器调整材质球上的参数观察效果。5.1 参数调节经验_NormalStrength从0.1开始慢慢调。太小没波浪太大会显得水面像沸腾的油锅不自然。_DepthFactor控制从浅水色过渡到深水色的速度。对于泳池或近岸水域可以调大如3-5让颜色变化更明显对于深海可以调小如0.5-1颜色更均匀。_RefractionStrength典型的“少即是多”参数。0.01到0.03之间通常就能得到不错的扭曲效果再大就容易让人眼晕了。_FresnelPower控制反射出现的范围。值越大反射越集中在边缘视角很斜时。5是一个不错的起始值。5.2 场景布置技巧水面网格用一个简单的平面Plane或细分更多的网格作为水面。对于大范围水域建议使用一个低多边形网格靠法线贴图提供细节。如果需要远处有海浪轮廓可以专门做一个带有起伏的网格作为远景。反射探针在水面中心位置放置一个反射探针Reflection Probe选择类型为“Realtime”或“Baked”。如果是静态场景烘焙一次即可。这对于反射周围静态环境至关重要。后期处理在URP的Volume中可以添加一些后期效果来增强水面Bloom让水面的高光部分“泛光”更有梦幻感。Color Adjustments微调对比度和饱和度让水的颜色更通透。Vignette轻微暗角可以引导视线聚焦到水面中心。6. 性能优化与常见问题排查一个好看但跑不动的效果是没有意义的。以下是针对URP水面的优化和排错指南。6.1 性能优化要点纹理与采样优化法线贴图尺寸不宜过大1024x1024甚至512x512对于大多数情况足够可以压缩为DXT5nmPC或ASTC移动端格式。在URP Asset中开启Opaque Downsampling如前所述。考虑在移动端或低配平台上只使用一张法线贴图或者降低法线滚动的计算频率比如在片元着色器中使用_Time.y * 0.5。Shader复杂度边缘泡沫和复杂的焦散效果是性能杀手。如果帧率吃紧优先考虑简化或移除这些效果。检查Shader的编译变体数量。过多的#pragma multi_compile会导致构建包体膨胀。可以使用Shader Variant Stripping工具或手动剔除不需要的特性。渲染状态水面的材质应设置为Transparent或Opaque如果不需折射。透明渲染开销更大如果水完全不透明可以设为Opaque并关闭折射计算。确保水面物体在相机的裁剪距离Culling Distance内是合理的不要渲染看不见的远处水面。6.2 常见问题与解决方案实录问题现象可能原因排查与解决步骤水面一片纯黑或纯白1. Shader编译错误。2. 深度/颜色纹理未正确声明或采样。1. 查看Console窗口是否有Shader错误红色报错。2. 检查Shader中TEXTURE2D_X/_CameraDepthTexture等声明是否正确拼写是否与URP内置变量名一致。3.确认URP Asset中已勾选Depth Texture和Opaque Texture。这是最常见的原因没有波浪效果1. 法线贴图未赋值或格式不对。2. 法线贴图导入设置未标记为“Normal Map”。3._NormalStrength为0。1. 检查材质球上法线贴图槽位。2. 在Project窗口选中法线贴图在Inspector中确保Texture Type为Default并勾选Bypass sRGB Sampling同时将Advanced下的Format设为Normal map。对于URP更推荐使用Texture Importer中的Normal Map纹理类型。3. 调整_NormalStrength参数。折射效果错乱或闪烁1. 深度计算错误特别是线性深度转换。2. 折射偏移量_RefractionStrength过大。3. 水面网格背面渲染问题。1. 使用Visualize Depth工具或写一个简单的Debug Shader输出深度值检查深度纹理是否正确。2. 将_RefractionStrength调小至0.01左右。3. 确保水面网格法线朝上。或在水面Shader的SubShader中设置Cull Off双面渲染试试。与场景光源交互异常无高光1. 自定义Shader未正确输出或计算高光信息。2. 表面数据SurfaceData中的smoothness或specular设置不正确。1. 如果基于URP Lit模板确保SurfaceData结构体中的smoothness和specular字段被正确赋值。2. 检查场景中主光源Directional Light的强度是否足够并启用了阴影。在Game视图正常打包后失效1. Shader变体被错误剥离。2. 某些图形API如OpenGL ES支持特性不同。1. 在Project Settings - Graphics - Shader Stripping中尝试关闭一些剥离选项或为你的Shader添加必要的multi_compile指令。2. 在Player Settings中检查目标图形API并测试在较低版本如GLES 2.0下的表现。简化Shader中可能不被广泛支持的函数。6.3 一个实用的调试技巧当你对效果不满意又不知道是哪部分出问题时可以创建一个“调试材质”。复制你的水Shader然后修改片元着色器让它只输出你关心的中间值。例如// 调试只显示法线 return float4(waveNormal * 0.5 0.5, 1.0); // 将法线从(-1,1)映射到(0,1)显示 // 调试只显示深度差 return float4(depthDifference.xxx * 0.1, 1.0); // 深度差通常很小乘个系数便于观察 // 调试只显示菲涅尔系数 return float4(fresnel.xxx, 1.0);通过这种方式你可以清晰地看到每一层效果的贡献从而精准定位问题。最后别忘了性能分析。使用Unity的Profiler特别是GPU模块和Frame Debugger观察绘制水面时的GPU耗时和Draw Call确保它在你的目标平台上运行流畅。实现动态水面是一个平衡艺术与技术的过程从基础的法线扰动开始逐步叠加反射、折射、深度色等效果每次只添加一个功能并充分测试是最高效的策略。希望这份结合了URP特性的指南能帮你少走弯路做出既漂亮又高效的水面。