
1. 项目概述从“水下看世界”说起在游戏和数字孪生项目中水体渲染一直是个能极大提升沉浸感的关键环节。一个逼真的湖泊、海洋或泳池其魅力不仅在于水面波光粼粼更在于水面之下那个若隐若现、光线扭曲的世界。很多开发者在水面反射、折射上下了不少功夫但往往忽略了“水下透视”这个细节。简单来说水下透视效果模拟的是当玩家角色潜入水中时视线所及之处的视觉变化光线衰减带来的颜色偏蓝绿、距离感模糊、以及因水面波动而产生的动态扭曲。这个效果直接决定了玩家是否真的感觉自己“浸入”了水中而不是仅仅站在一个贴着水纹理的平面上。我接手过不少需要高质量水体表现的项目从手机休闲游戏到PC端的大型开放世界发现水下透视是区分“还行”和“惊艳”的重要分水岭。它不是一个孤立的特效而是融合了颜色处理、深度信息、后期屏幕扭曲以及性能考量的综合技术。本文将基于Unity引擎从Shader和渲染管线的角度拆解如何实现一个基础但完整、性能可控的水下透视效果。无论你是刚接触Shader的新手还是想优化现有水体系统的开发者都能从中找到可直接落地的思路和代码。2. 核心效果拆解与实现思路水下透视不是一个单一算法而是多个视觉现象的叠加。我们需要先把它分解成几个可独立实现和调整的子效果再思考如何将它们有机组合。2.1 效果构成要素分析首先我们需要明确“水下视角”具体改变了什么。根据现实世界的物理光学和视觉经验可以归纳为以下几点颜色吸收与衰减Color Absorption Attenuation水对光有吸收作用不同波长的光被吸收的程度不同。红光波长最长最先被吸收因此在浅水处物体偏蓝绿色在深水处则迅速变暗趋向于深蓝甚至黑色。这是水下世界色调的基调。模糊与散射Blur Scattering水中有悬浮微粒导致光线发生散射。这会产生两种视觉影响一是整体画面的轻微模糊类似于薄雾效果二是“体积光”或“上帝之光”God Rays效果即从水面射入的光束变得可见。水面扭曲Surface Distortion当我们从水下看向水面以外的世界或水面本身因为水面的波动光线会发生动态的折射导致水面上方的景象产生扭曲。这是水下透视最具动态感的部分。深度感与雾效Depth Fog水下能见度有限远处的物体会更快地融入背景色中。这需要结合摄像机的深度信息来实现基于距离的颜色混合和透明度变化。焦散与光斑Caustics阳光穿过波动的水面在水底或物体上形成晃动的光斑图案。这是一个高级效果对性能要求较高在基础实现中可暂不考虑但它是提升真实感的重要加分项。2.2 技术方案选型为什么选择屏幕后处理实现这些效果主要有两种技术路径基于物体Object-space和基于屏幕后处理Screen-space Post-processing。基于物体为每个水下物体单独编写Shader在其表面着色器中计算吸收、散射等。这种方法精度高但管理复杂性能开销大且难以处理整个屏幕空间的统一效果如全局色调、与水面相交处的扭曲。基于屏幕后处理在摄像机完成所有不透明物体渲染后对最终生成的屏幕图像进行一次全局处理。这是目前最主流、最高效的实现水下透视的方法。为什么后处理方案更优后处理Shader能直接获取到当前帧的屏幕颜色缓冲和深度纹理这意味着我们天然拥有了全屏像素的信息和它们距离摄像机的深度。基于这些信息我们可以统一地、低成本地应用颜色校正、模糊、扭曲等效果完美匹配“水下视角”是一个影响整个视口的全局状态这一特性。Unity内置的渲染管线Built-in和通用渲染管线URP、高清渲染管线HDRP都提供了完善的后处理框架支持。我们的核心思路是创建一个后处理Shader在玩家进入水下时将该Shader应用于主摄像机。在这个Shader中我们将依次实现颜色衰减、深度雾、屏幕扭曲并探讨如何优雅地结合模糊效果。3. 核心Shader实现详解我们将使用Unity内置渲染管线的后处理栈Post Processing Stack v2或自定义的OnRenderImage方法作为载体。这里以更灵活的自定义方案为例进行说明其原理同样适用于URP的RenderFeature。3.1 构建基础后处理框架首先创建一个C#脚本UnderwaterPostEffect.cs挂载到主摄像机上。using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class UnderwaterPostEffect : MonoBehaviour { // 控制脚本是否生效 public bool isActive false; // 关联的Shader public Shader underwaterShader; private Material _material; // 属性参数后续会逐步添加 [Header(Color Absorption)] public Color shallowWaterColor new Color(0.4f, 0.9f, 1.0f, 1.0f); // 浅水区颜色偏蓝绿 public Color deepWaterColor new Color(0.0f, 0.2f, 0.4f, 1.0f); // 深水区颜色深蓝 public float depthFalloff 1.0f; // 深度衰减系数 void OnEnable() { // 确保摄像机生成深度纹理这是关键 GetComponentCamera().depthTextureMode | DepthTextureMode.Depth; } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (!isActive || underwaterShader null) { // 不生效时直接复制源图像 Graphics.Blit(source, destination); return; } // 懒加载材质 if (_material null) { _material new Material(underwaterShader); _material.hideFlags HideFlags.HideAndDontSave; } // 将参数传递给Shader _material.SetColor(_ShallowColor, shallowWaterColor); _material.SetColor(_DeepColor, deepWaterColor); _material.SetFloat(_DepthFalloff, depthFalloff); // 执行后处理 Graphics.Blit(source, destination, _material); } void OnDisable() { if (_material ! null) { DestroyImmediate(_material); } } }接下来创建对应的ShaderUnderwaterEffect.shader。Shader Hidden/UnderwaterEffect { Properties { _MainTex (Texture, 2D) white {} // 属性与C#脚本中对应 _ShallowColor (Shallow Water Color, Color) (0.4, 0.9, 1.0, 1.0) _DeepColor (Deep Water Color, Color) (0.0, 0.2, 0.4, 1.0) _DepthFalloff (Depth Falloff, Float) 1.0 } SubShader { Cull Off ZWrite Off ZTest Always // 后处理标准设置 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } sampler2D _MainTex; sampler2D _CameraDepthTexture; // Unity提供的深度纹理 float4 _ShallowColor; float4 _DeepColor; float _DepthFalloff; fixed4 frag (v2f i) : SV_Target { // 1. 采样原始屏幕颜色 fixed4 originalColor tex2D(_MainTex, i.uv); // 2. 采样线性深度值 float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth Linear01Depth(depth); // 转换为[0,1]的线性深度1为远裁剪面 // 3. 基于深度进行颜色混合基础的水色吸收 // 使用指数衰减来模拟光线随距离被吸收的效果 float waterDepthFactor 1.0 - exp(-_DepthFalloff * linearDepth); fixed4 waterTint lerp(_ShallowColor, _DeepColor, waterDepthFactor); // 4. 将水色与原始颜色混合这里使用乘法模拟吸收 fixed4 finalColor originalColor * waterTint; return finalColor; } ENDCG } } }关键点解析_CameraDepthTexture这是Unity在设置了depthTextureMode后自动提供的全局深度纹理。Linear01Depth函数将非线性的深度缓冲值转换到[0,1]范围的线性值这对于基于物理距离的计算至关重要。exp(-_DepthFalloff * linearDepth)这是模拟光线在水中衰减的经典公式比尔-朗伯定律的简化。_DepthFalloff系数控制衰减速度值越大颜色变得越“深”越快。lerp线性插值函数。根据waterDepthFactor在浅水色和深水色之间混合。waterDepthFactor为0时近处使用浅水色为1时理论上无限远使用深水色。3.2 实现动态水面扭曲效果静态的颜色衰减还不够我们需要让水面波动影响视线。这需要两个关键信息水面的法线或高度图以及当前像素是否位于水面之下。步骤一获取水面波动信息通常我们会使用一个法线贴图Normal Map或通过噪声函数如Unity自带的UnityGradientNoise或更经典的Perlin Noise实时生成波动。为了灵活性和性能我们采用法线贴图。// 在Shader Properties和CG变量区添加 _SurfaceNormalMap (Surface Normal Map, 2D) bump {} _DistortionStrength (Distortion Strength, Range(0, 0.1)) 0.05 _DistortionSpeed (Distortion Speed, Float) 1.0 sampler2D _SurfaceNormalMap; float4 _SurfaceNormalMap_ST; // 用于缩放和偏移 float _DistortionStrength; float _DistortionSpeed;步骤二修改片段着色器在采样屏幕颜色前应用扭曲扭曲的本质是使用法线贴图的RG通道代表XY方向的倾斜来扰动采样屏幕纹理的UV坐标。fixed4 frag (v2f i) : SV_Target { // --- 新增计算扭曲UV --- // 对法线贴图进行采样并随时间滚动以产生动态效果 float2 distortionUV i.uv * _SurfaceNormalMap_ST.xy _SurfaceNormalMap_ST.zw _Time.y * _DistortionSpeed; fixed4 normalMap tex2D(_SurfaceNormalMap, distortionUV); // 将法线颜色从[0,1]映射到[-1,1]并取RG通道作为XY方向的偏移 float2 distortion (normalMap.rg * 2 - 1) * _DistortionStrength; // 用扭曲后的UV去采样屏幕颜色和深度 float2 distortedUV i.uv distortion; fixed4 originalColor tex2D(_MainTex, distortedUV); // 注意这里采样的是扭曲后的UV float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, distortedUV); // 深度也需要用同样的UV采样 float linearDepth Linear01Depth(depth); // --- 扭曲部分结束 --- // ... 后续的颜色衰减计算保持不变但使用的是基于扭曲UV得到的originalColor和linearDepth float waterDepthFactor 1.0 - exp(-_DepthFalloff * linearDepth); fixed4 waterTint lerp(_ShallowColor, _DeepColor, waterDepthFactor); fixed4 finalColor originalColor * waterTint; return finalColor; }重要提示深度采样同步这是一个极易出错的点。计算颜色衰减所依赖的linearDepth必须与采样颜色的distortedUV保持一致。如果颜色用了扭曲UV深度却用了原始UV会导致颜色和深度信息错位产生诡异的视觉错误比如近处的物体却套上了远处的深水色。3.3 集成深度雾与边缘模糊颜色吸收已经提供了基础的深度感但为了模拟水中的悬浮物散射我们还需要一个基于深度的雾效并让远处更加模糊。深度雾效增强 我们可以将之前计算出的waterTint不仅仅用作乘法也作为一个加法项模拟远处物体被水色“覆盖”的效果。// 在颜色混合部分进行修改 float waterDepthFactor 1.0 - exp(-_DepthFalloff * linearDepth); fixed4 waterTint lerp(_ShallowColor, _DeepColor, waterDepthFactor); // 原始方法纯乘法吸收 // fixed4 finalColor originalColor * waterTint; // 增强方法结合乘法吸收和加法散射/覆盖 float fogStrength waterDepthFactor * 0.5; // 控制雾的强度系数可暴露为参数_FogStrength fixed4 finalColor originalColor * waterTint; // 基础吸收 finalColor.rgb lerp(finalColor.rgb, _DeepColor.rgb, fogStrength); // 叠加雾效边缘模糊实现 全屏模糊开销较大。一个取巧且效果不错的方法是只对深度较大的区域远处进行模糊。我们可以使用一个简单的散景模糊Bokeh Blur或高斯模糊但其采样权重与linearDepth相关。更简单的方法是使用Unity后处理栈中的泛光Bloom或自定义一个基于深度的可变模糊Variable Blur。由于实现较复杂这里给出一个概念在第二个Pass中根据linearDepth值决定模糊半径对原始颜色进行模糊再与第一Pass的结果混合。一个性能友好的替代方案是使用颜色抖动Dithering或屏幕空间雾效Screen Space Fog它们能有效增加视觉上的“颗粒感”和朦胧感而无需昂贵的模糊计算。我们可以通过在最终颜色上添加一个微弱的、基于屏幕坐标和深度的噪声来模拟。// 简单的屏幕空间噪声模拟散射 float2 noiseUV i.uv * _ScreenParams.xy * 0.1 _Time.y; // 缩放和滚动噪声 float noise UnityGradientNoise(noiseUV); // 使用Unity内置梯度噪声 noise (noise * 2 - 1) * 0.02; // 将噪声映射到微小偏移 // 将噪声影响与深度关联远处影响大 float scatterNoise noise * waterDepthFactor; finalColor.rgb scatterNoise;4. 性能优化与实战调试技巧一个效果再好如果让帧率暴跌也毫无意义。水下后处理尤其需要关注性能因为它每帧都在对全屏像素进行操作。4.1 关键性能优化策略降低采样精度Render Texture Format后处理通常不需要HDR或极高的精度。确保你的后处理渲染目标Render Texture格式是ARGB32或RGB111110Float如果只需要LDR而不是ARGBHalf或ARGBFloat这能显著减少带宽占用。优化扭曲计算使用低分辨率法线贴图256x256或512x512的法线贴图对于全屏扭曲通常足够因为扭曲是细微的。分离扭曲UV计算如果水面波动是均匀的可以考虑用一个低分辨率的Render Texture预先计算好整个屏幕的扭曲向量图Flow Map然后在后处理Shader中采样一次而不是每个像素都计算一次噪声。限制扭曲强度_DistortionStrength值不宜过大通常0.01到0.05之间视觉效果和性能最佳。善用LOD与条件执行在移动平台或性能吃紧时可以动态降低效果质量。例如当玩家快速移动或处于复杂场景时关闭模糊效果只保留基础的颜色衰减和轻微扭曲。避免全屏模糊如非必要不要使用标准的高斯模糊。如果确实需要模糊感优先考虑上述的噪声模拟或使用更高效的“双线性上采样下采样”组合来模拟模糊即先渲染到一半分辨率再上采样回来。4.2 常见问题与排查实录即使按照步骤实现了也可能会遇到各种“诡异”的显示问题。以下是我踩过的一些坑和解决方案问题一扭曲效果“错位”或“闪烁”现象水面上方的景物扭曲方向不对或者扭曲图案剧烈闪烁。排查检查法线贴图确保导入的法线贴图类型Texture Type设置为“Normal map”并且sRGB选项关闭。错误的设置会导致颜色空间错误RG通道的值不对。检查UV计算确认_Time.y乘以_DistortionSpeed的逻辑是否正确。_Time.y是自游戏开始以来的时间秒。如果速度过快会导致闪烁。可以尝试使用_Time.x自场景加载时间或对时间取模运算来获得循环动画。检查深度同步再次强调确保采样_MainTex和_CameraDepthTexture使用的是同一个扭曲后的UV坐标。问题二水下/水上边界处出现硬边或颜色突变现象在水面交界处效果突然出现或消失一条明显的线。排查深度缓冲精度在远近裁剪面差距极大的开放世界中远距离的深度缓冲精度可能不足。尝试调整摄像机的远近裁剪面使其尽可能贴近场景的实际范围。软边缘过渡不要简单地用isActive布尔值开关整个效果。可以计算摄像机到水面的距离或者使用一个物理触发器Trigger来产生一个渐变的权重值如0到1在Shader中用这个权重来线性插值最终效果和原始画面。// 在C#脚本中 public float transitionSmoothness 2.0f; private float _effectWeight 0f; void Update() { // 假设通过碰撞检测或射线检测获得了摄像机到水面的距离distanceToSurface float targetWeight (distanceToSurface 0) ? 1.0f : 0.0f; // 水下为1水上为0 _effectWeight Mathf.Lerp(_effectWeight, targetWeight, Time.deltaTime * transitionSmoothness); if (_material ! null) { _material.SetFloat(_EffectWeight, _effectWeight); } }// 在Shader中 float _EffectWeight; // ... fixed4 finalColorUnderwater ...; // 计算完整的水下效果 fixed4 finalColor lerp(originalColor, finalColorUnderwater, _EffectWeight);问题三透明物体如粒子特效渲染异常现象水下的烟雾、气泡等粒子系统颜色怪异或消失。排查后处理是在所有不透明物体渲染之后、透明物体渲染之前执行的取决于渲染队列。如果你的水下效果需要影响透明物体需要将脚本的OnRenderImage调用时机调整到透明物体渲染之后通过CameraEvent.AfterForwardAlpha等命令缓冲区实现但这会复杂很多。一个更简单实用的方案是让重要的水下透明物体如气泡使用自定义的Shader在其内部模拟类似的水下颜色吸收而不是依赖全局后处理。问题四移动设备上帧率下降严重现象在手机上效果卡顿。排查Profile!使用Unity Profiler查看GPU耗时定位是片段着色器复杂还是带宽过高。简化计算将exp运算替换为近似的1.0 / (1.0 _DepthFalloff * linearDepth)在移动GPU上可能更快。降低分辨率考虑以半分辨率或四分之三分辨率进行后处理然后上采样视觉损失小但性能提升明显。分帧渲染对于非核心的模糊效果可以每两帧或三帧计算一次利用帧间 coherence在视觉上不易察觉。实现一个令人信服的水下透视效果是技术性和艺术性的结合。从基础的深度颜色衰减到动态的水面扭曲再到性能调优每一步都需要仔细推敲和反复调试。最关键的是理解其背后的物理原理光在水中的吸收与散射和图形学实现手段深度纹理、屏幕后处理、UV扭曲。本文提供的方案是一个坚实的起点你可以在此基础上继续探索焦散效果的模拟、更复杂的散射模型如瑞利散射和米氏散射以及与屏幕空间反射SSR、环境光遮蔽SSAO等其他后处理效果的结合创造出属于你自己的独特水下世界。