Unity URP深度重建世界坐标实现体积雾与光线散射特效
1. 项目概述从屏幕像素到世界空间的魔法在Unity URP管线里做后处理特效尤其是体积光Volumetric Light和体积雾Volumetric Fog有一个绕不开的核心技术点如何从一张二维的屏幕深度图Depth Texture里把每个像素对应的三维世界坐标World Space Position给“算”回来。这听起来有点像魔法但却是实现一切基于屏幕空间Screen Space的体积效果的基础。没有准确的世界位置你的光线散射计算、雾浓度衰减就无从谈起效果要么穿帮要么性能开销巨大。我最初接触这个需求时也走了不少弯路。比如尝试在Shader里直接使用TransformObjectToWorld结果发现只对不透明物体有效对于半透明物体或者后处理全屏Pass根本拿不到原始的顶点信息。也试过在CPU端传一大堆矩阵和参数结果Draw Call和带宽成了瓶颈。最终深度重建世界位置成了唯一优雅且高效的解决方案。它不关心你场景里有什么模型只关心当前像素在深度缓冲里“埋”了多深然后通过一系列矩阵逆运算把它“挖”出来还原到三维世界中。这个项目要做的就是把这个过程在URP里跑通并基于它构建出性能与质量兼顾的体积雾和光线散射俗称“上帝之光”效果。这不仅仅是写个Shader那么简单它涉及到URP管线的资源设置、深度图的正确获取、矩阵的精确传递、以及最终在片元着色器中的高效重建计算。适合有一定Unity和Shader基础的开发者特别是那些对画面表现有追求不满足于内置雾效希望实现《巫师3》或《战神》里那种随着光线穿透森林、教堂窗户射入尘埃般的光柱效果的团队。接下来我会把整个流程掰开揉碎从原理到代码从踩坑到优化毫无保留地分享出来。2. 核心原理深度图与世界空间重建的数学本质2.1 深度图里到底存了什么首先必须彻底搞清楚我们从URP里采样到的深度值到底是什么。很多人以为深度图里存的就是距离相机多少米这是一个常见的误解。在Unity中深度纹理_CameraDepthTexture里存储的通常是非线性的、经过透视除法Perspective Division和归一化Normalized的深度值。更具体地说对于透视相机这个深度值d是像素点裁剪空间Clip Space坐标的z分量除以w分量后经过一个[0,1]或[1,0]取决于平台的映射结果。它反映的是从近裁剪面Near Clip Plane到远裁剪面Far Clip Plane的相对深度而不是绝对的欧几里得距离。为什么用非线性深度为了在近处提供更高的精度。想象一下透视投影下物体离相机越近一点微小的移动在屏幕上带来的变化越大越远则变化越不明显。非线性深度编码通常是1/z关系正好匹配了人眼和渲染的这种特性能在有限的精度如16位或32位纹理下在近处存储更精细的深度差异避免Z-fighting。所以我们采样到的depth值第一步是进行线性化Linearize。公式通常如下float linearDepth LinearEyeDepth(depth, _ZBufferParams);这个_ZBufferParams是Unity提供的一个float4向量包含了从非线性深度转换到线性眼空间深度即距离相机原点的绝对距离所需的参数。LinearEyeDepth函数内部帮我们处理了不同渲染APIDirectX/OpenGL的深度范围差异[0,1]vs[1,0]和非线性到线性的转换。2.2 从深度到世界坐标的四步重建法拿到了线性眼空间深度即距离相机的距离Z_eye我们怎么得到世界坐标呢核心思路是逆推渲染管线。重建屏幕UV和裁剪空间坐标Clip Space当前像素的屏幕位置是已知的i.uv。我们需要将其转换到裁剪空间。裁剪空间坐标(x_c, y_c, z_c, w_c)的x_c和y_c范围是[-1, 1]左右上下。可以通过一个简单的映射得到float2 uv i.uv; float x_c uv.x * 2.0 - 1.0; float y_c (1.0 - uv.y) * 2.0 - 1.0; // 注意UV的Y轴方向可能与屏幕空间相反在URP的全屏后处理Blit中通常已经处理好了坐标系我们可以直接用GetFullScreenTriangleTexCoord之类的宏来获取更精确的坐标。重建观察空间坐标View Space这是关键一步。在观察空间相机空间下相机的原点就是(0,0,0)。透视投影的本质是一个四棱台视锥体。已知裁剪空间的x_c, y_c和线性深度Z_eye以及相机的垂直视场角FOV和宽高比Aspect我们可以利用相似三角形原理反推出观察空间的X_view和Y_view。 更通用的方法是使用相机的投影矩阵的逆矩阵unity_CameraInvProjection。我们可以构造一个裁剪空间向量(x_c, y_c, 1, 1)然后用逆投影矩阵变换它再乘以线性深度Z_eye就能得到观察空间坐标。URP通常提供了GetViewSpacePosition函数或相关矩阵来简化这一步。观察空间到世界空间得到了观察空间坐标(X_view, Y_view, Z_eye, 1)我们离终点只差一步。观察空间到世界空间的变换就是乘以相机的观察矩阵的逆矩阵也就是世界到观察矩阵的逆矩阵——unity_MatrixInvV。float4 viewPos float4(X_view, Y_view, Z_eye, 1.0); float4 worldPos mul(unity_MatrixInvV, viewPos);这样worldPos.xyz就是我们要的该像素点对应的世界坐标。一个极其重要的注意事项上述计算得到的worldPos是当前像素所代表的无限远射线上与深度值对应的那个点的坐标。如果该像素位于背景如天空盒其深度值可能为1代表远裁剪面那么计算出的世界坐标就位于远裁剪面的那个平面上而不是“无限远”。这对于体积雾的计算至关重要——我们需要区分“有几何体的位置”和“无几何体的空间”。2.3 为什么不用内置函数性能与控制的权衡Unity 的ComputeWorldSpacePosition或某些 SRP 工具函数确实可以完成这个工作。但在追求极致性能和后处理控制的体积效果中我们往往需要自己实现。原因有三 第一灵活性内置函数可能封装了你不需要的步骤或者输出格式不直接满足你的需求。自己实现可以精确控制从深度采样、线性化到矩阵乘法的每一步便于插入自定义的调试可视化或优化。 第二理解深度自己推导一遍是理解整个渲染管线空间变换的最佳实践。当效果出现诡异错误比如雾在屏幕边缘扭曲时你能快速定位是深度图格式问题、矩阵问题还是UV问题。 第三性能微调你可以根据平台特性进行优化。例如在移动端可以权衡精度与速度使用简化版的矩阵运算或查找表LUT。在我的实际项目中尤其是面向移动端如使用Vulkan或Metal的Android/iOS项目自定义的重建函数往往比通用内置函数快上那么一点点积少成多对维持60帧至关重要。3. URP管线配置与深度纹理获取3.1 强制开启深度纹理URP默认不会为所有相机渲染深度纹理因为这会增加带宽和内存开销。我们需要显式地开启它。方法一通过Renderer Asset配置推荐这是最规范的方式。找到你项目使用的URP Renderer Asset通常叫UniversalRenderer_xxx。在Inspector窗口中找到Renderer Features列表。点击Add Renderer Feature选择Render Objects。但这并不是用来开启深度的正确位置。实际上深度纹理的开启是在Renderer的Depth Texture选项。在Renderer Asset的顶部或General部分勾选Depth Texture选项。这确保渲染管线在渲染不透明通道后将深度信息存储到一张_CameraDepthTexture中。方法二通过脚本动态开启如果项目需要在运行时根据场景动态开关深度纹理比如只有特定关卡需要体积雾可以在相机上挂载脚本using UnityEngine.Rendering.Universal; public class RequireDepthTexture : MonoBehaviour { private void OnEnable() { var cam GetComponentCamera(); if (cam ! null) { var additionalData cam.GetUniversalAdditionalCameraData(); if (additionalData ! null) { additionalData.requiresDepthTexture true; // 如果还需要法线纹理可以一并开启 // additionalData.requiresNormalTexture true; } } } }将这个脚本挂到需要体积雾效果的相机上即可。注意开启深度纹理会增加GPU内存占用和一定的渲染开销对于低端设备要谨慎使用。注意开启Depth Texture后URP实际上可能会根据硬件和设置将深度和法线信息打包到同一张_CameraDepthNormalsTexture中。在Shader中我们应该使用SAMPLER(_CameraDepthTexture)和tex2D(_CameraDepthTexture, uv)来采样Unity的宏会帮我们处理这些底层差异。但为了绝对精确地重建世界位置最好确保我们采样到的是纯粹的深度值。有时需要检查_CameraDepthTexture的实际纹理格式。3.2 创建体积雾后处理渲染器特性Renderer Feature体积雾和光通常作为全屏后处理效果实现。在URP中实现自定义全屏后处理的标准方式是创建一个ScriptableRendererFeature和对应的ScriptableRenderPass。步骤1创建Render Pass这个Pass负责执行具体的渲染命令。核心任务是在所有不透明和透明物体渲染完毕后将深度纹理和颜色纹理作为输入运行我们的体积雾Shader然后将结果输出到目标颜色缓冲区。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class VolumetricFogRenderPass : ScriptableRenderPass { private Material m_VolumetricFogMaterial; private RTHandle m_CameraColorTarget; // URP 12 推荐使用 RTHandle private RTHandle m_CameraDepthTarget; // 构造函数传入我们创建好的材质 public VolumetricFogRenderPass(Material material) { m_VolumetricFogMaterial material; renderPassEvent RenderPassEvent.BeforeRenderingPostProcessing; // 在后期处理前执行 // 也可以放在 AfterRenderingTransparents取决于你想让雾效与透明物体的混合关系 } // 每帧调用配置Pass public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) { // 通常不需要额外配置RenderTarget因为我们会直接渲染到相机颜色目标 } // 执行Pass public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_VolumetricFogMaterial null) return; // 获取相机的颜色和深度目标 var cameraData renderingData.cameraData; m_CameraColorTarget cameraData.renderer.cameraColorTargetHandle; m_CameraDepthTarget cameraData.renderer.cameraDepthTargetHandle; CommandBuffer cmd CommandBufferPool.Get(Volumetric Fog); // 核心使用Blit命令以深度纹理和颜色纹理为输入运行我们的材质 // 注意这里需要确保深度纹理已经绑定到 _CameraDepthTexture Blitter.BlitCameraTexture(cmd, m_CameraColorTarget, m_CameraColorTarget, m_VolumetricFogMaterial, 0); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } // 清理资源如果需要 public override void OnCameraCleanup(CommandBuffer cmd) { base.OnCameraCleanup(cmd); } }步骤2创建Renderer Feature这个Feature负责管理Render Pass的生命周期并将其添加到渲染器中。public class VolumetricFogFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material null; public RenderPassEvent passEvent RenderPassEvent.BeforeRenderingPostProcessing; } public Settings settings new Settings(); private VolumetricFogRenderPass m_RenderPass; public override void Create() { if (settings.material null) { Debug.LogWarning(Volumetric Fog material is not assigned.); return; } m_RenderPass new VolumetricFogRenderPass(settings.material); m_RenderPass.renderPassEvent settings.passEvent; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (renderingData.cameraData.postProcessEnabled settings.material ! null) { // 可以在这里根据相机类型如场景相机、预览相机决定是否添加Pass renderer.EnqueuePass(m_RenderPass); } } }步骤3在Renderer Asset中添加Feature将编写好的VolumetricFogFeature脚本拖到你的URP Renderer Asset的Renderer Features列表中并为其指定我们即将编写的体积雾材质。3.3 准备Shader与材质创建一个新的Unlit Shader Graph或者直接编写HLSL Shader。为了教学清晰这里以Shader Graph为例因为它能直观地展示节点连接但核心计算逻辑与代码Shader一致。创建Shader Graph在Project窗口右键 - Create - Shader - Universal Render Pipeline - Unlit Shader Graph。关键节点获取Camera Depth Texture节点用于采样深度。Scene Depth节点另一种采样深度的方式通常更直接。Linear Eye Depth节点将采样的深度值转换为线性眼空间深度。Screen Position节点获取当前像素的屏幕空间坐标多种模式可选。View Direction (World Space)节点获取从相机到当前片元的世界空间方向向量注意这个“当前片元”在重建世界位置前是未知的通常需要先重建。Matrix类型节点用于获取UNITY_MATRIX_I_V(Inverse View Matrix) 和UNITY_MATRIX_P(Projection Matrix) 等。在Shader Graph中可能需要通过Custom Function节点或Matrix 4x4属性从C#端传递。实际上在Shader Graph中完整实现深度重建世界坐标稍显复杂因为涉及较多的矩阵运算。许多有经验的开发者会选择编写一个自定义HLSL函数文件.hlsl在其中实现ReconstructWorldPositionFromDepth函数然后在Shader Graph中通过Custom Function节点调用它。这是兼顾可视化与灵活性的好方法。HLSL函数示例 (VolumetricFogUtils.hlsl):#ifndef VOLUMETRIC_FOG_UTILS_INCLUDED #define VOLUMETRIC_FOG_UTILS_INCLUDED #include Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl float3 ReconstructWorldPositionFromDepth(float2 uv, float4x4 inverseViewMatrix, float4x4 inverseProjectionMatrix) { // 1. 采样并线性化深度 float depth SampleSceneDepth(uv); float linearDepth LinearEyeDepth(depth, _ZBufferParams); // 2. 重建裁剪空间坐标 (范围[-1,1]) float4 clipPos float4(uv * 2.0 - 1.0, 1.0, 1.0); #if UNITY_UV_STARTS_AT_TOP clipPos.y -clipPos.y; // 处理平台差异 #endif // 3. 裁剪空间 - 观察空间 float4 viewPos mul(inverseProjectionMatrix, clipPos); viewPos.xyz * linearDepth; // Perspective divide 的逆操作 // 4. 观察空间 - 世界空间 float4 worldPos mul(inverseViewMatrix, float4(viewPos.xyz, 1.0)); return worldPos.xyz; } #endif在C#端我们需要将相机的camera.worldToCameraMatrix.inverse(即inverseViewMatrix) 和camera.projectionMatrix.inverse(即inverseProjectionMatrix) 通过Material.SetMatrix传递到Shader中。实操心得直接使用inverseProjectionMatrix进行重建是最准确、兼容性最好的方法它自动处理了视场角、宽高比、近远裁剪面等所有投影参数避免了手动计算时的误差。务必在每帧更新这两个矩阵因为相机会移动和旋转。4. 体积雾效的详细实现有了世界位置我们就可以计算每个像素点的雾效因子了。体积雾的核心是模拟光线在参与性介质如空气中的微小颗粒中传播时的衰减和散射。4.1 基于高度的指数雾最简单的体积雾是基于高度的。我们假设雾的密度随着高度的降低而增加就像现实中的地面雾。计算步骤如下计算像素的世界空间高度float worldHeight worldPos.y;计算雾密度使用指数衰减函数。float fogDensity _FogBaseDensity * exp(-_FogHeightFalloff * (worldHeight - _FogPlaneHeight));_FogBaseDensity: 在参考高度通常是_FogPlaneHeight处的基准密度。_FogHeightFalloff: 高度衰减系数。值越大雾随高度升高减少得越快。_FogPlaneHeight: 雾的“海平面”高度。低于此高度密度按公式计算可以简单理解为雾层开始的高度。计算雾效因子雾效因子fogFactor范围[0, 1]0表示完全清晰1表示完全被雾覆盖。常用的计算模型是fogFactor 1 - exp(-fogDensity * distanceToCamera)。其中distanceToCamera就是之前计算出的线性深度linearDepth。float fogFactor 1.0 - exp(-fogDensity * linearDepth);颜色混合将雾的颜色与原始场景颜色混合。finalColor lerp(originalColor, _FogColor, fogFactor);这种雾效实现简单性能开销极低能有效营造氛围和空间感但缺点是看起来比较“平”缺乏体积感因为密度只和高度有关与观察方向无关。4.2 基于Raymarching的体积雾要实现真正有体积感、能与光线交互的雾需要用到Raymarching光线步进技术。我们不是直接计算当前像素点的雾浓度而是模拟从相机出发穿过该像素指向场景的光线在这条光线上累积雾的浓度和光照。核心思想 对于屏幕上的每个像素我们从相机位置_WorldSpaceCameraPos出发向该像素对应的世界空间方向发射一条射线。这条射线会一直步进直到击中场景几何体由深度图定义为止。在每一步我们都采样该点的雾密度并累积其对最终颜色的贡献。Shader实现步骤定义参数float _FogDensity; // 全局密度 float _FogHeightFalloff; float _FogPlaneHeight; float _FogScatteringCoefficient; // 散射系数影响雾的亮度和颜色 int _StepCount; // 步进次数影响质量和性能 float _StepSize; // 步长 float _MaxRayDistance; // 最大步进距离通常等于远裁剪面计算射线float3 rayOrigin _WorldSpaceCameraPos; float3 rayDirection normalize(worldPos - rayOrigin); // 从相机指向重建的世界点 float rayLength min(linearDepth, _MaxRayDistance); // 射线长度不超过几何体深度Raymarching循环float totalTransmittance 1.0; // 光线透射率初始为1完全透过 float3 accumulatedLight float3(0,0,0); // 累积的光照用于体积光 float stepLength rayLength / _StepCount; float3 stepVector rayDirection * stepLength; float3 currentPos rayOrigin; for (int i 0; i _StepCount; i) { // 计算当前采样点的雾密度 float localDensity CalculateFogDensity(currentPos); float stepOpticalDepth localDensity * stepLength; // 计算这一步的透射率exp(-衰减系数 * 光学深度) float stepTransmittance exp(-_FogExtinctionCoefficient * stepOpticalDepth); // 累积透射率乘法 totalTransmittance * stepTransmittance; // 如果透射率已经很低光线几乎被完全阻挡可以提前终止循环以节省性能 if (totalTransmittance 0.01) break; // ### 体积光贡献计算见下一节### // accumulatedLight ... currentPos stepVector; }最终混合// 最终颜色 原始场景颜色 * 到达相机的光量 雾自身发出的光/散射的光 // 简化版直接使用透射率进行混合 float fogFactor 1.0 - totalTransmittance; float3 finalColor lerp(originalColor, _FogColor, fogFactor); // 更真实的版本需要加入 inscattering内散射项即 accumulatedLight finalColor originalColor * totalTransmittance accumulatedLight;性能优化技巧步进次数 (_StepCount)这是性能的关键。在PC上可以设置16-32步在移动端可能只能设置4-8步。可以通过降低分辨率Half/Quarter Res进行Raymarching然后上采样能极大提升性能。提前终止如上代码所示当totalTransmittance很低时后续步进对最终颜色贡献微乎其微直接跳出循环。使用Jittering对每条射线的起始步进位置施加一个随屏幕坐标变化的微小随机偏移抖动可以有效减少因步进数少而产生的带状瑕疵banding。** Temporal Reprojection**复用上一帧的雾效数据与当前帧混合可以平滑噪声允许使用更少的步进数是业界常用的高级优化手段但实现较为复杂。5. 体积光God Rays的实现体积光或者说光线散射Light Scattering是当强光源如太阳被场景中的物体如云、山、窗户遮挡时在遮挡物边缘产生的可见光柱现象。它的实现与体积雾的Raymarching紧密结合。5.1 原理与光源处理体积光的物理原理是米氏散射Mie Scattering。在实时渲染中我们常用一个简化的经验模型遮挡函数Occlusion Function与衰减函数的积分。我们需要一个或多个强光源。在Unity中通常是Directional Light平行光模拟太阳。我们需要在Shader中获取光源的方向_MainLightPosition.xyz对于平行光其w分量为0和颜色_MainLightColor.rgb。核心思路是在Raymarching的每一步我们不仅计算雾密度还要计算从当前采样点指向光源的方向上是否被场景物体遮挡。如果没有被遮挡那么该点就会接受到光源照射并沿着视线方向散射一部分光到相机中。5.2 阴影图采样与光线遮挡计算判断一个世界空间点currentPos是否被光源照亮最准确的方法是采样该光源的阴影贴图Shadow Map。在URP中我们可以使用SHADOWMAP和SampleShadow相关的宏。转换到光源的阴影空间需要将currentPos从世界空间转换到光源的裁剪空间。float4 posLightSpace mul(_MainLightWorldToShadow[0], float4(currentPos, 1.0)); // 对于级联阴影可能有多个矩阵采样阴影对阴影贴图进行采样比较采样得到的深度与posLightSpace的深度判断是否在阴影中。float shadow SampleShadowmap(posLightSpace); // 这是一个简化表示实际需处理级联和偏移shadow的值通常在[0,1]0表示完全在阴影中1表示完全被照亮。计算光照贡献如果shadow 0说明该点被照亮。那么它对视线的光散射贡献可以近似为float scattering _ScatteringIntensity * localDensity * shadow; // 应用一个基于角度的相位函数例如 Henyey-Greenstein 相位函数来控制前向/后向散射 float phase HGPhase(dot(rayDirection, -_MainLightDirection), _ScatteringAnisotropy); float3 stepLightContribution _MainLightColor.rgb * scattering * phase * stepLength; accumulatedLight stepLightContribution * totalTransmittance; // 乘以当前透射率表示之前介质对这部分光的衰减注意事项性能黑洞在Raymarching循环内对每个步进点都进行一次完整的阴影图采样特别是PCF软阴影是极其昂贵的。这是体积光效果性能开销的主要来源。优化策略降低采样频率不是每一步都采样阴影而是每隔几步采样一次中间插值。使用深度图进行近似遮挡一个更廉价但近似的方法是从currentPos向光源方向发射一条短射线并用场景深度图进行多次采样检查这条短射线是否与场景几何体相交。这被称为“屏幕空间遮挡”Screen Space Occlusion。虽然不如阴影图准确可能漏掉屏幕外的遮挡物但速度快很多。预计算/简化对于静态光源和静态场景可以考虑预计算体积光照贴图Light Volumes。5.3 与体积雾的整合体积光和体积雾在Raymarching循环中是同时计算的。完整的循环伪代码更新如下for (int i 0; i _StepCount; i) { // 1. 计算当前点密度 float density GetDensity(currentPos); // 2. 计算此步的光学深度和透射率 float stepOpticalDepth density * stepLength; float stepTransmittance exp(-_Extinction * stepOpticalDepth); totalTransmittance * stepTransmittance; // 3. 计算体积光贡献内散射 if (density 0) { float shadow GetShadow(currentPos); // 廉价或精确的阴影查询 if (shadow 0) { float phase PhaseFunction(dot(rayDir, -lightDir)); float3 scattering _ScatteringCoeff * density * shadow * phase * stepLength; // 光线从当前点散射到相机途中还会被 currentPos 到 camera 之间的介质衰减 // 但通常简化认为散射光产生于当前点并立即沿视线到达相机忽略自遮挡。 // 更准确的模型需要计算从 light-point 和 point-camera 的双重衰减计算量更大。 accumulatedLight lightColor * scattering * totalTransmittance; } } // 4. 提前终止 if (totalTransmittance 0.01) break; currentPos stepVector; }最终像素颜色float3 finalColor originalColor * totalTransmittance accumulatedLight;这个公式就是经典的体积渲染方程简化版的体现最终颜色 背景光衰减后的颜色 路径上累积的散射光。6. 性能优化与移动端适配实战将桌面级的体积效果搬到移动端是巨大的挑战。直接使用上述的完整Raymarching即使是中等步数在手机上也会导致帧率骤降。以下是我在多个移动项目中的实战优化经验。6.1 降分辨率渲染Half/Quarter Res这是最有效的一招。我们不需要在全屏幕分辨率下进行昂贵的Raymarching。创建低分辨率Render Texture在Render Pass中创建一个宽度和高度为相机目标一半或四分之一的Render Texture (RTHandle)。低分辨率Raymarching将体积雾/光的计算渲染到这个低分辨率RT上。此时你的步进循环只在(width/2) * (height/2)个像素上执行计算量减少到1/4。双边滤波上采样Bilateral Upsampling这是关键。不能简单地将低分辨率纹理用双线性滤波放大那样会导致边缘模糊和细节丢失。双边滤波在放大时会考虑深度和法线的连续性只在颜色、深度相似的区域进行平滑插值从而保持物体边缘的锐利。在Shader中上采样时同时采样低分辨率的颜色缓存和全分辨率的深度纹理。对于当前全分辨率像素找到其在低分辨率纹理中对应的4个纹素。计算当前全分辨率像素的深度/世界位置与4个低分辨率参考点的深度/世界位置进行比较。根据深度差异赋予不同的权重差异越大权重越小。然后用加权平均的方式插值出最终颜色。混合将上采样后的体积效果与全分辨率的场景颜色混合。实操心得降分辨率到1/4配合一个精心调校的3x3或5x5双边滤波在大多数移动设备上能以极小的视觉损失换取3-4倍的性能提升。滤波的权重函数基于深度差和颜色差需要反复调试以达到最佳边缘保持效果。6.2 基于平铺的优化Tiled对于包含多个局部光源点光源、聚光灯的体积光可以采用基于平铺Tiled的延迟渲染思想。将屏幕划分为多个小格子Tile每个Tile收集影响该区域的光源列表。在Raymarching时每个像素只需处理其所在Tile内的光源而不是全场景所有光源。这在多光源场景下能大幅减少计算量。不过在URP的前向渲染路径中实现此优化较为复杂可能需要自定义渲染管线扩展。6.3 简化光照模型省略相位函数对于各向同性散射_ScatteringAnisotropy 0相位函数是常数可以省略计算。简化阴影在移动端可以完全放弃精确的阴影图采样转而使用基于深度图的屏幕空间遮挡。或者对于方向光使用一个预计算的、简化的“遮挡纹理”该纹理存储了从相机角度看到的、光源方向的平均遮挡程度例如通过将场景深度图向下采样并做模糊得到。降低步进次数这是最直接的优化。通过结合Temporal Reprojection可以用少至4-8步获得相对平滑的结果。抖动Jittering对于消除低步数下的带状瑕疵至关重要。6.4 美术可控与参数烘焙给美术提供丰富的参数来控制性能/质量平衡_HalfRes开关一键开启/关闭半分辨率计算。_StepCount滑块在低、中、高画质预设下设置不同的值。_MaxRayDistance限制雾效和光效的最大距离远处的细节可以忽略。距离渐隐让雾效在远于一定距离后逐渐淡出避免在远处消耗性能。对于静态场景和静态光照考虑将体积光照信息烘焙到Lightmap或一张3D纹理Volume Texture中。运行时只需采样这张3D纹理开销极低。这适用于室内场景的固定雾气和光柱。7. 常见问题排查与调试技巧即使按照步骤实现体积效果也容易出现各种问题。这里记录几个我踩过的坑和解决方法。7.1 深度重建错误导致雾效扭曲问题现象雾效在屏幕边缘严重扭曲、拉伸或者随着相机移动而抖动。排查步骤可视化深度在Shader中将linearDepth或重建后的worldPos.y直接输出为颜色。检查深度值是否从近到远平滑变化世界高度是否正确。检查矩阵确保从C#传递到Shader的inverseViewMatrix和inverseProjectionMatrix是正确的。在Update中打印矩阵值或者直接在Shader中输出矩阵的某一行到颜色看是否随相机正常变化。检查UV和裁剪空间坐标确保从屏幕UV到裁剪空间坐标的转换考虑了平台差异如DX/GL的纹理V坐标方向是否使用UNITY_UV_STARTS_AT_TOP。一个常见的错误是忘记对Y坐标取反导致上下颠倒。检查深度图格式在Frame Debugger中查看_CameraDepthTexture的实际格式和内容。有时URP可能会使用R16_UNorm或R32_Float。确保你的线性化深度公式LinearEyeDepth与深度图格式匹配。解决方案使用Unity内置的ComputeWorldSpacePosition函数或GetViewSpacePosition函数进行交叉验证。如果内置函数结果正确而你的函数错误就逐行对比计算过程。7.2 体积光出现严重的带状瑕疵Banding问题现象光柱内部有明显的、一层一层的颜色条带而不是平滑的渐变。原因Raymarching步进数太少导致对光照积分的采样不足。在低步数下每个步进之间的密度和光照贡献变化剧烈量化现象明显。解决方案增加步进数最直接但影响性能。使用抖动Jittering为每条射线每个像素的起始步进位置添加一个基于屏幕坐标和帧号的伪随机偏移。这能将固定的条带模式打散成高频噪声而人眼对噪声不如对规则条带敏感。抖动公式示例float offset frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453 _Time.y * 0.1); currentPos rayDir * stepSize * offset;Temporal Reprojection结合历史帧数据进行累积和滤波可以极大地平滑噪声是消除条带和噪声的终极方案但实现复杂度高。7.3 性能热点分析与优化问题现象游戏帧率下降严重GPU Profiler显示Fragment Shader耗时极高。排查工具使用Unity的Frame Debugger和GPU Profiler如RenderDoc或Unity URP自带的SRP Debugger。定位Draw Call在Frame Debugger中找到执行体积雾的Render Pass查看其顶点和片元着色器的耗时。分析Shader复杂度在GPU Profiler中查看该Pass的Shader指令数、纹理采样次数、循环展开情况。Raymarching循环是主要的指令数贡献者。检查带宽查看是否因使用了全分辨率深度图和高精度Render Texture导致带宽过高。优化方向减少纹理采样确保深度图、阴影图只被采样必要的次数。避免在循环内采样不必要的高精度纹理。降低计算精度在移动端尝试将部分计算从float改为half。注意世界位置、深度等需要高精度的变量应保持为float。利用Early-Z确保体积雾的Shader有正确的深度写入通常不写入深度和测试避免过度绘制。7.4 与透明物体的渲染顺序问题问题现象半透明的粒子、UI或者特效要么被体积雾覆盖要么完全不参与雾效。原因体积雾通常是在所有不透明物体渲染之后、透明物体渲染之前执行的RenderPassEvent.BeforeRenderingTransparents。这个时机决定了它只能看到不透明物体的深度而透明物体的深度没有写入深度缓冲区。解决方案如果需要雾效影响透明物体将体积雾Pass移到透明物体之后AfterRenderingTransparents但这需要你能够获取到包含透明物体的最终颜色缓冲区并且透明物体的深度信息可能不完整实现难度大。更实用的方法接受体积雾不影响透明物体的现实。对于需要与雾交互的透明特效如烟雾粒子可以让粒子Shader自行计算雾效即基于粒子的世界位置计算雾因子并混合这被称为“每物体雾”Per-Object Fog。虽然不一致但在视觉上通常可以接受。调试时一个非常有用的技巧是创建一个简单的调试材质将中间变量如深度、世界高度、雾密度、散射光强度可视化为颜色。这能帮你快速定位问题是出在数据源深度/矩阵、计算过程还是最后的混合阶段。