☰
Unity体绘制大气散射:Raymarching实现与性能优化实战
2026/10/17 14:58:37 网站建设 项目流程

1. 项目缘起与整体设计思路

1.1 为什么选择体绘制来做大气散射

做Unity场景渲染的朋友大概率都遇到过这样一个尴尬:用传统的天空盒或者后处理方式做出来的大气效果,在地面平视时看着还行,一旦镜头拉高、俯视地平线,或者飞行穿过云层,整个大气层就像一张贴纸一样穿帮了。原因很简单——传统方案把大气当成一个二维的“壳”来处理,而真实的大气是一个有厚度的、参与光线传播的体积介质。

这个案例的核心思路,就是把大气当作一个真正的三维体积来做渲染,也就是体绘制(Volumetric Rendering)。具体来说,我们不再去渲染一个天空球,而是在摄像机前方放置一个代理几何体(通常是包围整个场景的立方体或者球体),然后在片元着色器里,沿着视线方向对这段体积进行光线步进(Raymarching),在每一步采样大气密度、计算散射贡献,最后累积成最终颜色。

为什么这么做值得?因为体绘制天然支持几个传统方案很难做到的事情:视线穿过大气层的路径长度会随视角自然变化,地平线附近的大气厚度会自然累积出更浓的散射,太阳角度变化时整个天空的色调过渡是物理自洽的。换句话说,你不需要为“日出”“正午”“黄昏”分别调三套参数,只要把太阳方向丢进去,剩下的交给物理模型就行。

1.2 整体架构拆解

整个方案我把它拆成四层,从底往上依次是:

  • 物理模型层:Rayleigh散射和Mie散射的解析式,负责描述“光在大气里怎么被散射”。
  • 数值积分层:光线步进循环,负责把连续的积分方程离散化成可计算的求和。
  • 着色器实现层:HLSL代码,负责把前两层翻译成GPU能跑的指令。
  • Unity集成层:材质、代理网格、相机脚本、参数暴露,负责让美术和策划能调。

这四层里,物理模型层决定了效果的上限,数值积分层决定了性能的下限,而着色器实现层是最容易踩坑的地方——很多教程只给你一段能跑的代码,但不告诉你为什么步进次数取16而不是32,为什么相位函数里那个g参数不能随便调。这个案例我会把这些“为什么”都补上。

1.3 适用场景与前置知识

这套方案适合以下几类需求:开放世界游戏的大气天空、飞行模拟的高空视角、科幻场景的行星大气、以及任何需要“镜头能自由升降”的项目。如果你只是做一个固定视角的室内场景,那用传统天空盒就够了,上体绘制属于杀鸡用牛刀。

前置知识方面,你需要对Unity的ShaderLab语法有基本了解,知道顶点着色器和片元着色器分别在干什么,最好还写过一两个后处理shader。数学上,向量点乘、归一化、指数函数这些是必须的,至于积分——你不需要会手推,但需要理解“把一段路径切成很多小段,每段算一点贡献再累加”这个直觉。我见过不少朋友卡在体绘制上,不是卡在代码,而是卡在“为什么要步进”这个思维转换上。

提示:如果你完全没接触过Raymarching,建议先找一个最基础的“球体Raymarching”教程练手,理解“从相机出发沿射线一步步前进”这个模式,再回来看大气散射会顺畅很多。

2. 核心物理原理与参数解析

2.1 Rayleigh散射与Mie散射的分工

大气散射里有两个主角:Rayleigh散射和Mie散射。它们的分工可以用一句话概括——Rayleigh负责“天空为什么是蓝的”,Mie负责“太阳周围为什么有一圈光晕”。

Rayleigh散射由空气分子引起,散射强度与波长的四次方成反比。这就是为什么蓝光(波长短)被散射得比红光(波长长)厉害得多,天空呈现蓝色,而日落时阳光穿过更厚的大气,蓝光被散射殆尽,剩下红光直达眼睛。在代码里,Rayleigh散射系数通常写成三个分量,对应RGB三个通道,蓝通道的系数最大。

Mie散射由气溶胶(尘埃、水汽、污染物)引起,散射强度与波长关系不大,所以它散射出来的光是偏白的。Mie散射还有一个关键特性——前向散射,也就是光更容易沿着原来的方向继续传播,这就是太阳周围那圈光晕的成因。这个特性由相位函数里的各向异性参数g控制。

散射类型起因波长依赖相位特性视觉表现
Rayleigh空气分子强(λ⁻⁴)近似各向同性蓝天、日落红
Mie气溶胶弱强前向散射太阳光晕、雾霾

2.2 散射系数的取值逻辑

很多人直接抄网上的散射系数,结果发现效果要么太浓要么太淡,这是因为那些系数是针对特定尺度(比如以千米为单位的大气厚度)标定的。你在Unity里用的时候,如果代理几何体的尺寸和真实大气尺度不一致,系数就必须相应缩放。

我的做法是:先把代理几何体设定为一个已知半径的球(比如半径设为1个单位代表地球半径的某个比例),然后根据这个比例反推散射系数。Rayleigh散射系数在海平面附近大约是 (5.8, 13.5, 33.1) × 10⁻⁶ 每米,这个数量级直接搬到shader里会小到看不见,所以通常要乘一个放大因子。这个放大因子不是随便定的,它等于“你的场景单位 / 真实米数”的比值。

注意:散射系数和密度是两回事。系数描述“单位密度下每米散射多少”,密度描述“这一点的介质有多浓”。两者相乘才是实际的散射率。很多新手把这两个概念混在一起调,越调越乱。

2.3 相位函数的物理意义

相位函数描述的是“光被散射后,往某个方向偏转的概率”。Rayleigh的相位函数很简单,近似各向同性,公式是3/(16π) * (1 + cos²θ),其中θ是散射角。Mie的相位函数用Henyey-Greenstein近似,公式是:

(1 - g²) / (4π * (1 + g² - 2g*cosθ)^1.5)

这里的g就是各向异性参数,取值范围-1到1。g=0表示各向同性,g>0表示前向散射,g<0表示后向散射。对于地球大气,Mie的g通常取0.76到0.8之间。这个值调大了,太阳光晕会变得又亮又集中;调小了,光晕会散开变淡。

我实测下来,g取0.76是个比较稳的起点,配合Mie散射系数取一个较小的值,能做出比较自然的太阳光晕。如果你想要更“电影感”的强光晕,可以往0.85靠,但要注意别让整个天空被光晕糊住。

2.4 光学深度与透射率

光线从A点走到B点,会被介质吸收和散射掉一部分能量,剩下的比例叫透射率(Transmittance),它等于exp(-光学深度)。光学深度就是“散射系数 × 密度 × 距离”沿路径的积分。

这个指数关系非常关键,它意味着透射率永远在0到1之间,距离越长衰减越接近0但不会变成负数。在shader里,我们每步进一次就累加一次光学深度,最后用exp函数算出透射率。这里有个性能技巧:如果某段路径的光学深度已经很大(比如超过10),透射率基本就是0了,可以提前终止步进,省下后面的计算。

3. 着色器实现与关键代码拆解

3.1 代理几何体与射线起点

第一步是确定射线从哪里出发、往哪里走。在Unity里,最省事的做法是挂一个Cube或者Sphere作为代理,材质用我们的散射shader,然后关闭背面剔除或者用Cull Front。这样相机在代理内部时,片元着色器依然会被调用。

在片元着色器里,我们需要拿到两个点:射线起点(相机位置)和射线终点(代理几何体表面)。起点可以直接用_WorldSpaceCameraPos,终点可以用i.worldPos(顶点着色器传过来的世界坐标)。射线方向就是normalize(worldPos - cameraPos)。

但这里有个坑:如果相机在代理外部,射线起点应该在代理表面而不是相机位置。所以稳妥的做法是做一个射线-球体求交,把起点和终点都限制在代理球内部。我一般会写一个RaySphereIntersect函数,返回两个交点,取近交点作为起点、远交点作为终点。

// 射线与球体求交,返回近远两个交点距离 float2 RaySphereIntersect(float3 rayOrigin, float3 rayDir, float3 sphereCenter, float sphereRadius) { float3 oc = rayOrigin - sphereCenter; float b = dot(oc, rayDir); float c = dot(oc, oc) - sphereRadius * sphereRadius; float d = b * b - c; if (d < 0) return float2(-1, -1); d = sqrt(d); return float2(-b - d, -b + d); }

3.2 光线步进循环的写法

拿到起点和终点后,就可以做步进了。核心循环长这样:

float3 totalRayleigh = 0; float3 totalMie = 0; float opticalDepthR = 0; float opticalDepthM = 0; float stepSize = (endDist - startDist) / numSteps; float3 currentPos = rayOrigin + rayDir * startDist; for (int i = 0; i < numSteps; i++) { float3 samplePoint = currentPos + rayDir * stepSize * 0.5; float height = length(samplePoint - planetCenter) - planetRadius; float densityR = exp(-height / scaleHeightR); float densityM = exp(-height / scaleHeightM); opticalDepthR += densityR * stepSize; opticalDepthM += densityM * stepSize; // 计算这一步的散射贡献... currentPos += rayDir * stepSize; }

这里有几个细节值得说。第一,采样点取的是每段的中点(stepSize * 0.5),这是中点积分法,比取起点或终点精度高。第二,密度用指数衰减exp(-height / scaleHeight),scaleHeight是标高,Rayleigh的标高约8km,Mie约1.2km,这意味着Mie散射主要集中在低空,Rayleigh能延伸到更高处。

3.3 太阳方向的光学深度

上面算的是“相机到采样点”的光学深度,但散射计算还需要“采样点到太阳”的光学深度,用来算太阳光在到达采样点之前被衰减了多少。这一步需要再套一个内层循环,从采样点向太阳方向步进。

float opticalDepthLightR = 0; float opticalDepthLightM = 0; float3 lightDir = normalize(sunDirection); float3 lightPos = samplePoint; for (int j = 0; j < numLightSteps; j++) { float3 lightSample = lightPos + lightDir * lightStepSize * 0.5; float lightHeight = length(lightSample - planetCenter) - planetRadius; if (lightHeight < 0) break; // 被行星挡住 opticalDepthLightR += exp(-lightHeight / scaleHeightR) * lightStepSize; opticalDepthLightM += exp(-lightHeight / scaleHeightM) * lightStepSize; lightPos += lightDir * lightStepSize; }

这个内层循环是性能大户。如果外层16步、内层8步,那就是128次采样。我的经验是外层16、内层8在桌面端够用,移动端要降到外层8、内层4,并且把内层循环的步长设大一点。

3.4 散射贡献的累积

有了两个光学深度,就可以算每一步的散射贡献了:

float3 tau = rayleighCoeff * (opticalDepthR + opticalDepthLightR) + mieCoeff * 1.1 * (opticalDepthM + opticalDepthLightM); float3 attenuation = exp(-tau); float3 rayleighContribution = densityR * rayleighCoeff * rayleighPhase * attenuation; float3 mieContribution = densityM * mieCoeff * miePhase * attenuation; totalRayleigh += rayleighContribution * stepSize; totalMie += mieContribution * stepSize;

最后把两部分加起来,乘以太阳强度,再乘上透射率,就是最终颜色。这里那个1.1是Mie散射的吸收修正系数,因为Mie散射同时包含散射和吸收,实际消光比纯散射略大。

3.5 参数暴露与美术调优

代码写完后,要把关键参数暴露到材质面板上,方便美术调。我一般暴露这几个:

  • _SunDirection:太阳方向,用Vector类型。
  • _SunIntensity:太阳强度,控制整体亮度。
  • _RayleighCoeff:Rayleigh散射系数,Color类型。
  • _MieCoeff:Mie散射系数,Float类型。
  • _MieG:各向异性参数,Range(0, 1)。
  • _ScaleHeightR/_ScaleHeightM:两个标高。
  • _NumSteps/_NumLightSteps:步进次数,Range类型。

实操心得:调参时先固定太阳方向为正上方,调Rayleigh系数让天空呈现合适的蓝色,再调Mie系数和g让太阳光晕自然,最后调太阳强度匹配场景整体曝光。顺序反了会互相干扰,越调越乱。

4. 常见问题与排查实录

4.1 效果发灰、发白没有层次

这是最常见的问题,八成是透射率算错了或者散射系数太小。先检查exp函数的参数是不是负数——如果光学深度算出来是正的,exp(-tau)才是正确的衰减。如果系数太小,散射贡献会淹没在背景里,看起来就是一片灰。

另一个可能是步进次数太少,导致积分精度不够,颜色被平均掉了。把步进次数从8提到16试试,如果明显改善,那就是精度问题。

4.2 地平线附近出现明显色带

色带(banding)是步进离散化的典型症状,因为每一步之间的密度变化没有被平滑处理。解决办法有两个:一是增加步进次数,二是给采样点加一点抖动(dithering),用噪声偏移每一步的采样位置,把色带打散成噪点,再配合TAA或者模糊就能消除。

float dither = frac(sin(dot(screenPos, float2(12.9898, 78.233))) * 43758.5453); float3 samplePoint = currentPos + rayDir * stepSize * (0.5 + dither * 0.5);

4.3 太阳光晕过曝或者消失

光晕问题基本都出在相位函数上。如果g设得太大(比如0.95),前向散射会极度集中,光晕变成一个刺眼的亮点;如果g设成0,光晕完全消失。建议从0.76开始,每次调0.05,观察变化。另外,Mie散射系数和g是耦合的,g调大时Mie系数要相应调小,否则光晕会糊成一片。

4.4 性能突然掉帧

体绘制的性能瓶颈几乎永远在内层循环。排查步骤:先把内层步进次数降到1,看帧率是否恢复,如果是,那就是内层循环的问题。优化手段包括:降低内层步数、增大内层步长、在光学深度超过阈值时提前break、以及把整个散射计算放到半分辨率RT上再做上采样。

问题现象可能原因排查方向解决手段
画面发灰系数过小/透射率错误检查exp参数符号调大系数、修正tau
地平线色带步进精度不足增加步数看是否改善加抖动、提步数
光晕过曝g值过大逐步降低gg降到0.76附近
掉帧严重内层循环过多内层步数降为1测试降步数、半分辨率
天空全黑射线求交失败检查代理几何体修正求交、关剔除

4.5 相机进入大气层后效果异常

如果相机高度低于大气层底部,射线起点就在介质内部,这时候射线求交要特殊处理——近交点应该取0而不是负数。另外,如果相机穿到行星内部,整个计算应该直接返回黑色或者地面颜色。我一般会在shader开头加一个高度判断,低于地面高度就直接输出背景色。

5. 进阶优化与扩展方向

5.1 预计算透射率查找表

内层循环是性能大头,但它算的其实是“从某点向太阳方向的光学深度”,这个值只和采样点高度与太阳天顶角有关,和相机无关。所以可以预计算一张二维查找表(LUT),横轴是天顶角余弦,纵轴是高度,运行时直接采样,省掉整个内层循环。这一招能把性能提升好几倍,代价是需要一张额外的纹理和一次预计算Pass。

5.2 与体积云、雾的融合

大气散射算出来的透射率,其实可以直接用来给体积云和高度雾做光照衰减。云在采样时,把大气透射率乘上去,云的颜色就会随太阳角度自然变化,日出时云底泛红、正午时云顶发白,这些都能自动出来。这个融合是免费的,因为透射率本来就算好了。

5.3 多散射近似

单次散射模型在太阳接近地平线时会偏暗,因为真实大气里光会经过多次散射才到达眼睛。完整的多散射计算量太大,但可以用一个经验近似:在单次散射结果上叠加一个与太阳高度相关的环境项,模拟多次散射的补光。这个近似不物理,但视觉上很有效,尤其是做黄昏场景时。

5.4 移动端适配要点

移动端做体绘制要非常克制。我的建议是:外层步进不超过8,内层用LUT替代,分辨率降到半分辨率甚至四分之一,并且只在相机高度超过一定阈值时才启用体绘制,低空时切回传统天空盒。这样既保证了高空视角的效果,又不会让地面场景白白消耗性能。

提示:半分辨率渲染时,边缘会有锯齿,记得配合一个边缘感知的上采样,或者干脆用全屏模糊把边缘糊掉,反正大气本身也是低频信号,糊一点看不出来。

6. 我在实际项目中的几点体会

这套体绘制大气我前后在三个项目里用过,踩的坑不算少。最大的体会是:不要一上来就追求物理正确。物理公式是骨架,但最终效果要靠美术调。我见过有人死磕散射系数的真实值,结果画面又暗又灰,因为真实大气的散射在游戏曝光下本来就不够“好看”。正确的做法是先用物理公式搭出结构,然后大胆地调系数、调强度,让画面服务于项目的美术风格。

另一个体会是性能预算要提前定。体绘制的开销和步进次数是线性关系,而步进次数直接决定画质。我的经验是:桌面端给到外层16内层8,主机端外层12内层6,移动端外层8内层用LUT。这个预算在项目初期就要和主程对齐,不然后期优化会非常痛苦。

最后分享一个小技巧:调试时把每一步的散射贡献单独输出成颜色,比如Rayleigh贡献输出到红通道、Mie贡献输出到绿通道,这样一眼就能看出是哪部分出了问题。这个调试视图我到现在还留着,每次效果不对先切过去看一眼,比盲调快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询