三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity体积云密度场重构:参数解耦、性能优化与电影级效果实现

Unity体积云密度场重构:参数解耦、性能优化与电影级效果实现

1. 项目概述:为什么我们需要重构体积云密度场?

在Unity中实现体积云,是很多图形程序员的“成人礼”。它不像一个简单的粒子特效,堆叠一下参数就能出效果。体积云的核心在于其密度场——一个定义了空间中每一点“云有多浓”的三维函数。几年前,当我第一次尝试在Unity URP管线里实现体积云时,最头疼的就是这个密度场。网上能找到的方案,无论是基于Ray Marching的经典方法,还是结合3D噪声纹理的“标准答案”,用起来总感觉差点意思:要么参数耦合严重,调一个风速,云的形态和厚度全变了;要么性能开销巨大,在移动端根本跑不起来;更别提想要实现那种层次分明、既有蓬松感又有细节的“电影级”云朵,简直是无从下手。

这次要聊的“重构体积云密度场”,正是为了解决这些痛点。它不是从零造轮子,而是对现有主流技术的一次深度整合与再设计。简单来说,我们的目标是构建一个参数解耦、调控直观、性能可控的新密度场系统。这意味着,美术同学可以像在Houdini里捏地形一样,直观地调整云层的高度、覆盖范围、蓬松程度,而不用担心动一个滑块引发“蝴蝶效应”;程序同学则可以清晰地控制采样步长、噪声层数,在效果和帧率之间找到最佳平衡。最终,我们希望这个系统不仅能产出视觉上足够惊艳的云海,更能成为项目里一个稳定、可靠、易于维护的图形模块。

2. 核心思路:融合与解耦的艺术

重构的起点,是承认没有一种“银弹”算法能解决所有体积云的需求。因此,我们的核心思路是融合多种主流方法的优势,并通过架构设计将它们解耦

2.1 主流密度场生成方法剖析

在动手之前,我们先拆解一下常见的几种体积云密度场生成思路:

  1. 基于3D噪声纹理(Procedural 3D Noise):这是最经典的方法。使用一张或叠加多张3D Perlin或Worley噪声纹理,通过世界空间坐标进行采样,来模拟云朵自然、无序的形状。它的优点是效果相对自然,资源消耗固定(取决于纹理大小和采样次数)。但缺点也很明显:参数调控不直观,改变噪声纹理或缩放因子,整个云的形态会天翻地覆;而且单一的3D纹理很难模拟出云层清晰的底部和蓬松的顶部。

  2. 基于Ray Marching的体素化方法:在Shader中通过Ray Marching循环采样,每一步根据高度、噪声等因素计算密度。这种方法非常灵活,可以实现动态变化(如风场影响)。但它的性能是硬伤,每一步采样都意味着一次昂贵的纹理读取和复杂计算,在移动端或复杂场景中难以承受。

  3. 基于预计算体积纹理(Precomputed Volume Texture):提前在DCC工具或离线程序中烘焙好云的密度信息,存储在一张3D纹理中。运行时直接采样。性能极佳,效果也可以非常精美。但牺牲了所有动态性,云是“静态”的,无法实现随风飘动、形态演变的效果,且资源体积巨大。

我们的新方案,决定以“基于高度场的宏观形状 + 基于多频噪声的微观细节”为骨架进行融合。

2.2 新系统的分层设计

我们将密度场Density(p)的计算分解为三个相乘的独立部分:

Density(p) = BaseShape(p) * Detail(p) * Erosion(p)
  1. BaseShape (基础形状层):这一层决定云层出现在哪里、有多厚。它完全由高度参数控制。我们定义一个CloudMinHeightCloudMaxHeight,在此区间内,密度根据高度有一个分布曲线(例如,底部密度大,顶部密度渐变为0)。这一层提供了云层清晰的边界和宏观的体积感,参数(最小/最大高度、密度分布曲线)非常直观,美术易于理解。

  2. Detail (细节噪声层):这一层在基础形状之上,添加云朵的团块状结构。我们采用2-3层不同频率和类型的3D噪声(例如,低频Worley噪声定义大团块,高频Perlin噪声添加小细节)进行混合。这一层的参数(噪声纹理选择、频率、强度)控制云的“款式”,是像积云还是层云。

  3. Erosion (侵蚀细节层):这是点睛之笔,用于模拟云团边缘被“吹散”的丝缕状细节。通常使用另一组更高频的噪声,或者对Detail层的噪声结果进行一些数学操作(如pow函数)来实现。这一层参数独立,可以控制边缘的“硬度”或“蓬松度”。

通过这种乘法形式的解耦,调整基础形状(比如拉高云层)不会破坏已经调好的细节噪声模式,反之亦然。这从根本上解决了参数联动的问题。

3. 密度场生成系统的具体实现

理论清晰后,我们进入实战环节。以下是在Unity URP中实现该系统的关键步骤。

3.1 资源准备与参数定义

首先,我们需要准备噪声纹理。推荐使用Textures文件夹存储。可以自己生成,也可以使用Asset Store上一些优秀的噪声纹理包。通常需要准备:

  • 一张3D Worley噪声纹理(用于Detail层的大结构)
  • 一张3D Perlin噪声纹理(用于Detail层的小细节和Erosion层)

接着,创建一个VolumeCloudProfile的ScriptableObject资产,用于集中管理所有参数。这是实现参数调控直观化的关键。

// VolumeCloudProfile.cs using UnityEngine; [CreateAssetMenu(fileName = "New Volume Cloud Profile", menuName = "Rendering/Volume Cloud Profile")] public class VolumeCloudProfile : ScriptableObject { [Header("Base Shape")] public float cloudMinHeight = 1500f; public float cloudMaxHeight = 4000f; public AnimationCurve densityHeightCurve = AnimationCurve.Linear(0, 1, 1, 0); // 底部密,顶部稀 [Header("Detail Noise")] public Texture3D worleyNoiseTex; public Texture3D perlinNoiseTex; public float detailNoiseFrequency = 0.001f; [Range(0, 1)] public float detailNoiseStrength = 0.8f; [Header("Erosion")] public float erosionFrequency = 0.005f; [Range(0, 1)] public float erosionStrength = 0.3f; public float erosionPower = 2.0f; // 用于pow函数,控制侵蚀锐度 [Header("Performance")] public int maxRaymarchingSteps = 64; public float raymarchingStepSize = 80.0f; }

使用ScriptableObject的好处是,可以为不同的天气、不同的场景创建多个Profile(如“晴空积云”、“暴风雨层云”),一键切换,极大提升了工作流效率。

3.2 Shader中的密度场函数

这是最核心的部分。我们在URP的自定义Shader中编写密度计算函数。

// 在Shader的CGINCLUDE或HLSLINCLUDE块中 TEXTURE3D(_WorleyNoiseTex); SAMPLER(sampler_WorleyNoiseTex); TEXTURE3D(_PerlinNoiseTex); SAMPLER(sampler_PerlinNoiseTex); float _CloudMinHeight; float _CloudMaxHeight; float _DetailNoiseFreq; float _DetailNoiseStrength; float _ErosionFreq; float _ErosionStrength; float _ErosionPower; // 核心密度场函数 float GetCloudDensity(float3 worldPos) { // 1. 基础形状层:基于高度的密度 float heightPercent = (worldPos.y - _CloudMinHeight) / (_CloudMaxHeight - _CloudMinHeight); heightPercent = saturate(heightPercent); // 限制在0-1 float baseDensity = _DensityHeightCurve.Evaluate(heightPercent); // 如果低于云层或高于云层,密度直接为0,提前返回优化性能 if (baseDensity <= 0.001) return 0.0; // 2. 细节噪声层 float3 samplePos = worldPos * _DetailNoiseFreq; float worleyNoise = SAMPLE_TEXTURE3D(_WorleyNoiseTex, sampler_WorleyNoiseTex, samplePos).r; float perlinNoise = SAMPLE_TEXTURE3D(_PerlinNoiseTex, sampler_PerlinNoiseTex, samplePos * 1.7).r; // 使用不同频率增加变化 float detail = lerp(worleyNoise, perlinNoise, 0.3); // 混合两种噪声 detail = saturate(detail * _DetailNoiseStrength); // 3. 侵蚀细节层 float3 erosionSamplePos = worldPos * _ErosionFreq; float erosionNoise = SAMPLE_TEXTURE3D(_PerlinNoiseTex, sampler_PerlinNoiseTex, erosionSamplePos).r; erosionNoise = pow(erosionNoise, _ErosionPower); // 使用pow使噪声值更极端,产生更锐利的侵蚀边缘 float erosion = saturate(erosionNoise * _ErosionStrength); // 4. 合并三层 float finalDensity = baseDensity * detail * (1.0 - erosion); // 侵蚀层是减弱密度 return finalDensity; }

注意:这里使用(1.0 - erosion)来实现侵蚀效果,是因为我们的噪声值在0-1之间,erosion越强(值越大),(1.0 - erosion)越小,从而在噪声值高的地方“挖掉”云层,形成丝缕状空洞。这是一种常见且高效的手法。

3.3 整合进渲染管线:Ray Marching循环

有了密度函数,我们需要在片元着色器中通过Ray Marching来积分光线在云中的衰减。

// 在片元着色器中 half4 Frag(Varyings input) : SV_Target { // 计算摄像机到当前像素的世界空间射线 float3 worldPos = GetWorldPosFromDepth(input.uv); float3 rayOrigin = _WorldSpaceCameraPos; float3 rayDir = normalize(worldPos - rayOrigin); // 计算射线与云层包围盒的交点(优化步进范围) float2 cloudIntersection = RayCloudLayerIntersect(rayOrigin, rayDir, _CloudMinHeight, _CloudMaxHeight); float rayStartDist = cloudIntersection.x; float rayEndDist = cloudIntersection.y; if (rayStartDist >= rayEndDist) { // 射线没有穿过云层,直接返回背景色 return SampleSkybox(input.uv); } // Ray Marching float stepSize = _RaymarchingStepSize; int numSteps = (int)((rayEndDist - rayStartDist) / stepSize); numSteps = min(numSteps, _MaxRaymarchingSteps); // 限制最大步数 float3 currentPos = rayOrigin + rayDir * rayStartDist; float transmittance = 1.0; // 透光率 float3 lightEnergy = float3(0,0,0); // 用于计算多次散射(简化版这里先忽略) for (int i = 0; i < numSteps; i++) { float density = GetCloudDensity(currentPos); if (density > 0.01) { // 计算此点的光照(简化版,仅考虑直接光衰减) float lightAttenuation = SampleSunLight(currentPos); // 这是一个简化函数,实际需要向光源方向再次步进 float3 scatteredLight = _CloudColor.rgb * lightAttenuation * density; // 更新透光率和光能 float extinction = density * _ExtinctionCoefficient; // 消光系数 float stepTransmittance = exp(-extinction * stepSize); transmittance *= stepTransmittance; lightEnergy += scatteredLight * transmittance * stepSize; // 累加散射光 } currentPos += rayDir * stepSize; if (transmittance < 0.01) break; // 透光率太低,提前终止 } // 混合云颜色和背景色 half4 backgroundColor = SampleSkybox(input.uv); half3 finalColor = backgroundColor.rgb * transmittance + lightEnergy; return half4(finalColor, 1.0); }

这个循环是性能消耗的大头。_RaymarchingStepSize_MaxRaymarchingSteps是需要仔细权衡的参数。步长太大,云会有明显的分层感(“云梯”);步数太多,帧率撑不住。

3.4 性能优化关键: temporal reprojection 与降低采样

直接全屏逐像素进行上述Ray Marching,即使是桌面平台也压力山大。我们必须引入优化。

  1. 降低采样分辨率:最常见的优化是云渲染到一半或四分之一分辨率的缓冲区,然后再与主画面合并。这能直接减少3/4的像素计算量。在URP中,可以通过RenderTextureBlit命令实现。

  2. Temporal Reprojection:这是提升视觉质量和稳定性的神技。原理是利用上一帧的渲染结果,通过摄像机运动向量(Motion Vector)反投影到当前帧,与当前帧的低分辨率结果混合。这能有效消除因降采样和低步数带来的闪烁和噪声,让你可以用更激进的性能参数(更少的步数、更低的分辨率)获得平滑的结果。实现起来较复杂,需要维护上一帧的云渲染纹理和摄像机矩阵。

  3. 动态步长调整:根据像素到摄像机的距离动态调整步长。远处云朵细节要求低,可以用更大的步长。这需要对射线进行分段处理。

  4. 使用Hi-Z或深度图进行遮挡剔除:对于被地形或建筑物完全遮挡的像素,完全跳过云渲染计算。这需要访问深度纹理。

4. 参数调试心得与常见“坑点”

系统搭好了,但调出好看的云才是真正的挑战。以下是我踩过无数坑后总结的经验。

4.1 参数调试指南

我们的参数系统是解耦的,调试也应有顺序:

  1. 先调BaseShape:关闭Detail和Erosion层,只显示基础形状。调整cloudMinHeightcloudMaxHeight,确定云层在天空中的位置和厚度。调整densityHeightCurve,让云层底部浓密、顶部稀薄过渡自然。这一步确保云的“骨架”是对的。

  2. 再调Detail:打开Detail层,调整detailNoiseFrequency。这个值非常敏感,0.001可能代表几公里一个云团,0.0001则可能是几十公里。通常先从0.0005开始尝试。detailNoiseStrength控制团块的对比度,太弱云会像雾,太强则像一块块棉花糖。

  3. 最后调Erosion:打开Erosion层。erosionFrequency通常比Detail频率高,用于产生小尺度细节。erosionPower是关键,值大于1会使噪声对比度增强,侵蚀边缘更锐利;值在0-1之间会使噪声平滑,侵蚀效果更柔和。配合erosionStrength,一点点增加,直到云团边缘出现你想要的丝缕感。

实操心得:调试时,务必创建一个简单的测试场景:一个平坦地形,一个摄像机,关闭所有后期效果和雾效。将太阳光方向调整到侧光或逆光位置,这样云的体积感最强。使用一个Slider UI实时联动修改Profile的参数,并立即看到效果,效率最高。

4.2 常见问题与排查表

问题现象可能原因解决方案
云渲染全黑或全白密度计算函数返回值异常(如NaN或极大值),或光线步进起点/终点计算错误。1. 在Shader中使用clip(density)或输出密度到颜色调试。2. 检查RayCloudLayerIntersect函数,确保在摄像机位于云层内时也能正确计算。
云有明显的“层”或“带”状条纹Ray Marching的stepSize太大。减小_RaymarchingStepSize。如果性能允许,直接减半尝试。同时考虑引入抖动(Jittering),在每一步的起始位置增加一个微小的随机偏移,可以打散条纹。
云边缘闪烁或抖动严重降采样导致,或噪声纹理过滤模式不当。1. 启用Temporal Reprojection是根本解决方法。2. 确保3D噪声纹理的过滤模式设为Trilinear,Wrap Mode设为Repeat
移动端帧率极低Ray Marching步数太多,或全分辨率渲染。1. 将_MaxRaymarchingSteps降到16-32。2. 启用半分辨率或四分之一分辨率渲染。3. 在低端设备上,可以完全关闭Erosion层,甚至使用更简化的密度模型。
云的颜色不自然,像灰色棉花光照模型过于简单,只有消光(变暗)没有散射(变亮)。实现一个简化的多次散射近似。最常用的是Henyey-Greenstein相位函数,配合向光源方向的“次级光线步进”(Light Marching)来估算光照衰减。即使只做一次次级采样,效果也会有质的提升。
云与天空盒交界处有硬边云的渲染没有与天空盒深度正确融合。确保在Ray Marching循环中,当transmittance很低时提前终止。在最后混合时,使用transmittance作为Alpha进行Blend,而不是直接覆盖。公式应为:finalColor = skyColor * transmittance + cloudColor

4.3 关于噪声纹理的“坑”

噪声纹理的质量直接决定云的“颜值”。这里有几个关键点:

  • 自己生成 vs 使用资源:如果追求极致控制和包体大小,可以写程序在Runtime或Editor下生成3D噪声纹理。但更推荐在初期使用成熟的资源包,如Unity-NoiseShader或从纹理网站下载,把精力先放在算法调试上。
  • 纹理尺寸与内存:一张128x128x128的RGBA32 3D纹理,内存占用约为128*128*128*4 ≈ 8MB。叠加2-3张,内存压力就上来了。在移动平台,可以考虑使用64x64x64甚至32x32x32的纹理,并通过更聪明的采样方式来弥补精度损失(比如增加采样频率)。
  • Worley vs Perlin:Worley噪声(也叫Cell噪声)天生具有“细胞”或“团块”状结构,非常适合模拟云朵的大体形态。Perlin噪声则更连续、更平滑,适合添加细节和侵蚀效果。混合使用效果最佳。

5. 进阶:动态效果与后期集成

一个静态的体积云系统已经能带来巨大提升,但要让世界真正“活”起来,还需要动态效果。

5.1 风场与形态演变

让云动起来,本质上是在采样噪声时,对世界坐标施加一个时变的偏移。

// 在GetCloudDensity函数中,修改samplePos的计算 float _WindDirection; // 风向(角度) float _WindSpeed; float _TimeScale; float2 windVector = float2(cos(_WindDirection), sin(_WindDirection)) * _WindSpeed * _Time.y * _TimeScale; float3 samplePos = worldPos * _DetailNoiseFreq; samplePos.xz += windVector; // 主要在水平方向移动

更高级的玩法是使用两个不同速度的噪声层叠加,模拟大云团缓慢移动,小细节快速变化的效果。还可以根据高度改变风速,模拟高空急流。

5.2 与天空盒、雾效的融合

云不应该是一个孤立的特效,它必须与环境融为一体。

  • 深度融合:在Ray Marching开始时,需要获取当前像素的天空盒颜色和雾效颜色作为背景。在渲染云时,要考虑雾对云本身的消光作用。一个简单的方法是,在计算云的最终颜色后,再与根据距离计算的雾系数进行混合。
  • 光照统一:云接收的光照颜色和强度,必须与场景中其他物体(特别是天空盒中的太阳)保持一致。直接从场景主光源(_MainLight)获取方向和颜色。

5.3 性能分级与LOD策略

对于大型开放世界项目,必须有一套性能分级方案。

  • 低配(Low):关闭体积云,使用传统的平面云片(Cloud Card)或简化的天空盒云纹理。
  • 中配(Medium):启用体积云,但使用最低的步数(如16步)、半分辨率渲染、关闭侵蚀层。
  • 高配(High):启用全部特性,步数增加到64步,使用四分之一分辨率渲染+Temporal Reprojection。
  • 超高配(Ultra):全分辨率渲染,更高精度的噪声纹理,更复杂的多次散射光照模型。

这个策略可以通过Quality Settings或自定义的图形设置面板来驱动。

重构体积云密度场的过程,是一个在美学、性能与工程可维护性之间不断寻找平衡点的过程。这套解耦的分层模型,经过多个项目的验证,被证明是行之有效的。它最大的价值不在于提供了某个“最牛”的算法,而是提供了一套清晰、可扩展的框架。在这个框架下,你可以方便地替换噪声生成算法、尝试新的光照模型、或者集成更先进的风场模拟。当美术同学能够独立调出他心中“完美的黄昏积云”,而程序同学不再为性能优化焦头烂额时,你会觉得这一切的折腾都是值得的。图形编程的乐趣,就在于将脑海中的画面,通过一行行代码,变成屏幕上流动的风景。

← 返回列表