
1. 项目概述从静态天空到动态世界的跨越做实时渲染的朋友尤其是对开放世界、飞行模拟或者任何需要宏大天空背景的项目有追求的开发者大概都经历过一个阶段从资源商店下载一个精美的静态天空盒感觉世界都美好了然后一运行起来太阳不会动云彩是贴图昼夜交替靠换贴图瞬间就觉得这个世界“假”了起来。静态天空盒是入门的好选择但它终究是一个“背景板”缺乏生命力和沉浸感。我们今天要聊的“程序化天空盒”就是要把这个背景板变成一个活的、会呼吸的动态系统。它不依赖大量预烘焙的纹理而是通过代码和着色器实时生成天空的视觉元素比如大气散射、云层、光照变化等。这次我们聚焦在“程序化天空盒”系列的第二部分核心目标是解决云层的动态细节表现。具体来说就是实现“云扰动”、“边缘光”和“消散效果”。为什么是这三个想象一下真实的云它的轮廓绝不是光滑平整的表面有丰富的、不断变化的细节扰动当阳光从侧面或背面照射时云层边缘会透出温暖的光晕边缘光云也不是永恒不变的固体它会形成、聚集、也会变薄、散开消散。这三个效果正是将一团程序生成的、可能看起来像棉絮或噪声的“云状物”提升为具有视觉说服力和艺术美感的“云”的关键。整个过程将在Shader中完成基于Ray Marching光线步进技术对一张3D噪声纹理进行采样和加工最终实现动态、可交互的体渲染云层。2. 核心思路与架构设计构建云的体积感在动手写代码之前我们必须把云的“物理”和“渲染”逻辑想清楚。程序化云的核心是“体积渲染”我们不是画一个云的表面而是定义一片空间区域在这个区域里每个点都有一个“密度”值表示那里有多少云物质。光线穿过这片区域时会与这些密度点发生交互吸收和散射光线最终累积成我们看到的云的颜色和不透明度。2.1 基础云形生成3D噪声的魔法一切始于3D噪声。Perlin噪声、Worley噪声也叫Voronoi噪声或其变种如FBM - 分形布朗运动是我们的画笔。单纯的一种噪声往往效果单一常见的策略是混合使用。例如用Worley噪声生成云团大的、蓬松的结构基础因为它能产生很好的细胞状、团状图案再用Perlin噪声叠加高频的、丝缕状的细节。我们将多组不同频率frequency和权重amplitude的噪声叠加起来这个过程就是创建FBM。频率决定细节的尺度低频是大形状高频是小细节权重决定该频率噪声对最终结果的贡献强度。// 一个简化的FBM函数示例 float fbmSample(float3 position) { float value 0.0; float amplitude 1.0; float frequency 1.0; float persistence 0.5; // 每次octave的振幅衰减系数 for (int i 0; i NUM_OCTAVES; i) { // sampleNoise可以是任何3D噪声函数 value amplitude * sampleNoise(position * frequency); amplitude * persistence; frequency * LACUNARITY; // 通常大于1如2.0表示频率倍增 } return value; }通过调整一个密度阈值densityThreshold我们可以将连续的噪声值转化为二进制的“有云”和“无云”区域阈值越高云越稀疏洞越多。2.2 动态系统的引入时间与扰动静态的噪声云依然很假。我们需要引入时间变量_Time.y。最直接的应用是让云飘动这可以通过在采样噪声前对世界空间坐标加上一个随时间移动的偏移量来实现比如position float3(_Time.y * windSpeed, 0, 0)。但这只是整体的平移。“云扰动”效果要更精细。它模拟的是云内部气流的微小湍流导致云的形状发生持续的、局部的、不规则的变化。实现上我们可以在采样主噪声之前先对采样坐标本身施加一层额外的、低频的、随时间变化的噪声偏移。这相当于在捏橡皮泥时手在不停地微微颤抖使得最终形状产生了丰富的有机感。// 扰动示例使用一个低频噪声来扭曲采样坐标 float3 ApplyTurbulence(float3 samplePos) { float3 turb float3(0,0,0); // 使用一个低频的Perlin噪声来生成扰动向量 turb.x sampleLowFreqNoise(samplePos * 0.1 _Time.y * 0.05); turb.y sampleLowFreqNoise(samplePos * 0.1 _Time.y * 0.05 100.0); turb.z sampleLowFreqNoise(samplePos * 0.1 _Time.y * 0.05 200.0); // 将扰动向量缩放到合适强度并加到原坐标上 return samplePos turb * _TurbulenceStrength; }2.3 光照模型体积光的精髓云看起来是立体的关键在于光照。我们采用基于物理的简化体积光照模型。对于云中的每一个采样点我们需要计算它接收到的光量。这不仅仅是简单的LambertN·L因为光在云内部会发生多次散射。一个经典且高效的模型是“Henyey-Greenstein”相位函数它描述了光在介质中向前或向后散射的概率分布。对于云这种高散射介质我们通常使用一个较强的向前散射项这会让面向光源的云部分显得更亮、更“透”。“边缘光”是这个光照模型下的一个突出表现。当观察方向视线与光源方向接近相反时即我们看的是云的背光面光线在云内部经过复杂的散射有一部分会从云层边缘透射出来形成发光的轮廓。在Shader中我们可以通过计算视线方向与光源方向的点积dot(V, L)当这个值接近 -1即两者反向时给一个额外的亮度加强。// 简化的体积光强计算 float CalculateLightEnergy(float3 samplePos, float3 lightDir, float3 viewDir) { // 基础光照衰减可考虑阴影 float directLight 1.0; // 相位函数近似例如使用点积的指数函数来模拟前向散射 float phase pow(saturate(dot(viewDir, -lightDir)) * 0.5 0.5, _ForwardScatteringPower); // 边缘光强化当视线与光源反向时增强 float rim pow(1.0 - saturate(dot(viewDir, lightDir)), _RimPower); float rimLight rim * _RimIntensity; return directLight * phase rimLight; }这里有一个关键技巧为了效果真实光线在射向采样点的途中也可能被其他的云物质遮挡从而产生体积阴影。一个完全精确的做法是进行第二次Ray Marching称为“光步进”但这开销巨大。常见的优化是进行少量步进如4-8步或者使用预计算的、低分辨率的阴影图来近似。2.4 消散效果密度重映射的艺术“消散”效果模拟云逐渐变薄、消失的过程。它不仅仅是让整体透明度线性变化那样会像一层雾在褪去而不是云在消散。真实的云消散是从边缘开始形状逐渐瓦解内部可能出现空洞。我们可以通过动态地修改密度重映射曲线来实现。原本我们用一个固定的阈值将噪声值转化为密度。现在我们引入一个全局的“消散因子”_Dissipation范围0到1并让它影响密度计算。一种有效的方法是使用一个平滑函数如smoothstep来创建一个密度过渡区。当_Dissipation增加时我们将密度重映射函数的“高密度区间”向更高的噪声值挤压同时扩大“零密度区间”。这意味着原来那些处于中等噪声值即将形成云的区域现在被映射到了更低的密度甚至零密度云就从最薄弱的边缘和内部开始“融化”了。我们还可以将_Dissipation因子与另一层高频噪声结合让消散过程产生不规则、斑驳的效果更像自然蒸发。// 动态密度重映射示例 float RemapDensityForDissipation(float rawNoise, float dissipation) { // 基础阈值和宽度 float baseThreshold _DensityThreshold; float baseWidth _DensityWidth; // 消散因子影响阈值提高宽度可能也变化 float adjustedThreshold lerp(baseThreshold, 1.0, dissipation); float adjustedWidth lerp(baseWidth, baseWidth * 0.5, dissipation); // 使用smoothstep进行平滑重映射 // 低于 (adjustedThreshold - adjustedWidth) 的密度为0 // 高于 (adjustedThreshold) 的密度为1 // 中间平滑过渡 float density smoothstep(adjustedThreshold - adjustedWidth, adjustedThreshold, rawNoise); // 可选叠加消散噪声产生斑驳效果 float dissNoise sampleDissipationNoise(worldPos, _Time.y); density * saturate(1.0 - dissipation * dissNoise); return density; }3. 着色器实现与关键代码解析理论铺垫完毕我们进入Unity Shaderlab的实际编码环节。我们将编写一个Unlit Shader在片元着色器Fragment Shader中实现Ray Marching。3.1 渲染设置与属性定义首先我们需要设置正确的渲染队列和混合模式。云是半透明物体应该放在Transparent队列。混合模式通常使用SrcAlpha OneMinusSrcAlphaAlpha混合来叠加颜色但对于体积光感强烈的云有时也会用SrcAlpha One加法混合来增强发光效果我们可以将其作为一个可选项。Shader Custom/ProceduralSkyClouds { Properties { // 噪声纹理3D纹理需在外部生成或使用程序化噪声函数 _NoiseTex (3D Noise Texture, 3D) white {} _NoiseScale (Noise Scale, Float) 1.0 // 云密度参数 _DensityThreshold (Density Threshold, Range(0, 1)) 0.4 _DensityMultiplier (Density Multiplier, Range(0.1, 10)) 1.0 // 光照参数 _LightAbsorption (Light Absorption, Range(0.1, 10)) 1.0 _ScatteringCoeff (Scattering Coefficient, Range(0, 1)) 0.5 _RimIntensity (Rim Intensity, Range(0, 5)) 1.0 _RimPower (Rim Power, Range(0.1, 10)) 4.0 // 扰动参数 _TurbulenceScale (Turbulence Scale, Float) 0.1 _TurbulenceStrength (Turbulence Strength, Range(0, 1)) 0.1 _TurbulenceSpeed (Turbulence Speed, Float) 0.05 // 消散参数 _Dissipation (Dissipation, Range(0, 1)) 0.0 _DissipationNoiseScale (Diss Noise Scale, Float) 0.5 // 风 _WindDirection (Wind Direction (XY), Vector) (1, 0, 0, 0) _WindSpeed (Wind Speed, Float) 0.1 // 颜色 _CloudColor (Cloud Color, Color) (1,1,1,1) _SunColor (Sun Color, Color) (1,0.8,0.6,1) } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc // ... 属性变量声明 ...注意使用3D纹理存储噪声性能较好但需要预生成。另一种完全程序化的方式是在Shader中实时计算噪声如使用哈希函数生成梯度噪声灵活性更高但计算量稍大。对于移动平台3D纹理是更稳妥的选择。3.2 光线步进Ray Marching主循环这是整个Shader的核心。我们从摄像机发射一条射线与一个定义云层范围的“云盒”Cloud Volume求交获得步进的起点和终点。然后在这个区间内进行固定次数的循环采样。// 假设我们有一个函数 rayBoxIntersect 计算射线与轴对齐包围盒的交点 // 返回是否相交以及进入(tmin)和退出(tmax)的距离 bool rayBoxIntersect(float3 rayOrigin, float3 rayDir, float3 boxMin, float3 boxMax, out float tMin, out float tMax) { // ... 标准的slab方法实现 ... } fixed4 frag (v2f i) : SV_Target { float3 rayOrigin _WorldSpaceCameraPos; float3 rayDir normalize(i.worldPos - rayOrigin); float3 cloudBoxMin float3(-50, 10, -50); // 云层范围根据场景调整 float3 cloudBoxMax float3(50, 40, 50); float tEnter, tExit; if (!rayBoxIntersect(rayOrigin, rayDir, cloudBoxMin, cloudBoxMax, tEnter, tExit)) { // 射线未击中云盒直接返回背景可能是天空盒颜色 return fixed4(0,0,0,0); } // 确保起点在摄像机前方 tEnter max(tEnter, 0); float rayLength tExit - tEnter; float stepSize rayLength / _Steps; // _Steps是全局定义的步数如64 float3 step rayDir * stepSize; // 从进入点开始步进 float3 currentPos rayOrigin rayDir * tEnter; // 累积颜色和透明度 fixed4 finalColor fixed4(0,0,0,0); float transmittance 1.0; // 光线穿透率初始为1完全穿透 for (int s 0; s _Steps; s) { // 1. 计算当前采样点的世界坐标考虑风场和基础飘移 float3 worldPos currentPos; worldPos.xz _WindDirection.xy * _WindSpeed * _Time.y; // 2. 应用坐标扰动实现云扰动效果 float3 turbPos worldPos; turbPos.x sin(_Time.y * _TurbulenceSpeed worldPos.y * _TurbulenceScale) * _TurbulenceStrength; turbPos.z cos(_Time.y * _TurbulenceSpeed * 0.7 worldPos.y * _TurbulenceScale) * _TurbulenceStrength; // 更复杂的扰动可以采样一张3D噪声图 // 3. 采样3D噪声获取原始密度 float rawDensity tex3D(_NoiseTex, turbPos * _NoiseScale).r; // 4. 应用消散效果重映射密度 float dissipationEffect _Dissipation; // 可以添加基于位置的高频噪声来使消散不均匀 float dissNoise tex3D(_NoiseTex, worldPos * _DissipationNoiseScale).g; dissipationEffect saturate(_Dissipation dissNoise * 0.3); float densityThreshold lerp(_DensityThreshold, 0.9, dissipationEffect); float density smoothstep(densityThreshold - 0.1, densityThreshold, rawDensity); density * (1.0 - dissipationEffect * 0.7); // 整体密度减弱 density max(0, density - dissipationEffect * 0.2); // 提升消散时的基础阈值 density * _DensityMultiplier; if (density 0.01) { // 忽略密度过低的点以优化性能 // 5. 计算当前点的光照 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 float lightEnergy CalculateLightEnergy(turbPos, lightDir, rayDir); // 6. 计算当前采样点颜色 fixed4 sampleColor; sampleColor.rgb lerp(_CloudColor.rgb, _SunColor.rgb, lightEnergy * 0.5); sampleColor.a density; // 7. 体积渲染累积公式 (Front-to-Back) float alpha 1.0 - exp(-density * stepSize * _LightAbsorption); sampleColor.rgb * alpha; // 预乘Alpha finalColor.rgb transmittance * sampleColor.rgb; transmittance * (1.0 - alpha); // 8. 早期终止如果光线已被完全遮挡提前结束循环 if (transmittance 0.01) { break; } } currentPos step; } // 最终颜色与背景混合这里假设背景是黑色实际应采样天空盒 finalColor.a 1.0 - transmittance; return finalColor; }关键点解析早期终止if (transmittance 0.01) break;这是一个重要的性能优化。当光线穿透率已经很低云变得非常不透明时后续的采样对最终像素贡献微乎其微直接跳出循环。Front-to-Back累积我们采用从前到后的累积方式transmittance代表了剩余光强。每次采样消耗一部分光强颜色根据剩余的transmittance进行加权累加。这是体积渲染的标准做法。密度判断优化if (density 0.01)避免了在完全空白的区域进行昂贵的光照计算。3.3 光照与边缘光函数实现现在我们来完善上面用到的CalculateLightEnergy函数。这里实现一个包含简化阴影和边缘光的效果。float CalculateLightEnergy(float3 samplePos, float3 lightDir, float3 viewDir) { // 基础光照兰伯特项确保背光面也有基础亮度 float basicLit saturate(dot(float3(0,1,0), lightDir)) * 0.2 0.8; // 相位函数近似HG相位函数简化版偏向于前向散射 float cosTheta dot(normalize(viewDir), normalize(-lightDir)); float g _ScatteringCoeff; // 例如0.7正值表示前向散射 float phase (1.0 - g*g) / (4.0 * 3.14159 * pow(1.0 g*g - 2.0*g*cosTheta, 1.5)); phase saturate(phase * 0.1); // 归一化到一个合适的范围 // 边缘光视线与光源方向相反时增强 float rim 1.0 - saturate(dot(viewDir, lightDir)); rim pow(rim, _RimPower) * _RimIntensity; // 简化体积阴影向光源方向步进几次累积遮挡 float shadow 1.0; float3 shadowStep lightDir * _ShadowStepSize; // 定义一个较小的步长 float3 shadowPos samplePos shadowStep; // 从当前点向光源方向偏移一点开始避免自遮挡 for (int i 0; i _ShadowSteps; i) { float shadowDensity SampleDensity(shadowPos); // 调用密度采样函数 shadow * exp(-shadowDensity * _ShadowStepSize * _LightAbsorption); shadowPos shadowStep; if (shadow 0.05) break; // 阴影过重则提前终止 } return basicLit * phase * shadow rim; }实操心得_ShadowSteps通常设置得很小如4步_ShadowStepSize可以比主步进大一些因为阴影不需要高精度。这个阴影计算是性能消耗的大头但又是提升体积感的关键。一个折中方案是每2个主步进采样一次阴影或者使用一张低分辨率的、从光源视角渲染的“云阴影图”。4. 参数调试与视觉打磨程序化效果的好坏一半靠算法一半靠调参。下面是一个关键参数的调试指南。参数组参数名作用与影响调试建议云形基础_NoiseScale缩放噪声坐标。值越大云朵细节越小、越密集。从1.0开始调整到云团大小符合你的世界尺度。_DensityThreshold密度阈值。值越高云越稀疏、破碎值越低云越浓厚、连绵。配合_DensityMultiplier使用通常在0.3-0.6之间寻找平衡。_DensityMultiplier密度乘数。直接放大密度值让云更“实”。小心使用过大会导致云像固体失去体积感。动态效果_WindSpeed云层整体移动速度。根据场景氛围调整暴风雨天快风和日丽天慢。_TurbulenceStrength扰动强度。控制云内部形状变化的剧烈程度。从0.05开始微调过强会导致云像沸腾的开水不自然。_TurbulenceSpeed扰动变化速度。通常比_WindSpeed慢模拟缓慢的内部流动。光照表现_LightAbsorption光吸收系数。值越大云越不透明颜色越深。控制云的总体明暗和通透感。晴天可偏低如0.5阴天可偏高如2.0。_ScatteringCoeff散射系数相位函数参数。正值趋向前散射面向光源亮负值趋向后散射。云通常使用较大的正值0.6-0.9以产生明亮的、毛茸茸的向阳面。_RimIntensity边缘光强度。背光时调整能让云层轮廓从天空背景中分离出来增强立体感。_RimPower边缘光衰减功率。值越大光晕越集中在轮廓边缘。通常设置在2-8之间控制边缘光的“硬度”。消散控制_Dissipation消散程度。从0无消散到1完全消散。可以通过脚本动画驱动此参数实现云的生消动画。_DissipationNoiseScale消散噪声尺度。控制消散不均匀性的细节大小。使用与主噪声不同的尺度避免模式重复。调试流程建议先定形后动态首先将_WindSpeed、_TurbulenceStrength、_Dissipation设为0关闭所有动态效果。集中调整_NoiseScale、_DensityThreshold得到一组基础形态让你满意的静态云。再打光在静态云基础上调整光照相关参数。先调_LightAbsorption确定整体浓淡再调_ScatteringCoeff和_Rim参数观察不同太阳角度下的表现确保背光面和迎光面都有好看的过渡。最后加动画逐步引入风、扰动和消散。每次只开启一个动态效果进行微调理解每个参数对运动形态的影响。扰动和消散的速度一定要慢符合自然观察。多角度观察务必在场景中移动摄像机从不同高度、不同光照角度清晨、正午、黄昏观察云的表现。一个参数可能在某个角度下完美在另一个角度下就穿帮了。5. 性能优化与常见问题排查实时体积云是性能杀手。在移动端或需要大量云层的场景中优化至关重要。5.1 核心性能优化策略降低步进数_Steps这是最直接的手段。可以尝试从128步降到64甚至32步。为了弥补精度损失可以引入“动态步长”或“自适应采样”在密度低的区域用大步长快速穿越在密度高的区域云内部切换为小步长精细计算。降低渲染分辨率将云渲染到一个比屏幕分辨率更低的Render Texture上然后再上采样到全屏。由于云本身是低频、柔和的适当降采样如0.5倍视觉损失很小但能节省数倍的像素计算量。使用LOD多层次细节根据摄像机距离使用不同精度的云渲染。远距离时可以大幅减少步进数、使用更简单的噪声、甚至用2D的云贴图 impostor 代替。优化噪声采样3D纹理采样开销大。如果使用程序化噪声确保噪声函数尽可能高效。可以考虑使用预计算的2D时间维度的噪声动画来模拟3D效果。简化光照与阴影体积阴影Light Marching是性能瓶颈。可以彻底关闭或用一张简单的、跟随太阳角度旋转的2D噪声图来模拟宏观阴影。_ShadowSteps一定要严格控制4。5.2 常见视觉问题与修复问题现象可能原因解决方案云看起来像2D贴片或“纸片”缺乏体积光照特别是边缘光和内部亮度变化。步进数太少密度计算过于简单。强化_RimIntensity和相位函数 (_ScatteringCoeff)。增加步进数或确保在云内部有足够采样。检查密度重映射曲线确保有平滑过渡而非二值化。云边缘有明显的“锯齿”或“方格”感噪声纹理分辨率不足或噪声尺度 (_NoiseScale) 设置不当导致采样到噪声纹理的像素级细节。使用更高分辨率的3D噪声纹理。尝试对噪声坐标进行三线性滤波采样。轻微调整_NoiseScale或叠加不同尺度的噪声来打破规则图案。云的运动不自然像整体滑动或抽搐扰动算法过于规则如只用正弦函数或扰动速度 (_TurbulenceSpeed) 太快。风的移动没有与扰动叠加好。使用更复杂的噪声如Perlin噪声作为扰动源。将_TurbulenceSpeed调至一个很低的值如0.01。确保风场移动 (_WindSpeed) 是作用于世界坐标而扰动是叠加在局部坐标上的。消散效果像整体淡出没有“瓦解”感消散因子 (_Dissipation) 是均匀应用的没有与云的局部密度挂钩。让消散因子与另一层高频噪声相乘产生不均匀的消散。修改密度重映射函数让消散过程优先将中等密度区域降至0模拟从薄处开始消失。性能开销巨大帧率骤降步进数 (_Steps) 过高或光线步进循环中没有做早期终止和低密度跳过优化。阴影计算步进太多。实施上述所有性能优化策略。务必添加if (density 0.01)和if (transmittance 0.01) break;判断。将阴影步进减到最低。一个高级技巧蓝噪声采样。在Ray Marching中使用固定的步长可能会在视觉上产生一种规则的模式尽管很微弱。可以使用蓝噪声Blue Noise纹理来抖动Jitter每一步的起始采样位置。这不仅能消除潜在的规则图案还能以更少的采样次数达到更好的视觉质量是一种非常高效的“性价比”优化。实现方法是在步进循环开始时从一个蓝噪声纹理中根据像素屏幕位置取一个随机偏移加到初始的tEnter上。// 在片段着色器开头获取屏幕UV float2 screenUV i.screenPos.xy / i.screenPos.w; // 采样一张蓝噪声纹理可以是2D纹理值在0-1之间 float blueNoise tex2D(_BlueNoiseTex, screenUV * _BlueNoiseScale).r; // 将噪声映射到一步之内的偏移 float jitter blueNoise * stepSize; currentPos rayOrigin rayDir * (tEnter jitter); // 从抖动后的位置开始走到这一步你的程序化天空盒应该已经拥有了动态、生动且性能可接受的云层。从一团简单的噪声到拥有扰动、边缘光和消散效果的体积云这个过程充满了对自然现象的观察、对物理模型的简化和对Shader技巧的打磨。记住所有参数都没有绝对的正确值它们服务于你的艺术目标。多参考真实的云照片和视频不断对比调试才能让这段代码生成出真正打动人心的数字天空。最后别忘了将云渲染的结果与你之前可能实现的大气散射天空背景进行正确的Alpha混合让云层自然地融入天际线一个完整的动态天空系统就此初具雏形。