ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity水下透视效果全解析:从深度雾效到焦散渲染的完整实现

Unity水下透视效果全解析:从深度雾效到焦散渲染的完整实现 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) { // 假设焦散投影在水底平面上Y0这是一个简化。更复杂的实现需要根据水面法线和光方向计算。 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理解的一次深度潜水。
返回列表