1. 项目缘起:从“一潭死水”到“水下视界”
在游戏开发,尤其是开放世界或海洋题材的项目中,水体渲染的质量直接决定了场景的沉浸感。很多开发者,包括我自己在早期,都曾满足于一个漂亮的、带菲涅尔反射和法线扰动的表面水体。然而,当玩家角色潜入水下时,那种沉浸感往往会瞬间崩塌——视线清晰无比,仿佛只是戴上了一副蓝色滤镜,完全失去了水下那种光线衰减、模糊、扭曲的真实感。这就是我们今天要解决的核心问题:如何让Unity中的水体渲染,在角色潜入水下时,呈现出符合物理直觉和视觉经验的“水下透视效果”。
这个效果绝不仅仅是简单地在摄像机上加一个全屏的蓝色后处理。它是一套综合的视觉欺骗系统,需要处理深度衰减、散射模拟、边缘模糊、焦散投影等多个层面。网上能找到的教程要么过于零散,只讲某个单一效果(比如屏幕空间深度雾),要么过于复杂,直接上体积渲染,让初学者望而却步。我希望能通过这篇文章,拆解这个效果的实现链条,从最基础的屏幕深度获取开始,一步步构建一个完整、可调、性能可控的水下视觉方案。无论你是Shader新手,还是想优化现有水体系统的开发者,这篇文章都能提供一条清晰的实现路径和关键的避坑指南。
2. 核心原理拆解:水下视觉的四大支柱
要实现逼真的水下透视,我们需要理解光线在水下传播时发生的几个关键物理现象,并将其转化为Shader中可计算的模型。
2.1 深度衰减与散射(水下雾效)
这是水下视觉效果的基础。水不是透明的空气,其中悬浮着无数微小粒子(浮游生物、泥沙等)。光线在水中传播时,会与这些粒子发生散射,传播距离越远,原始光线的强度衰减越厉害,同时从各个方向散射过来的环境光会增强。最终结果就是:物体离摄像机越远,其颜色越趋向于一个固定的“水下环境色”(通常是蓝绿色),对比度降低。
在Shader中,我们通常用基于深度的指数衰减来模拟这一现象。公式可以简化为:finalColor = lerp(originalColor, fogColor, 1.0 - exp(-depth * density))这里的depth是物体到摄像机的实际水下深度,density是控制衰减快慢的密度系数。lerp函数根据衰减系数在物体原色和雾效颜色之间进行混合。
注意:这里获取的
depth必须是水下部分的深度。如果简单使用摄像机的线性深度,会把水面以上的部分也计算进去,导致水面以上的山体也蒙上水雾,这是常见错误。我们需要结合场景深度和水面高度,动态计算出“水下深度”。
2.2 边缘模糊与色散(Caustics与视觉扭曲)
水面的波动不仅影响反射,更会影响折射。当光线穿过起伏的水面射入水下时,其路径会发生弯曲,导致水下物体看起来有轻微的扭曲和动态模糊。同时,水面如同一个巨大的透镜,会将阳光聚焦成晃动的光斑投影到水底,这就是焦散(Caustics)效果。
在实时渲染中,完全精确的物理折射计算开销巨大。我们通常采用一种屏幕空间近似方法:
- 扭曲:使用一张法线贴图或通过噪声(如
Unity自带的Mathf.PerlinNoise,这也是热词中提到的)模拟水面起伏,基于此对水下场景的屏幕采样坐标进行扰动。这会让水下的物体边缘产生波光粼粼的扭曲感。 - 模糊:对扭曲后的水下场景进行一个轻微的高斯模糊或运动模糊处理,模拟光线散射导致的视觉锐度下降。这个模糊强度也可以与深度关联,越深的地方模糊越强。
- 焦散:通过投影一张动态的、高对比度的噪声纹理到水下场景的深度平面上来实现。通常需要根据水面高度和太阳方向,计算投影坐标,让焦散图案随着水面波动而移动。
2.3 水面交界处的处理(Waterline)
水面与空气的交界处是一个视觉关键点。在真实世界中,由于水面张力、泡沫和视线角度,交界处并非一条清晰的线,而是一个有厚度、有变化的区域。在Shader中,我们需要特别处理摄像机靠近水面(即部分在水下、部分在水上)的情况,以及水面边缘的抗锯齿。
一个实用的技巧是,在水面交界处附近(通过深度差判断),混合一个微弱的“水线”特效,比如增加一些白色的泡沫像素或进行边缘平滑,可以极大地增强真实感,避免出现生硬的裁剪线。
2.4 性能考量:全屏后处理 vs 深度写入方案
实现水下效果主要有两种架构:
- 全屏后处理(Post-processing):编写一个后处理Shader,在全屏范围内,根据像素是否在水下(通过比较像素世界坐标的Y值和水面高度)来应用上述效果。这是最灵活、最常用的方法,易于集成到现有的后处理栈中。
- 深度写入与自定义渲染队列:编写一个渲染水体的Shader,在渲染水体背面(即水下部分)时,将水下效果直接计算并写入颜色缓冲区。这种方法更符合渲染管线逻辑,但需要对Shader的渲染队列(Queue)和深度写入(ZWrite)有更精细的控制,实现起来更复杂。
对于大多数项目,我推荐使用全屏后处理方案。它解耦了水体渲染和水下效果,允许你独立调整和开关水下视觉,也便于管理性能。下文也将基于此方案进行实现。
3. 实战构建:一个完整的水下后处理Shader
让我们从零开始,在Unity URP(通用渲染管线)中构建一个水下后处理效果。我们将创建一个C#脚本和一个Shader文件。
3.1 第一步:创建后处理脚本与渲染器特征
首先,在Unity中创建一个C#脚本,命名为UnderwaterPostProcess。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class UnderwaterPostProcess : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; public Material material; public float waterLevel = 0; // 世界空间中的水面高度 public Color fogColor = new Color(0, 0.4f, 0.7f, 1); public float fogDensity = 0.1f; public float maxFogFactor = 0.8f; // 雾效最大强度 public Texture2D distortionNormalMap; public float distortionStrength = 0.02f; public float distortionSpeed = 0.5f; public Texture2D causticsTexture; public float causticsScale = 5.0f; public float causticsSpeed = 0.3f; public float causticsIntensity = 0.5f; } public Settings settings = new Settings(); private UnderwaterRenderPass _renderPass; public override void Create() { _renderPass = new UnderwaterRenderPass(settings); _renderPass.renderPassEvent = settings.renderPassEvent; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.material == null) { Debug.LogWarning("UnderwaterPostProcess material is missing."); return; } renderer.EnqueuePass(_renderPass); } private class UnderwaterRenderPass : ScriptableRenderPass { private Settings _settings; private RenderTargetIdentifier _source; private RenderTargetHandle _tempTexture; public UnderwaterRenderPass(Settings settings) { _settings = settings; _tempTexture.Init("_TempUnderwaterTexture"); } public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) { // 配置临时RT cmd.GetTemporaryRT(_tempTexture.id, cameraTextureDescriptor); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get("Underwater Post Process"); var source = renderingData.cameraData.renderer.cameraColorTarget; _source = source; // 将参数传递给Shader Material mat = _settings.material; if (mat != null) { mat.SetFloat("_WaterLevel", _settings.waterLevel); mat.SetColor("_FogColor", _settings.fogColor); mat.SetFloat("_FogDensity", _settings.fogDensity); mat.SetFloat("_MaxFogFactor", _settings.maxFogFactor); mat.SetTexture("_DistortionNormalMap", _settings.distortionNormalMap); mat.SetFloat("_DistortionStrength", _settings.distortionStrength); mat.SetFloat("_DistortionSpeed", _settings.distortionSpeed); mat.SetTexture("_CausticsTex", _settings.causticsTexture); mat.SetFloat("_CausticsScale", _settings.causticsScale); mat.SetFloat("_CausticsSpeed", _settings.causticsSpeed); mat.SetFloat("_CausticsIntensity", _settings.causticsIntensity); // 从源RT Blit到临时RT,应用材质 cmd.Blit(source, _tempTexture.Identifier(), mat); // 再从临时RT Blit回源RT cmd.Blit(_tempTexture.Identifier(), source); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void FrameCleanup(CommandBuffer cmd) { // 释放临时RT cmd.ReleaseTemporaryRT(_tempTexture.id); } } }这个脚本定义了一个URP的Renderer Feature,它会在指定的渲染阶段插入一个自定义的Pass,使用我们即将编写的Shader对屏幕图像进行处理。
3.2 第二步:编写核心水下效果Shader
接下来,创建一个Shader文件,命名为UnderwaterEffect.shader。这是效果的核心。
Shader "Hidden/UnderwaterEffect" { Properties { _MainTex ("Texture", 2D) = "white" {} _WaterLevel ("Water Level", Float) = 0 _FogColor ("Fog Color", Color) = (0,0.4,0.7,1) _FogDensity ("Fog Density", Float) = 0.1 _MaxFogFactor ("Max Fog Factor", Range(0,1)) = 0.8 _DistortionNormalMap ("Distortion Normal Map", 2D) = "bump" {} _DistortionStrength ("Distortion Strength", Float) = 0.02 _DistortionSpeed ("Distortion Speed", Float) = 0.5 _CausticsTex ("Caustics Texture", 2D) = "white" {} _CausticsScale ("Caustics Scale", Float) = 5.0 _CausticsSpeed ("Caustics Speed", Float) = 0.3 _CausticsIntensity ("Caustics Intensity", Float) = 0.5 } 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; float3 viewVector : TEXCOORD1; // 用于重建世界位置 }; sampler2D _MainTex; sampler2D _CameraDepthTexture; // URP中需通过变量获取深度图 float4 _MainTex_TexelSize; float _WaterLevel; float4 _FogColor; float _FogDensity; float _MaxFogFactor; sampler2D _DistortionNormalMap; float _DistortionStrength; float _DistortionSpeed; sampler2D _CausticsTex; float _CausticsScale; float _CausticsSpeed; float _CausticsIntensity; // 重建世界位置函数(简化版,适用于特定FOV) float3 ReconstructWorldPos(float2 uv, float depth) { float4 clipPos = float4(uv * 2.0 - 1.0, depth, 1.0); float4 worldPos = mul(unity_CameraToWorld, mul(UNITY_MATRIX_I_P, clipPos)); return worldPos.xyz / worldPos.w; } v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; // 计算视线向量(从摄像机到远裁剪面) float3 viewVector = mul(unity_CameraInvProjection, float4(v.uv * 2.0 - 1.0, 0.0, -1.0)).xyz; o.viewVector = mul(unity_CameraToWorld, float4(viewVector, 0.0)).xyz; return o; } fixed4 frag (v2f i) : SV_Target { // 1. 基础采样 fixed4 col = tex2D(_MainTex, i.uv); float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth = Linear01Depth(rawDepth); float3 worldPos = _WorldSpaceCameraPos + i.viewVector * linearDepth * _ProjectionParams.z; // 2. 判断是否在水下 bool isUnderwater = _WorldSpaceCameraPos.y < _WaterLevel; if (!isUnderwater) { // 摄像机在水面上,只处理水面以下的部分 if (worldPos.y > _WaterLevel) { return col; // 水上部分,直接返回原色 } } // 摄像机在水下,或当前像素在水下,则应用效果 // 3. 计算水下深度(关键步骤) float underwaterDepth = 0; if (isUnderwater) { // 摄像机在水下:深度为从摄像机到物体的距离(考虑水面上物体) underwaterDepth = linearDepth * _ProjectionParams.z; // 如果物体在水面上,我们不应该对其应用水下雾效(可选,这里简化处理) } else { // 摄像机在水上,但当前像素在水下:深度为从水面交点到物体的距离 // 这里需要求射线与水面的交点,是一个简化近似:用水面高度和摄像机高度差作为基础深度 float rayToWaterDist = (_WaterLevel - _WorldSpaceCameraPos.y) / i.viewVector.y; if (rayToWaterDist > 0) { underwaterDepth = distance(worldPos, _WorldSpaceCameraPos + i.viewVector * rayToWaterDist); } } // 4. 应用深度雾效 float fogFactor = 1.0 - exp(-underwaterDepth * _FogDensity); fogFactor = clamp(fogFactor, 0, _MaxFogFactor); // 限制最大雾效 col.rgb = lerp(col.rgb, _FogColor.rgb, fogFactor); // 5. 应用屏幕空间扭曲(模拟折射) float2 distortionUV = i.uv; if (_DistortionStrength > 0) { float2 distortion = tex2D(_DistortionNormalMap, i.uv * _CausticsScale + _Time.y * _DistortionSpeed).rg; distortion = distortion * 2.0 - 1.0; // 从[0,1]映射到[-1,1] distortionUV += distortion * _DistortionStrength * saturate(underwaterDepth); // 深度越深,扭曲可能越强 // 用扭曲后的UV重新采样颜色(轻微模糊效果) fixed4 distortedCol = tex2D(_MainTex, distortionUV); col.rgb = lerp(col.rgb, distortedCol.rgb, 0.3); // 混合原色和扭曲色,模拟模糊 } // 6. 应用焦散投影 if (_CausticsIntensity > 0) { // 假设焦散投影在水底平面上(Y=0),这是一个简化。更复杂的实现需要根据水面法线和光方向计算。 float2 causticsUV = worldPos.xz * 0.1 * _CausticsScale + float2(_Time.y, _Time.y * 0.7) * _CausticsSpeed; float caustics = tex2D(_CausticsTex, causticsUV).r; // 焦散强度随水下深度减弱 float causticsStrength = _CausticsIntensity * (1.0 - saturate(underwaterDepth / 20.0)); col.rgb += col.rgb * caustics * causticsStrength; // 乘性叠加,模拟光照增强 } // 7. 水线处理(简易版) float waterSurfaceDist = abs(worldPos.y - _WaterLevel); if (waterSurfaceDist < 0.5) { // 靠近水面,可以增加一些颜色混合或亮度变化 float surfaceFactor = 1.0 - smoothstep(0, 0.5, waterSurfaceDist); col.rgb = lerp(col.rgb, _FogColor.rgb * 1.2, surfaceFactor * 0.3); } return col; } ENDCG } } }这个Shader完成了我们之前讨论的核心功能:深度判断、雾效混合、屏幕扭曲和焦散叠加。它接收来自C#脚本的参数,并对每个像素进行处理。
3.3 第三步:在Unity中配置与使用
- 将
UnderwaterPostProcess脚本挂载到你的URP渲染器资产上(在Project窗口找到UniversalRenderPipelineAsset, 在Inspector中找到Renderer List, 点击编辑,在Renderer Features列表中添加)。 - 创建一个Material,使用我们刚写的
Hidden/UnderwaterEffectShader。 - 在
UnderwaterPostProcess的Inspector中,将创建好的Material拖入Material槽位。 - 配置参数:
Water Level: 设置你场景中水面的世界Y坐标。Fog Color/Density: 调整水下雾效的颜色和浓度。Distortion Normal Map: 提供一张法线贴图来驱动扭曲效果。Caustics Texture: 提供一张黑白的、高对比度的噪声或焦散图案纹理。
- 确保你的URP摄像机启用了后处理,并且深度纹理(Depth Texture)被生成(在URP Asset设置中勾选)。
4. 进阶优化与关键避坑指南
实现基础功能后,性能和效果优化才是真正体现经验的地方。以下是几个关键的优化点和常见陷阱。
4.1 性能优化:将计算转移到低分辨率
全屏后处理对每个像素执行,开销不小。特别是扭曲和焦散计算涉及额外的纹理采样。一个有效的优化策略是降采样(Downsample)。
我们可以在后处理Pass中,先将原图渲染到一个一半(或四分之一)分辨率的Render Texture上,然后在这个低分辨率RT上执行大部分水下效果计算(尤其是扭曲和焦散),最后再将结果上采样(Upsample)回屏幕分辨率。因为水下效果本身带有模糊属性,轻微的细节损失不易察觉,但性能提升显著(像素处理量减少为1/4或1/16)。
在UnderwaterRenderPass.Configure中,可以创建更低描述符的RT,在Execute中分两步Blit。
4.2 深度重建的精度与通用性问题
上面Shader中的ReconstructWorldPos函数是一个简化版本。在实际项目中,为了兼容不同的摄像机FOV和裁剪平面,需要使用更通用的重建方法。URP提供了ComputeWorldSpacePosition函数(在Common.hlsl中),或者可以传递摄像机的逆投影矩阵(UNITY_MATRIX_I_VP)来精确重建。不正确的世界位置重建会导致雾效边界错位、焦散投影漂浮等问题。
避坑点:务必在Shader中验证重建的世界位置是否正确。一个简单的调试方法是将世界位置的Y值输出为颜色,观察其是否与场景中的物体高度对应。
4.3 处理透明物体与粒子系统
标准的_CameraDepthTexture不包含透明物体的深度信息。如果你的水下有半透明的鱼群、气泡等粒子效果,它们不会被深度测试,可能导致它们“悬浮”在雾效之上,看起来不自然。
解决方案有两种:
- 使用
_CameraOpaqueTexture:在URP中,可以先渲染所有不透明物体到一张纹理,后处理基于这张纹理进行,透明物体在其后渲染。这样水下效果不会影响透明物体。这需要调整渲染顺序。 - 自定义深度写入:为需要参与水下雾效的透明物体编写一个Shader,将其深度写入一个自定义的深度纹理。后处理Shader同时采样标准深度图和自定义深度图,取最近的有效深度。这种方法更灵活但更复杂。
4.4 与屏幕空间反射(SSR)等效果的兼容性
如果你的项目使用了屏幕空间反射、环境光遮蔽(SSAO)等后处理效果,需要注意执行顺序。水下扭曲效果会改变屏幕坐标,如果SSR在其后执行,它将在已经被扭曲的图像上寻找反射射线,导致错误的反射结果。
通常的解决顺序是:先渲染不透明物体和深度→进行SSR、SSAO等需要精确几何信息的后处理→最后应用水下扭曲、雾效等“全屏美化”效果。这需要在URP的Renderer Feature中仔细安排各个Pass的RenderPassEvent。
4.5 参数的艺术:调试出“感觉对”的效果
水下效果的参数调节非常依赖视觉感受,没有绝对正确的数值。这里有一些调节心得:
- 雾效密度(Fog Density):从小值开始(如0.05),确保在近处(3-5米内)物体依然清晰,在远处(15-20米)基本融入环境色。过高的密度会让水下看起来像浓汤。
- 扭曲强度(Distortion Strength):非常微妙,通常0.01到0.05就够了。它应该是一种不易察觉但能增加动态感的细节,而不是让整个屏幕晃动。可以结合水面波动频率来调。
- 焦散(Caustics):焦散纹理最好使用动画序列或通过Shader程序化生成动态图案。强度(Intensity)不宜过高,避免过曝。让焦散图案缓慢移动,速度与水面波动相关联。
- 水线处理:这是提升真实感的点睛之笔。除了颜色混合,还可以考虑在水面附近采样屏幕空间法线,模拟水面的镜面高光(闪白)效果。
5. 从基础到扩展:更多可能性探索
当你掌握了基础的水下透视效果后,可以在此基础上添加更多细节,打造独一无二的水下环境。
5.1 动态水质变化:浑浊度与能见度
静态的水下参数看起来会很假。你可以根据角色位置(靠近海底泥沙时)、天气系统(暴风雨后)、或者游戏事件(爆炸)来动态调整_FogDensity和_FogColor。例如,靠近海底时,雾效颜色可以从蓝绿色偏向土黄色,密度增加。
5.2 体积光(God Rays)与水下光束
当阳光以一定角度射入水中时,会形成清晰可见的光束。这可以通过体积光(Volumetric Light)技术实现。在后处理中,从屏幕像素向光源方向(太阳方向)进行射线步进(Ray Marching),累计散射光强度。虽然计算量较大,但结合降采样和优化,在中等配置上是可以实现的。它能极大地增强水下的神圣感和空间层次。
5.3 整合屏幕空间接触阴影(Contact Shadows)
水下的物体,特别是靠近海底或墙壁的物体,阴影会变得更加柔和和弥散。URP自带的屏幕空间接触阴影(SCS)可以在后处理阶段提供高质量的软阴影。确保你的水下后处理在SCS之后执行,这样扭曲效果也会应用到阴影上,使阴影看起来是在水下发生的。
5.4 音频与视觉的联动
不要忘记听觉。当摄像机进入水下时,除了视觉变化,还应触发低通滤波器(Low-pass Filter)的音频效果,让声音听起来发闷、遥远。同时,可以添加水下特有的环境音效(水流声、气泡声)。视听同步是沉浸感的关键。
实现一个令人信服的水下透视效果,是技术实现和艺术感知的结合。它没有唯一的正确答案,最终目的是服务于游戏的氛围和玩家的体验。我建议从本文提供的基础框架开始,将其集成到你的项目中,然后花时间仔细观察真实的水下摄影或高质量的游戏参考(如《深海迷航》、《刺客信条:奥德赛》),不断微调参数,直到它“感觉对了”。这个过程本身,就是对渲染和Shader理解的一次深度潜水。