实时3D水面渲染:反射折射与岸边柔边的Shader实现与优化

发布时间:2026/8/12 10:26:18
实时3D水面渲染:反射折射与岸边柔边的Shader实现与优化 1. 项目概述从“一片死水”到“波光粼粼”的实时挑战在游戏开发、数字孪生或者影视级的实时可视化项目中一片逼真的水域往往是提升场景沉浸感的关键。但很多开发者包括我自己在早期都曾面对过这样的困境要么用一张静态的、毫无生机的贴图敷衍了事要么为了实现动态水面性能开销大到让帧率“跳水”。今天要分享的就是如何高效地实现一套支持反射、折射、水深效果与水岸柔边的实时3D水面渲染方案。这不仅仅是把几个Shader效果堆砌起来更关乎如何在视觉真实感与实时性能之间找到精妙的平衡点。简单来说这个方案要解决的核心问题是如何让水面既能像镜子一样映出天空和周围的物体反射又能让人看到水面下的游鱼和水草折射同时靠近岸边时水会变浅、颜色变淡并且与岸边的交接处是柔和过渡的而不是生硬的裁剪。这一切都需要在每帧几十毫秒内完成计算。我们会从最基础的网格与法线扰动开始逐步深入到反射纹理的生成、折射效果的模拟、基于深度的颜色混合最后处理最难搞的岸边过渡。无论你是刚接触Shader编程的新手还是想优化现有水面效果的老手相信这套经过多个项目验证的思路和实操细节都能给你带来直接的帮助。2. 核心原理与方案选型为什么是这些技术在动手写代码之前搞清楚“为什么”比知道“怎么做”更重要。实时水面渲染不是一个单一技术而是多个图形学原理在性能约束下的工程化组合。2.1 视觉基石反射与折射的物理简化真实世界中水面的光影来自于复杂的物理光学现象。但在实时渲染领域我们无法进行全物理模拟必须做合理的简化。反射的本质是视角向量关于法线的镜面反射。最经典且高效的方法是屏幕空间反射SSR和平面反射Planar Reflection。SSR通过当前屏幕深度图进行光线步进寻找交点效果精确但计算量大且对屏幕外物体无效。平面反射则更为常用我们将摄像机镜像到水面平面以下以这个“镜像摄像机”渲染一张场景纹理再将这张纹理贴到水面上。它的优点是原理直观性能可控特别适合湖泊、池塘等相对平静的水面。本方案将以此为基础进行构建。折射的模拟则更加取巧。我们并不真正去计算光线弯曲而是采用一种称为扰动屏幕UV的技术。简单说就是先获取水面下的场景颜色通常通过抓取当前帧的屏幕纹理实现然后用水面的法线纹理去扰动采样这张屏幕纹理的UV坐标。这样当法线因为波浪而起伏时采样的像素位置就会发生偏移从而产生光线弯曲的错觉。这是一种视觉效果导向的“欺骗”但极其高效。2.2 深度与颜色菲涅尔效应与水深衰减为什么水面在远处看像镜子近处看却能看清水下这要用到菲涅尔效应Fresnel Effect。菲涅尔公式描述了视线与表面法线夹角越大掠射角反射越强夹角越小垂直视角折射越强。在Shader中我们用一个简化的近似公式fresnel pow(1.0 - saturate(dot(viewDir, normal)), 5.0)来计算一个混合系数用于动态混合反射和折射颜色这是水面质感逼真的关键。水深效果则依赖于场景的深度信息。我们通过比较水面像素的深度和场景底部或水下物体的深度得到实际的水深。然后根据这个深度值对水的颜色进行混合。通常水越深基础色越偏向于深蓝或绿色水越浅则更透明并可能混合水底的泥沙颜色。同时深度也用于控制折射效果的强度浅水区的折射扰动应该更弱。2.3 岸边柔边对抗锯齿状边缘的艺术使用一个简单的水面网格其边缘与岸边地形相交时必然会产生锯齿状的硬边这是实时渲染的大忌。水岸柔边的核心思路是让水面在靠近岸边时“逐渐消失”。我们通过一张额外的深度渐变纹理Depth Fade Texture或者直接利用顶点到岸边的距离场Distance Field信息在Shader的片段着色器中让水面像素的Alpha值不透明度随着靠近岸边而平滑衰减到0。这样水面与岸边的交界处就会产生一个半透明的过渡带视觉上非常柔和自然。注意方案选型上我们放弃了追求物理绝对正确的路径追踪而是选择了平面反射 屏幕空间折射扰动 深度混合 距离场柔化这套组合拳。它是在当前主流GPU上能以稳定60帧运行的同时提供高质量视觉效果的“性价比之选”。3. 核心实现细节拆解从网格到像素理解了原理我们进入实战环节。我会按照渲染管线的顺序逐一拆解每个环节的实现要点和代码片段以Unity的ShaderLab和HLSL/Cg为例原理通用。3.1 网格准备与顶点动画让水面“活”起来静态的水面网格是第一步但我们需要它动起来。通常使用一个低频率的正弦波或Gerstner波叠加一个高频的法线贴图扰动。// 在顶点着色器中添加简单的正弦波动画 float3 waveVertexOffset(float4 vertex, float2 uv, float time) { float3 offset float3(0, 0, 0); // 主波浪低频大振幅 float wave1 sin(vertex.x * _WaveFrequency1 time * _WaveSpeed1) * _WaveAmplitude1; // 次级波浪高频小振幅方向不同 float wave2 sin(vertex.z * _WaveFrequency2 time * _WaveSpeed2) * _WaveAmplitude2; offset.y wave1 wave2; return offset; }同时我们需要计算动画后的法线。可以直接对位移函数求偏导来得到精确的法线但更常见的做法是使用一张或两张法线贴图Normal Map通过不同速度和方向的UV滚动来模拟复杂的波光粼粼细节。在片段着色器中采样这些法线图并混合作为后续反射、折射计算的基础法线。// 采样并混合两张法线贴图 float3 normal1 UnpackNormal(tex2D(_NormalMap1, uv1 _Time.x * _ScrollSpeed1)); float3 normal2 UnpackNormal(tex2D(_NormalMap2, uv2 _Time.y * _ScrollSpeed2)); float3 worldNormal normalize(mul(float4(normal1 normal2, 0), unity_WorldToObject).xyz);3.2 平面反射纹理的生成创建水中的“倒影世界”这是反射效果的核心。我们需要在脚本中动态创建一个摄像机和一个RenderTexture。计算镜像摄像机根据主摄像机的位置和方向以及水面的平面方程通常是Y0的世界平面计算出镜像摄像机的位置和旋转。关键点是翻转摄像机的Up向量否则渲染出来的图像是上下颠倒的。设置裁剪平面为了避免反射摄像机渲染到水面以上的部分那会导致错误反射必须为镜像摄像机设置一个裁剪平面Clip Plane通常是水面向上的平面只渲染水面以下的部分。渲染到纹理将镜像摄像机的输出目标设置为一张RenderTexture。这张纹理就是我们的反射贴图。实操心得反射纹理的分辨率不必和屏幕分辨率一致通常使用512x512或1024x1024就能在中等距离获得不错的效果这是重要的性能优化点。另外可以考虑每2-3帧更新一次反射纹理对于动态不高的场景能大幅节省性能。3.3 折射效果的模拟扰动出来的“清澈见底”折射效果在片段着色器中实现。我们首先需要获取当前像素位置的水下场景颜色。获取屏幕坐标与深度将水面像素的世界坐标转换到屏幕空间得到其UV坐标。扰动UV用之前计算出的水面法线的XY分量或从法线贴图采样乘以一个可控制的强度系数_RefractionStrength去扰动屏幕UV。float2 screenUV i.screenPos.xy / i.screenPos.w; float2 refractedUV screenUV worldNormal.xz * _RefractionStrength; float4 sceneColor tex2D(_GrabTexture, refractedUV); // _GrabTexture是抓取的屏幕纹理深度校正简单的UV扰动在岸边会导致“偷看”到水面以上的物体因为UV偏移到了岸上的像素。解决方法是利用深度差进行钳制。如果采样点的深度比水面像素的深度小很多说明采样点在水面之上则丢弃这次扰动回退到未扰动的UV采样。3.4 水深与颜色混合营造层次感水不是单一颜色的。我们需要根据水深来混合水的基调色、深水色和可能的水底颜色。计算像素处水深float sceneDepth LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenUV)); float surfaceDepth i.screenPos.w; // 水面到相机的距离 float waterDepth sceneDepth - surfaceDepth; // 实际水深 waterDepth max(0, waterDepth); // 确保非负基于深度混合颜色使用saturate(waterDepth / _MaxDepth)得到一个0-1的系数然后用这个系数在浅水色和深水色之间进行线性插值。同时这个系数也可以用来控制水体的整体透明度越浅越透明。整合菲涅尔混合最后用菲涅尔系数fresnel来混合反射颜色和折射颜色再与上一步得到的水体基础色相乘或叠加得到最终的颜色输出。float3 finalColor lerp(refractedColor, reflectedColor, fresnel); finalColor * waterColor; // 混合水体颜色3.5 水岸柔边处理实现无缝衔接这是让水面自然融入场景的最后一步。我们通常在顶点着色器或片段着色器中计算每个顶点或像素到最近岸边的距离。生成距离场信息最实用的方法是在地形编辑阶段额外生成一张距离场贴图Distance Field Texture。这张图的每个像素值代表了该世界位置到最近岸边或特定高度的距离。将这张图传入Shader。在Shader中衰减Alpha在片段着色器中采样距离场贴图得到当前像素到岸边的距离distToShore。然后使用一个平滑函数如smoothstep来让Alpha值在距离小于某个阈值_ShoreFadeWidth时从1平滑过渡到0。float fade smoothstep(0.0, _ShoreFadeWidth, distToShore); finalAlpha * fade;备选方案如果没有预计算的距离场一个简单的替代方案是使用顶点颜色。在建模时手动将靠近岸边的顶点涂上渐变的颜色如红色通道在Shader中用这个通道值来控制边缘淡化。虽然不够精确但对于简单形状的水体是有效的。4. 完整Shader代码结构与关键参数解析下面是一个高度简化的Unity Surface Shader框架展示了如何将上述模块组织在一起。请注意这是一个概念性框架实际使用时需要填充具体计算和添加更多属性。Shader Custom/RealisticWater { Properties { // 法线贴图 _NormalMap1 (Normal Map 1, 2D) bump {} _NormalMap2 (Normal Map 2, 2D) bump {} _ScrollSpeed1 (Scroll Speed 1, Vector) (0.05, 0.05, 0, 0) _ScrollSpeed2 (Scroll Speed 2, Vector) (-0.03, 0.04, 0, 0) // 反射 _ReflectionTex (Reflection Texture, 2D) white {} _ReflectionDistort (Reflection Distort, Range(0, 0.1)) 0.01 // 折射 _RefractionStrength (Refraction Strength, Range(0, 0.1)) 0.02 // 颜色与深度 _ShallowColor (Shallow Water Color, Color) (0.2, 0.6, 0.8, 0.8) _DeepColor (Deep Water Color, Color) (0.0, 0.1, 0.3, 1.0) _MaxDepth (Max Depth, Range(0, 50)) 10 // 菲涅尔 _FresnelPower (Fresnel Power, Range(1, 10)) 5 _FresnelScale (Fresnel Scale, Range(0, 1)) 0.5 // 岸边柔边 _ShoreFadeWidth (Shore Fade Width, Range(0, 10)) 2.0 } SubShader { Tags { RenderTypeTransparent QueueTransparent } LOD 200 GrabPass { _GrabTexture } // 抓取屏幕用于折射 CGPROGRAM #pragma surface surf Water alpha:fade vertex:vert #include UnityCG.cginc // ... 变量声明与上面Properties对应 ... struct Input { float2 uv_NormalMap1; float2 uv_NormalMap2; float4 screenPos; float3 worldPos; float3 viewDir; INTERNAL_DATA }; void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 此处添加顶点波浪动画 // v.vertex.xyz waveVertexOffset(v.vertex, v.texcoord, _Time.y); } void surf (Input IN, inout SurfaceOutput o) { // 1. 计算法线 float3 worldNormal CalculateWorldNormal(IN); // 2. 计算菲涅尔系数 float fresnel CalculateFresnel(worldNormal, IN.viewDir); // 3. 采样反射纹理带法线扰动 float3 reflection SampleReflection(IN, worldNormal); // 4. 采样折射屏幕抓取扰动 float3 refraction SampleRefraction(IN, worldNormal); // 5. 混合反射与折射 float3 waterColor lerp(refraction, reflection, fresnel); // 6. 计算水深并混合颜色 float depth CalculateWaterDepth(IN); float depthFactor saturate(depth / _MaxDepth); float3 depthColor lerp(_ShallowColor.rgb, _DeepColor.rgb, depthFactor); waterColor * depthColor; // 7. 应用岸边柔边 float shoreFade CalculateShoreFade(IN.worldPos); float finalAlpha _ShallowColor.a * shoreFade; // 基础透明度乘以岸边衰减 o.Albedo waterColor; o.Alpha finalAlpha; o.Normal worldNormal; } // ... 具体的计算函数实现CalculateWorldNormal, CalculateFresnel等 ... ENDCG } FallBack Diffuse }关键参数解析与调优经验_RefractionStrength折射强度通常设置在0.01到0.05之间。值太大会导致失真像透过毛玻璃太小则折射效果不明显。对于游泳池或非常清澈的水可以调低对于湍急的河流可以调高。_FresnelPower菲涅尔指数控制反射与折射的过渡锐利度。值越大过渡越尖锐垂直看水很清稍微倾斜就变成全反射。对于平静的湖面可以用5-7对于有泡沫的海面可以降低到3-4让反射区域更广。_MaxDepth最大深度这个值需要和你的场景尺度匹配。它定义了从浅水色过渡到深水色的深度范围。调得太小大片水域会很快变成深色不自然调得太大则水深变化不明显。建议先在场景中放置一个测试物体直观地调整。_ShoreFadeWidth岸边淡出宽度单位是世界空间单位米。决定了从岸边开始多远的距离内水面会完全淡出。对于平缓的沙滩这个值可以大一些如3-5米对于陡峭的岩壁这个值应该很小如0.5-1米。5. 性能优化与常见问题排查一个好看但跑不动的效果是没有意义的。以下是几个关键的优化方向和踩坑记录。5.1 性能优化策略反射纹理的降级与更新频率这是最大的性能热点。务必使用合理分辨率的RenderTexture如512x512。对于动态要求不高的场景将反射摄像机的渲染事件设置为OnPreRender或OnPostRender并配合Time.frameCount % 2 0这样的条件使其每2帧更新一次性能提升立竿见影。法线贴图的选择与混合使用两张较小的法线贴图如256x256进行混合比使用一张巨大的法线贴图效果更丰富且可能更省带宽。确保贴图压缩格式为DXT5nm移动端用ETC2这是为法线贴图优化的格式。Shader复杂度管理避免在片段着色器中进行过多的复杂数学运算如多次sin/cos、循环等。将能移到顶点着色器的计算如低频波浪就移上去。减少纹理采样次数合理利用纹理的RGBA通道打包数据。水面网格的细分程度网格不是越密越好。过密的网格会增加顶点着色器的负担。根据摄像机与水面的距离可以考虑使用LOD多层次细节系统远距离使用低模水面。5.2 常见问题与解决方案速查表问题现象可能原因排查与解决方案反射图像闪烁或抖动1. 镜像摄像机位置计算每帧有微小误差。2. 反射纹理分辨率过低导致像素化。1. 确保计算镜像摄像机时使用的平面方程水面高度是精确且稳定的浮点数。2. 适当提高反射纹理分辨率或为反射纹理启用双线性/三线性过滤。岸边出现“折射泄露”折射UV扰动过大采样到了水面之上的像素。引入深度比较。在片段着色器中对比扰动后UV采样点的深度与当前水面像素的深度。如果采样点深度远小于水面深度即它在水上则丢弃扰动使用原始屏幕UV。水面与地形交界处有硬边或Z-Fighting1. 水面网格与地形完全共面。2. 没有启用深度写入或测试设置不当。1.永远不要让水面网格与地形完全共面。将水面网格整体略微抬高一个微小值如0.01单位。2. 在Shader中设置正确的ZWrite和ZTest。对于透明水面通常ZWrite OffZTest LEqual。移动设备上帧率过低Shader计算过于复杂或反射纹理开销大。1. 为移动端准备一个简化版Shader关闭反射或使用静态立方体贴图Cubemap代替平面反射。2. 大幅降低法线贴图分辨率和滚动速度。3. 彻底关闭折射效果或使用一个简单的颜色抖动模拟。水深颜色变化不自然_MaxDepth参数设置不当或深浅颜色对比度过大。1. 在场景中放置一个带有刻度的测试棒直观调整_MaxDepth使颜色在视觉合理的深度完成过渡。2. 调整_ShallowColor和_DeepColor使其色相相近主要通过明度和饱和度来区分深浅。法线纹理接缝明显法线贴图UV平铺次数过低导致纹理拉伸或两张法线图滚动速度相同。1. 增加法线贴图的UV平铺次数tiling让纹理细节更密集。2. 确保两张法线贴图的滚动速度_ScrollSpeed不同且方向不同以破坏重复 pattern。5.3 进阶效果延伸思路当基础效果稳定后可以考虑加入更多细节来提升品质高光与镜面反射在Surface Shader中你可以通过o.Specular和o.Gloss属性或者直接在高光通道中计算基于菲涅尔的镜面反射使用环境BRDF让水面在特定角度有闪亮的高光点。泡沫线与浪花在岸边和波浪峰值处添加泡沫。可以通过顶点色或另一张噪声图根据法线的Y分量坡度或位置近岸来混合一张泡沫纹理。焦散Caustics模拟水底的光斑效果。可以使用一张动态的焦散纹理图根据水面法线扰动其投影UV投影到水底物体上。与后期效果的结合将水面渲染到一个自定义的缓冲区以便在后期处理中统一应用全屏的色差、模糊或颜色校正使水面更好地融入整体画面色调。实现一个逼真的实时水面是一个不断在美学和技术之间做权衡的过程。我最深的体会是没有“绝对正确”的参数只有“当前场景下最合适”的参数。最好的调试方式就是一边看着屏幕一边微调Shader参数同时关注着GPU性能分析器。从一片单调的蓝色平面到最终呈现出波光粼粼、清澈见底、与岸边自然融合的动态水面这个过程本身就是图形编程最大的乐趣所在。希望这份详细的拆解能帮你少走些弯路更快地创造出令人惊叹的水域。