资讯动态

Unity体绘制大气散射Shader实战:从瑞利米氏散射到性能优化

发布时间:2026/10/18 17:32:45 来源:尧图企业网站定制
1. 从一张“假天空”说起为什么体绘制大气散射值得折腾如果你做过Unity项目尤其是偏写实风格或者开放世界类型的场景大概率遇到过这样一个尴尬天空盒贴图看起来挺漂亮但一旦镜头拉近、视角变化或者太阳位置移动整个天空的“空气感”就崩了。远处山体边缘像被刀切过一样生硬地平线附近没有那种从暖黄到冷蓝的渐变过渡云层和天空之间也缺少光线穿透的层次。说白了就是“假”——像一张贴纸糊在场景外面。传统做法里很多人会用一张高动态范围全景图做天空盒再叠一层雾效来模拟大气。这个方案在固定视角、固定时间点的静态展示里够用但一旦涉及动态时间系统、飞行穿越、或者大场景俯视问题就暴露了雾效是屏幕空间的后期处理它不知道光线在到达相机之前到底穿过了多厚的空气也不知道太阳方向对散射颜色的影响。结果就是太阳在头顶时地平线还是灰蒙蒙的太阳落山时整个天空均匀变暗完全没有瑞利散射和米氏散射该有的物理表现。体绘制大气散射的核心思路是把大气当成一个参与介质而不是一层贴图。光线从太阳出发穿过大气层经过散射和吸收最终进入相机。这个过程可以用物理公式描述也可以在Shader里用数值积分近似。Unity里实现这套东西不需要什么黑科技但需要对几个关键概念有清晰的认识光学厚度、散射系数、相位函数、以及最重要的——如何把无限厚的大气层塞进一个有限步数的循环里。这篇文章适合谁看如果你已经写过基本的Unity Shader了解顶点/片元着色器的基本流程知道怎么在C#里给Shader传参数那就可以直接上手。如果你对光照模型、射线与球体求交这些概念还比较模糊也没关系我会在关键地方补上必要的数学直觉。最终目标不是让你复制一段代码就完事而是让你理解每一步为什么这么写参数怎么调以及在实际项目里怎么权衡效果和性能。2. 大气散射的物理直觉瑞利、米氏和那些被忽略的细节2.1 瑞利散射为什么天空是蓝的日落是红的瑞利散射描述的是光线被远小于波长的粒子比如空气分子散射的现象。它的散射强度与波长的四次方成反比也就是说蓝光比红光散射得强烈得多。白天太阳高悬时光线穿过大气的路径相对短蓝光被散射到四面八方所以我们抬头看到的是蓝色天空。傍晚太阳接近地平线光线要穿过更厚的大气层蓝光在途中几乎被散射殆尽剩下红光和橙光直达眼睛这就是晚霞的成因。在Shader里瑞利散射通常用三个通道的散射系数来表示分别对应红、绿、蓝。一个常见的经验值是红色通道约5.8e-6绿色约13.5e-6蓝色约33.1e-6单位是每米。这些数值不是随便拍的它们来自标准大气模型在特定海拔下的近似。你可以根据项目的美术需求微调但理解它们的相对比例很重要——蓝色系数大约是红色的5.7倍这个比例决定了天空的基础色调。2.2 米氏散射雾霾感、光晕和太阳周围的亮斑米氏散射发生在粒子尺寸与波长相当或更大的时候比如气溶胶、水滴、灰尘。它和瑞利散射最大的区别是米氏散射对波长不那么敏感各个颜色通道的散射强度比较接近所以它产生的是偏白或偏灰的散射效果。同时米氏散射有很强的前向散射特性——光线更倾向于沿着原来的方向继续传播这就解释了为什么太阳周围会有一圈明亮的光晕而背对太阳的方向相对较暗。在实现中米氏散射通常用一个标量散射系数加上一个各向异性因子g来控制。g的范围是-1到1g0表示各向同性散射g0表示前向散射为主g0表示后向散射。对于地球大气g通常取0.76到0.8之间这个值能让太阳附近的光晕看起来比较自然。如果你把g调得太高太阳周围会出现一个非常锐利的亮环像镜头光晕一样假调得太低光晕又太散缺少方向感。2.3 相位函数散射方向分布的数学表达相位函数描述的是光线被散射后向某个方向传播的概率分布。瑞利散射的相位函数比较简单只和散射角有关公式是(3/(16π))*(1cos²θ)。米氏散射的相位函数复杂得多但Henyey-Greenstein近似是一个在图形学里广泛使用的简化模型它只依赖一个参数g形式紧凑计算量小效果也足够好。这里有一个容易踩的坑很多人会把相位函数和散射系数搞混。散射系数决定“有多少光被散射”相位函数决定“散射到哪个方向”。两者缺一不可。如果你只乘了散射系数而忘了相位函数太阳周围的光晕会消失天空会变得均匀而平淡。反过来如果相位函数乘错了方向可能会出现太阳背面比太阳正面还亮的情况那就完全反物理了。2.4 光学厚度与透射率光线走了多远还剩多少光学厚度是沿着一条射线路径对散射系数和吸收系数之和的积分。透射率则是光学厚度的负指数T exp(-opticalDepth)。这个公式来自比尔-朗伯定律描述的是光线穿过介质时被衰减的比例。在体绘制大气散射里我们需要计算两段路径的光学厚度一段是从相机到大气层内某个采样点另一段是从那个采样点到太阳或者到大气层外边界。实际写Shader的时候不可能对无限长的路径做精确积分所以要用数值积分。常见做法是在视线方向上取若干个采样点每个采样点再向太阳方向取若干个采样点形成嵌套循环。采样点数量直接决定效果和性能。8x8的采样在大多数情况下已经能看16x16会明显更细腻但帧率也会掉得厉害。移动端通常要降到4x4甚至更少然后靠抖动和时域滤波来弥补。3. 在Unity里搭一个可用的体绘制大气散射Shader3.1 场景准备一个球体、一个方向光、一个相机体绘制大气散射不需要复杂的场景。你可以在Unity里新建一个空场景放一个方向光代表太阳再放一个球体或者胶囊体作为地面参考物方便观察光线变化。相机最好能自由旋转方便从不同角度检查散射效果。如果你要做的是行星级别的大气那就需要一个半径远大于地面的球体来代表大气层外边界。关键设置方向光的旋转角度直接决定太阳方向。在Shader里我们会用方向光的forward向量作为太阳方向。注意Unity的方向光forward指向的是光线传播方向而我们需要的是从表面指向太阳的方向所以实际使用时通常要取反。这个细节很容易搞错一旦方向反了太阳会出现在地平线以下天空颜色完全不对。3.2 射线与大气层球体求交进入点和离开点体绘制的第一步是确定视线射线与大气层球体的交点。假设大气层是一个以行星中心为球心、半径为R_atmosphere的球体相机位置为camPos视线方向为rayDir。我们需要解一个二次方程求出射线与球体的两个交点t0和t1。如果相机在大气层内部那么t0可能是负数实际进入点就是相机本身离开点是t1。这个求交过程在Shader里通常用一个函数完成返回一个float2分别表示进入和离开的距离。如果射线与球体没有交点说明视线完全避开了大气层直接返回背景色或者天空盒颜色。如果相机在大气层外部进入点就是t0离开点是t1。如果相机在内部进入点取0离开点取t1。这里有一个数值精度问题当相机非常接近大气层边界时t0和t1可能非常接近甚至出现t0t1的情况。如果不做保护后续的积分循环会出现除零或者负步长导致画面出现噪点或者全黑。一个简单的做法是在求交后加一个clamp确保t1t0并且步长至少有一个极小值。3.3 视线方向的数值积分从相机到大气层边缘确定进入点和离开点之后我们在[t0, t1]区间内均匀取N个采样点。每个采样点代表一小段路径我们需要计算这段路径上的散射贡献。具体来说对于每个采样点我们计算它到太阳方向的光学厚度然后根据瑞利和米氏散射系数、相位函数、以及透射率累加颜色贡献。伪代码大致是这样的float3 totalRayleigh 0; float3 totalMie 0; float stepSize (t1 - t0) / numSamples; for (int i 0; i numSamples; i) { float t t0 (i 0.5) * stepSize; float3 samplePos camPos rayDir * t; float3 sunDir normalize(sunPos - samplePos); float sunRayLength intersectAtmosphere(samplePos, sunDir); float3 transmittance exp(-(rayleighCoeff * rayleighOpticalDepth mieCoeff * mieOpticalDepth)); float3 rayleighScatter rayleighCoeff * rayleighPhase * transmittance; float3 mieScatter mieCoeff * miePhase * transmittance; totalRayleigh rayleighScatter * stepSize; totalMie mieScatter * stepSize; } float3 finalColor sunIntensity * (totalRayleigh totalMie);这段代码看起来简单但有几个地方需要特别注意。第一太阳方向的计算要用采样点位置减去太阳位置而不是直接用方向光的forward。第二太阳方向的光学厚度需要单独积分通常也要取若干个采样点。第三透射率计算里的光学厚度是视线方向和太阳方向两段路径的总和不能只算一段。3.4 太阳方向的光学厚度嵌套循环的代价与优化太阳方向的光学厚度计算是性能大头。如果视线方向取16个采样点每个采样点再向太阳方向取16个采样点那就是256次循环。每次循环里还有指数运算和向量乘法在移动端基本不可接受。优化手段有几个方向一是降低太阳方向的采样数。因为太阳方向的光学厚度变化相对平缓取4到8个点通常就够。二是预计算光学厚度查找表。如果大气参数不随时间变化可以把不同高度和角度的光学厚度预先算好存到纹理里运行时直接采样。三是利用解析近似。对于均匀球体大气光学厚度有解析解虽然不完全精确但速度快很多。我在实际项目里常用的折中方案是视线方向12个采样太阳方向6个采样总共72次循环。在桌面端跑1080p没问题移动端需要降到8x4或者6x4并且配合半分辨率渲染。如果项目对帧率要求极高可以考虑把大气散射渲染到一张低分辨率RT上再和主画面合成。这样虽然边缘会有点模糊但整体氛围感还在性能收益非常明显。4. 参数调优与视觉校准让天空看起来“对”4.1 散射系数的量级与单位为什么你的天空总是太暗或太亮散射系数的量级很容易搞错。如果你直接从论文里抄了5.8e-6这样的数值放进Shader里发现天空几乎全黑不要慌这是正常的。因为那个数值的单位是每米而Unity的世界单位通常是米但大气层半径可能是6371公里也就是6.371e6米。两者相乘之后光学厚度会变得非常大透射率趋近于零。解决办法有两个一是把散射系数放大到适合Unity世界单位的量级比如乘以1e5或者1e6二是把大气层半径缩小到几百或几千个单位让光学厚度落在合理范围内。我通常倾向于后者因为这样数值更直观调试起来也方便。比如把行星半径设为1000大气层厚度设为100散射系数用0.001到0.01的量级出来的效果就比较可控。4.2 太阳强度与曝光HDR管线下的注意事项在HDR管线里太阳强度可以设得比较高比如10到50然后靠后期曝光来压。这样做的好处是太阳周围的高光不会轻易截断光晕过渡更自然。但在LDR管线里太阳强度超过1就会直接变成白色失去颜色信息。如果你的项目用的是默认的Gamma空间建议把太阳强度控制在1到3之间然后通过调整散射系数来平衡整体亮度。还有一个容易忽略的点Unity的方向光强度会影响Shader里传入的_LightColor。如果你在Shader里直接用_LightColor作为太阳强度那方向光的Intensity设置就会生效。但如果你自己定义了一个太阳强度变量就要确保它和方向光的视觉亮度匹配否则会出现天空很亮但地面阴影很暗的割裂感。4.3 地平线附近的颜色断层采样不足还是相位函数问题地平线附近是大气散射最容易出问题的地方。常见症状是天空从蓝色突然跳到橙色中间没有平滑过渡像有一条硬边。这个问题通常有两个原因。一是视线方向的采样点在地平线附近分布不够密因为地平线附近的光学厚度变化最剧烈。解决办法是改用非均匀采样在地平线附近多取点高空少取点。二是相位函数在接近90度散射角时变化太快如果采样点刚好跨过这个区域就会出现颜色跳变。可以尝试把米氏散射的g值稍微调低或者增加采样数。我在一个飞行模拟项目里遇到过类似问题最后发现是相机远裁剪面太近导致大气层球体被裁掉了一部分。把远裁剪面调大之后地平线的颜色过渡立刻平滑了。所以如果你也遇到类似情况先检查一下相机的裁剪面设置再怀疑Shader本身。4.4 性能与画质的平衡采样数、分辨率和时域抖动采样数是画质和性能最直接的调节旋钮。我的经验是桌面端1080p视线方向16个采样、太阳方向8个采样基本能跑满60帧如果掉帧先降到12x6肉眼几乎看不出区别。移动端建议从8x4起步配合半分辨率渲染和时域抖动。时域抖动的做法是每帧对采样点的偏移加一个随机扰动然后把多帧结果累加。这样即使用很低的采样数也能得到比较平滑的画面。Unity的Post Processing Stack里自带时域抗锯齿可以配合使用但要注意它和半透明物体的兼容性。还有一个取巧的办法把大气散射的渲染拆成两部分。高空部分变化慢可以每几帧更新一次地平线附近变化快每帧更新。这样能省下不少计算量但实现起来稍微复杂一点适合对性能有极致要求的项目。5. 踩坑记录那些让我熬夜的瞬间5.1 相机进入大气层内部后的画面翻转第一次实现体绘制大气散射时我测试了从太空俯视行星的效果一切正常。但当我控制相机穿过大气层边界进入内部之后整个天空突然变成一片死黑偶尔闪过几帧噪点。排查了很久才发现问题出在射线与球体求交的函数上。当相机在球体内部时二次方程的两个根一正一负我之前的代码直接取了较小的根作为进入点结果进入点是负数后续积分从负距离开始整个循环全乱了。修复方法很简单在求交后判断相机是否在球体内部。如果在内部进入点取0离开点取正根如果在外部进入点取小根离开点取大根。同时要确保离开点大于进入点否则直接返回背景色。这个坑让我意识到体绘制里所有的距离和方向都必须做边界检查不能想当然。5.2 方向光旋转导致的太阳闪烁另一个让我头疼的问题是太阳闪烁。当方向光缓慢旋转时太阳周围的亮斑会突然跳一下像被什么东西闪了一下。一开始我以为是采样数不够把采样数翻倍后问题依旧。后来用帧调试器逐帧检查发现是太阳方向的光学厚度计算里当太阳方向与大气层球体几乎相切时求交函数返回了一个非常小的值导致透射率突然跳变。解决办法是在太阳方向的光学厚度计算里加一个平滑过渡。当太阳方向与大气层边界的夹角小于某个阈值时不再精确求交而是用一个近似值代替。这个近似值可以通过插值得到虽然牺牲了一点物理精度但视觉上完全看不出差别闪烁问题也消失了。5.3 移动端半分辨率渲染的边缘锯齿为了在移动端跑动我把大气散射渲染到了半分辨率RT上。性能确实上去了但画面边缘出现了明显的锯齿尤其是地平线和太阳边缘。这是因为半分辨率下每个像素覆盖的场景范围更大采样点更容易跨过颜色突变区域。解决办法是在合成时对低分辨率RT做一次双边滤波或者用FXAA之类的后处理抗锯齿。但双边滤波在移动端也有开销最后我选择了一个折中方案把大气散射渲染到0.75倍分辨率而不是0.5倍这样锯齿明显减轻性能也还能接受。5.4 多个方向光同时存在时的颜色叠加有些场景里不止一个方向光比如主太阳加一个月亮或者补光。如果Shader里只处理了一个方向光其他光源对大气散射没有贡献画面就会显得不协调。但要把所有方向光都纳入体绘制计算性能开销会成倍增加。我的做法是只把强度最高的方向光作为主散射光源其他光源只影响地面物体不影响大气。这样既保证了视觉一致性又控制了性能。如果项目要求月亮也有大气散射效果可以单独写一个简化版的散射计算只处理米氏散射跳过瑞利散射因为月光的大气散射通常以米氏散射为主。6. 从案例到项目这套方案还能怎么扩展体绘制大气散射本身是一个相对独立的渲染模块但它可以和很多其他效果结合产生更有意思的结果。比如和体积云结合云层内部的光线散射可以复用同一套相位函数和透射率计算让云和大气在光照上保持一致。再比如和屏幕空间反射结合大气散射的颜色可以作为反射的环境光让水面或者金属表面的反射更自然。如果你要做的是太空到地面的无缝过渡那还需要处理大气层外部的星空和行星表面。这时候可以把大气散射的渲染顺序放在星空之后、地面之前并且根据相机高度动态调整散射强度。当相机完全离开大气层时散射贡献降为零星空完全显现当相机进入大气层时散射逐渐增强星空被遮蔽。这个过渡如果做得平滑玩家几乎感觉不到切换。性能方面如果项目支持计算着色器可以把大气散射的积分放到GPU的compute shader里利用线程组并行计算每个像素的散射颜色然后写到一个3D纹理或者2D查找表里。运行时只需要采样速度会快很多。不过这个方案对硬件有一定要求适合桌面端或者高端主机。最后分享一个我在实际项目里总结的小技巧把大气散射的关键参数暴露成MaterialPropertyBlock而不是直接改材质。这样可以在不打断合批的情况下动态调整不同区域的大气效果比如沙漠区域和海洋区域用不同的散射系数或者白天和夜晚用不同的太阳强度。这个做法在开放世界项目里特别有用能让不同生物群系的大气氛围有明显区别而不用切换材质或者Shader变体。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑