三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity URP深度重建世界坐标实现体积雾与光线散射特效

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函数内部帮我们处理了不同渲染API(DirectX/OpenGL)的深度范围差异([0,1]vs[1,0])和非线性到线性的转换。

2.2 从深度到世界坐标的四步重建法

拿到了线性眼空间深度(即距离相机的距离Z_eye),我们怎么得到世界坐标呢?核心思路是逆推渲染管线。

  1. 重建屏幕UV和裁剪空间坐标(Clip Space):当前像素的屏幕位置是已知的(i.uv)。我们需要将其转换到裁剪空间。裁剪空间坐标(x_c, y_c, z_c, w_c)x_cy_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之类的宏来获取更精确的坐标。

  2. 重建观察空间坐标(View Space):这是关键一步。在观察空间(相机空间)下,相机的原点就是(0,0,0)。透视投影的本质是一个四棱台(视锥体)。已知裁剪空间的x_c, y_c和线性深度Z_eye,以及相机的垂直视场角FOV和宽高比Aspect,我们可以利用相似三角形原理反推出观察空间的X_viewY_view。 更通用的方法是使用相机的投影矩阵的逆矩阵unity_CameraInvProjection。我们可以构造一个裁剪空间向量(x_c, y_c, 1, 1),然后用逆投影矩阵变换它,再乘以线性深度Z_eye,就能得到观察空间坐标。URP通常提供了GetViewSpacePosition函数或相关矩阵来简化这一步。

  3. 观察空间到世界空间:得到了观察空间坐标(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)。

  1. 在Inspector窗口中,找到Renderer Features列表。
  2. 点击Add Renderer Feature,选择Render Objects。但这并不是用来开启深度的正确位置。实际上,深度纹理的开启是在RendererDepth Texture选项。
  3. 在Renderer Asset的顶部或General部分,勾选Depth Texture选项。这确保渲染管线在渲染不透明通道后,将深度信息存储到一张_CameraDepthTexture中。

方法二:通过脚本动态开启如果项目需要在运行时根据场景动态开关深度纹理(比如只有特定关卡需要体积雾),可以在相机上挂载脚本:

using UnityEngine.Rendering.Universal; public class RequireDepthTexture : MonoBehaviour { private void OnEnable() { var cam = GetComponent<Camera>(); 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一致。

  1. 创建Shader Graph:在Project窗口右键 -> Create -> Shader -> Universal Render Pipeline -> Unlit Shader Graph。
  2. 关键节点获取
    • 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 基于高度的指数雾

最简单的体积雾是基于高度的。我们假设雾的密度随着高度的降低而增加(就像现实中的地面雾)。计算步骤如下:

  1. 计算像素的世界空间高度float worldHeight = worldPos.y;
  2. 计算雾密度:使用指数衰减函数。float fogDensity = _FogBaseDensity * exp(-_FogHeightFalloff * (worldHeight - _FogPlaneHeight));
    • _FogBaseDensity: 在参考高度(通常是_FogPlaneHeight)处的基准密度。
    • _FogHeightFalloff: 高度衰减系数。值越大,雾随高度升高减少得越快。
    • _FogPlaneHeight: 雾的“海平面”高度。低于此高度,密度按公式计算;可以简单理解为雾层开始的高度。
  3. 计算雾效因子:雾效因子fogFactor范围[0, 1],0表示完全清晰,1表示完全被雾覆盖。常用的计算模型是fogFactor = 1 - exp(-fogDensity * distanceToCamera)。其中distanceToCamera就是之前计算出的线性深度linearDepth
    float fogFactor = 1.0 - exp(-fogDensity * linearDepth);
  4. 颜色混合:将雾的颜色与原始场景颜色混合。finalColor = lerp(originalColor, _FogColor, fogFactor);

这种雾效实现简单,性能开销极低,能有效营造氛围和空间感,但缺点是看起来比较“平”,缺乏体积感,因为密度只和高度有关,与观察方向无关。

4.2 基于Raymarching的体积雾

要实现真正有体积感、能与光线交互的雾,需要用到Raymarching(光线步进)技术。我们不是直接计算当前像素点的雾浓度,而是模拟从相机出发,穿过该像素指向场景的光线,在这条光线上累积雾的浓度和光照。

核心思想: 对于屏幕上的每个像素,我们从相机位置(_WorldSpaceCameraPos)出发,向该像素对应的世界空间方向发射一条射线。这条射线会一直步进,直到击中场景几何体(由深度图定义)为止。在每一步,我们都采样该点的雾密度,并累积其对最终颜色的贡献。

Shader实现步骤

  1. 定义参数
    float _FogDensity; // 全局密度 float _FogHeightFalloff; float _FogPlaneHeight; float _FogScatteringCoefficient; // 散射系数,影响雾的亮度和颜色 int _StepCount; // 步进次数,影响质量和性能 float _StepSize; // 步长 float _MaxRayDistance; // 最大步进距离,通常等于远裁剪面
  2. 计算射线
    float3 rayOrigin = _WorldSpaceCameraPos; float3 rayDirection = normalize(worldPos - rayOrigin); // 从相机指向重建的世界点 float rayLength = min(linearDepth, _MaxRayDistance); // 射线长度不超过几何体深度
  3. 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; }
  4. 最终混合
    // 最终颜色 = 原始场景颜色 * 到达相机的光量 + 雾自身发出的光/散射的光 // 简化版:直接使用透射率进行混合 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中,我们可以使用SHADOWMAPSampleShadow相关的宏。

  1. 转换到光源的阴影空间:需要将currentPos从世界空间转换到光源的裁剪空间。
    float4 posLightSpace = mul(_MainLightWorldToShadow[0], float4(currentPos, 1.0)); // 对于级联阴影,可能有多个矩阵
  2. 采样阴影:对阴影贴图进行采样,比较采样得到的深度与posLightSpace的深度,判断是否在阴影中。
    float shadow = SampleShadowmap(posLightSpace); // 这是一个简化表示,实际需处理级联和偏移
    shadow的值通常在[0,1],0表示完全在阴影中,1表示完全被照亮。
  3. 计算光照贡献:如果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。

  1. 创建低分辨率Render Texture:在Render Pass中,创建一个宽度和高度为相机目标一半或四分之一的Render Texture (RTHandle)。
  2. 低分辨率Raymarching:将体积雾/光的计算渲染到这个低分辨率RT上。此时,你的步进循环只在(width/2) * (height/2)个像素上执行,计算量减少到1/4。
  3. 双边滤波上采样(Bilateral Upsampling):这是关键。不能简单地将低分辨率纹理用双线性滤波放大,那样会导致边缘模糊和细节丢失。双边滤波在放大时会考虑深度和法线的连续性,只在颜色、深度相似的区域进行平滑插值,从而保持物体边缘的锐利。
    • 在Shader中,上采样时同时采样低分辨率的颜色缓存和全分辨率的深度纹理。
    • 对于当前全分辨率像素,找到其在低分辨率纹理中对应的4个纹素。
    • 计算当前全分辨率像素的深度/世界位置,与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 深度重建错误导致雾效扭曲

问题现象:雾效在屏幕边缘严重扭曲、拉伸,或者随着相机移动而抖动。排查步骤

  1. 可视化深度:在Shader中,将linearDepth或重建后的worldPos.y直接输出为颜色。检查深度值是否从近到远平滑变化,世界高度是否正确。
  2. 检查矩阵:确保从C#传递到Shader的inverseViewMatrixinverseProjectionMatrix是正确的。在Update中打印矩阵值,或者直接在Shader中输出矩阵的某一行到颜色,看是否随相机正常变化。
  3. 检查UV和裁剪空间坐标:确保从屏幕UV到裁剪空间坐标的转换考虑了平台差异(如DX/GL的纹理V坐标方向,是否使用UNITY_UV_STARTS_AT_TOP)。一个常见的错误是忘记对Y坐标取反,导致上下颠倒。
  4. 检查深度图格式:在Frame Debugger中查看_CameraDepthTexture的实际格式和内容。有时URP可能会使用R16_UNormR32_Float。确保你的线性化深度公式LinearEyeDepth与深度图格式匹配。

解决方案:使用Unity内置的ComputeWorldSpacePosition函数或GetViewSpacePosition函数进行交叉验证。如果内置函数结果正确,而你的函数错误,就逐行对比计算过程。

7.2 体积光出现严重的带状瑕疵(Banding)

问题现象:光柱内部有明显的、一层一层的颜色条带,而不是平滑的渐变。原因:Raymarching步进数太少,导致对光照积分的采样不足。在低步数下,每个步进之间的密度和光照贡献变化剧烈,量化现象明显。解决方案

  1. 增加步进数:最直接,但影响性能。
  2. 使用抖动(Jittering):为每条射线(每个像素)的起始步进位置添加一个基于屏幕坐标和帧号的伪随机偏移。这能将固定的条带模式打散成高频噪声,而人眼对噪声不如对规则条带敏感。抖动公式示例:float offset = frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453 + _Time.y * 0.1); currentPos += rayDir * stepSize * offset;
  3. Temporal Reprojection:结合历史帧数据进行累积和滤波,可以极大地平滑噪声,是消除条带和噪声的终极方案,但实现复杂度高。

7.3 性能热点分析与优化

问题现象:游戏帧率下降严重,GPU Profiler显示Fragment Shader耗时极高。排查工具:使用Unity的Frame DebuggerGPU Profiler(如RenderDoc,或Unity URP自带的SRP Debugger)。

  1. 定位Draw Call:在Frame Debugger中找到执行体积雾的Render Pass,查看其顶点和片元着色器的耗时。
  2. 分析Shader复杂度:在GPU Profiler中查看该Pass的Shader指令数、纹理采样次数、循环展开情况。Raymarching循环是主要的指令数贡献者。
  3. 检查带宽:查看是否因使用了全分辨率深度图和高精度Render Texture导致带宽过高。

优化方向

  • 减少纹理采样:确保深度图、阴影图只被采样必要的次数。避免在循环内采样不必要的高精度纹理。
  • 降低计算精度:在移动端,尝试将部分计算从float改为half。注意世界位置、深度等需要高精度的变量应保持为float
  • 利用Early-Z:确保体积雾的Shader有正确的深度写入(通常不写入深度)和测试,避免过度绘制。

7.4 与透明物体的渲染顺序问题

问题现象:半透明的粒子、UI或者特效,要么被体积雾覆盖,要么完全不参与雾效。原因:体积雾通常是在所有不透明物体渲染之后、透明物体渲染之前执行的(RenderPassEvent.BeforeRenderingTransparents)。这个时机决定了它只能看到不透明物体的深度,而透明物体的深度没有写入深度缓冲区。解决方案

  • 如果需要雾效影响透明物体:将体积雾Pass移到透明物体之后(AfterRenderingTransparents),但这需要你能够获取到包含透明物体的最终颜色缓冲区,并且透明物体的深度信息可能不完整,实现难度大。
  • 更实用的方法:接受体积雾不影响透明物体的现实。对于需要与雾交互的透明特效(如烟雾粒子),可以让粒子Shader自行计算雾效(即基于粒子的世界位置计算雾因子并混合),这被称为“每物体雾”(Per-Object Fog)。虽然不一致,但在视觉上通常可以接受。

调试时,一个非常有用的技巧是创建一个简单的调试材质,将中间变量(如深度、世界高度、雾密度、散射光强度)可视化为颜色。这能帮你快速定位问题是出在数据源(深度/矩阵)、计算过程,还是最后的混合阶段。

← 返回列表