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遮蔽不是整个场景都需要体积光。你可以创建多个局部VolumeLocal Volumes并设置其边界Bounds。只在重要的、需要强调的区域如窗口、洞口、森林空地启用高质量体积光在其他区域使用更低参数或完全关闭。5.2 参数调节艺术让光柱“真实”起来调参数是个手感活但有几个原则光强Intensity与散射系数Scattering这两个参数共同决定光柱的“浓度”。Intensity是整体乘数Scattering决定介质有多“浑浊”。先从较小的值开始如Intensity0.5 Scattering0.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快速找到主要遮挡边缘然后在边缘附近集中进行高精度步进采样这能在保持视觉效果的同时优化性能。实现一个美观且高效的体积光系统是理解现代实时渲染管线、屏幕空间技术和光照模型的一个绝佳实践。它没有想象中那么神秘核心就是屏幕空间的光线步进与累积。通过调整那些物理或准物理的参数你能亲手操控“光”这种虚拟介质为你的场景注入灵魂。我最享受的时刻就是调好参数后看着一束原本平平无奇的方向光变成一道照亮空气中微尘的、有着实体感的光柱那一刻整个场景的故事感和情绪都随之改变了。希望这份指南能帮你跨过最初的门槛剩下的就是发挥你的创意去打造属于你自己的那片光了。
Unity URP屏幕空间体积光实现:光线步进算法与性能优化指南
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遮蔽不是整个场景都需要体积光。你可以创建多个局部VolumeLocal Volumes并设置其边界Bounds。只在重要的、需要强调的区域如窗口、洞口、森林空地启用高质量体积光在其他区域使用更低参数或完全关闭。5.2 参数调节艺术让光柱“真实”起来调参数是个手感活但有几个原则光强Intensity与散射系数Scattering这两个参数共同决定光柱的“浓度”。Intensity是整体乘数Scattering决定介质有多“浑浊”。先从较小的值开始如Intensity0.5 Scattering0.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快速找到主要遮挡边缘然后在边缘附近集中进行高精度步进采样这能在保持视觉效果的同时优化性能。实现一个美观且高效的体积光系统是理解现代实时渲染管线、屏幕空间技术和光照模型的一个绝佳实践。它没有想象中那么神秘核心就是屏幕空间的光线步进与累积。通过调整那些物理或准物理的参数你能亲手操控“光”这种虚拟介质为你的场景注入灵魂。我最享受的时刻就是调好参数后看着一束原本平平无奇的方向光变成一道照亮空气中微尘的、有着实体感的光柱那一刻整个场景的故事感和情绪都随之改变了。希望这份指南能帮你跨过最初的门槛剩下的就是发挥你的创意去打造属于你自己的那片光了。