公司动态
Unity URP体积云渲染:从光线步进原理到移动端优化实战
1. 项目概述为什么我们需要一个URP体积云项目在Unity里做天空很多开发者可能还停留在拉一个Skybox材质球或者用Procedural Skybox的阶段。对于移动端或是一些对性能要求苛刻的项目这确实够用了。但当你需要制作一款3A级别的开放世界游戏或者一个追求极致视觉体验的影视级演示时那种扁平、缺乏深度的天空就完全不够看了。天空尤其是云层是营造氛围、烘托情绪、甚至引导玩家视线的关键元素。这就是“UnityVolumetricCloudsURP”这个项目存在的意义——它不是一个简单的天空盒替换而是一套在Unity通用渲染管线URP下实现动态、可交互、具有真实物理体积感的云层渲染解决方案。我最初接触体积云渲染是因为一个山地飞行模拟器的项目需求。客户想要那种飞入云层时视线逐渐模糊机身传来轻微颠簸穿过云层后豁然开朗的感觉。用传统的面片云或者粒子系统模拟要么穿帮严重要么性能开销巨大且效果僵硬。经过一番折腾和踩坑我总结出了这套基于URP、兼顾效果与性能的体积云实现方案。它不依赖任何昂贵的第三方插件核心是Ray Marching光线步进算法和一系列精心设计的噪声函数让你能从零开始理解并掌控天空中的每一片云。这个教程适合已经熟悉Unity基本操作和Shader编程基础的开发者。如果你对URP的Shader Graph和HLSL有初步了解那会更容易上手。我们的目标不仅仅是“做出效果”而是要弄明白背后的原理知道每一个参数调整会影响视觉的哪个部分最终让你能根据自己的项目需求定制出独一无二的动态天空。2. 核心原理拆解体积渲染与光线步进要理解体积云首先得抛开“物体表面”这个固有概念。云没有坚硬的表面它是一大团密度不均匀的“介质”。渲染这类对象我们用的不是光栅化像渲染一个模型那样而是体积渲染Volume Rendering。你可以把它想象成观察一杯浑浊的牛奶光线射入牛奶中会与悬浮的颗粒发生散射光线改变方向和吸收光线能量被减弱最终到达你眼睛的光线是沿途所有散射和吸收事件累积的结果。在Shader中我们通过Ray Marching算法来模拟这个过程。具体到我们的云层生成观察射线从摄像机屏幕的每个像素出发发射一条射线Ray进入场景。定义云体积区域我们在世界空间中定义一个巨大的立方体区域或两个高度层之间的区域作为云可能存在的空间。步进采样射线进入这个云区域后我们将其切割成许多小段。从进入点开始以固定步长Step Size一步步向前推进Marching。每一步我们都计算当前采样点在云体积中的“密度”。密度计算与光照密度的计算是整个效果的核心通常通过多种不同尺度的3D噪声图如Worley噪声、Perlin噪声叠加而成模拟云团蓬松、破碎的形态。在每一个采样点我们不仅获取密度还要计算光照。这包括主光源太阳光照计算从当前采样点指向太阳方向的“次级光线步进”累积这条光路上的云密度来模拟光线在云中散射和衰减的效果这直接决定了云的明暗、体积感和银边Silhouette效果。环境光模拟来自天空其他方向的环境光照让云的背光面不至于一片死黑。累积与合成根据当前采样点的密度和光照计算结果按照体积渲染的公式通常使用类似finalColor density * light * stepSize * transmittance的规则累加到该像素的最终颜色上。同时计算一个“透射率”Transmittance代表剩余光线的强度随着在云中步进越深透射率越低颜色累积也会减弱。退出与混合当射线步进累积的颜色接近不透明或者透射率接近零或者步进超出了云区域循环终止。将最终计算出的体积颜色与场景中已有的不透明物体颜色进行混合通常是Alpha混合得到最终画面。注意步长Step Size是性能与质量权衡的关键。步长太长云会显得有锯齿和断层步长太短计算量呈指数增长。一个常见的优化是使用“分层采样”在云密度低的区域用大步长快速穿越在密度高的核心区域切换为小步长精细计算。2.1 噪声云的灵魂塑造者云的形状千变万化这全靠噪声函数。我们不会使用单一的噪声纹理。基础形状噪声Base Shape通常使用WorleyVoronoi噪声的变体。这种噪声能产生类似细胞、蓬松团块的结构非常适合作为云的基础形态。我们可能会用3-4个不同频率Scale和权重Weight的Worley噪声叠加来创建不同尺度的云团细节。细节噪声Detail使用更高频的Perlin或Simplex噪声。它的作用是“侵蚀”基础形状在云团的边缘和表面添加破碎、丝缕状的细节让云看起来更自然而不是一个个光滑的棉花球。卷云噪声Cirrus对于高空的卷云可能需要一种更偏向2D、丝缕状明显的噪声比如使用经过特定变换的Gradient噪声。在Shader中我们会将世界空间坐标乘以不同的频率系数输入到这些噪声函数中然后将结果按权重混合再通过一个remap操作和高度梯度因子映射到最终的密度值0到1之间。3. URP项目配置与基础场景搭建在开始写代码之前我们必须把Unity工程环境配置好。URP的体积云渲染对管线设置有一定要求。3.1 创建URP项目与关键设置新建项目使用Unity Hub创建一个新的3D项目模板选择“Universal Render PipelineURP”。这能确保项目初始就包含了URP的核心资产。检查URP Asset在Project窗口找到Assets/Settings文件夹下的UniversalRP-HighQuality资产名称可能略有不同。选中它在Inspector面板中我们需要关注几个关键设置Depth Texture和Opaque Texture必须勾选。体积云渲染需要场景的深度信息来判断射线与场景中不透明物体的相交也需要不透明纹理来正确进行后期混合。HDR强烈建议开启。云层特别是被阳光强烈照射的部分亮度范围会超出标准的LDR0-1开启HDR能保留这些高光细节避免过曝区域一片死白。MSAA对于PC和主机平台可以开启以平滑边缘。但注意MSAA与一些后处理效果如我们可能用到的TAA可能有冲突且对体积渲染内部的噪点无平滑作用。移动平台通常使用更高效的FXAA或TAA。Render Scale保持为1.0。低于1.0的渲染缩放会降低深度图和屏幕纹理的分辨率可能导致云与场景物体交界处出现难看的锯齿或错误。3.2 创建体积云渲染组件我们不直接修改URP管线资产而是通过一个自定义的MonoBehaviour脚本和对应的RendererFeature来注入渲染逻辑。创建C#控制脚本新建一个C#脚本命名为VolumetricCloudsController。这个脚本将挂在摄像机上负责暴露所有可调节的参数如云层高度、密度、噪声参数、光照参数等并将这些参数传递给渲染器。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [ExecuteAlways] public class VolumetricCloudsController : MonoBehaviour { [Header(Volume Bounds)] public float cloudStartHeight 1500f; // 云层起始高度世界单位 public float cloudThickness 4000f; // 云层厚度 [Header(Raymarching)] [Range(16, 256)] public int maxSteps 128; // 最大步进数 public float stepSize 80f; // 基础步长 [Header(Noise Settings)] public Texture3D baseNoiseTexture; // 基础3D噪声纹理 public Texture3D detailNoiseTexture; // 细节3D噪声纹理 public float baseScale 0.0001f; public float detailScale 0.001f; public float detailStrength 0.2f; [Header(Density)] public float densityMultiplier 0.8f; public float densityThreshold 0.4f; [Header(Lighting)] public Light sunLight; [ColorUsage(false, true)] public Color sunColor Color.white; [Range(0, 1)] public float ambientLightFactor 0.3f; [Range(0, 5)] public float silverLiningIntensity 1.5f; // 银边强度 // 其他参数... private Material _cloudMaterial; private VolumetricCloudsFeature _feature; void OnEnable() { /* 初始化获取或创建Material和Feature */ } void Update() { /* 每帧更新Material参数 */ } void OnValidate() { /* 编辑器下参数变化时调用更新材质 */ } }创建URP Renderer Feature这是URP管线扩展的核心。创建一个新的C#脚本继承ScriptableRendererFeature例如VolumetricCloudsFeature。在这个类中你需要定义一个ScriptableRenderPass例如VolumetricCloudsPass。在VolumetricCloudsPass的Execute方法中你会拿到一个CommandBuffer在这里调用Blitter或CoreUtils.DrawFullScreen来绘制一个全屏四边形并执行我们编写好的体积云Shader。这个Pass的插入时机至关重要。通常我们选择在RenderPassEvent.BeforeRenderingPostProcessing渲染后处理之前执行。这样云已经与不透明场景混合可以继续接受Bloom、Tonemapping等后处理效果。3.3 准备3D噪声纹理Unity没有直接生成3D噪声纹理的功能我们需要借助外部工具或运行时生成。为了项目简洁和性能我推荐在开发阶段用工具预生成。使用工具生成你可以使用像Texture3D Generator这样的Unity Asset Store免费工具或者用Houdini、Blender等DCC软件生成。生成时选择RGBA32或RGBAHalf格式尺寸可以是128x128x128。需要生成两张一张低频大尺度Base一张高频小尺度Detail。导入设置将生成的.asset文件Unity的Texture3D格式导入项目。在Inspector中确保Wrap Mode设置为Repeat这样噪声才能无缝平铺。Filter Mode设置为Trilinear以获得平滑的采样插值。取消勾选Generate Mip Maps体积渲染通常不需要Mipmap且能节省内存和带宽。4. 着色器核心编写体积云HLSL/Shader Graph这是项目的核心。我们将创建一个Unlit Shader它主要包含一个Fragment片元着色器在其中实现前述的Ray Marching算法。4.1 顶点/片元着色器基础结构我们创建一个新的Shader文件比如VolumetricClouds.shader。由于逻辑复杂我倾向于直接编写HLSL代码而不是完全依赖Shader Graph虽然Graph也能实现部分但复杂逻辑和循环效率较低。Shader Hidden/Universal Render Pipeline/VolumetricClouds { Properties { // 所有参数将通过C#脚本绑定这里可以留空或做默认声明 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } LOD 100 ZWrite Off ZTest Always Cull Off Blend One OneMinusSrcAlpha // 混合模式 Pass { Name VolumetricCloudsPass HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl #include Packages/com.unity.render-pipelines.core/ShaderLibrary/Noise.hlsl // 可能需要自定义噪声 // 从C#脚本传递过来的参数 float4 _CloudParams; // x: startHeight, y: thickness, z: stepSize, w: maxSteps float4 _NoiseParams1; // x: baseScale, y: detailScale, z: detailStrength, w: densityMultiplier float _DensityThreshold; float3 _SunDirection; float4 _SunColor; float _AmbientFactor; float _SilverLining; sampler3D _BaseNoiseTex; sampler3D _DetailNoiseTex; struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; float3 viewVector : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); // 计算从摄像机到远裁剪面四个角的方向向量在顶点着色器中计算一次 float3 viewVector mul(unity_CameraInvProjection, float4(input.positionOS.xy * 2 - 1, 0, -1)).xyz; output.viewVector mul(unity_CameraToWorld, float4(viewVector, 0)).xyz; return output; } // 噪声采样与密度计算函数 float SampleDensity(float3 worldPos) { // 1. 根据高度计算密度衰减例如底部和顶部密度低中间密度高 float height worldPos.y - _CloudParams.x; float heightFraction saturate(height / _CloudParams.y); float heightGradient saturate(sin(heightFraction * PI)); // 一个简单的梯度模型 // 2. 采样基础噪声 float3 baseUV worldPos * _NoiseParams1.x; float baseNoise tex3Dlod(_BaseNoiseTex, float4(baseUV, 0)).r; // 3. 采样细节噪声并混合 float3 detailUV worldPos * _NoiseParams1.y; float detailNoise tex3Dlod(_DetailNoiseTex, float4(detailUV, 0)).r; detailNoise saturate(detailNoise * 2 - 1); // 将细节噪声重映射到[-1,1]以进行侵蚀 baseNoise - detailNoise * _NoiseParams1.z; // 4. 应用阈值和乘数 float density saturate(baseNoise - _DensityThreshold) * _NoiseParams1.w; density * heightGradient; // 应用高度梯度 return density; } // 光线步进主函数 half4 Frag(Varyings input) : SV_Target { // 1. 重建世界空间射线 float3 rayOrigin _WorldSpaceCameraPos; float3 rayDirection normalize(input.viewVector); // 2. 计算射线与云层边界盒的交点近点t1远点t2 float cloudTop _CloudParams.x _CloudParams.y; float2 cloudHit RayBoxIntersect(rayOrigin, rayDirection, _CloudParams.x, cloudTop); if (cloudHit.x cloudHit.y) return half4(0,0,0,0); // 未击中云层 // 3. 与场景深度相交确保云在物体后面 float sceneDepth SampleSceneDepth(input.positionCS.xy); float3 sceneWorldPos ComputeWorldSpacePosition(input.positionCS.xy, sceneDepth); float raySceneDist distance(rayOrigin, sceneWorldPos); cloudHit.y min(cloudHit.y, raySceneDist); // 云不能渲染在场景物体前面 float rayLength cloudHit.y - cloudHit.x; if (rayLength 0) return half4(0,0,0,0); // 4. 光线步进循环 float stepSize _CloudParams.z; int numSteps (int)min(_CloudParams.w, rayLength / stepSize); stepSize rayLength / numSteps; // 重新计算精确步长 float3 currentPos rayOrigin rayDirection * (cloudHit.x stepSize * 0.5); // 从中间开始避免边缘锯齿 half4 accumulatedColor half4(0,0,0,0); float transmittance 1.0; for (int i 0; i numSteps; i) { float density SampleDensity(currentPos); if (density 0.001) { // 计算光照简化版主光环境光 float sunLight CalculateSunLight(currentPos, _SunDirection); float3 ambientLight unity_AmbientSky.rgb * _AmbientFactor; float3 lightEnergy (sunLight * _SunColor.rgb ambientLight) * density; // 银边效果增强当视线方向与光源方向接近相反时 float silver pow(saturate(dot(-rayDirection, _SunDirection)), _SilverLining); lightEnergy silver * _SunColor.rgb * density * 0.5; // 体积渲染累积 float3 stepColor lightEnergy * stepSize; accumulatedColor.rgb stepColor * transmittance; // 透射率衰减 float attenuation exp(-density * stepSize); transmittance * attenuation; // 提前退出优化 if (transmittance 0.01) break; } currentPos rayDirection * stepSize; } // 最终颜色Alpha通道代表云的不透明度 accumulatedColor.a 1.0 - transmittance; return accumulatedColor; } ENDHLSL } } }注意以上是极度简化的示例代码省略了关键的RayBoxIntersect、CalculateSunLight需要次级光线步进、以及更复杂的噪声混合函数。CalculateSunLight函数内部需要再进行一次指向太阳方向的Ray Marching来计算光衰减这是性能消耗的大头需要仔细优化。4.2 光照模型与银边效果云的光照模型CalculateSunLight函数是体积感的关键。它模拟了“米氏散射”Mie Scattering其特点是前向散射强烈。这意味着光线更容易沿着原方向继续传播导致当我们逆光看云时视线与光源方向接近180度云的边缘会被照得非常亮形成“银边”或“光晕”。在实现上我们除了计算从采样点到太阳方向的光线衰减次级步进还会应用一个基于dot(viewDir, lightDir)的相位函数Phase Function来模拟这种前向散射。一个常用的近似是Henyey-Greenstein相位函数。银边效果可以通过调整此函数的参数或像上面代码中那样用一个额外的pow项来加强。5. 性能优化与移动端适配策略体积云渲染是出了名的性能杀手。在PC上跑得流畅不代表在手机上也行。以下是我在实际项目中总结的优化层级你需要根据目标平台逐级应用。5.1 渲染分辨率与步进优化半分辨率渲染这是最有效的一招。将体积云渲染到一个一半宽高的Render Texture上然后再上采样到屏幕。云本身是低频、模糊的效果半分辨率带来的视觉损失很小但性能直接提升近4倍像素数变为1/4。在URP Pass中你可以配置RenderTextureDescriptor时将width和height设置为camera.pixelWidth / 2。自适应步进不要在整个云层中使用固定的、很小的步长。动态步长可以根据当前采样点的密度动态调整步长。密度低接近0的区域步长可以加大密度高的区域步长减小。这需要更复杂的控制逻辑但能显著减少总步进次数。蓝噪声抖动使用蓝噪声纹理对步进的起始位置进行微小的随机偏移。这能打破因步长固定而产生的规则性条带瑕疵让你在步长较大的情况下也能获得相对平滑的视觉效果。限制采样次数严格限制maxSteps。在移动端可以从64甚至32开始测试。同时增加stepSize。通过半分辨率大步长蓝噪声的组合可以在可接受的质量损失下大幅提升帧率。5.2 光照计算优化次级光线步进计算太阳光衰减是性能瓶颈中的瓶颈。简化光照模型在移动端可以完全省略次级步进改用一种近似方法。例如使用一张预计算的、基于高度和视角的LUT查找表来获取光照衰减或者使用一种非常粗糙的、步长很大的次级步进比如只步进4-8次。分帧计算如果云的运动不快可以考虑将光照计算分摊到多帧中进行比如每2帧或4帧完整计算一次太阳光衰减中间帧复用上一帧的结果。这会导致光照变化有延迟但对于缓慢变化的自然光可能可以接受。5.3 后处理与抗锯齿体积云内部本身就有噪声所以对屏幕空间的抗锯齿如MSAA不敏感。相反TAA时域抗锯齿是体积渲染的好朋友。TAA能将多帧的噪声平均化显著减少闪烁和噪点让你可以用更少的采样步数获得更平滑的结果。确保在URP的Post Processing配置中启用TAA。6. 动态效果与实战调试技巧静态的体积云已经很好但动态的云海才是灵魂所在。6.1 实现风场与云层运动平移运动最简单的方式是在Shader中对采样噪声的世界坐标加上一个基于时间的偏移。worldPos.xz _WindDirection.xy * _Time.y * _WindSpeed。这会让整个云层飘动。形态变化要实现云团自身形态的缓慢变化如聚散需要对噪声的采样坐标进行更复杂的变换。例如可以对噪声的UV施加一个非常缓慢的、不同频率的滚动或者使用两张噪声图进行插值过渡。分层控制现实中云是分层的。你可以在控制脚本中定义多个云层参数不同高度、厚度、密度、运动速度在Shader中分别计算然后叠加。这能极大地丰富天空的层次感。6.2 调试视图与参数调节心得直接调节一堆抽象参数非常痛苦。建立调试视图至关重要。创建调试模式在VolumetricCloudsController脚本中增加一个DebugMode枚举并在Shader中实现对应的分支。密度视图直接可视化SampleDensity函数的输出白色代表高密度黑色代表无云。这能让你最直观地看到噪声形状和密度分布。光照视图只显示CalculateSunLight的计算结果检查光照衰减和银边是否正确。步进视图用颜色编码显示每个像素的步进次数帮助你发现性能热点区域。参数调节顺序先定范围调整cloudStartHeight和cloudThickness让云层出现在你场景中合适的高度。再塑形调节baseScale,detailScale,detailStrength和densityThreshold在密度视图下观察直到得到满意的云团基础形状。记住densityThreshold提高会“蒸发”掉稀薄的云让云团更集中、轮廓更清晰。后打光调整sunLight相关参数和ambientLightFactor。先确保在正午光照方向下云的亮暗面分明。然后再调节silverLiningIntensity逆光观察拉出漂亮的金边。最后调性能逐步增加maxSteps和减小stepSize直到在目标帧率下获得可接受的画质。优先使用“半分辨率渲染”来换取性能空间。常见“穿帮”与修复云与山脉交界处闪烁这通常是深度检测精度问题。确保在步进循环中与场景深度的比较留有一点余量cloudHit.y min(cloudHit.y, raySceneDist - 0.1)并且深度纹理采样使用了正确的线性化转换。云边缘有锯齿或条带启用TAA。增加蓝噪声抖动。如果问题在云层边界检查RayBoxIntersect函数的精度或者尝试在边界处让密度平滑衰减到0。云看起来像硬块不蓬松检查你的噪声组合。可能是细节噪声强度不够或者基础噪声的阈值太高。尝试增加detailStrength或者使用pow(baseNoise, 0.5)这样的操作来软化噪声的对比度。7. 进阶扩展天气系统与场景交互基础系统完成后你可以考虑如何让它融入游戏逻辑。天气过渡定义多套云参数如晴天、多云、阴天、暴雨。通过脚本在它们之间插值Lerp。例如阴天参数可能具有更高的基础密度、更低的密度阈值和更灰暗的环境光。平滑的插值可以实现动态的天气变化。场景物体交互让云与场景中的物体如山峰交互。一种方法是使用一张世界空间的高度图Heightmap。在SampleDensity函数中如果当前采样点的世界坐标对应的高度图值高于某个阈值则强制将该点密度设为0。这样就能实现云海绕山而过的效果。这张高度图可以离线烘焙也可以由地形系统实时提供。局部云团除了全局的云层你还可以定义一些局部的、可放置的云团体积。这可以通过在场景中放置带有Sphere或Box碰撞体的CloudVolume组件来实现。在Shader中额外采样这些局部体积的密度并与全局云密度相加。这可以用来制作固定位置的云朵、山顶的帽状云等。实现一个完整的、高性能的体积云系统是一个不断迭代和权衡的过程。从最初一个灰蒙蒙的方块到一片在夕阳下翻滚着金色边缘的动态云海每一次参数调整和优化突破都充满成就感。记住没有“完美”的参数只有最适合你当前项目艺术方向和性能预算的参数。多参考真实世界的照片和视频用调试工具仔细观察耐心微调你终将能驾驭这片属于你的数字天空。