《体积云与大气散射:Raymarching、Beer-Lambert 与相位函数》

体积云与大气散射是实时渲染中最具「物理感」的效果之一。本文从体积渲染方程出发,讲解 Raymarching 积分、Beer-Lambert 吸收定律、Henyey-Greenstein 与 Mie 相位函数、Precomputed Atmospheric Scattering 的 LUT 方案、Worley/Perlin 噪声构造云形,以及降采样、时间重投影、蓝噪声抖动等性能优化手段,给出可落地的工程实现路径。

天空、云层与大气是场景「氛围」的来源,却也是传统光栅化管线最难处理的部分——它们没有明确表面,光在介质内部边行进边被吸收和散射。要渲染云与大气,必须放弃「求交 + 着色」的表面模型,转而求解体积渲染方程。本文从介质物理出发,串联起 Raymarching 积分、Beer-Lambert 定律、相位函数、大气 LUT 与云噪声,最后落到实时工程里的降采样与时间重投影优化。配套阅读可参考 https://plumephp.com/graphics-lighting-shadows/ 与 https://plumephp.com/graphics-real-time-gi/。


一、体积渲染方程与 Raymarching

一句话:体积渲染不再问「表面在哪」,而是沿视线对介质逐段积分,累加每段的散射贡献并扣除被吸收的部分。

1.1 从表面到介质

表面渲染的辐射度方程假设光只在表面上交互:

L_o(x, ω_o) = ∫ f_r(x, ω_i, ω_o) L_i(x, ω_i) (n · ω_i) dω_i

介质中不存在 n,取而代之的是沿射线 x(t) = x_0 + t·ω 的体积渲染方程:

L(x, ω) = ∫_0^D T(t) · σ_s(x_t) · ∫_{S²} p(ω, ω') L_i(x_t, ω') dω' dt  +  T(D) · L_surface(D)

其中 T(t) 是透射率(transmittance),σ_s 是散射系数,p 是相位函数。直觉上:视线穿过介质时,每一点都朝相机散射一部分光,但这段光在到达相机前又会被前面的介质衰减掉。

1.2 前向 Raymarching

离散化上述积分,就是经典的 Raymarching:沿射线以步长 Δt 采样 N 次。

// GLSL:基础体积 Raymarching 骨架
vec4 raymarchVolume(vec3 rayOrigin, vec3 rayDir, float maxDist) {
    float t = 0.0;
    vec3 scatteredLight = vec3(0.0);   // 累加散射光
    float transmittance = 1.0;         // 当前透射率

    for (int i = 0; i < MAX_STEPS; ++i) {
        vec3 pos = rayOrigin + rayDir * t;
        float density = sampleDensity(pos);          // 介质密度
        if (density > 0.001) {
            vec3 lit = integrateLightAt(pos);        // 该点的入射散射光
            float sigma_t = density * extinctionCoeff; // 消光系数
            float sampleTrans = exp(-sigma_t * stepSize);
            // 能量守恒的解析积分(而非简单相乘)
            float clampedExt = max(sigma_t, 1e-4);
            vec3 S = lit * sigma_s / clampedExt;      // 源项
            scatteredLight += transmittance * (1.0 - sampleTrans) * S;
            transmittance *= sampleTrans;
        }
        t += stepSize;
        if (transmittance < 0.01) break;              // 提前退出
    }
    return vec4(scatteredLight, transmittance);
}

1.3 步长、抖动与欠采样

均匀步长会产生明显的层状条带(banding)。工程上常用两种补救:

方案做法代价
蓝噪声抖动每像素起点偏移随机相位引入噪点,需 TAA/降噪
分层采样每步在子区间内随机收敛更快
指数步长近处密、远处疏远处细节丢失
// 蓝噪声抖动:打散条带
float blueNoise = texture(blueNoiseTex, gl_FragCoord.xy / 64.0).r;
float t = stepSize * blueNoise;   // 起点随机偏移

二、Beer-Lambert 定律

一句话:光穿过介质时按指数衰减,透射率 T = exp(-∫σ_t dt)——这是所有体积渲染的能量守恒基石。

2.1 定律本身

Beer-Lambert 定律描述单色光穿过均匀吸收介质时的强度衰减:

T = exp(-σ_t · d)

σ_t 是消光系数(extinction,= 吸收 σ_a + 散射 σ_s),d 是穿行距离。若介质不均匀,则对密度沿路径积分:

T(t) = exp( -∫_0^t σ_t(x_s) ds )

2.2 数值稳定性

直接写 exp(-sigma_t * stepSize) 在 sigma_t 很小时会出现 1 - T → 0 的精度问题。标准做法是解析求解一步内的积分,避免「乘法累积」带来的偏差:

// HLSL:一步内的解析积分,能量更准确
float3 IntegrateScatteringWithinSegment(float3 sigmaS, float3 sigmaT, float stepSize) {
    // 单段透射率
    float3 transmittance = exp(-sigmaT * stepSize);
    // 单段散射积分:∫ sigmaS * exp(-sigmaT * s) ds, s in [0, stepSize]
    float3 integrated = (sigmaS / max(sigmaT, 1e-5)) * (1.0 - transmittance);
    return integrated;
}

2.3 各向异性消光

真实大气中不同波长的 σ_t 不同(瑞利散射 ∝ 1/λ⁴),这正是天空「蓝」而夕阳「红」的物理根源。工程上用 RGB 三通道分别存 σ_t:

通道相对散射强度(瑞利)视觉效果
R (680 nm)1.0长波,穿透力强
G (550 nm)2.4中等
B (440 nm)5.7短波,被强烈散射

一句话:瑞利散射的 λ⁻⁴ 依赖让蓝光散射得最厉害,于是白天天空是蓝的、日落时穿过更长大气的光只剩红橙。


三、相位函数:Henyey-Greenstein 与 Mie

一句话:相位函数决定「光朝哪个方向散得更多」,HG 用一个各向异性参数 g 同时表达前向、后向与均匀散射。

3.1 相位函数是什么

相位函数 p(ω, ω') 描述入射方向 ω' 的光散射到出射方向 ω 的概率分布,满足归一化 ∫_{S²} p dω = 1。各向同性散射时 p = 1/(4π)。

3.2 Henyey-Greenstein

HG 相位函数是实时渲染的事实标准,因为它是唯一能用单个参数 g 解析控制各向异性的常用近似:

p_HG(θ) = (1 - g²) / (4π · (1 + g² - 2g·cosθ)^{3/2})

g ∈ (-1, 1):g > 0 前向散射(光继续前进,如雾、云),g = 0 各向同性,g < 0 后向散射。

// GLSL:Henyey-Greenstein 相位函数
float phaseHG(float cosTheta, float g) {
    float g2 = g * g;
    float denom = 1.0 + g2 - 2.0 * g * cosTheta;
    return (1.0 - g2) / (4.0 * PI * pow(max(denom, 1e-4), 1.5));
}

// 云常用双叶 HG:前向瓣 + 后向瓣叠加,模拟多重散射
float phaseCloud(float cosTheta) {
    const float gF = 0.8;    // 前向
    const float gB = -0.5;   // 后向
    return mix(phaseHG(cosTheta, gF), phaseHG(cosTheta, gB), 0.5);
}

3.3 瑞利与 Mie

大气散射分为两类,对应不同相位函数:

类型粒子尺度相位函数视觉特征
瑞利散射≪ 波长(空气分子)3/(16π)(1+cos²θ)天空蓝、日落红
Mie 散射≈ 波长(气溶胶)HG,g ≈ 0.76太阳周围光晕
// GLSL:瑞利相位函数
float phaseRayleigh(float cosTheta) {
    return (3.0 / (16.0 * PI)) * (1.0 + cosTheta * cosTheta);
}

一句话:把瑞利相位(对称的 1+cos²θ)与 Mie 相位(强前向 HG)按高度混合,就能复现「天空蓝 + 太阳光晕」的完整大气。


四、大气散射 LUT

一句话:实时逐帧积分大气太贵,Bruneton 方案把散射预计算进几张 4D/3D LUT,运行时只做几次纹理查询。

4.1 Precomputed Atmospheric Scattering

Eric Bruneton 的方案把大气散射拆成两步预计算:

  1. Transmittance LUT(2D,256×64):存 (高度 r, 天顶角 μ) 到大气顶的透射率。
  2. Scattering LUT(4D,分解为 3D 32×128×32 + 2D):存某点朝某方向的总散射。

运行时对每条视线采样 LUT,得到天空颜色,成本从 O(N·M) 降到几次采样。

Transmittance LUT 参数化:
  u_r  = 归一化高度  (r - Rg) / (Rt - Rg)      // 地表到大气顶
  u_mu = 天顶角余弦 μ = cos(θ_view)
  存储值 = exp(-∫ σ_t ds)  三通道 RGB

4.2 运行时采样

// GLSL:从 LUT 采样天空颜色
vec3 computeSkyColor(vec3 worldDir, vec3 sunDir, float camHeight) {
    float r = length(camPos + worldDir * 0.0) ;      // 相机高度
    float mu = dot(normalize(worldDir), normalize(camPos));
    float uR = (r - Rg) / (Rt - Rg);
    vec3 transmittance = texture(transmittanceLut, vec2(mu * 0.5 + 0.5, uR)).rgb;

    // 散射 LUT:需要 (高度, 视线天顶角, 视线-太阳夹角)
    float muS = dot(normalize(worldDir), sunDir);
    vec3 inscatter = sampleScatteringLUT(r, mu, muS);

    return inscatter * sunIntensity;
}

4.3 简化替代方案

LUT 方案精度高但实现复杂。工程上还有更轻量的选择:

方案复杂度质量适用
单次散射解析低中移动端、风格化
双次散射近似中高多数 3A
Bruneton 4D LUT高最高写实天空、太空游戏
程序化天空盒极低低原型

五、云形状:噪声与 3D 纹理

一句话:云的形状来自噪声叠加——Worley 噪声提供团块状基础结构,Perlin/FBM 补充细节,再用高度梯度做侵蚀。

5.1 Worley(Cellular)噪声

Worley 噪声把空间划分为随机特征点的细胞,输出到最近特征点的距离。它天然产生团块状、蓬松的结构,正是云的形状:

// GLSL:3D Worley 噪声(F1 距离)
float worley3D(vec3 p) {
    vec3 cell = floor(p);
    vec3 local = fract(p);
    float minDist = 1.0;
    for (int x = -1; x <= 1; ++x)
    for (int y = -1; y <= 1; ++y)
    for (int z = -1; z <= 1; ++z) {
        vec3 neighbor = vec3(x, y, z);
        vec3 featurePoint = neighbor + hash3(cell + neighbor);
        float d = length(featurePoint - local);
        minDist = min(minDist, d);
    }
    return minDist;
}

5.2 FBM 叠加与侵蚀

把多层噪声按频率叠加(Fractal Brownian Motion),再用「高度梯度侵蚀」让云底部扁平、顶部蓬松:

// GLSL:云密度函数
float cloudDensity(vec3 pos) {
    // 高度归一化:0 = 云底, 1 = 云顶
    float heightFrac = (pos.y - cloudBottom) / (cloudTop - cloudBottom);
    // 高度梯度:底部和顶部衰减
    float heightGradient = smoothstep(0.0, 0.15, heightFrac)
                         * smoothstep(1.0, 0.65, heightFrac);
    if (heightGradient <= 0.0) return 0.0;

    // 基础形状:低频 Worley + Perlin
    float baseShape = worley3D(pos * 0.05) * 0.6 + fbm(pos * 0.1) * 0.4;
    // 侵蚀:高频噪声减去,让边缘破碎
    float erosion = fbm(pos * 0.4) * 0.35;
    // 高度重映射:让底部密度更高
    float density = remap(baseShape, erosion, 1.0, 0.0, 1.0) * heightGradient;

    return clamp(density, 0.0, 1.0);
}

5.3 天气图(Weather Map)

大尺度云分布用一张 2D 天气图控制:R 通道存覆盖率(coverage),G 存云类型,B 存云底高度,A 存风速。这让美术能像画地形一样「画」出天气。

通道含义影响
R云覆盖率决定哪里是晴天、哪里多云
G云类型(层云↔积云)影响高度梯度与密度曲线
B云底高度垂直偏移
A风向偏移时间演化

六、光照模型:多光源与能量守恒

一句话:云不是自发光体,它的亮度来自太阳直射(经相位函数调制)+ 环境光(天空、地面反射)+ 多次散射近似。

6.1 单次散射

对每个采样点,朝太阳方向再 march 一次(或采样光锥 LUT),得到直射光,用 Beer-Lambert 衰减:

// GLSL:单次散射光照
vec3 lightMarch(vec3 pos, vec3 sunDir) {
    float opticalDepth = 0.0;
    for (int i = 0; i < LIGHT_STEPS; ++i) {
        vec3 p = pos + sunDir * (i + 0.5) * lightStepSize;
        opticalDepth += sampleDensity(p) * lightStepSize;
    }
    float transmittance = exp(-opticalDepth * extinctionCoeff);
    // 相位函数调制
    float phase = phaseCloud(dot(rayDir, sunDir));
    return sunColor * transmittance * phase * sunIntensity;
}

6.2 多次散射近似

真实云是「厚」的,单次散射会让云过于暗、对比过强。工程近似:

  • Beer’s Powder 近似:用 1 - exp(-2·depth) 模拟粉末效应,让背光边缘透亮。
  • 多级散射 LUT:预计算 1~8 次散射的贡献,运行时按深度混合。
  • Ambient / Sky 项:加上天空环境光,避免阴影侧全黑。
// GLSL:Beer's Powder 近似,让云边缘透光
float powder = 1.0 - exp(-density * 2.0 * stepSize);
float beer = exp(-opticalDepth);
float light = mix(beer, beer * powder, 0.5);

6.3 阴影与自阴影

云对地面投射阴影可用云阴影贴图:从太阳视角渲染一张云密度图,地面着色时投影采样。

光照项来源近似手段
直射太阳光锥 march + 相位函数
环境天空/地面环境光探针 / SH
多次散射云内部Powder + 多级 LUT
阴影云自身云阴影贴图

一句话:让云「活」的关键不是单次散射,而是把粉末效应、环境光与多次散射近似叠加,避免暗部死黑。


七、性能优化

一句话:体积云的性能瓶颈是采样次数,优化核心是「减少像素 × 减少步数 × 时间上摊开」。

7.1 降采样

云是低频信号,可以在一半甚至四分之一分辨率渲染,再与全分辨率场景合成:

// C++:UE5 风格的体积云分辨率控制
struct FVolumetricCloudParams {
    float ResolutionScale = 0.5f;   // 半分辨率
    int32 MaxRaymarchSteps = 64;
    int32 LightMarchSteps = 6;
    bool  bTemporalReprojection = true;
    bool  bCheckerboard = true;     // 棋盘渲染,隔行更新
};

7.2 时间重投影(Temporal Reprojection)

每帧只更新部分像素,其余用运动向量从上一帧重投影,配合邻域钳制(neighborhood clamp)抑制拖影。这把有效采样预算放大了数倍。

7.3 优化清单

优化收益风险
降采样 1/2 或 1/44×~16× 填充率边缘锯齿
时间重投影有效采样 ×N拖影、鬼影
棋盘更新采样减半需重建
早期退出视 T 动态停无
空域跳跃(SDF)大步跳过空洞需距离场
蓝噪声抖动条带消失噪点需 TAA

7.4 与其他系统的协同

体积云要读取深度做遮挡、写深度做 TAA,还要和半透明排序协调。在延迟/前向混合管线里,通常安排在不透明几何之后、半透明之前,并用单独的低分辨率 buffer 参与降噪。更多后期管线细节可参考 https://plumephp.com/graphics-post-processing/。


八、常见问题与工程建议(FAQ)

Q:云看起来太「平」怎么办?
A:检查高度梯度曲线与侵蚀强度。底部密度应高于顶部,边缘用高频噪声打碎;同时提高多次散射近似强度,让背光透亮。

Q:Raymarching 有雪花噪点?
A:这是蓝噪声抖动与步长不足的必然产物,需接 TAA/降噪器;也可增大每像素采样数(性能换质量)。

Q:大气在相机高空时穿帮?
A:LUT 方案要注意近地表与太空的过渡,Bruneton 方案需处理 r < Rg 的钳制与地平面遮挡。

Q:移动端能做体积云吗?
A:可以,但要用极简模型:低频 3D 纹理 + 16~24 步 march + 无光照 march 或预烘焙光照。

Q:云和半透明物体排序冲突?
A:云应参与深度写入或单独降噪,半透明物体按「云前 / 云后」分层,避免混合顺序错误。

一句话:体积云没有「银弹」,只有「物理正确的光照模型 + 保守的分辨率 + 时间上的摊销」三者权衡。


总结

体积云与大气散射是实时渲染里少见的「必须理解物理才能做好」的效果。本文的脉络可以归纳为:

  1. 方程层:体积渲染方程把「表面着色」换成沿视线积分,Raymarching 是它的离散实现。
  2. 物理层:Beer-Lambert 决定衰减、相位函数决定方向分布,二者共同决定介质如何与光交互。
  3. 数据层:大气散射用 LUT 预计算换运行时效率;云的形状来自 Worley/Perlin 噪声与天气图。
  4. 光照层:单次散射不够,需要粉末效应、环境光与多次散射近似叠加。
  5. 工程层:降采样、时间重投影、蓝噪声抖动与早期退出共同把成本压进帧预算。

掌握这套体系后,你不仅能做出写实的云海与霞光,也能把同样的体积思维迁移到雾、烟、体积光(God Ray)乃至体积 GI 的实现中。下一步建议结合 https://plumephp.com/graphics-raytracing-hybrid/ 了解体积渲染与光追的协同,理解它在整个渲染管线中的位置。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 《物理模拟与渲染集成:固定步长、插值与确定性同步》
  2. 《色彩管理与 HDR 管线:线性工作流、ACES 色调映射与宽色域》
  3. 《可见性剔除系统:视锥、BVH、Hi-Z 与 GPU Driven 剔除》