1. 项目概述:为什么我们需要一个高效的URP雾效方案?
在实时渲染的世界里,雾效(Fog)远不止是营造氛围的“气氛组”。它承担着至关重要的视觉和性能职责:一是提供深度线索,帮助玩家理解场景中物体的远近关系,避免视觉上的“扁平感”;二是作为性能优化手段,通过远处物体的雾化淡出,可以优雅地隐藏LOD(细节层次)切换的接缝,甚至直接作为远裁剪面之外的“软边界”。在Unity的通用渲染管线(URP)中,虽然内置了基础的雾效支持,但很多开发者,尤其是对画面表现有更高要求的中重度项目,常常感到“不够用”或“性能吃紧”。这就是“URPFog”这类自定义、优化型雾效方案诞生的背景。
URPFog的核心目标,直指“快速”和“高效”。它不是一个从零开始发明轮子的过程,而是对URP现有雾效系统的一次深度定制和性能攻坚。想象一下,在一个开放世界或大场景中,你需要一个既能覆盖远山、又能渗透进森林峡谷,同时还能与动态光照(如昼夜交替、手电筒)正确交互的体积雾。URP内置的指数高度雾可能显得力不从心,而直接启用HDRP级别的体积雾又杀鸡用牛刀,对移动端或性能敏感的平台极不友好。URPFog就是在URP的框架下,寻找那个性能与表现力的黄金平衡点。它适合那些已经使用URP,但觉得默认雾效效果单一、性能有优化空间,或者需要实现特定艺术风格(如风格化雾、场景过渡雾)的开发者。接下来,我将拆解实现这样一个高效雾效方案的核心思路、技术细节以及避坑指南。
2. 核心设计思路:在URP框架下重新定义雾
实现一个自定义的URP雾效,首先要彻底理解URP的渲染流程和扩展点。URP采用可编程渲染管线(SRP),其核心在于一系列可配置的RenderPass。我们的雾效计算,理想的位置是在所有不透明物体渲染完成之后,天空盒渲染之前或之后,作为一个全屏后处理效果来应用。但更高效、更物理正确的方式,是将其集成到光照计算和物体着色中。
2.1 方案选型:屏幕空间 vs 体积纹理 vs 顶点/像素着色
- 屏幕空间后处理雾:这是最直观的方式。在
RenderPass中获取相机深度纹理(_CameraDepthTexture)和法线纹理,根据每个像素的深度值(即距离)计算雾的强度因子,然后与屏幕颜色进行混合。优点是实现简单,独立于场景物体。缺点是它是“2.5D”的,无法表现光线在雾中穿行的体积感(即物体边缘和内部没有被雾“包裹”的感觉),且对透明物体处理复杂。 - 体积纹理(3D Texture)雾:这是向HDRP体积雾看齐的方案。预先计算或实时生成一个3D纹理,其中每个纹素存储了世界空间中某一点的雾密度、颜色等数据。在着色器中,通过射线步进(Ray Marching)对这个3D纹理进行采样,累积雾的贡献。效果最真实,能实现光线散射、上帝光等复杂效果。但性能开销巨大,尤其是步进次数多时,在移动端基本不可行。
- 顶点/像素着色器集成雾:这是URPFog追求“高效”的关键。我们不在后处理中做,而是在每个物体的着色器(Shader)中,根据其世界空间位置,实时计算雾因子。URP内置的
UniversalFragmentShaderInput结构体通常已经包含了世界空间位置positionWS。我们可以在URP的Lighting.hlsl或自定义的着色器函数中,插入雾的计算。这种方式:- 性能佳:计算分散到每个物体,且与物体本身的复杂度绑定,无需全屏后处理开销。
- 效果合理:每个顶点/像素独立计算,能体现物体形状与雾的交互(尽管不是真正的体积计算)。
- 兼容性好:能与URP的光照模型、阴影自然融合。
URPFog的选择:为了兼顾效率与足够好的表现力,我们采用方案三(着色器集成)作为主干,并对其进行增强。同时,我们可以提供一个可选的、轻量级的屏幕空间雾作为后备或补充,用于处理那些没有使用我们定制着色器的物体(如第三方资产),或者实现全局的雾效覆盖。
2.2 雾效模型选择:从简单到复杂
确定了计算位置,接下来要决定用什么数学模型来描述雾。
- 线性雾(Linear):雾密度从起始距离到结束距离线性增长。计算最简单,但效果生硬,不自然。
float fogFactor = clamp((distance - fogStart) / (fogEnd - fogStart), 0.0, 1.0); - 指数雾(Exponential):雾密度随距离呈指数增长。这是URP内置的常用模型,效果比线性自然。
// 指数雾 float fogFactor = 1.0 - exp(-fogDensity * distance); // 指数平方雾,衰减更快 float fogFactor = 1.0 - exp(-(fogDensity * distance) * (fogDensity * distance)); - 高度雾(Height Fog):雾密度不仅随距离,也随高度(Y轴)变化。这是实现“地面雾”、“云层”效果的关键。通常结合指数模型使用。
float heightFactor = clamp((worldPos.y - fogBaseHeight) / fogHeight, 0.0, 1.0); heightFactor = exp(-fogHeightFalloff * heightFactor); // 高度衰减因子 float distanceFactor = exp(-fogDensity * distance); // 距离衰减因子 float fogFactor = 1.0 - (distanceFactor * heightFactor); - 体积雾近似(Anisotropic/Scattering Approximation):为了实现更逼真的光线散射效果(如丁达尔效应),我们需要在着色器中模拟光线在介质中的散射。这通常使用Henyey-Greenstein相位函数等模型,计算光源方向与视线方向的点积,来模拟前向或后向散射。虽然这不是真正的体积计算,但能在物体着色上添加一种“光晕”感。
URPFog的设计:我们将实现一个可配置的混合模型。核心是指数高度雾,因为它能覆盖大部分自然场景需求(远处和低洼处雾浓)。同时,我们将可选地集成一个简化的散射近似计算,用于主要方向光(如太阳),以低成本模拟上帝光效果。通过Shader关键字(#pragma shader_feature)或材质属性开关来控制这些功能的启用,确保不需要时零开销。
3. 核心实现细节与Shader编码
理论清晰后,我们进入实战环节。我们将创建一个名为URPFog的渲染特性(ScriptableRendererFeature)和对应的通道(ScriptableRenderPass),但核心逻辑封装在HLSL文件中,供着色器包含。
3.1 创建雾效计算的核心HLSL库
首先,创建一个URPFog.hlsl文件。这个文件定义雾的参数结构体、计算函数,并负责与Unity的Shader属性(_FogColor,_FogDensity等)对接。
// URPFog.hlsl #ifndef URP_FOG_INCLUDED #define URP_FOG_INCLUDED // 雾效参数,对应C#脚本中设置的Shader全局属性 float4 _FogColor; float _FogDensity; float _FogStart; float _FogEnd; float _FogHeight; float _FogBaseHeight; float _FogHeightFalloff; float _FogScatteringIntensity; float _FogScatteringDirection; // 类似Anisotropy,控制散射方向 // 雾效模式定义 #define FOG_MODE_NONE 0 #define FOG_MODE_EXPONENTIAL 1 #define FOG_MODE_EXPONENTIAL_SQUARED 2 #define FOG_MODE_HEIGHT 3 int _FogMode; // 核心雾因子计算函数 float ComputeFogFactor(float3 worldPos, float distanceToCam) { float fogFactor = 0.0; if (_FogMode == FOG_MODE_EXPONENTIAL) { fogFactor = 1.0 - exp(-_FogDensity * distanceToCam); } else if (_FogMode == FOG_MODE_EXPONENTIAL_SQUARED) { fogFactor = 1.0 - exp(-_FogDensity * _FogDensity * distanceToCam * distanceToCam); } else if (_FogMode == FOG_MODE_HEIGHT) { // 计算高度衰减 float height = worldPos.y - _FogBaseHeight; float heightFactor = exp(-_FogHeightFalloff * max(height, 0.0)); // 计算距离衰减 float distanceFactor = exp(-_FogDensity * distanceToCam); // 合并 fogFactor = 1.0 - (distanceFactor * heightFactor); // 确保在地面以下也有雾(可选) if (height < 0.0) { fogFactor = max(fogFactor, 1.0 - exp(-_FogHeightFalloff * abs(height))); } } // 线性雾作为备选,这里省略... return saturate(fogFactor); // 确保在[0,1]范围 } // 应用雾效到颜色 float4 ApplyFog(float4 inputColor, float fogFactor, float3 worldPos, float3 viewDir, float3 lightDir) { float4 fogColor = _FogColor; // 可选:简单的散射近似(需要光源方向) #if defined(_FOG_SCATTERING_ON) if (_FogScatteringIntensity > 0) { // 使用简化的HG相位函数近似 float cosTheta = dot(normalize(viewDir), normalize(-lightDir)); // 视线与光线的点积 float scattering = 1.0 + _FogScatteringDirection * cosTheta; scattering = max(scattering, 0.1); // 防止过暗 // 散射强度随雾因子增强 fogColor.rgb *= lerp(1.0, scattering, _FogScatteringIntensity * fogFactor); } #endif // 标准线性混合 return lerp(inputColor, fogColor, fogFactor); } #endif3.2 集成到URP Lit Shader中
接下来,我们需要修改URP的Lit着色器,使其在计算最终颜色时调用我们的雾效函数。我们不直接修改Unity的Shader文件,而是通过自定义的RendererFeature在渲染时插入我们的代码,或者更常见的是,复制一份URP的Lit Shader Graph节点或Shader源码进行修改。
这里以修改Shader源码为例,在片元着色器(Fragment Shader)中,在计算完所有光照和表面颜色之后,应用雾效:
// 在片元着色器函数中,假设已经计算出了表面颜色 surfaceColor float3 worldPos = input.positionWS; float3 viewDirWS = GetWorldSpaceNormalizeViewDir(worldPos); float distanceToCam = length(viewDirWS); // 注意:viewDir是从相机到点的向量,其长度就是距离 float fogFactor = ComputeFogFactor(worldPos, distanceToCam); // 获取主光源方向用于散射计算 Light mainLight = GetMainLight(); float3 mainLightDir = mainLight.direction; float4 finalColor = ApplyFog(float4(surfaceColor, 1.0), fogFactor, worldPos, viewDirWS, mainLightDir); return finalColor;关键技巧:为了最大化兼容性和性能,我们应该通过
#pragma shader_feature_local _FOG_SCATTERING_ON来让散射计算成为可选的变体。这样,在材质中不启用散射的物体,就不会编译相关代码,节省Shader变体和GPU指令。
3.3 创建C#脚本控制雾效参数
我们需要一个MonoBehaviour脚本,或者更好的,一个VolumeComponent(继承自VolumeComponent和IPostProcessComponent),来在运行时动态控制雾效参数,并将其传递给Shader。使用Volume系统可以与URP的场景后期处理设置无缝集成,是更专业的选择。
// URPFogVolume.cs using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [System.Serializable, VolumeComponentMenu("Custom/URP Fog")] public class URPFogVolume : VolumeComponent, IPostProcessComponent { public FogModeParameter mode = new FogModeParameter(FogMode.Exponential); public ColorParameter color = new ColorParameter(Color.gray, true, true, true); public MinFloatParameter density = new MinFloatParameter(0.01f, 0.0f); public MinFloatParameter start = new MinFloatParameter(0.0f, 0.0f); public MinFloatParameter end = new MinFloatParameter(100.0f, 0.0f); public MinFloatParameter height = new MinFloatParameter(10.0f, 0.0f); public FloatParameter baseHeight = new FloatParameter(0.0f); public MinFloatParameter heightFalloff = new MinFloatParameter(0.2f, 0.0f); public ClampedFloatParameter scatteringIntensity = new ClampedFloatParameter(0.0f, 0.0f, 1.0f); public ClampedFloatParameter scatteringDirection = new ClampedFloatParameter(0.0f, -0.9f, 0.9f); // 类似各向异性 public bool IsActive() => density.value > 0.0f; public bool IsTileCompatible() => false; // 通常雾效不是瓦片兼容的 [System.Serializable] public sealed class FogModeParameter : VolumeParameter<FogMode> { public FogModeParameter(FogMode value, bool overrideState = false) : base(value, overrideState) { } } public enum FogMode { None, Exponential, ExponentialSquared, Height } }然后,我们需要一个URPFogRendererFeature来在渲染过程中,将Volume中的参数设置到Shader的全局属性(Shader.SetGlobal*)中。这个RendererFeature可以插入到RenderPassEvent.BeforeRenderingPostProcessing或AfterRenderingSkybox等位置,确保在物体渲染前参数已就绪。
// URPFogRenderFeature.cs public class URPFogRenderFeature : ScriptableRendererFeature { class URPFogRenderPass : ScriptableRenderPass { private Material fogMaterial; // 如果使用屏幕空间后处理雾,才需要材质 private URPFogVolume fogVolume; private RenderTargetIdentifier source; private RenderTargetHandle tempTexture; public void Setup(RenderTargetIdentifier source) { this.source = source; var stack = VolumeManager.instance.stack; fogVolume = stack.GetComponent<URPFogVolume>(); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (fogVolume == null || !fogVolume.IsActive()) { // 如果雾效未激活,只设置一个默认值或清除全局属性 Shader.SetGlobalFloat("_FogMode", 0); return; } // 将Volume参数传递给Shader全局属性 Shader.SetGlobalInt("_FogMode", (int)fogVolume.mode.value); Shader.SetGlobalColor("_FogColor", fogVolume.color.value); Shader.SetGlobalFloat("_FogDensity", fogVolume.density.value); Shader.SetGlobalFloat("_FogStart", fogVolume.start.value); Shader.SetGlobalFloat("_FogEnd", fogVolume.end.value); Shader.SetGlobalFloat("_FogHeight", fogVolume.height.value); Shader.SetGlobalFloat("_FogBaseHeight", fogVolume.baseHeight.value); Shader.SetGlobalFloat("_FogHeightFalloff", fogVolume.heightFalloff.value); Shader.SetGlobalFloat("_FogScatteringIntensity", fogVolume.scatteringIntensity.value); Shader.SetGlobalFloat("_FogScatteringDirection", fogVolume.scatteringDirection.value); // 如果需要屏幕空间后处理雾,在这里进行Blit操作 // if (fogMaterial != null) { ... } } } private URPFogRenderPass fogPass; public override void Create() { fogPass = new URPFogRenderPass(); fogPass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; // 在后期处理前设置参数 } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { fogPass.Setup(renderer.cameraColorTarget); renderer.EnqueuePass(fogPass); } }4. 性能优化与高级技巧
实现功能只是第一步,让它在各种设备上流畅运行才是URPFog“高效”二字的体现。
4.1 性能优化关键点
- 计算精度取舍:在片元着色器中,
worldPos和distance的计算是性能敏感点。对于移动平台,可以考虑在顶点着色器中计算雾因子,然后通过插值传递给片元着色器(interpolated fog)。虽然精度稍差,但对于平滑变化的雾效,视觉差异很小,却能显著节省性能。// 顶点着色器中 output.fogFactor = ComputeFogFactor(positionWS, distanceToCam); // 片元着色器中直接使用input.fogFactor - Shader变体管理:我们通过
shader_feature定义了_FOG_SCATTERING_ON。务必在项目设置(Graphics -> Shader Stripping)中,或者通过自定义的ShaderVariantCollection来管理这些变体,避免生成不必要的Shader变体,导致包体膨胀和运行时加载卡顿。 - 距离计算优化:
length(viewDirWS)需要开方运算。对于指数雾,我们可以使用距离的平方来近似,避免sqrt操作。因为exp(-density * distance)和exp(-density * sqrt(distanceSqr))在视觉上差异不大时,可以用前者替换。float distanceSqr = dot(viewDirWS, viewDirWS); // 使用近似:fogFactor = 1.0 - exp(-_FogDensity * _FogDensity * distanceSqr); - 分平台配置:在
URPFogVolume中,可以为不同平台(如UNITY_ANDROID或UNITY_IOS)设置不同的默认参数或限制参数范围(如最大密度、禁用散射),确保低端机也能运行。
4.2 效果增强与艺术控制
- 噪声扰动:让雾的密度产生动态变化,可以增加真实感。在计算雾密度因子时,采样一张3D噪声纹理或程序化噪声,根据世界坐标和时间进行偏移。
float3 noiseUVW = worldPos * _FogNoiseScale + _Time.y * _FogNoiseSpeed; float noise = tex3D(_FogNoiseTex, noiseUVW).r; float finalDensity = _FogDensity * (1.0 + noise * _FogNoiseStrength); - 基于深度的边缘柔化:在雾的边缘(特别是线性雾的结束处),与天空盒或其他清晰物体的交界可能很生硬。可以在雾因子计算的最后,用一个
smoothstep函数进行柔化。fogFactor = smoothstep(0.0, 1.0, fogFactor); // 简单的平滑 // 或者基于距离的平滑 float smoothRange = 10.0; fogFactor = smoothstep(_FogEnd - smoothRange, _FogEnd, distanceToCam); - 雾效与天空盒融合:确保远处雾的颜色与天空盒颜色能平滑过渡。我们的
ApplyFog函数最终是用雾颜色与物体颜色混合。对于天空盒,我们需要确保在远裁剪面处,雾因子接近1,这样天空盒就会完全被雾色覆盖。这通常需要根据相机的远平面距离来调整雾的结束距离。
4.3 常见问题与排查实录
在实际集成和使用URPFog的过程中,你几乎一定会遇到下面这些问题:
雾效在透明物体上不正确:透明物体(如粒子、半透明材质)的渲染顺序在Opaque之后。如果雾效计算依赖于深度纹理(屏幕空间方案),透明物体的深度可能没有被正确写入,或者其颜色混合方式特殊。解决方案:对于着色器集成方案,确保你的透明Shader也包含了雾效计算代码。对于屏幕空间方案,可能需要单独为透明物体编写一个后处理Pass,或者使用双Pass渲染。
雾效与后期处理(如Color Grading)冲突:如果雾效在后处理之前应用,那么后续的色调映射、颜色分级会改变雾的颜色,可能不符合预期。解决方案:将雾效的渲染Pass顺序(
renderPassEvent)调整到所有后期处理之后(AfterRenderingPostProcessing),但这意味着你需要将结果Blit到最终屏幕上。更常见的做法是,在着色器集成方案中,雾是光照计算的一部分,它发生在后期处理之前,这就需要美术在调整后期时,将雾的颜色考虑进去。移动设备上性能骤降:即使采用了顶点雾,在植被茂密(顶点数多)的场景也可能有压力。排查步骤:
- 使用Unity Profiler的GPU模块,查看是哪个Shader或Pass耗时高。
- 检查是否意外为所有材质都启用了散射计算(
_FOG_SCATTERING_ON)。通过Frame Debugger查看材质使用的Shader变体。 - 确认是否在片元着色器中进行了昂贵的噪声纹理采样或复杂的数学运算。考虑降级方案,如在低画质下关闭噪声和散射。
雾效在VR中双眼不一致或产生立体感错误:在VR中,每只眼睛的渲染视图略有不同。如果雾效计算完全基于屏幕空间或单个相机的位置,会导致立体感破坏。解决方案:在VR模式下,确保雾效计算基于每只眼睛各自的视图矩阵和世界空间位置。在Shader中,使用
unity_StereoEyeIndex和对应的视图/投影矩阵数组来进行正确的计算。与第三方Shader或Asset不兼容:这是着色器集成方案的最大挑战。你不能强迫所有第三方资源使用你的Shader。后备方案:保留并优化一个屏幕空间雾效的后处理Pass作为全局覆盖。在
URPFogRenderPass的Execute方法中,如果检测到当前渲染的物体是使用标准URP Lit Shader(或我们标记的自定义Shader),就使用着色器雾;否则,在渲染完所有不透明物体后,用这个屏幕空间Pass对整个画面施加一层雾。这虽然牺牲了一点性能,但保证了效果的统一性。
我的实操心得:在项目中期接入URPFog时,最头疼的就是处理已有的上百种材质。我的策略是,先开发出屏幕空间版本作为“安全网”,确保场景整体有雾效。然后,写一个编辑器脚本,批量扫描项目中的材质,将使用标准URP Lit的材质,替换为我们的定制版本(复制一份并修改Shader引用)。对于复杂的自定义Shader,则逐个评估,手动将雾效计算函数
#include进去。这个过程很繁琐,但一劳永逸。对于性能,一定要在目标最低配置的设备上(比如一台旧的安卓手机)做真机测试,参数调校要以低端机的表现为准,高端机自然没问题。