做风格化场景时我一直对天空处理比较挑剔,倒不是画面不够炫,而是云一走进镜头就露馅。这次在UE 5.7.4里尝试把风格化体积云作为默认天空方案,从密度场搭建、Beer–Lambert光学衰减计算,到实时雾和云隙光柱的串联,完整走了一遍。写这篇文章主要想记录两件事:一是这套东西不是靠某个现成插件点开就能用,二是真正决定成败的地方往往不在云本身,而在光照假设和雾的衔接。内容适合正在做风格化天空、又不想被默认物理天空绑架的朋友,也适合那些已经在VolumetricCloud里折腾过、但发现云总是“灰”“糊”“脏”的开发者。
先说结论:风格化体积云和写实体积云最大的区别,不在采样算法,而在你愿不愿意为了“可读的轮廓”去牺牲一部分物理真实度。密度场上多叠噪声、光照上多搬公式,反而容易让云变得一团死气;真正舒服的风格化云,往往是密度场结构干净、光照模型故意“做假”的产物。
1. 为什么是“风格化体积云”而不是云层贴图
1.1 平面云的死穴:视角一偏就穿帮
我最早的项目用的是多层云面片加视差偏移。正面看还不错,美术花了一下午调的云型,旋转镜头后就像挂在玻璃上的贴纸。问题出在视差欺骗的极限:天空球离相机太远,云面片相对摄像机的位移变化微乎其微;镜头一旦仰视、旋转、穿过云层,偏移量会突然跳变,穿帮速度比地面贴图还快。
后来试过URP里的点云方案和粒子云,粒子云的优点是能快速堆出蓬松感,缺点是光照方向感差、体积感基本靠透明度叠加,而且每一片粒子都在跟摄像机玩“排队”。粒子数量一上去,移动端直接热降频;数量压下来,云就像棉絮压在玻璃板上。
体积云的优势在于它本质是“一个可以被光线穿过的场”。摄像机进入云层、在云层附近横移、从云隙里看太阳,这些视角变化都基于真实的三维射线采样,和人眼看到雾、烟、云的方式一致。风格化项目不是不需要这种真实,是需要这种真实带来的“镜头自由”。
1.2 风格化不等于低精度:参数极值化
很多团队听到风格化体积云,第一反应是把采样次数从32降到8、把噪声层数砍一半。我实测发现这个思路是错的。风格化云的“风格”来自形状与光照的可读性,而不是“做得糙”。
我的做法是:密度场结构层保持足够精度(至少三层FBM做主形),但把噪声权重、覆盖度曲线、边缘软化度这类美术参数直接顶到写实云不太会用的极值。比如覆盖度阈值从常规的0.35拉到0.55,云基底的垂直衰减率从0.8调成0.3,让云底非常干脆地“断掉”;再把边缘细节噪声的影响范围集中在0.6~0.9区间,云顶边缘就有一种被风撕开的“须状感”。
这种参数极值化让云的轮廓非常明确,观众第一眼看到的是“云朵的形状”,而不是“一堆噪点”。写实云需要的是让形状藏在光照里,风格化云恰恰要让形状跳出来。
2. 密度场:云的骨架是噪声堆出来的
2.1 噪声选型与层数配比
体积云密度场最常用的组合是Perlin噪声做低频大形,Worley噪声做高频空洞。Perlin噪声自带连续性,适合生成云的团块边界;Worley噪声擅长产生“内部空洞”和“边缘碎絮”,没它云会像土豆泥。
我在UE 5.7.4的工程里用了三层噪声:
- 第1层:2D Perlin(或者说分形叠加),负责云的整体长宽轮廓和大型团块;
- 第2层:3D Perlin,负责云体的纵向起伏,尤其影响云塔和云底高度;
- 第3层:3D Worley,负责细节腐蚀,也就是把云的大团块啃出边缘碎絮和内部空隙。
层数不是越多越好。超过五层,帧数掉得肉眼可见,而画面提升你需要在静帧里找半天。我的实践是:如果目标平台是中高端PC或主机,5层噪声(2层低频形状+2层中频细节+1层高频腐蚀)够用;如果是风格化项目,三层有时反而更“干净”,因为细节层越少,云的卡通轮廓越锐利。
2.2 形状层与细节层的分工
密度场的核心不是“采样噪声”,而是“把噪声映射成云密度”。我习惯把整个过程拆成两个阶段:
第一个阶段是形状采样。用低频噪声得到一个大范围的覆盖蒙版,再乘上一个垂直高度梯度函数。高度梯度的作用是把云限定在云顶高度和云底高度之间,越靠近云底密度衰减得越快。这一步决定了云是扁平的层积云,还是高耸的积雨云。
第二个阶段是细节腐蚀。把高频Worley噪声采样结果作为减法因子,从形状密度里抠掉一部分。这里有一个关键技巧:不要直接减,而是先把形状密度通过一个Remap节点映射到特定区间,再和细节噪声做乘加操作。
用HLSL表示大概是这样的逻辑:
float shapeNoise = FBM3D(worldPos * shapeFreq + timeOffset); float heightGradient = smoothstep(cloudBottom, cloudTop, worldPos.z); float coverageMask = smoothstep(1.0 - cloudCoverage, 1.0, shapeNoise + heightFalloff); float baseDensity = shapeNoise * heightGradient; baseDensity = remap(baseDensity, float2(0.3, 0.9), float2(0.0, 1.0)); float detailNoise = Worley3D(worldPos * detailFreq + timeOffset * detailDrift); float erodedDensity = baseDensity - detailNoise * detailStrength; erodedDensity = max(erodedDensity, 0.0);注意timeOffset不能直接乘一个大值,否则云会整体平移得像传送带。正确的做法是给不同噪声层不同速度的偏移,低频层移动慢一点,高频层移动快一点,云才会有“滚动翻涌”的感觉。
2.3 覆盖度与垂直梯度:风格化“云型”的两大旋钮
美术调体积云,最常用的两个旋钮就是CloudCoverage和HeightFalloff。
CloudCoverage控制的是云在天上的总面积。它本质是一个smoothstep阈值:当低频噪声值高于阈值时才算云,低于阈值就是天空。风格化项目里我把coverage的可调范围设在0.2到0.8之间,低于0.2云太碎,高于0.8天空几乎被糊满。
HeightFalloff控制云底和云顶之间的垂直密度衰减。很多写实项目喜欢让云底有渐变过渡,但风格化云恰恰需要“云底边界清晰”的那种感觉。我把云底附近的衰减曲线设成接近阶梯状:云底以下密度迅速归零,云底以上一小段密度快速提升到峰值,然后在中上区域缓慢衰减。这样云朵看起来像“浮”在半空,而不是贴在地平线上的一层棉被。
调试密度场时,我强烈建议先用一个纯灰色材质输出密度值,不开光照、不看颜色。把所有参数调到能看清云的形状为止,再加光照。如果顺序反过来,你会永远分不清是密度场丑还是光影脏。
3. Beer–Lambert不是神秘公式:透射率、散射与云朵明暗的关系
3.1 Transmittance为什么用指数衰减
体积云光照的基础是Beer–Lambert定律,描述光穿过介质后剩余的能量。公式长这样:
T = exp(-extinction_coefficient * optical_depth)其中optical_depth是采样路径上所有密度值的累加。密度越大、穿过的距离越长,透射率就越小,光越难穿过去。
为什么必须是exp而不是线性衰减?我自己的理解是:指数衰减天然符合“光被一层层吃掉”的物理直觉。太阳光进入云体,外层云先吸收一部分,剩余的光进入内层再被吸收一部分,剩余比例是每次剩余比例的乘积。连续化之后就是指数函数。
在实现时,很多人直接把density * stepDistance喂给exp,得到的结果是云心黑得像墨汁。物理上没错,但观感不舒服。风格化渲染不需要100%忠实于物理,我给透射率加了一个softness系数:
float beer = exp(-density * stepDistance * extinctionScale); float softBeer = pow(beer, 1.0 / (1.0 + softness * 2.0));这样云心不会死黑,光线能柔和地渗透进云体内部。调softness时从0.0开始,每次加0.1,你会发现云慢慢从“实心铁块”变成“蓬松棉花”。
3.2 HG相位函数:让亮部跟着太阳走
密度场只回答“哪里有云”,光照模型则要回答“云有多亮、哪里亮”。在单次散射近似下,每个采样点接收太阳光,并向摄像机的方向散射一部分。散射强度与角度有关:光主要沿原方向继续向前,所以朝着太阳看云会特别亮,背光面则暗淡。描述这个角度分布的经典函数是Henyey-Greenstein相位函数:
phase = (1 - g^2) / (4 * PI * pow(1 + g^2 - 2 * g * dot(sunDir, viewDir), 1.5))参数g控制各向异性程度。g为0时云各个方向亮度一样,g接近1时光几乎只沿原方向传播,云会呈现非常强烈的“背光黑、向光亮”对比。
风格化云的g我建议取值范围在0.4到0.7之间,不要超过0.8。超过0.8后,云的暗部会像被墨汁浸泡过,失去体积感。调到0.5左右,再配合柔化透射率,云朵的亮暗交界处会呈现一种很舒服的“软雕塑”质感。
3.3 多重散射的近似:不建模型也能骗过去
真正的多重散射需要递归追踪光子,实时渲染中不可能做。常见的替代方案有三种:
- PBR里面的“pow透射率”软化阴影;
- 双倍单次散射近似:把前向散射的贡献加倍,模拟光在云内多弹了一次;
- 预计算漫反射传递贴图,把云距离场烘焙成光照探针。
第三种在可交互项目里基本不现实。我的选择很直接:单次散射算完后,加一个饱受好评的“环境补光项”。
float3 multipleScatterApprox = skyAmbient * pow(saturate(transmittance), 0.5); float3 finalLight = singleScatter * sunColor + multipleScatterApprox;为什么用pow(transmittance, 0.5)?因为透射率接近1的地方(云边缘)补光弱,透射率接近0的地方(云心)补光强,正好产生“内部自发光”的假象。天空环境色选偏蓝紫或者偏暖橙,根据是白天还是黄昏。这一步做好了,云会从“贴图感”变成“又胖又软”,代价只是一次乘法。
3.4 风格化光比控制
写实云会追求暗部和亮部的大对比,风格化云要克制。我习惯把最终云颜色映射到一个可控的高光范围:
float3 litColor = lerp(shadowColor, lightColor, softLightFactor);softLightFactor由透射率和相位函数的乘积决定。再乘一个cloudAlbedo(0.8到0.95之间),相当于给云上色。风格化项目里我通常把albedo调得偏暖,配合天空球的色调,让云看起来像被环境光轻微染色。
提示:调试光照阶段,不要用纯白太阳光。把阳光方向固定,但单独拉一个太阳色温参数,从暖黄往暖橙调,云层会立刻有层次。纯白阳光会让风格化云瞬间变成“灰蒙蒙的教科书云”。
4. 实时雾与云隙光柱:把大气感补回来的两个动作
4.1 指数高度雾与体积云衔接容易翻车
体积云做完,天空往往会显得很“干”。实际大气里,云和雾是互相影响的:云底附近的水汽会让空气变浑,光柱穿过云隙时会把雾照亮。所以需要在场景里加实时雾。
UE里最常用的是指数高度雾(ExponentialHeightFog)。但直接应用有两个问题:一是雾会均匀笼罩场景,把云底和远处的地形都罩成一坨白;二是雾的散射颜色和太阳方向不联动,黄昏时云被日光染成金色,地面却因为雾变成灰色。
我的调整思路是:控制雾的“影响距离”。指数高度雾的浓度随着距离指数增加,超过某个距离后天空球附近的雾会完全吞掉云。所以需要把雾的FogDensity控制在0.02~0.06之间,同时开启VolumetricFog(体积雾)但把TemporalBlurWeight调低,避免云体本身被雾二次糊化。
另一种衔接方式是给雾的散射颜色挂上太阳光方向。黄昏时太阳在地平线附近,雾会偏向橙红色;正午太阳高度角高,雾会偏灰白。这个效果用材质函数实现非常简单:用dot(worldNormal, viewDirection)和太阳方向做混合,归一化后得到雾色。
4.2 云隙光柱实现的三种路径
云隙光柱(God Rays / 体积光)是风格化天空的“点睛之笔”。常见实现有三条路:
| 方案 | 原理 | 画面上限 | 成本 | 适用场景 |
|---|---|---|---|---|
| 太阳光Light Shaft | 引擎自带的Directional Light Light Shaft Bloom/Occlusion | 中等 | 低 | 大多数项目首选 |
| 自定义Occlusion Mask材质 | 用云密度投影一张遮罩,喂给径向模糊后叠加 | 高 | 中 | 风格化云专属方案 |
| 体积云Shader内直接步进光路 | 在采样云的循环里同时跟踪太阳光线 | 最高 | 极高 | 写实镜头、影视级调试 |
第一个方案最快,但光柱形状依赖场景几何体,跟云的形状没有直接关系,容易出现“光柱方向对了但云块对不上”的跳跃感。
第二个方案是我真正稳定落地的方案,具体做法是:新建一个材质作为Directional Light的Light Shaft Occlusion Mask材质。材质里采样云密度场的投影,让云的亮部区域(即光能穿透的区域)产生遮罩,暗部(光被挡住)的遮罩值为暗。光穿过这些遮罩缝隙,会在后处理阶段被径向模糊拉成放射状光柱。
Mask值 = 1 - exp(-opticalDepthAlongSunView * shadowStrength)太阳方向选好后,Mask值高的地方说明光能穿透,光柱强;Mask值低说明光被云挡住,光柱弱。这个方案最大优点是与云密度场天然绑定:云块的缝隙就是光的裂缝,不会有“光在天上乱射”的违和感。
4.3 光柱参数:降采样、模糊与闪烁
光柱效果出片很容易,但一跑动态就有闪烁。闪烁主要来源是云密度场的高频细节和光柱后处理的径向模糊步长打架。
我的经验是把Occlusion Mask的材质放到较低分辨率渲染,比如降到屏幕的四分之一,再做2~3次径向模糊。低分辨率天然带平滑效果,能吃掉高频闪烁。模糊半径控制在屏幕宽度的2%~5%之间,半径再大就变成“探照灯”而不是云隙光。
另一个隐蔽问题是时域闪烁:摄像机轻微移动时,采样步长的抖动会让光柱边缘跳动。解决思路是在Occlusion Mask材质里对采样坐标做时域累积,类似TAA的思想,用帧索引作为偏移扰动量,把上一帧的遮罩值递归累积进来。这个改动只需要几个常数,不需要额外RT,代价很低。
注意:云隙光柱不要全局强度拉满。我通常把光柱强度控制在0.15~0.3,再叠加一层方向光的泛光,观感刚好。拉到0.5以上,整个屏幕会被光线洗白,反而看不清云的轮廓。
4.4 雾的颜色要和光柱统一
光柱完成之后,雾和光柱必须看作同一个大气系统。光柱把阳光引入场景,雾则负责把这些光“扩散”到观察者的眼睛里。实际操作中,我会把雾的散射颜色和光柱着色的太阳色保持一致,用同一个参数控制。要么都是暖橙,要么都是冷灰,不要一个暖一个冷,那是典型的“各玩各的天空”。
还有一个技巧:云底沿线的地形、建筑上,叠加一层薄薄的“云影”。云影不是指光照阴影,而是云层在地表投下的渐变阴影带。这个可以用云密度场在世界空间的投影生成一张低分辨率Shadow Map,叠加到场景光照之后。云影会让云和地面产生真实的连接感,光柱里再带一点云影,层次立刻拉开。具体做法我会在下一节展开。
5. UE 5.7.4里的工程配置与性能控制
5.1 材质图整体布局与关键参数
在UE 5.7.4里,我使用的是VolumetricCloud组件配合自定义材质。材质域设为Volume域,材质模型选Unlit,通过着色器代码完成密度采样、光照积分和透射率计算。材质图整体可以分成四个区块:输入参数区、密度场采样区、光照积分区、输出合成区。
输入参数区至少要有:太阳颜色和方向、天空环境色、云底高度、云顶高度、覆盖度、细节强度、步进距离和步数。这些参数全部做成材质参数,UI上每个都写好范围提示,方便美术在编辑器里直接拖。
密度场采样区就是我前面说的噪声堆叠逻辑,用Custom节点包起来,输入世界坐标和时间,输出单通道密度值。这里要注意世界坐标的精度问题:摄像机距离原点太远时,float精度会崩,云会抖动。我的做法是在材质里把采样坐标转成相对摄像机的局部坐标。
float3 localPos = WorldPosition - CameraPosition; float3 samplePos = localPos + timeOffset * speed; float density = SampleCloudDensity(samplePos);光照积分区用一个for循环完成步进。循环次数在风格化项目里我推荐24~32步,不要用64步,除非目标平台是高端PC且镜头很少近距离穿过云层。每次步进计算透射率和散射光,再把结果传回材质输出。
最后输出合成区需要输出透明度和发射颜色。透明度直接用最终透射率的补数,即1 - accumulatedTransmittance。发射颜色就是散射积分累加值。要注意把RGB和A分开输出,不然半透明排序会乱。
5.2 性能预算:一张表说清楚
帧数压力最大的通常是三块:采样步数、噪声采样层数、体积雾和光影重叠。我维护了一个性能预算表,工程里所有参数都按这个表控制:
| 配置项 | 低端预算 | 中端预算 | 高端预算 | 影响范围 |
|---|---|---|---|---|
| 采样步数 | 16 | 24 | 32 | 云形细节、内部光照精度 |
| 低频形状噪声层数 | 1 | 2 | 3 | 云的整体轮廓 |
| 细节噪声层数 | 1 | 2 | 3 | 云边缘碎絮、内部空洞 |
| 体积雾开启 | 否 | 是 | 是 | 云和地面大气衔接 |
| 光柱遮罩分辨率 | 屏幕1/8 | 屏幕1/4 | 屏幕1/4 | 光柱锐度 |
| 光照积分类型 | 单次散射 | 单次散射+环境 | 单次+多重近似 | 云的体积感和立体感 |
采样步数和帧数的关系不是线性的。16步到24步会增加约50%的积分开销,但观感提升明显;24步到32步只增加10%的观感提升。所以我的默认值是24步。
噪声层数同理:三层噪声是最佳性价比。第四层会让云边缘细腻度提升,但视觉上细微到需要截图对比才能发现。而且噪声采样次数多了,GPU纹理带宽压力会大,移动端尤其明显。
5.3 我踩过且值得说一说的坑
第一个坑是云底漏光。云底和地平线交界处会出现一条亮线,原因是云底密度不够高,散射积分把太阳光直接透了过去。解决办法是在云底高度下方加一层额外的衰减墙,让云底密度在最后100米内快速升到阈值以上。
第二个坑是云层“游泳感”。摄像机静止时云也在缓慢变形,像水母。这是噪声采样速度参数设置不当,高频细节层时间偏移太强。把细节层的时间速度降到形状层的一半,同时给细节层的采样坐标乘以0.8左右的缩放,能有效压制。
第三个坑是光柱变成“光斑”。这通常不是光柱参数的问题,而是深度降采样时TemporalAA的样本数量不够。出现光斑时,先别急着加模糊,把TAA的样本数从4提高到8,再看效果。如果还不行,再调光柱Mask材质里的smoothstep阈值。
第四个坑是材质在项目打包后参数不更新。用VolumetricCloud材质时,有一些参数(比如云顶高度)如果设为“静态开关”或“默认参数锁定”,打包后调UI不会生效。检查方法是留在引擎里运行,切到Game模式测一下,如果参数不变化,就把该参数改回“可变参数”。
5.4 进阶:把云密度场投影成地表云影
云影是我这次工程里最满意的部分。实现思路不复杂:生成一张低分辨率(256x256或512x512)的RenderTarget,用云密度场在世界空间沿着阳光方向做一次便宜的降采样投影,得到地表各处的云密度遮挡值。然后在场景光照里叠加一个负向光亮度项。
云影和光柱是同一套数据:光柱负责天空方向的光线表现,云影负责地表的光照变化。两者共用云密度场,风格才会统一。有一次我调完光柱后忘了同步云影强度,地面亮成一块,天空却密云压顶,看起来特别恍惚。
做云影的时候有一个性能技巧:不需要每帧更新。云形变化慢,阴影变化也慢,可以每2~3帧更新一次,或者只在摄像机移动超过一定距离时更新。这样能省掉大量采样开销,视觉上几乎无差别。
到这里,整套风格化体积云方案在工程里就算成型了:密度场提供云形,Beer–Lambert加上散射近似提供光照,实时雾和光柱负责把云和场景焊在一起,地表云影补上最后一道真实感。做完这套之后我最大的体会是:体积云不能孤立地看,它必须和雾、光、地面阴影作为同一个系统去调。而且风格化项目的调法真的可以大胆一点,把覆盖度、衰减曲线、光比这些参数推到物理值域之外,图形学公式不是束缚,反而是你手里最趁手的工具。最后再分享一个小技巧:调试时先把太阳高度拉低到15度附近,云的轮廓和光柱层次会特别明显,这时候调密度场和光比,比正午大太阳下盲调效率高一倍。