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。g0表示各向同性g0表示前向散射g0表示后向散射。对于地球大气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的标高约8kmMie约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太阳强度控制整体亮度。_RayleighCoeffRayleigh散射系数Color类型。_MieCoeffMie散射系数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贡献输出到绿通道这样一眼就能看出是哪部分出了问题。这个调试视图我到现在还留着每次效果不对先切过去看一眼比盲调快得多。