1. 项目概述Unity体积雾的挑战与价值在Unity中实现高质量的体积雾效果是很多3D项目尤其是那些追求电影感、氛围感或写实风格的游戏和模拟应用时绕不开的一个技术课题。你可能在Asset Store里见过不少体积雾插件或者在网上搜到过各种基于粒子、后处理或Ray Marching的实现方案。但当你真正动手把这些方案集成到自己的项目里尤其是面对URP/HDRP管线、移动端性能、WebGL发布等具体场景时各种各样的问题就会接踵而至性能开销巨大、效果失真、与光照系统冲突、在特定平台崩溃……这绝不仅仅是“拖一个预制体进来”那么简单。这个项目就是基于我过去几年在多个商业和独立项目中反复折腾、踩坑、优化体积雾方案的经验整理出的一份“常见问题解决方案”手册。它不是教你从零开始写一个体积雾Shader而是假设你已经有了一个基础实现无论是自己写的还是第三方插件然后聚焦于那些让你头疼的、阻碍项目上线的实际问题。无论你是正在为性能发愁的TA还是被美术追着要效果的程序或是独立开发者希望为自己的世界增添一抹灵动的雾气这里的内容都能提供直接的、可落地的解决思路。2. 体积雾核心实现方案选型与问题根源分析在深入具体问题之前我们必须先理解不同体积雾实现方案背后的原理因为绝大多数问题都根植于其技术路径的选择。知其然更要知其所以然这样才能在遇到问题时快速定位到是方案本身的局限还是我们实现上的偏差。2.1 主流实现方案及其固有缺陷目前Unity社区主流的体积雾实现大致可以归为以下几类每一类都有其“阿喀琉斯之踵”基于粒子的体积雾这是最直观、最易上手的方式。使用大量半透明的粒子如Quad或Cube来模拟雾的团块感。它的优势在于动态效果好可以轻松实现雾的流动、消散。但其问题也极为突出性能消耗大尤其是为了覆盖大范围场景需要巨量粒子时渲染排序棘手粒子与场景物体、粒子与粒子之间的穿插会导致严重的视觉错误Z-fighting或错误的透明叠加缺乏真实的体积光照很难与场景中的动态光源如手电筒、车灯进行正确的交互看起来就像一层贴在前景的贴纸。基于后处理屏幕空间体积光/雾这种方法通常在摄像机的后处理阶段通过Ray Marching光线步进的方式从屏幕像素反向追踪光线与“雾介质”的交互。它能够产生非常漂亮的光束God Rays效果并且与屏幕空间深度信息结合可以表现出一定的距离感。然而它的致命弱点是**“屏幕空间”**。这意味着任何被遮挡的、不在当前摄像机视野内的雾效都会消失。当你转动摄像机时雾会不自然地出现或消失破坏沉浸感。此外Ray Marching的步进次数直接决定了效果质量和性能在移动端高分辨率下几乎不可用。基于3D体素化3D Voxel的体积雾这是目前实现高质量、物理正确体积雾的主流高级方案尤其在HDRP中内置的体积系统就是基于此原理。它将场景中的雾视为一个3D密度场并将其离散化到一个个小立方体体素中。在每个体素中计算光照散射、吸收等。这种方法能实现最真实的距离衰减、高度雾、以及复杂的光照交互。但其代价是极高的计算复杂度无论是CPU端的体素化过程还是GPU端的体积光照积分都对硬件有很高要求并且会消耗大量的显存和带宽。基于深度纹理的平面/高度雾这是一种简化的、性能友好的方案。它利用摄像机的深度纹理根据像素的世界空间位置尤其是Y轴高度来混合雾的颜色。实现简单开销极低是移动端的首选。但它只能模拟均匀的、全局的雾无法表现局部的、异形的雾团也无法实现体积光。2.2 问题诊断地图你的问题属于哪一类当你遇到体积雾的问题时首先问自己我用的是哪一种实现方案这能帮你快速缩小排查范围。如果问题是“性能卡顿”粒子方案请检查粒子数量和Overdraw后处理方案请检查Ray Marching步进次数和分辨率体素方案请检查体素网格的分辨率和深度。如果问题是“效果闪烁或失真”粒子方案首先怀疑渲染排序和Z-Test/Z-Write设置后处理方案检查深度纹理的采样精度和相机近/远裁剪面设置体素方案检查抖动Jitter算法和体素边界对齐。如果问题是“与光照不兼容”粒子方案基本无解需考虑升级方案后处理方案需确保光源信息位置、颜色、强度正确传入Shader体素方案需检查体素光照注入Light Injection是否完整。如果问题是“在WebGL或移动端崩溃/不显示”极有可能是Shader兼容性问题使用了不支持的指令或精度、或者资源如3D纹理超出平台限制。注意方案混合使用。在实际项目中我们常常采用混合方案。例如用性能友好的深度雾作为全局背景雾再用粒子系统在关键区域如山谷、洞穴入口添加细节雾团用后处理体积光突出核心光源。明确每个部分的作用能更好地管理其带来的问题。3. 性能优化从“幻灯片”到“流畅运行”的实战策略性能问题是体积雾的头号杀手。下面我将针对不同方案给出从“紧急止血”到“根治优化”的层层递进的策略。3.1 粒子体积雾的性能急救与深度优化急救措施快速提升帧率减少粒子数量这是最有效的方法。不要试图用粒子填满整个场景。使用粒子系统的Emission模块控制速率使用Size over Lifetime让远处的粒子更小或更早消失。降低Overdraw使用简单的几何体用Billboard Quad代替复杂的Mesh。优化材质使用Cutout或Fade渲染模式而非Transparent可以减少透明混合开销。如果必须用Transparent确保Shader尽可能简单。层级剔除Layer Culling为体积雾粒子设置独立的Layer并在摄像机中设置该Layer的远裁剪距离让远处的粒子根本不被渲染。启用GPU Instancing如果你的雾粒子使用相同的材质和Mesh务必在材质球上勾选Enable GPU Instancing。这能大幅降低Draw Call。深度优化结构性调整将静态雾烘焙到Lightmap或Light Probe对于场景中固定不变的雾如一片始终笼罩的丛林雾气可以考虑将其光照影响烘焙到光照探针中然后用一个简单的、低消耗的Shader在物体上模拟雾的染色效果这比实时渲染大量粒子要高效得多。用Shader替代粒子模拟运动对于缓慢飘动的雾可以不用粒子系统模拟位移而是用一个平面Mesh配合Shader里基于时间和世界坐标的噪声纹理扰动来模拟流动。一个Draw Call就能解决一片区域的动态雾效。3.2 后处理体积雾的性能调优要点后处理体积雾的性能核心在于Ray Marching的步进次数Step和降采样Downsample。动态调整步进次数不要使用固定的高步进数如64步。实现一个根据性能预算动态调整的机制。例如在移动端或低端PC上将步进数降到16-24步在静止画面或摄像机移动缓慢时可以适当降低步进数以节省性能。// 伪代码示例根据平台和设置动态选择步进数 int GetAdaptiveStepCount() { #if UNITY_IOS || UNITY_ANDROID return QualitySettings.GetQualityLevel() 1 ? 20 : 12; #else return _highQualityMode ? 64 : 32; #endif }使用降采样渲染体积雾不需要全分辨率渲染。你可以先将场景渲染到一半甚至四分之一分辨率的Render Texture中在这个低分辨率缓冲区进行昂贵的Ray Marching计算最后再上采样Upsample到屏幕分辨率。上采样时配合双边滤波Bilateral Filter可以减少模糊感。这是专业后处理插件如Post Processing Stack中的常见做法。优化噪声采样体积雾通常需要3D噪声纹理来制造不均匀感。确保使用经过优化的、低分辨率的3D纹理如32x32x32并在Shader中使用三线性过滤Trilinear Filtering以减少缓存未命中。考虑使用可平铺的Tileable2D噪声结合时间动画来模拟3D效果以节省采样开销。3.3 体素化体积雾的“降本增效”技巧这是优化中最具挑战性的一环因为其开销是立体的与体素分辨率的三次方相关。自适应体素分辨率Adaptive Resolution不要对整个场景使用均匀的高分辨率体素网格。实现一个“相机近处高分辨率远处低分辨率”的稀疏体素化Sparse Voxel Octree系统。虽然实现复杂但能极大提升效率。HDRP的体积系统内部就采用了类似的分块Tiling和细节层级LoD技术。限制体积计算范围定义一个围绕摄像机的“体积雾影响盒”Volume Fog Bounds。只在这个盒子范围内进行体素化和光照计算。这可以通过在Shader中判断世界坐标是否在Bounds内来实现快速剔除。利用时间性重投影Temporal Reprojection这是抗锯齿和性能优化的高级技术。利用上一帧的体积雾数据通过摄像机运动向量Motion Vector重投影到当前帧并与当前帧的部分计算结果混合。这样可以在保持视觉稳定的前提下大幅降低每帧需要计算的新体素或步进数量。这是将体积雾应用到实时游戏中的关键优化之一。4. 渲染与视觉瑕疵解决闪烁、穿透与失真解决了性能下一个拦路虎就是各种令人头疼的视觉Bug。4.1 粒子雾的渲染排序噩梦与解决方案粒子雾的经典问题是雾粒子穿进了山体、角色走进雾里时边缘出现硬边或闪烁。根本原因透明物体的渲染顺序。Unity默认的渲染队列无法正确处理大量交叉重叠的透明物体。解决方案使用两个摄像机进行渲染双Pass渲染Pass 1 - 不透明物体摄像机渲染所有不透明物体。将体积雾粒子的Layer从这个摄像机中排除。Pass 2 - 雾粒子摄像机新建一个摄像机Clear Flags设为Depth onlyCulling Mask只选择雾粒子的Layer。这个摄像机的深度设置为比主摄像机稍大一点如主摄像机Depth0雾摄像机Depth1并确保其Projection矩阵与主摄像机完全一致。这样雾粒子总是在所有不透明物体之后、在所有透明物体如UI之前被渲染完美解决了与场景物体的穿插问题。但代价是多一个摄像机的开销。深度写入Z-Write与Alpha Test的魔法组合为粒子Shader使用AlphaTest渲染队列QueueAlphaTest并开启深度写入ZWrite On。同时将材质的渲染模式设为Cutout。这样每个像素的雾要么完全透明被Discard要么完全不透明并写入深度。这能解决大部分排序问题但代价是雾的边缘会有锯齿可以通过软裁切Soft Particles或开启抗锯齿缓解。软粒子Soft ParticlesUnity粒子系统自带此功能。它通过比较粒子与深度缓冲区中场景的深度差来柔化粒子与场景接触的边缘。务必开启这是改善粒子雾边缘硬边的标配。但注意它依赖于深度纹理需要确保相机已生成深度。4.2 后处理体积雾的屏幕空间瑕疵处理问题摄像机旋转时雾的边缘“抖动”或“爬行”。原因Ray Marching中使用的噪声纹理采样如果直接使用屏幕空间坐标会随着摄像机移动而“滑动”。解决在Shader中使用世界空间坐标通过深度纹理和屏幕UV反推得到来采样噪声。这样噪声就“固定”在世界中摄像机移动时噪声图案保持稳定。// 伪代码使用世界坐标采样噪声 float3 worldPos ReconstructWorldPosFromDepth(uv, depth); float noise tex3D(_NoiseTex, worldPos * _NoiseScale _Time.y * _WindSpeed).r;问题雾在物体边缘出现“光晕”或“渗色”。原因后处理雾在物体边缘的深度值变化剧烈步进采样不足或上采样时边缘信息丢失。解决增加Ray Marching在深度不连续区域通过深度差判断的采样权重或额外采样。在上采样阶段使用边缘感知Edge-Aware的上采样滤波器如联合双边上采样Joint Bilateral Upsampling它会在颜色/深度变化平缓的区域进行平滑在边缘处保持锐利。4.3 体素雾的“游泳”现象与时间性抗锯齿体素化雾在摄像机移动时经常会出现体素边界闪烁或整体图案“游泳”Swimming的现象这是因为体素网格是离散的且每帧的采样位置可能因摄像机运动而突变。解决方案时间性抗锯齿TAA for Volumetrics历史缓冲区在上一帧不仅存储最终的颜色还存储每个像素对应的体积雾的“历史数据”如累积的散射光、透射率。重投影当前帧利用摄像机运动向量将当前像素位置重投影到上一帧的屏幕空间取出历史数据。混合将历史数据与当前帧新计算的数据进行混合通常历史权重较高如0.9。这能极大地平滑帧间的变化消除闪烁。失效处理当发生遮挡解除、镜头快速切换等导致重投影失效的情况时需要检测并丢弃历史数据避免拖影Ghosting。常见的检测方法是比较当前深度与重投影深度的差异或者检查屏幕运动向量的长度。实现完整的体积TAA比较复杂但即使实现一个简化的版本——比如只是对体积雾的噪声采样坐标进行每帧的微小随机偏移Jitter并在多帧间累积——也能显著改善“游泳”感。5. 跨平台与发布适配攻克WebGL与移动端难关你的体积雾在编辑器里跑得好好的一到WebGL或Android/iOS上就黑屏、报错或性能骤降。这是平台特性差异导致的。5.1 Shader兼容性WebGL与GLES的雷区问题1编译错误提示tex2Dlod未定义或精度问题。原因WebGL 1.0对应OpenGL ES 2.0和部分移动端GLES 2.0设备不支持直接在片段着色器中使用纹理LOD函数或者对变量精度有严格要求。解决在Shader开头明确定义精度这是GLES的强制要求。#ifdef GL_ES precision mediump float; precision mediump int; // 对于需要高精度的计算如世界坐标可以局部使用highp #endif避免在片段着色器中使用tex2Dlod。如果必须在顶点着色器中使用确保在#ifdef VERTEX块内。一个常见的替代方案是使用tex2D并手动计算Mip级别但这可能影响性能。使用UNITY_UV_STARTS_AT_TOP、UNITY_NEAR_CLIP_VALUE等Unity内置宏来保证平台间坐标一致性。问题2体积雾在WebGL上完全失效屏幕一片黑或纯色。原因很可能用到了WebGL不支持的Shader语法或特性例如 - 循环次数可变的for循环GLSL ES 1.0要求循环次数是常量表达式。 - 过深的递归或函数调用。 - 使用了discard指令在某些驱动上导致深度缓冲问题。解决将Ray Marching的循环次数改为编译时常量。// 错误使用uniform变量控制循环 for (int i 0; i _StepCount; i) // WebGL 1.0可能报错 // 正确使用常量或宏 #define STEP_COUNT 32 for (int i 0; i STEP_COUNT; i)简化Shader移除不必要的分支和复杂计算。考虑为WebGL提供一个简化版的体积雾Shader关闭昂贵的光照散射计算只保留基础的距离雾。5.2 资源与内存限制3D纹理之殇体素化体积雾常常需要使用3D纹理来存储体素数据如密度、光照。3D纹理是内存消耗大户。问题在移动端或WebGL平台创建大尺寸如128x128x128的3D纹理可能导致内存不足、创建失败或运行时卡顿。解决大幅降低3D纹理分辨率在移动端尝试使用64x64x64甚至32x32x32的分辨率。虽然精度下降但配合良好的滤波和抖动效果仍可接受。使用2D纹理阵列Texture2DArray模拟3D纹理如果只是存储密度场可以考虑用多层2D纹理叠加来模拟。但这对光照数据的存储不友好。彻底放弃3D纹理使用计算着色器Compute Shader这是更现代的方案但WebGL不支持Compute Shader。对于移动端支持Vulkan或Metal的较新设备这是一个方向。你可以用Compute Shader将体积数据直接计算并存储在Structured Buffer中但这套方案跨平台复杂度高。降级方案对于低端平台直接回退到基于深度的平面高度雾。虽然效果降级但保证了运行稳定性和基本氛围。通过代码在运行时动态判断平台能力切换不同的体积雾组件或材质。5.3 Unity版本与渲染管线适配URP/HDRP中的体积雾HDRP直接使用内置的Volumetric Fog和Local Volumetric Fog组件。这是最稳定、功能最强大的方案。问题通常出在配置不当如体积分辨率Volume Resolution设得过高或体积平铺Volume Tiling导致边界瑕疵。仔细阅读官方文档调整参数。URP官方没有内置的体积雾。你需要使用第三方方案或自己实现。此时要特别注意URP的Shader Library和常量缓冲区CBUFFER与内置管线不同。确保你的Shader包含了正确的URP头文件如Packages/com.unity.render-pipelines.universal/ShaderLibrary/...并使用了CBUFFER_START(UnityPerMaterial)来声明属性否则在SRP Batcher下可能出错。Addressables资源管理 如果你的体积雾材质、Shader或噪声纹理通过Addressables系统进行远程加载可能会遇到著名的“TMP材质紫了”类似问题——即Shader丢失导致材质显示为洋红色Purple。根本原因Addressables打包时Shader没有被正确依赖包含或者Shader变体Variants丢失。解决方案在Graphics Settings的Always Included Shaders列表中添加你的体积雾Shader。或者在体积雾材质的Inspector中勾选Shader属性下方的Allow material override不这里的关键是使用ShaderVariantCollection。创建一个ShaderVariantCollection文件将你的体积雾Shader及其所有可能用到的关键字Keywords变体添加进去。然后将这个Collection文件也打入Addressables包并确保在加载材质之前加载并预热WarmUp这个Collection。6. 与光照、后期效果的集成与冲突解决体积雾不是孤立存在的它必须与场景的光照、阴影、后期特效如Bloom、Color Grading和谐共处。6.1 让体积雾“被照亮”动态光源集成后处理和体素化方案都需要将光源信息传入体积计算。光源数据收集在C#端每帧收集所有需要影响体积雾的光源通常是Light组件。获取其位置或方向、颜色、强度、范围/角度。数据传递到GPU由于光源数量可能变化最佳实践是使用ComputeBuffer或MaterialPropertyBlock来传递一个光源数组到Shader。避免每盏灯都设置一次材质属性。// 示例使用MaterialPropertyBlock传递光源数组 Vector4[] lightPositions new Vector4[lightCount]; Vector4[] lightColors new Vector4[lightCount]; // ... 填充数据 MaterialPropertyBlock props new MaterialPropertyBlock(); props.SetVectorArray(_FogLightPositions, lightPositions); props.SetVectorArray(_FogLightColors, lightColors); props.SetInt(_FogLightCount, lightCount); fogRenderer.SetPropertyBlock(props);在Shader中进行体积光照积分在Ray Marching循环中对每一步的采样点计算其到每个有效光源的距离和衰减累加光照贡献。这是性能消耗的主要部分务必限制参与计算的光源数量例如只取最近的4-8盏灯。6.2 与屏幕空间环境光遮蔽SSAO、Bloom的交互问题体积雾让SSAO效果变淡或失效。原因SSAO通常在深度/法线纹理上计算而体积雾是叠加在所有不透明物体之上的半透明效果。标准的SSAO不会“感知”到雾的存在。解决这是一个高级话题。一种方案是修改SSAO算法将体积雾的透射率Transmittance作为一个衰减因子考虑进去让雾越浓的地方环境遮蔽越弱。但这需要修改URP/HDRP的SSAO实现侵入性较强。更实用的方法是调整美术效果适当降低雾浓度或调整SSAO强度使二者在视觉上平衡。问题Bloom使体积雾的亮部“爆炸”产生不自然光晕。原因Bloom特效会提取高亮区域进行模糊。体积雾中受强光照射的区域如光束亮度可能很高被Bloom过度放大。解决对体积雾输出单独应用Bloom阈值理想情况下体积雾应该在一个独立的渲染通道中完成其输出先经过一次自己的亮度提取和模糊使用比场景主Bloom更低的阈值和不同的参数再与场景颜色混合。但这需要定制渲染管线。在URP/HDRP中调整Bloom参数降低Bloom的Intensity或Scatter值提高Threshold值让只有最亮的部分才触发Bloom。同时可以尝试调整体积雾Shader的输出亮度避免产生过于极端的高光。使用颜色分级Color Grading压暗高光在后期处理栈中通过Color Grading的Gain或Gamma曲线将高光部分适当压暗可以间接缓解Bloom过曝。6.3 局部体积雾体积盒与全局雾的融合场景中常常既有全局的高度雾又有局部的体积雾如房间内的烟、魔法特效。融合的关键深度处理。渲染顺序先渲染全局雾通常作为全屏后处理再渲染局部体积雾。深度写入局部体积雾如一个Cube形状的雾效需要写入深度吗通常不写入因为它本身是半透明的。但它需要读取当前深度缓冲区以知道哪些部分被场景物体遮挡。混合公式当局部雾与全局雾叠加时需要使用正确的混合模式。通常雾的混合是“基于物理的”FinalColor SceneColor * Transmittance InScattering。当两者叠加时透射率需要相乘TotalTransmittance GlobalFogTransmittance * LocalFogTransmittance。散射光则需要分别计算并叠加。在Shader中实现时需要小心处理这些计算避免出现亮度叠加错误。7. 调试、监控与性能分析实战当效果不如预期或出现性能问题时系统的调试方法比盲目尝试更有效。7.1 可视化调试工具搭建不要只靠肉眼观察最终效果。搭建内部调试视图可以透视体积雾的内部状态。密度场可视化在Shader中将计算出的雾密度Density直接映射为颜色输出如密度越高越白。这能让你清晰地看到雾的形状、范围是否与预期一致。光照场可视化将体积内某一点接收到的光照强度可视化。这有助于调试光源是否正确地影响了雾。Ray Marching步进点可视化在Shader中将Ray Marching的每个采样点位置用小点画出来。这能直观地看到采样是否均匀在哪些区域采样不足。使用自定义渲染纹理RenderTexture将体积雾的中间计算结果如散射光RT、透射率RT在屏幕上单独显示出来。Unity的Frame Debugger是查看这些RT的绝佳工具。7.2 性能Profiling专项检查使用Unity Profiler针对体积雾进行专项性能分析。CPU开销脚本开销检查你的C#脚本中每帧收集光源、更新参数、设置材质属性的耗时。确保没有在每帧进行昂贵的查找如FindObjectsOfTypeLight使用缓存。Draw Call粒子雾会产生大量Draw Call。使用Stats面板或Frame Debugger查看并通过合批Batching或GPU Instancing来降低。GPU开销在Profiler的GPU模块中找到渲染体积雾的Pass。重点关注Verts和Tris对于全屏后处理雾这是固定的通常是两个三角形。如果异常高检查是否有误渲染。Fill Rate过高的填充率特别是粒子雾表明Overdraw严重。Shader Processing时间这是Shader本身的复杂度体现。如果这个时间很长比如超过2ms就需要优化Shader减少纹理采样、简化数学计算、降低循环次数。使用RenderDoc或Nsight这些外部工具可以抓取一帧完整的GPU调用让你看到每个Draw Call的详细状态、纹理绑定、Shader指令是进行Shader级深度优化的必备工具。7.3 常见参数误区与调优指南很多效果问题源于参数设置不当。以下是一些关键参数的经验值Ray Marching步进数Step Count桌面端高质量可设64-128平衡模式32-48移动端/WebGL务必控制在16-32以内。步进长度Step Size通常与雾的密度和范围相关。经验公式StepSize FogRange / StepCount。步长太大会导致采样不足出现带状瑕疵Banding步长太小则性能浪费。可以引入动态步长根据密度调整在密度高的区域用小步长精细采样密度低的区域用大步长快速穿过。噪声缩放Noise Scale控制雾的细节程度。过小则噪点太细密像电视雪花过大则雾团过于平滑缺乏细节。通常结合一张大尺度噪声控制整体形状和一张小尺度噪声控制细节进行混合。雾的衰减系数Extinction/Scattering Coefficient这决定了雾的“浓稠度”。Extinction系数影响光线被吸收/散射的程度值越大雾越浓能见度越低。Scattering系数影响雾的亮度和颜色。一个常见的误区是只调一个参数。实际上它们共同决定了雾的外观。可以参考物理公式Transmittance exp(-Extinction * Distance)。你可以先用这个公式估算一个初始值。调试体积雾是一个需要耐心和系统方法的过程。从方案选型开始就预见其问题在实现时搭建好调试工具在优化时有的放矢才能最终在效果与性能之间找到属于你项目的最佳平衡点。记住没有“完美”的体积雾方案只有“最适合”当前项目约束的解决方案。