1. 项目概述:为什么URP体积光值得你投入时间
最近在几个独立游戏和视觉化项目中,我反复被问到同一个问题:“如何在Unity的URP管线里,用免费或低成本的方式,实现电影级的体积光(Volumetric Lighting)效果?” 无论是想营造森林中穿透树叶的“丁达尔效应”,还是打造末日废土中尘埃弥漫的浑浊光束,甚至是模拟清晨雾气中朦胧的太阳光晕,体积光都是提升场景氛围和视觉沉浸感的杀手锏。然而,很多开发者,尤其是刚接触URP或图形学不久的朋友,一听到“体积光”、“大气散射”这些词就觉得门槛太高,要么被商业插件的价格劝退,要么被复杂的Shader代码吓跑。
这个项目,就是基于我近期在一个中型项目中的实战经验,整理出的一份完全免费、基于URP内置后处理框架(V2)的体积光实现指南。它不依赖任何付费插件,核心是利用URP提供的可编程渲染管线特性,通过自定义的全屏后处理效果来实现。你可能会在网上看到各种基于旧版内置管线或复杂Compute Shader的方案,但那些要么迁移成本高,要么对移动端不友好。我们这套方案的优点在于深度整合URP、性能可控、效果可调、完全免费。
简单来说,它能帮你解决什么问题?第一,低成本获得高级视觉特效,让你的项目在视觉上脱颖而出,尤其是在展示、独立游戏、建筑可视化等领域。第二,理解URP后处理的工作原理,这是掌握现代Unity渲染技术的必修课。第三,获得一套可复用、可魔改的代码框架,你可以基于它扩展出体积云、动态雾效等更复杂的效果。无论你是独立开发者、技术美术,还是对图形学感兴趣的程序员,只要你的项目在使用URP,这份指南都能让你在1-2小时内,让场景里的灯光“看得见摸得着”。
2. 核心原理与方案选型:体积光是如何被“算”出来的?
在动手写代码之前,我们必须搞清楚体积光到底在模拟什么。用最生活化的例子来说,当你在清晨有雾的树林里,看到阳光变成一束束清晰的光柱,那就是体积光。其物理本质是光线在空气中传播时,遇到了微小的颗粒(如灰尘、水珠、雾滴)发生散射(Scattering),部分散射光进入了我们的眼睛,使得光路本身变得可见。
在实时渲染中,我们无法像离线渲染那样精确模拟每一条光线的物理路径,那计算量是天文数字。因此,主流的实时体积光算法都是一种“聪明的作弊”。我们项目采用的是一种称为屏幕空间体积光(Screen-Space Volumetric Lighting)的方法,它结合了光线步进(Raymarching)技术。其核心思想可以分解为三步:
第一步:从相机出发,向屏幕上的每个像素“发射”一道光线。这道光线的方向,就是从这个像素对应的世界空间位置,指向光源(比如方向光)的方向。想象一下,你的相机是观察者,对于画面中的每一个点,你都沿着光来的方向“看回去”。
第二步:沿着这条光线,一步步“前进”采样。这就是“光线步进”。我们不会一次性计算整条光线,而是把它分成很多小段(Step),从相机近平面开始,一步步向光源方向迈进。在每一步的位置,我们都要计算两件事:1. 当前点是否被场景中的物体遮挡(即是否在阴影中)?2. 当前点的“介质密度”是多少(可以理解为雾或尘埃的浓度)?
第三步:累积光照贡献。光线每前进一步,我们都会根据该点的光照强度(考虑阴影)和介质密度,计算出一个微小的亮度贡献。然后,沿着整条光线,把所有步进点的贡献累加起来。这个累加的结果,就代表了从相机到这个像素方向,可见的“光柱”的总体亮度。最后,把这个计算出来的体积光亮度,叠加到我们原本渲染好的画面上。
为什么选择屏幕空间+光线步进的方案?
- 与URP兼容性最佳:URP的后处理框架天然工作在屏幕空间。我们可以轻松获取到深度纹理(Depth Texture)、法线纹理(Normal Texture)和颜色纹理(Source Texture),这些都是我们计算所需的输入。URP内置的阴影贴图也能方便地用于遮挡判断。
- 性能可控:步进次数(Step Count)和步进距离(Step Size)是核心的性能旋钮。对于移动端或性能敏感的场景,我们可以减少步数,牺牲一些质量来换取帧率;在PC上则可以调高,获得更精细的效果。这种灵活性是很多“黑盒”插件不具备的。
- 效果质量足够:对于大多数中近距离的、需要与场景交互(如被物体遮挡)的体积光效果,屏幕空间方案在视觉上已经非常出色。它虽然无法处理相机视野外的光(比如一束光从屏幕侧面射入),但对于游戏内的大部分镜头构图来说,这完全够用。
- 零成本入门:所有实现都基于URP自带的功能和HLSL/Shader Graph,你不需要为任何一个基础功能付费。
注意:屏幕空间体积光有一个固有局限,即“屏幕外不可见”。如果一束光完全从屏幕侧面射入,没有穿过屏幕内的任何像素,那么这束光柱就不会被渲染出来。这在固定镜头的场景中不是问题,但在镜头快速旋转的游戏中可能会偶尔出现光柱“突然出现”的情况。我们的优化策略是,让光线步进的起始点稍微超出近平面,并合理控制光强的衰减,来平滑这种过渡。
3. 环境准备与核心资产创建
在开始编写核心Shader之前,我们需要在Unity编辑器中搭建好基础环境。确保你使用的是Unity 2021 LTS或更新版本,并且项目已经切换为Universal Render Pipeline (URP)。如果你是从空项目开始,可以通过Package Manager安装“Universal RP”包。
3.1 配置URP Asset与后处理
首先,检查或创建你的URP Asset(通常命名为UniversalRP-HighQuality或类似)。我们需要确保它启用了体积光计算所必须的几个特性:
- 打开你的URP Asset。
- 在
Rendering部分,确保Depth Texture和Opaque Texture是开启的。深度纹理用于重建世界位置,不透明纹理用于后处理采样。 - 在
Shadow部分,根据你的需求配置阴影质量。体积光会依赖主方向光的阴影贴图,因此至少需要开启级联阴影映射(Cascaded Shadows)。
接下来,创建后处理配置文件。在Project窗口中右键 -> Create -> Rendering -> URP -> Volume Profile。我通常命名为PostProcessingProfile。然后,创建一个全局Volume:在Hierarchy中右键 -> Volume -> Global Volume。将刚创建的Profile赋给它。
3.2 创建自定义后处理Renderer Feature与Shader
这是最核心的一步。URP的后处理效果需要通过Renderer Feature来插入到渲染管线中。
- 创建Renderer Feature脚本:在Project中创建C#脚本,命名为
VolumetricLightRendererFeature.cs。这个脚本负责将我们的体积光Pass添加到URP的渲染队列中,通常是在不透明物体渲染之后,天空盒渲染之前(AfterRenderingOpaques)或之后(AfterRenderingSkybox),取决于你是否希望体积光影响天空盒。这里我们选择AfterRenderingOpaques,这样体积光可以叠加在天空盒之上,效果更自然。 - 创建Volume组件脚本:创建另一个C#脚本,命名为
VolumetricLightVolume.cs,继承自VolumeComponent。这个脚本用于在Volume组件中暴露我们所有的调节参数(如强度、颜色、步进数等),实现运行时动态调整。 - 编写Shader:在Project中创建Shader Graph,或者直接编写HLSL Shader文件。对于学习原理而言,我强烈建议从HLSL开始,这样你能更清晰地理解每一步计算。我们创建一个Unlit Shader,命名为
VolumetricLight.shader。它的主要任务就是实现前面提到的光线步进累积算法。
这里给出VolumetricLightVolume.cs的参数定义框架,你可以看到我们将要控制哪些方面:
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [System.Serializable, VolumeComponentMenu("Custom/Volumetric Light")] public class VolumetricLightVolume : VolumeComponent { public BoolParameter isActive = new BoolParameter(false); [Header("Light Settings")] public ColorParameter lightColor = new ColorParameter(Color.white); public ClampedFloatParameter lightIntensity = new ClampedFloatParameter(1.0f, 0.0f, 5.0f); public ClampedFloatParameter scatteringCoefficient = new ClampedFloatParameter(0.5f, 0.01f, 1.0f); [Header("Raymarching Settings")] public ClampedIntParameter steps = new ClampedIntParameter(16, 8, 64); public ClampedFloatParameter stepSize = new ClampedFloatParameter(0.05f, 0.01f, 0.2f); public ClampedFloatParameter maxDistance = new ClampedFloatParameter(50.0f, 10.0f, 200.0f); [Header("Noise & Animation")] public TextureParameter noiseTexture = new TextureParameter(null); public ClampedFloatParameter noiseStrength = new ClampedFloatParameter(0.1f, 0.0f, 0.5f); public ClampedFloatParameter noiseScale = new ClampedFloatParameter(1.0f, 0.1f, 5.0f); public FloatParameter noiseSpeed = new FloatParameter(0.1f); }3.3 连接所有部件
创建完上述资产后,我们需要进行组装:
- 将
VolumetricLightRendererFeature添加到你的URP Renderer Data中(在URP Asset里指定)。 - 在你的全局Volume的Profile中,添加
VolumetricLightVolume组件。 - 将
VolumetricLight.shader材质赋值给Renderer Feature中对应的位置(通常是通过Material字段)。
至此,我们的框架就搭好了。接下来,就是往Shader里填充灵魂——光线步进算法。
4. Shader核心算法实现详解
打开VolumetricLight.shader,我们主要工作在Fragment Shader(片元着色器)部分。以下是分步实现的逻辑,我会结合代码片段和注释进行讲解。
4.1 获取基础数据
首先,我们需要从URP提供的纹理和矩阵中,重建当前像素对应的世界空间位置和视图方向。
// 在Shader顶部声明需要的纹理和采样器 TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); TEXTURE2D(_MainLightShadowmapTexture); SAMPLER(sampler_MainLightShadowmapTexture); // 在片元着色器中 float2 uv = i.uv; // 采样深度纹理,并转换为线性深度(0到1之间,1代表远平面) float rawDepth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r; float linearDepth = Linear01Depth(rawDepth, _ZBufferParams); // 使用深度和UV,通过相机视锥体射线插值,重建世界空间位置 // _CameraViewTopLeftCorner等向量需要从C#脚本传递进来,这是标准做法 float3 worldPos = _CameraViewTopLeftCorner + uv.x * _CameraViewXExtent + uv.y * _CameraViewYExtent; worldPos *= linearDepth; worldPos += _WorldSpaceCameraPos; // 计算视图方向(从相机指向世界位置) float3 viewDir = normalize(worldPos - _WorldSpaceCameraPos);4.2 构建光线步进循环
这是算法的核心。我们假设体积光主要由场景中的主方向光(如太阳)产生。
// 获取主方向光的方向和颜色强度 Light mainLight = GetMainLight(); float3 lightDir = -mainLight.direction; // 光方向指向光源,所以取反得到从表面指向光源的向量 float3 lightColor = mainLight.color * mainLight.distanceAttenuation; // 光线步进参数 float stepSize = _StepSize; // 从Volume组件传入 int numSteps = _Steps; // 从Volume组件传入 float totalDistance = min(_MaxDistance, linearDepth * _ProjectionParams.z); // 步进总距离,不能超过实际几何体的深度 float3 raymarchStartPos = _WorldSpaceCameraPos; // 从相机位置开始 float3 raymarchEndPos = worldPos; // 到当前像素的世界位置结束 // 但实际上,我们是从相机向光源方向步进一段距离。更准确的是: // 计算从相机出发,沿着光的方向,能到达的最大距离(考虑最大距离和深度限制) float3 rayEnd = _WorldSpaceCameraPos + lightDir * totalDistance; // 初始化累积颜色和透光率 float3 accumulatedLight = float3(0, 0, 0); float transmittance = 1.0; // 初始透光率为1(完全透过) for (int i = 0; i < numSteps; i++) { // 计算当前步进位置 float t = (float)i / (float)max(numSteps - 1, 1); float3 currentPos = lerp(raymarchStartPos, rayEnd, t); // 检查当前点是否在阴影中(关键!) float shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(currentPos)); // 如果阴影完全遮挡(shadow ≈ 0),则此点对体积光无贡献 // 计算当前点的“介质”密度(例如,基于高度雾或噪声图) float density = CalculateDensity(currentPos); // 计算此步的光照贡献:光颜色 * 阴影衰减 * 密度 * 步长 * 散射系数 float3 stepContribution = lightColor * shadow * density * stepSize * _ScatteringCoeff; // 将贡献乘以其路径上的当前透光率,并累加 accumulatedLight += stepContribution * transmittance; // 更新透光率:光在穿过此步介质后会被吸收一部分 transmittance *= exp(-density * stepSize * _ExtinctionCoeff); // extinctionCoeff是消光系数,通常与散射系数相关 } // 最终,accumulatedLight就是该像素接收到的体积光亮度 return float4(accumulatedLight, 1.0);4.3 密度函数与噪声应用
上面代码中的CalculateDensity函数是艺术控制的关键。一个简单的密度函数可以结合高度雾和噪声:
float CalculateDensity(float3 worldPos) { float baseDensity = 0.0; // 1. 高度雾:离地面越近,密度可能越高(模拟地面尘埃) float heightFactor = exp(-_HeightFalloff * (worldPos.y - _GroundHeight)); baseDensity += heightFactor * _HeightDensity; // 2. 噪声:使用3D噪声纹理模拟不均匀的尘埃/雾团 if (_NoiseTexture != null) { float3 noiseUV = worldPos * _NoiseScale + _Time.y * _NoiseSpeed; float noise = SAMPLE_TEXTURE3D(_NoiseTexture, sampler_NoiseTexture, noiseUV).r; noise = noise * 2.0 - 1.0; // 映射到[-1, 1] baseDensity += noise * _NoiseStrength; } // 确保密度非负 return max(baseDensity, 0.0); }通过调节_HeightFalloff、_GroundHeight、_NoiseScale和_NoiseStrength,你可以创造出从均匀的晨雾到局部尘埃云团的各种效果。给噪声UV加上_Time.y,就能让体积光产生缓慢流动的动画,效果立刻生动起来。
4.4 与场景颜色混合
最后一步,我们需要将计算出的体积光亮度(通常存储在名为_VolumetricLightTex的临时渲染目标中)与原始场景颜色混合。这通常在Render Feature的第二个Pass或一个单独的Blit操作中完成,使用一个简单的混合Shader:
// 混合Shader的片元着色器 float4 sceneColor = SAMPLE_TEXTURE2D(_SourceTex, sampler_SourceTex, uv); float4 volumetricLight = SAMPLE_TEXTURE2D(_VolumetricLightTex, sampler_VolumetricLightTex, uv); // 常用的叠加模式:相加(Additive)或屏幕(Screen) // 相加模式会让亮部过曝,但光柱感更强 float4 finalColor = sceneColor + volumetricLight * _Intensity; // 屏幕模式更自然,但可能对比度不够 // float4 finalColor = 1.0 - (1.0 - sceneColor) * (1.0 - volumetricLight * _Intensity); return finalColor;5. 性能优化与参数调节实战心得
实现功能只是第一步,让它在目标平台上流畅运行且效果美观,才是真正的挑战。以下是几条血泪换来的经验。
5.1 性能优化技巧
降低分辨率渲染:体积光效果本质上是屏幕空间的模糊效果,对绝对精度要求不高。最有效的优化手段就是将体积光Pass的渲染目标尺寸降低为原屏幕的1/2甚至1/4。在
VolumetricLightRendererFeature中,你可以通过设置RenderTextureDescriptor的width和height缩放比例来实现。降采样后,光柱边缘会略微柔和,但视觉差异很小,性能提升却是立竿见影的(像素数变为1/4)。动态步进控制:不要对所有像素使用相同的步进次数。一个简单的优化是,根据像素到相机的距离动态减少步数。远处像素的细节本就看不见,可以减少步数。更高级的可以结合屏幕空间自适应,对高对比度边缘区域使用更多步数,平坦区域减少步数。
早期跳出(Early Exit):在光线步进循环中,如果
transmittance(透光率)已经低于一个很小的阈值(比如0.01),意味着后续的光几乎无法穿透过来,就可以直接break跳出循环,节省计算。善用Volume遮蔽:不是整个场景都需要体积光。你可以创建多个局部Volume(Local Volumes),并设置其边界(Bounds)。只在重要的、需要强调的区域(如窗口、洞口、森林空地)启用高质量体积光,在其他区域使用更低参数或完全关闭。
5.2 参数调节艺术:让光柱“真实”起来
调参数是个手感活,但有几个原则:
- 光强(Intensity)与散射系数(Scattering):这两个参数共同决定光柱的“浓度”。
Intensity是整体乘数,Scattering决定介质有多“浑浊”。先从较小的值开始(如Intensity=0.5, Scattering=0.3),避免画面一片惨白。 - 步进数(Steps)与步长(Step Size):这是一对权衡。
Steps决定精度,Step Size决定采样距离。通常我会先固定一个合理的Step Size(如0.05),然后增加Steps直到光柱内部的噪点消失(通常16-32步在1080p下足够)。如果性能吃紧,先尝试减少Steps。 - 噪声(Noise):这是打破均匀感、增加“体积”感和动态感的关键。一张好的3D噪声图(如Perlin噪声)至关重要。
Noise Strength不宜过大,否则光柱会显得很“脏”。Noise Scale控制着噪点的大小,大尺度模拟云团,小尺度模拟尘埃。 - 最大距离(Max Distance):这个参数控制光柱从相机开始延伸多远。设置得太短,光柱突然截断;设置得太长,又浪费性能。通常设置为相机远裁剪平面(Far Clip)的1/2到2/3,并配合指数衰减,让光柱末端自然淡出。
实操心得:调试时,我习惯创建一个纯色(如灰色)的测试场景,放一个方向光,然后单独显示体积光的渲染结果(可以通过临时修改混合Shader,只输出
volumetricLight纹理)。这样能最清晰地看到参数变化对光柱形状、密度和噪点的影响,效率远高于在复杂场景中调试。
6. 常见问题排查与效果进阶
即使按照步骤操作,你也可能会遇到一些“坑”。这里列几个我常被问到的问题。
6.1 光柱边缘有严重的锯齿或像素块
问题描述:体积光的边缘不是平滑渐变的,而是一层一层的阶梯状或明显的方形像素块。原因与解决:
- 降采样导致:如果你使用了半分辨率渲染,这是正常现象。可以尝试在最终混合前,对体积光纹理进行一次轻微的高斯模糊(Blur),能有效平滑边缘。URP的
Blit方法结合一个简单的模糊Shader就能实现。 - 步进数不足:这是最常见的原因。增加
Steps参数(比如从16增加到24)。注意,步进数增加会线性增加性能开销。 - 噪声尺度太大:如果
Noise Scale太小,噪声纹理采样变化剧烈,也会产生颗粒感。适当调大Noise Scale,让噪声变化更平缓。
6.2 光柱穿透了本应遮挡它的物体
问题描述:一堵墙后面的光柱依然可见,阴影遮挡失效。原因与解决:
- 阴影图精度不足:方向光的阴影贴图分辨率可能太低,或者级联阴影(Cascades)设置不合理,导致步进采样阴影时获取的值不准确。尝试提高URP Asset中的阴影贴图分辨率,或者增加级联数量。
- 步进起始点问题:确保你的光线步进是从相机位置(
_WorldSpaceCameraPos)开始的,而不是从像素对应的近平面位置。同时,在采样阴影时,用于变换到阴影空间的坐标(TransformWorldToShadowCoord)必须和主光照计算阴影时用的方法完全一致,确保矩阵和偏差(Bias)匹配。有时需要手动添加一个微小的深度偏移(Depth Bias)来消除阴影痤疮(Shadow Acne)在体积光上造成的漏光。 - 最大距离过长:
Max Distance设置得比阴影能覆盖的距离还远,超出的部分自然无法被正确遮挡。确保Max Distance在阴影的有效范围内。
6.3 移动设备上帧率暴跌
问题描述:在PC上运行良好,一到手机或平板就卡成幻灯片。原因与解决:
- 首要检查分辨率:立即启用半分辨率或四分之一分辨率渲染。这是移动端必选项。
- 大幅减少步进数:在移动端,尝试将
Steps降到8-12。同时适当增加Step Size来补偿覆盖距离。 - 关闭或简化噪声:3D纹理采样和噪声计算是耗能大户。在低端设备上,可以设置
Noise Strength为0,仅使用高度雾来模拟基础密度,效果虽然单调但能保帧率。 - 使用Shader LOD:为你的体积光Shader创建多个变体(Variant),根据设备性能自动切换。在Shader中使用
#pragma shader_feature_local来定义开关,在Renderer Feature中根据设备等级决定启用哪些特性。
6.4 进阶效果拓展思路
当基础体积光稳定后,你可以尝试以下扩展,让效果更上一层楼:
- 多光源支持:目前的方案主要针对主方向光。你可以扩展循环,遍历
GetAdditionalLights,为每个点光源或聚光灯计算其体积光贡献。注意性能开销会成倍增加,务必做好裁剪(Culling)和性能分级。 - 与雾效系统融合:将体积光的密度计算与URP的雾效(Fog)参数联动,让雾的颜色和密度自然影响光柱的颜色和亮度,实现统一的大气环境。
- 加入颜色衰减:模拟瑞利散射(Rayleigh Scattering),让短波光(蓝色)比长波光(红色)散射得更厉害。这意味着穿过厚介质的光,远端会偏红黄色(夕阳效果)。可以在累积贡献时,根据步进距离对光颜色进行偏移。
- God Ray(上帝之光)优化:对于从狭窄缝隙(如云缝、窗户)透出的强烈光束,可以尝试在步进前先用一个低样本的射线投射(Raycast)快速找到主要遮挡边缘,然后在边缘附近集中进行高精度步进采样,这能在保持视觉效果的同时优化性能。
实现一个美观且高效的体积光系统,是理解现代实时渲染管线、屏幕空间技术和光照模型的一个绝佳实践。它没有想象中那么神秘,核心就是屏幕空间的光线步进与累积。通过调整那些物理或准物理的参数,你能亲手操控“光”这种虚拟介质,为你的场景注入灵魂。我最享受的时刻,就是调好参数后,看着一束原本平平无奇的方向光,变成一道照亮空气中微尘的、有着实体感的光柱,那一刻,整个场景的故事感和情绪都随之改变了。希望这份指南能帮你跨过最初的门槛,剩下的,就是发挥你的创意,去打造属于你自己的那片光了。