尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity交互式水面Shader实战:从基础波纹到动态涟漪的完整实现

Unity交互式水面Shader实战:从基础波纹到动态涟漪的完整实现 你是否曾想过为什么许多游戏和影视作品中的水面效果有的看起来廉价而虚假有的却能让你仿佛感受到湖面的微风和波光粼粼这背后水面Shader的实现质量是决定沉浸感的关键分水岭。对于Unity、UE等引擎的开发者而言实现一个“能看”的水面或许不难但打造一个兼具美感、性能与交互性的水面却是一个涉及数学、物理、图形学和工程化的综合挑战。本文并非泛泛而谈水面渲染的概念而是从一个更实战、更落地的角度切入如何系统性地拆解并实现一个从基础波纹到支持交互的完整水面Shader。我们将以Unity引擎的ShaderLab和HLSL/GLSL为例但核心原理适用于任何支持可编程渲染管线的环境。文章将摒弃空洞的理论堆砌聚焦于“为什么这么做”以及“实际项目中如何避坑”目标是让你读完不仅能复现效果更能理解其背后的设计逻辑从而具备定制和优化自己项目水面效果的能力。1. 这篇文章真正要解决的问题很多开发者学习水面Shader时容易陷入两个误区一是盲目复制粘贴网上零散的代码片段结果效果诡异、性能低下且无法调试二是被复杂的数学公式如FFT、Gerstner波吓退认为高质量水面是图形程序员的专属领域。实际上一个适用于大多数游戏场景的“够用且好看”的水面其技术栈是可以被清晰拆解的。本文要解决的核心问题是如何为你的游戏或交互应用构建一个结构清晰、易于扩展、性能可控的交互式水面Shader系统。我们将从最基础的噪声扰动开始逐步叠加法线贴图、深度混合、高光反射、折射等核心视觉特征最终实现玩家或物体与水面动态交互的涟漪效果。整个过程你会看到每个技术点如何被独立模块化以及它们组合后如何产生“112”的视觉增益。无论你是独立开发者还是技术美术TA这篇文章都将提供一个从零到一的完整实现路径和工程化思考。2. 基础概念与核心原理水面渲染的“三层楼”在动手写代码前我们需要建立一个清晰的认知模型。一个高质量的实时水面渲染可以抽象为三个层次第一层几何与形状Shape这是水面的“骨架”决定了宏观的波浪形态。最简单的是使用一个平面网格Plane。为了表现起伏我们需要在Shader中动态计算顶点偏移。这里就引入了顶点着色器Vertex Shader的核心作用根据时间、位置等信息修改每个顶点的世界空间坐标。常用的波形函数有正弦波、噪声Noise函数以及更复杂的Gerstner波能产生更真实的波峰尖、波谷平的效果。第二层表面细节Surface Detail这是水面的“皮肤”决定了近距离观察时的质感。主要通过法线贴图Normal Map来实现。我们通常使用一张或两张用于流动混合水波纹法线贴图在Shader中根据时间和方向进行滚动Scrolling和混合Blending模拟出细腻的波纹细节。法线信息是后续计算光照、反射、折射的基础。第三层光学特性Optics这是水面的“灵魂”决定了它看起来像水。核心是模拟光与水的相互作用反射Reflection通常通过捕获场景的反射贴图Render Texture或使用屏幕空间反射SSR实现。折射Refraction根据水面法线扰动屏幕背景颜色模拟水下物体的扭曲效果。菲涅尔效应Fresnel Effect视线与水面法线夹角越大掠射角反射越强夹角越小垂直视角折射越强。这是水面边缘看起来更反光的关键。高光Specular模拟阳光或点光源在水面波峰上的闪耀。深度色Depth Color根据水深通过深度纹理获取混合浅水色和深水色表现水体的通透感和深浅变化。理解了这三层我们就知道Shader的每个部分在为什么服务。接下来我们从环境搭建开始。3. 环境准备与前置条件我们的实战环境基于Unity 2022.3 LTS或更高版本。选择这个版本是因为它提供了稳定且现代的渲染管线支持。你也可以使用Unity 2019.4 LTS或2021.3 LTS但部分API可能略有不同。核心依赖与设置渲染管线选择本文示例基于Built-in Render Pipeline内置渲染管线因其通用性最强。如果你使用URPUniversal Render Pipeline或HDRPHigh Definition Render PipelineShader语法和部分节点如获取深度需要转换但核心算法完全一致。我们会在关键处注明差异。项目设置打开Edit - Project Settings - Graphics确保Shader兼容级别足够。为了获取深度纹理用于折射和深度色我们需要让相机生成深度纹理。最简单的方法是为主相机添加一个Camera组件默认就有并确保其Allow MSAA和Allow HDR设置与你的项目需求一致。对于内置管线我们通常需要手动开启深度纹理。创建测试场景创建一个新场景添加一个平面GameObject - 3D Object - Plane作为水面将其Scale放大例如10,1,10。创建一个球体或立方体部分浸入水中用于测试折射和交互。调整光源Directional Light角度以观察高光和反射。资源准备准备一张水波纹法线贴图。可以在Unity Asset Store搜索“Water Normal Map”找到免费资源或使用任何一张蓝紫色调的法线贴图。4. 核心流程拆解从平面到活水我们将实现过程分为六个关键步骤每一步都对应一个可测试的中间状态。4.1 步骤一创建基础Shader与材质首先在Project窗口中右键Create - Shader - Standard Surface Shader命名为WaterShader。但Surface Shader对于完全自定义的水面控制不够直接我们更推荐从Unlit Shader开始改造以获得完全控制权。不过为了快速入门我们先使用Surface Shader框架。双击打开Shader将其整体替换为我们逐步构建的代码。创建一个新材质Material将Shader指定为我们刚创建或修改的WaterShader并将其拖拽给场景中的水面Plane。4.2 步骤二实现顶点动画波浪这是“第一层几何与形状”的实现。我们将在顶点着色器中使用正弦函数叠加噪声让平面动起来。关键思路使用世界坐标或模型坐标的XZ分量作为输入。引入_Time.y来控制动画。使用sin函数生成基础波形。使用noise函数如Unity_Noise_float或在Shader中实现一个简单的值噪声增加随机性。将计算出的高度偏移displacement加到顶点Y坐标上。4.3 步骤三应用法线贴图波纹细节这是“第二层表面细节”的核心。我们将在片元着色器中采样法线贴图。关键思路在Properties块中定义_NormalMap法线贴图和_NormalScale强度属性。声明对应的变量。在片元着色器中使用tex2D采样法线贴图。为了模拟流动需要根据时间_Time.y和流动速度_Speed对UV坐标进行偏移。通常使用两张法线贴图以不同的速度和方向滚动然后混合可以避免明显的重复图案。将采样的法线从切线空间转换到世界空间需要worldNormal和worldTangent等信息以供后续光照计算使用。4.4 步骤四计算基础光学效果菲涅尔、反射、折射这是“第三层光学特性”的主体。我们将逐步加入这些效果。菲涅尔效应 使用dot函数计算视线方向View Dir与水面法线Normal的点积。根据点积结果用lerp或smoothstep函数混合反射颜色和折射颜色。反射 简易方法使用立方体贴图Cubemap进行环境反射。这适合天空盒反射。 高级方法使用反射探针Reflection Probe或渲染到纹理Render to Texture生成平面反射。这需要额外的渲染通道。折射 简易方法屏幕空间折射。使用GrabPass或内置的_GrabTexture内置管线来捕获屏幕背景。然后用法线信息扰动采样UV坐标模拟扭曲。 注意URP/HDRP中GrabPass的用法不同通常使用_CameraOpaqueTexture。4.5 步骤五加入深度与边缘融合没有深度处理的水面会像浮在空中的彩色玻璃。我们需要让水体的颜色和透明度随着水深变化。关键思路使用LinearEyeDepth或Linear01Depth函数解码相机的深度纹理_CameraDepthTexture。计算水面当前像素的深度与场景中物体实际深度的差值得到水深。根据水深用smoothstep函数平滑地混合浅水颜色、深水颜色以及完全不透明如水下物体。4.6 步骤六实现交互式涟漪这是让水面“活”起来的点睛之笔。当物体如角色、雨滴与水碰撞时生成动态涟漪。关键思路数据传递在脚本中C#检测碰撞或触发将碰撞点的世界坐标、强度、半径等信息通过Shader全局属性Shader.SetGlobalVector或材质属性数组传递给Shader。Shader处理在片元着色器中对于每个像素计算其到所有活动涟漪中心的距离。波形计算根据距离、时间和衰减参数使用一个衰减的正弦波或高斯函数来计算该点应附加的高度偏移和法线扰动。叠加将计算出的交互式法线扰动与原有的法线贴图法线进行混合。5. 完整示例与代码实现下面我们将分模块呈现一个简化但完整的内置渲染管线水面Shader代码。请注意这是一个教学示例可能需要根据你的具体需求进行调整和优化。5.1 Shader属性与变量定义Shader Custom/InteractiveWater { Properties { // 颜色 _ShallowColor (浅水颜色, Color) (0.1, 0.3, 0.5, 0.8) _DeepColor (深水颜色, Color) (0.0, 0.1, 0.2, 1.0) _FresnelColor (菲涅尔边缘色, Color) (1,1,1,1) _SpecularColor (高光颜色, Color) (1,1,1,1) _Gloss (光泽度, Range(8, 256)) 20 // 法线贴图 _NormalMap (法线贴图, 2D) bump {} _NormalScale (法线强度, Range(0, 2)) 1.0 _ScrollSpeed1 (流动速度1, Vector) (0.03, 0.03, 0, 0) _ScrollSpeed2 (流动速度2, Vector) (-0.02, 0.01, 0, 0) // 波浪 _WaveAmplitude (波浪幅度, Float) 0.1 _WaveFrequency (波浪频率, Float) 1.0 _WaveSpeed (波浪速度, Float) 0.5 // 深度与透明度 _MaxDepth (最大可见深度, Float) 5 _DepthFalloff (深度衰减, Float) 1 // 交互涟漪 _RippleStrength (涟漪强度, Float) 0.05 _RippleDecay (涟漪衰减, Float) 2.0 } SubShader { Tags { RenderTypeTransparent QueueTransparent IgnoreProjectorTrue } LOD 300 // 抓取屏幕纹理用于折射内置管线 GrabPass { _GrabTexture } CGPROGRAM #pragma surface surf Standard alpha:fade vertex:vert #pragma target 3.0 #include UnityCG.cginc #include UnityLightingCommon.cginc // 变量声明与Properties对应 sampler2D _NormalMap; float4 _NormalMap_ST; float _NormalScale; float2 _ScrollSpeed1, _ScrollSpeed2; fixed4 _ShallowColor, _DeepColor, _FresnelColor, _SpecularColor; float _Gloss; float _WaveAmplitude, _WaveFrequency, _WaveSpeed; sampler2D _CameraDepthTexture; // 深度纹理 float _MaxDepth, _DepthFalloff; sampler2D _GrabTexture; // 抓取的屏幕纹理 float4 _GrabTexture_TexelSize; float _RippleStrength, _RippleDecay; // 假设我们通过脚本传递一个涟漪中心 float3 _RippleCenter; float _RippleTime; // 结构体 struct Input { float2 uv_NormalMap; float3 worldPos; float3 worldNormal; INTERNAL_DATA float4 screenPos; float3 viewDir; }; // 顶点着色器实现基础波浪 void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 基础正弦波 float wave sin(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveAmplitude; // 简单噪声增加细节这里用正弦波模拟 float noise sin(_Time.y * 0.7 v.vertex.x * 2.3 v.vertex.z * 1.7) * 0.03; v.vertex.y wave noise; // 计算世界法线简化处理 v.normal normalize(float3(-cos(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveAmplitude * _WaveFrequency, 1.0, 0)); }代码解释Properties块定义了所有可在材质面板调节的参数。GrabPass用于捕获渲染水面之前的屏幕图像。vert函数是顶点着色器它修改了顶点Y坐标来模拟波浪并粗略估算了新的法线方向。5.2 片元着色器核心光学计算// 表面着色函数 void surf (Input IN, inout SurfaceOutputStandard o) { // 1. 采样并混合法线贴图 float2 uv1 IN.uv_NormalMap _Time.y * _ScrollSpeed1; float2 uv2 IN.uv_NormalMap _Time.y * _ScrollSpeed2; float3 normal1 UnpackNormal(tex2D(_NormalMap, uv1)); float3 normal2 UnpackNormal(tex2D(_NormalMap, uv2)); float3 tangentNormal normalize(normal1 normal2) * _NormalScale; tangentNormal.z sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 确保单位长度 o.Normal tangentNormal; float3 worldNormal WorldNormalVector(IN, o.Normal); // 2. 计算交互涟漪对法线的影响 float3 toRipple IN.worldPos - _RippleCenter; toRipple.y 0; // 忽略Y轴距离 float distance length(toRipple); float rippleEffect 0; if(distance 1.0 _RippleTime 0) { // 简单距离和存在性判断 // 一个简单的衰减正弦波模拟涟漪 rippleEffect _RippleStrength * sin(distance * 20 - _RippleTime * 10) * exp(-distance * _RippleDecay); // 涟漪会导致法线向四周扩散这里简化处理为对法线的扰动 float3 rippleNormal normalize(float3(toRipple.x, rippleEffect * 5, toRipple.z)); worldNormal normalize(worldNormal rippleNormal * 0.3); } // 3. 计算菲涅尔效应 float fresnel dot(normalize(IN.viewDir), worldNormal); fresnel saturate(1.0 - fresnel); fresnel pow(fresnel, 3); // 调整菲涅尔边缘硬度 // 4. 屏幕空间折射简化版 float4 screenUV IN.screenPos; screenUV.xy / screenUV.w; #if UNITY_UV_STARTS_AT_TOP screenUV.y 1 - screenUV.y; #endif // 用法线扰动屏幕UV float2 refractionOffset worldNormal.xz * 0.02; fixed3 refractionColor tex2D(_GrabTexture, screenUV.xy refractionOffset).rgb; // 5. 深度处理与颜色混合 float existingDepth01 Linear01Depth(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(IN.screenPos)).r); float sceneDepth LinearEyeDepth(existingDepth01); float surfaceDepth LinearEyeDepth(IN.screenPos.z / IN.screenPos.w); float waterDepth sceneDepth - surfaceDepth; float depthFactor saturate(waterDepth / _MaxDepth); depthFactor pow(depthFactor, _DepthFalloff); // 非线性衰减 // 根据深度混合浅水和深水颜色 fixed3 waterColor lerp(_ShallowColor.rgb, _DeepColor.rgb, depthFactor); // 将折射颜色与水底色混合深度越浅折射越明显 waterColor lerp(refractionColor, waterColor, depthFactor * 0.5 0.5); // 6. 菲涅尔混合反射/边缘高光 fixed3 finalColor lerp(waterColor, _FresnelColor.rgb, fresnel * 0.3); // 7. 高光计算Blinn-Phong简化 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float3 halfDir normalize(lightDir normalize(IN.viewDir)); float spec pow(saturate(dot(worldNormal, halfDir)), _Gloss); finalColor _SpecularColor.rgb * spec * _LightColor0.rgb; o.Albedo finalColor; o.Alpha lerp(_ShallowColor.a, _DeepColor.a, depthFactor); // 透明度随深度变化 o.Metallic 0.0; o.Smoothness 0.5; } ENDCG } FallBack Diffuse }代码解释法线混合两张法线贴图以不同速度滚动后混合得到基础水面法线。交互涟漪计算像素到涟漪中心的距离生成一个随时间衰减的波形并用来扰动世界法线。菲涅尔计算视线与法线夹角用于混合水色和边缘高光色。折射使用_GrabTexture和扰动后的UV采样屏幕背景。深度混合通过深度纹理计算水深并据此混合浅水色、深水色和折射颜色这是实现水体通透感的关键。高光使用经典的Blinn-Phong模型计算镜面反射高光。5.3 C# 交互脚本示例创建一个名为WaterInteraction.cs的脚本挂载到水面物体或管理对象上。using UnityEngine; public class WaterInteraction : MonoBehaviour { public Material waterMaterial; // 拖入你的水面材质 public float rippleDuration 2.0f; private float rippleStartTime -10f; private Vector3 rippleCenter; void Update() { // 示例点击鼠标生成涟漪 if (Input.GetMouseButtonDown(0)) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { // 假设水面在Y0的平面 rippleCenter hit.point; rippleCenter.y 0; // 确保在水面平面 rippleStartTime Time.time; // 将涟漪中心和时间传递给Shader if (waterMaterial ! null) { waterMaterial.SetVector(_RippleCenter, rippleCenter); waterMaterial.SetFloat(_RippleTime, Time.time - rippleStartTime); } } } // 更新Shader中的时间驱动涟漪动画 if (waterMaterial ! null Time.time - rippleStartTime rippleDuration) { waterMaterial.SetFloat(_RippleTime, Time.time - rippleStartTime); } else { // 涟漪结束后重置时间或传递一个负值让Shader忽略 waterMaterial.SetFloat(_RippleTime, -1.0f); } } // 也可以被其他物体调用 public void CreateRippleAt(Vector3 position) { rippleCenter position; rippleCenter.y 0; rippleStartTime Time.time; } }代码解释这个脚本监听鼠标点击将点击位置转换为世界坐标假设在水面平面然后将涟漪中心_RippleCenter和自涟漪开始经过的时间_RippleTime传递给Shader。Shader利用这些数据计算每个像素的涟漪效果。6. 运行结果与效果验证将上述Shader赋给材质材质赋给水面Plane并将脚本挂载并关联材质后运行游戏。预期效果静态观察水面应有缓慢起伏的波浪表面有流动的细腻波纹。从不同角度观察水面边缘菲涅尔效应应更亮。浅水区域应能隐约看到水底折射颜色较浅深水区域颜色变深。动态观察移动视角水面的高光应随光源方向变化。水波和法线贴图应持续流动。交互测试点击水面任意位置应在点击处产生一个向外扩散的圆形涟漪波纹并逐渐衰减消失。涟漪会影响水面的法线从而改变反射和折射的扭曲效果。验证步骤检查波浪暂停游戏观察水面网格顶点是否在Y轴上有规律偏移。检查法线在Scene视图中将光照模式切换为“Normal”观察水面法线图是否连续且动态变化。检查深度将Shader中waterDepth或depthFactor直接输出为颜色如o.Albedo float3(depthFactor, depthFactor, depthFactor);观察从岸边到水中央是否呈现从白到黑的渐变。检查交互点击后在Frame Debugger或直接观察中确认_RippleCenter和_RippleTime的值是否正确更新。7. 常见问题与排查思路问题现象可能原因排查方式解决方案水面全黑或不渲染Shader编译错误渲染队列Queue设置不当透明度混合模式问题。1. 查看Console窗口是否有Shader编译错误。2. 检查材质球Shader是否选择正确。3. 检查SubShader的Tags中QueueTransparent。1. 根据错误信息修正语法。2. 确保使用Transparent队列并设置正确的混合模式如Blend SrcAlpha OneMinusSrcAlpha。没有波浪或法线效果顶点着色器未生效法线贴图未正确采样或缩放为0时间变量_Time未使用。1. 在顶点着色器中输出一个固定颜色测试。2. 检查_NormalMap纹理是否赋值_NormalScale是否大于0。3. 确认UV偏移计算中使用了_Time.y。1. 简化顶点着色器先做一个简单的上下运动测试。2. 确保纹理类型为Normal map并正确导入。折射效果错乱或全屏扭曲GrabPass未正确工作屏幕UV计算错误法线扰动强度过大。1. 直接输出_GrabTexture采样结果不加扰动看是否为正确屏幕截图。2. 检查screenPos的透视除法screenPos.xy / screenPos.w。3. 减小折射偏移量如worldNormal.xz * 0.02中的0.02。1. 在内置管线中确保GrabPass在SubShader内。2. 注意Unity的UV坐标系是否上下翻转。3. 将扰动强度设为可调参数。深度边缘有硬边或闪烁深度纹理精度不足深度比较时未考虑线性化水面与物体穿插Z-fighting。1. 检查相机是否启用了depthTextureMode DepthTextureMode.Depth;。2. 确保使用LinearEyeDepth解码深度。3. 观察水面与物体接触面的深度值。1. 在相机脚本的OnEnable中设置GetComponent ().depthTextureMode交互涟漪不显示或位置不对Shader全局属性未成功传递坐标空间不一致涟漪计算逻辑有误。1. 在Shader中直接输出_RippleCenter作为颜色看看是否变化。2. 确认脚本中传递的是世界坐标且Shader中也在世界空间计算距离。3. 检查涟漪强度_RippleStrength是否为正数。1. 使用MaterialPropertyBlock替代SetGlobal以避免影响其他材质。2. 在Shader中打印IN.worldPos和_RippleCenter的值进行比对。3. 简化涟漪公式先用一个圆形测试。性能开销过大像素复杂度太高如过多纹理采样、复杂计算GrabPass每帧全屏抓取涟漪计算循环过多。1. 使用Unity Profiler的GPU模块分析Shader耗时。2. 检查是否每帧都在更新所有Shader属性。1. 减少法线贴图层数从两张减为一张。2. 考虑使用较低精度的变量如half。3. 对于移动平台可以禁用折射或使用简化版菲涅尔。4. 限制同时存在的涟漪数量。8. 最佳实践与工程建议分层与模块化将Shader代码按功能分块如波浪、法线、光学、交互并使用#ifdef指令包装便于通过Shader变体Variants或自定义编辑器开关来启用/禁用特定功能以适应不同平台和画质设置。参数艺术化控制暴露给美术或策划的参数如颜色、速度、强度应命名清晰并给予合理的Slider范围。可以考虑编写一个自定义的ShaderGUI将相关参数分组提升易用性。性能分级高配开启所有效果双法线、精确折射、动态涟漪、软阴影接收。中配使用单张法线贴图用立方体贴图反射代替平面反射简化或预计算涟漪。低配/移动端只保留最基本的法线扰动和菲涅尔颜色禁用GrabPass折射使用顶点动画代替像素级别的复杂计算。交互优化对于大量、频繁的交互如雨滴、密集子弹不应为每个交互点都向Shader传递一个属性。更高效的做法是使用一张渲染纹理Render Texture作为“高度图”或“法线扰动图”将所有交互影响烘焙到这张纹理上然后在Shader中采样这张纹理。这需要更复杂的GPU管线设计但性能好得多。与后处理结合高质量的水面往往需要后处理Post-processing加持。例如使用屏幕空间环境光遮蔽SSAO增强水与岸边的接触阴影使用抗锯齿如TAA平滑水面的闪烁使用颜色分级Color Grading统一水体的色调与场景氛围。测试场景建立一个包含不同水深、不同底部材质沙地、岩石、水草、不同光照条件正午、黄昏、夜晚的测试场景确保你的Shader在各种环境下都表现稳健。从一张静止的平面贴图到一个随波逐流、光影交错、能与世界互动的生动水面我们一步步拆解并实现了其中的关键技术。这个过程清晰地展示了Shader开发的核心思路将复杂的视觉现象分解为可计算的数学模型和可控制的渲染流程。本文提供的代码是一个功能完整的起点但绝不是终点。你可以在此基础上深入探索用更物理的Gerstner波函数替换简单的正弦波用FFT快速傅里叶变换模拟广阔海域的频谱波浪用光线步进Raymarching实现更精确的水下焦散Caustics效果或者将这套逻辑移植到URP/HDRP利用其更现代的渲染特性。最重要的是理解每个环节“为什么”要这样做。当你掌握了从顶点动画到片元光学再到CPU-GPU数据交互的这一套方法论后你所收获的将不仅是一个水面Shader而是一把能够创造各种动态、沉浸式视觉效果的万能钥匙。建议你将此项目收藏作为图形学入门和Shader实战的参考模板在未来的项目中反复运用和迭代。
返回列表