天空、云层与大气是场景「氛围」的来源,却也是传统光栅化管线最难处理的部分——它们没有明确表面,光在介质内部边行进边被吸收和散射。要渲染云与大气,必须放弃「求交 + 着色」的表面模型,转而求解体积渲染方程。本文从介质物理出发,串联起 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 的方案把大气散射拆成两步预计算:
- Transmittance LUT(2D,
256×64):存(高度 r, 天顶角 μ)到大气顶的透射率。 - 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/4 | 4×~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:云应参与深度写入或单独降噪,半透明物体按「云前 / 云后」分层,避免混合顺序错误。
一句话:体积云没有「银弹」,只有「物理正确的光照模型 + 保守的分辨率 + 时间上的摊销」三者权衡。
总结
体积云与大气散射是实时渲染里少见的「必须理解物理才能做好」的效果。本文的脉络可以归纳为:
- 方程层:体积渲染方程把「表面着色」换成沿视线积分,Raymarching 是它的离散实现。
- 物理层:Beer-Lambert 决定衰减、相位函数决定方向分布,二者共同决定介质如何与光交互。
- 数据层:大气散射用 LUT 预计算换运行时效率;云的形状来自 Worley/Perlin 噪声与天气图。
- 光照层:单次散射不够,需要粉末效应、环境光与多次散射近似叠加。
- 工程层:降采样、时间重投影、蓝噪声抖动与早期退出共同把成本压进帧预算。
掌握这套体系后,你不仅能做出写实的云海与霞光,也能把同样的体积思维迁移到雾、烟、体积光(God Ray)乃至体积 GI 的实现中。下一步建议结合 https://plumephp.com/graphics-raytracing-hybrid/ 了解体积渲染与光追的协同,理解它在整个渲染管线中的位置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。