前言

普通的 3D 渲染处理的是表面——三角形、球面、平面——光线打到表面就结束了。

但云、雾、火焰、体积光……这些东西没有明确的表面。光线穿进去,在里面反复散射,最终才到达眼睛。这类渲染统称体积渲染(Volume Rendering)

今天我们用 C++ 从零实现一个体积云渲染器,效果如下:

体积云渲染效果

可以看到云层有明显的厚薄变化云底阴影受光面亮背光面暗的立体感。

整个项目涉及 4 个核心技术,我们逐一讲清楚:

  1. Ray Marching — 如何穿越体积
  2. Perlin FBM — 如何生成云的形状
  3. Beer-Lambert — 如何模拟光被吸收
  4. Henyey-Greenstein 相位函数 — 如何模拟散射方向

一、Ray Marching:像走路一样穿越体积

原理

表面渲染里,光线打到三角形就有精确的交点。但体积没有”表面”,怎么求交?

Ray Marching 的答案很简单:沿光线方向一步一步走,每走一步就采样一次。

1
2
3
起点 ──→ ● ● ● ● ● ● ● → 终点
↑ ↑ ↑ ↑ ↑ ↑
每步采样一次密度场

伪代码:

1
2
3
4
5
6
t = t_start
while t < t_end:
pos = ray_origin + ray_dir * t // 当前位置
density = sampleDensity(pos) // 采样该点的密度
color += computeScattering(...) // 累积光照贡献
t += step_size // 前进一步

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Vec3 raymarch(const Vec3& origin, const Vec3& dir) const {
// 先和包围盒求交,确定步进范围 [tmin, tmax]
float tmin, tmax;
if (!intersectAABB(origin, dir, bMin, bMax, tmin, tmax)) {
return skyColor(dir); // 光线没打到云区域,直接返回天空色
}

const float STEP_SIZE = 0.35f; // 每步0.35个单位
const int MAX_STEPS = 180; // 最多走180步

Vec3 accColor(0, 0, 0); // 累积的颜色
float transmittance = 1.0f; // 当前透射率(初始=1,完全透明)

float t = tmin;
for (int step = 0; step < MAX_STEPS && t < tmax; step++) {
Vec3 pos = origin + dir * t;
float d = volume.density(pos); // 采样密度

if (d > 0.002f) {
// ... 计算光照,见后续章节
}

t += STEP_SIZE; // 前进一步
}

// 最终颜色 = 天空背景 × 剩余透射率 + 累积的云散射光
return skyColor(dir) * transmittance + accColor;
}

关键变量含义:

  • transmittance:光线到达当前位置还剩多少”透明度”。从1.0开始,穿过越多云就越小
  • accColor:沿途每步散射进来的光的累积
  • 最后混合:透射率越低(云越厚),天空背景越看不见,云的颜色越突出

二、Perlin FBM:生成云的形状

原理:为什么用噪声?

云的形状是随机但有结构的——大块形状 + 中等卷曲 + 细小絮状。

单纯的随机数(白噪声)没有任何结构;纯几何体太规则。Perlin 噪声恰好在两者之间:连续、平滑,但有随机感。

更进一步,**FBM(分形布朗运动)**把多个不同频率的 Perlin 噪声叠加,产生分形感:

1
2
FBM = 低频噪声×0.5 + 中频噪声×0.25 + 高频噪声×0.125 + ...
↑大形状 ↑中等细节 ↑细微纹理

频率越高振幅越小,这和自然界里”大波浪叠加小波纹”的规律一致。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float fbm(float x, float y, float z, int octaves = 6) const {
float val = 0.0f;
float amplitude = 0.5f; // 第一层振幅
float frequency = 1.0f; // 第一层频率
float maxVal = 0.0f; // 用于归一化

for (int i = 0; i < octaves; i++) {
// 每层:采样当前频率的Perlin噪声,乘以振幅
val += noise(x*frequency, y*frequency, z*frequency) * amplitude;
maxVal += amplitude;

amplitude *= 0.5f; // 下一层振幅减半
frequency *= 2.0f; // 下一层频率翻倍
}
return val / maxVal; // 归一化到 [-1, 1]
}

把噪声变成云的密度

原始 FBM 输出 [-1, 1],需要处理成密度场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
float density(const Vec3& p) const {
// 1. 高度遮罩:只在云层高度范围内有云
float yBottom = smoothstep(-2.0f, 0.5f, p.y); // 云底软边界
float yTop = 1.0f - smoothstep(1.5f, 4.5f, p.y); // 云顶软边界
float heightMask = yBottom * yTop;
if (heightMask < 0.001f) return 0.0f;

// 2. 大形状(低频,3倍频):决定云团的整体位置
float shape = noise.fbm(p.x*0.05f, p.y*0.08f, p.z*0.05f, 3);
shape = (shape + 1.0f) * 0.5f; // 映射到 [0, 1]

// 3. 中频细节(4倍频):云的卷曲边缘
float detail = noise.fbm(p.x*0.18f, p.y*0.25f, p.z*0.18f, 4);
detail = (detail + 1.0f) * 0.5f;

// 4. 高频细节(3倍频):表面絮状纹理
float fine = noise.fbm(p.x*0.55f, p.y*0.70f, p.z*0.55f, 3);
fine = (fine + 1.0f) * 0.5f;

// 5. 组合:大形状主导,细节补充变化感
float combined = shape*0.60f + detail*0.28f + fine*0.12f;

// 6. 密度截断 + 幂次增强对比度
// combined > 0.46 的地方才有云,其余都是晴空
float d = std::max(0.0f, combined - 0.46f) / 0.54f;
d = std::pow(d, 1.6f); // 幂次:云心浓、边缘薄

return d * heightMask * 3.5f;
}

参数调优直觉:

参数 作用 调大效果
threshold(0.46) 云的稀疏度 云更少但更扎实
pow(d, 1.6f) 指数 云的软硬边界 指数越大,边缘越清晰
密度乘数(3.5) 云的整体厚度 云越厚越不透明
yBottom/yTop 范围 云层高度 控制云层厚度

三、Beer-Lambert 定律:光如何被云吸收

物理原理

现实中,光穿过有雾气的介质时会被逐渐吸收和散射,越深越暗。这个规律叫 Beer-Lambert 定律:

$$T = e^{-\sigma \cdot d}$$

其中:

  • $T$:透射率(0=完全不透明,1=完全透明)
  • $\sigma$:吸收系数(材质本身的属性)
  • $d$:路径长度

举个例子:如果 $\sigma = 1.8$,穿过 1 单位长度的云后,透射率 = $e^{-1.8} \approx 0.165$,只剩 16.5% 的光能穿过。

代码实现

在 Ray Marching 的每一步,我们根据当前采样到的密度更新透射率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (d > 0.002f) {
const float ABSORPTION = 1.8f; // 吸收系数

// Beer-Lambert:这一步的透射率
// d=密度,STEP_SIZE=步长,ABSORPTION=吸收系数
float sampleT = std::exp(-d * STEP_SIZE * ABSORPTION);

// 这一步的光照贡献量
// "contribution" = 这段云散射出多少光 × 它前面有多透明
float contribution = (1.0f - sampleT) * transmittance;
accColor += scatterColor * contribution;

// 更新全局透射率:乘以本步透射率
transmittance *= sampleT;

// 优化:透射率 < 0.5%,后面全是不透明的云,可以提前退出
if (transmittance < 0.005f) break;
}

直觉理解

  • 1.0f - sampleT:这一步”消耗掉”的光,也就是被散射出来的
  • 乘以 transmittance:只有能穿透前面所有云的光,才能到达这步被散射

把每步的贡献累加,就是完整的积分。


四、Henyey-Greenstein 相位函数:散射方向

原理

光打到云的微粒,会向各个方向散射。但不是均匀散射——云有前向散射性,意思是光更容易朝原来的方向继续传播(向前散射),而不是反弹回去。

Henyey-Greenstein(HG)相位函数描述这种方向分布:

$$p(\theta) = \frac{1 - g^2}{4\pi (1 + g^2 - 2g\cos\theta)^{3/2}}$$

其中:

  • $\theta$:散射角(入射光和出射光的夹角)
  • $g$:各向异性参数,取值 [-1, 1]
    • $g = 0$:均匀散射(各向同性)
    • $g > 0$:前向散射(朝光源方向看更亮)
    • $g < 0$:后向散射(背对光源时更亮)

云的 $g$ 通常取 0.3 ~ 0.5(轻微前向散射)。

代码实现

1
2
3
4
5
6
7
8
9
// 计算光线方向和太阳方向的夹角 cosine
float cosTheta = dir.dot(sunDir); // dir=视线方向,sunDir=太阳方向

float g = 0.35f; // 各向异性参数(云:轻微前向散射)
float denom = 1.0f + g*g - 2.0f*g*cosTheta;
float phase = (1.0f - g*g) / (4.0f*M_PI * std::pow(denom, 1.5f));

// 归一化调整(让数值在合理范围内)
phase = clamp(phase * 4.5f, 0.08f, 5.0f);

直观效果

  • 当视线背对太阳时($\cos\theta \approx -1$),相位函数值小 → 云看起来更暗
  • 当视线朝向太阳时($\cos\theta \approx 1$),相位函数值大 → 云边缘有光晕(”银边”效果)

五、自遮蔽阴影:云为什么有暗色底部?

原理

真实的云,底部比顶部暗。原因是:太阳光从上方照来,要穿过厚厚的云层才能到达云底,被吸收了很多,所以云底暗。

在 Ray Marching 中模拟这个效果的方法:从当前采样点额外向太阳方向再走一段,统计路径上的云密度,用 Beer-Lambert 计算有多少阳光能到达这里。

1
2
3
4
太阳 ↓↓↓↓↓↓
████████ ← 云层上部(遮挡阳光)

采样点 ← 我们在这里,想知道有多少阳光能到达

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float lightTransmittance(const Vec3& pos) const {
const int SHADOW_STEPS = 8; // 向太阳方向走8步
const float SHADOW_STEP_SIZE = 0.6f; // 每步0.6单位
float shadowAbs = 0.0f;

for (int i = 0; i < SHADOW_STEPS; i++) {
// 沿太阳方向的采样点
Vec3 shadowPos = pos + sunDir * (SHADOW_STEP_SIZE * (i + 0.5f));
// 累积路径上的密度(吸收量)
shadowAbs += volume.density(shadowPos) * SHADOW_STEP_SIZE;
}

// Beer-Lambert:从太阳到当前点的透射率
// 吸收系数用 3.0(比主步进的1.8更大,让云底更暗)
return std::exp(-shadowAbs * 3.0f);
}

然后在主步进里,把阳光乘以这个透射率:

1
2
3
4
float lightT = lightTransmittance(pos);   // 阳光能到达这里多少?

// 散射颜色 = 太阳颜色 × 光照强度 × 阳光透射率 × 相位函数
Vec3 scatterColor = sunColor * (sunIntensity * lightT * phase);

直观效果

  • 云的顶部:lightT 接近 1.0,很亮
  • 云的底部:需要穿过大量云层,lightT 很小(接近 0),很暗

六、完整流程串联

把上面所有模块串起来,完整的 Ray Marching 流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
对每个像素:
1. 从相机发射光线
2. 与云层包围盒求交 → 确定步进范围 [tmin, tmax]
3. 沿光线步进:
for t in [tmin, tmax]:
pos = origin + dir * t
d = density(pos) // Perlin FBM 密度场
if d > 0:
lightT = lightTransmittance(pos) // 自遮蔽阴影
phase = HenyeyGreenstein(...) // 散射方向
scatterColor = sun * lightT * phase + ambient
sampleT = exp(-d * stepSize * absorption) // Beer-Lambert
contribution = (1 - sampleT) * transmittance
accColor += scatterColor * contribution
transmittance *= sampleT
if transmittance < 0.005: break // 提前退出
4. 最终颜色 = sky * transmittance + accColor
5. ACES 色调映射 + Gamma 校正 → 输出像素

七、参数调优经验

这是整个项目最花时间的部分。主要遇到了以下问题:

问题1:云太均匀、看不出立体感

原因:密度阈值太低,整片空间都有薄薄的云,没有明确的”实心云团”。

解法

1
2
3
4
5
6
// ❌ 阈值太低:0.42,到处都是薄云
float d = max(0, combined - 0.42f) / 0.58f;

// ✅ 阈值提高到 0.46,加幂次增强对比
float d = max(0, combined - 0.46f) / 0.54f;
d = pow(d, 1.6f); // 幂次:让中心浓、边缘稀

问题2:光照太平、云底不暗

原因:阴影采样的吸收系数太小,阳光能轻松穿透所有云层。

解法

1
2
3
4
5
// ❌ 吸收系数太小:1.8
return exp(-shadowAbs * 1.8f);

// ✅ 增大到 3.0,云底明显更暗
return exp(-shadowAbs * 3.0f);

问题3:视角太近,看不出宏大感

解法:相机后移 + 减小FOV(相当于”望远镜效果”):

1
2
3
4
5
6
7
// ❌ 近距离,大视角
Vec3 camOrigin(0, 1.5f, 8.0f); // z=8,距离近
float fovY = 60.0f; // 60°,广角

// ✅ 远距离,小视角
Vec3 camOrigin(0, -1.5f, 20.0f); // z=20,距离远
float fovY = 45.0f; // 45°,更像望远镜

参数速查表

参数 当前值 调大效果 调小效果
密度阈值 0.46 云更少更扎实 云更多更蓬松
密度幂次 1.6 边缘更清晰 边缘更模糊
主步进吸收 1.8 云更不透明 云更透明
阴影吸收 3.0 云底更暗 云底更亮
步长 0.35 渲染更快但有噪点 更平滑但更慢
太阳 g 值 0.35 前向散射更强 更均匀散射

总结

体积云渲染的核心思路:把”穿越体积”分解成很多小步,每步采样密度、计算光照,最后累积

4 个关键技术缺一不可:

  • Ray Marching — 解决”如何遍历体积”
  • Perlin FBM — 解决”云长什么样”
  • Beer-Lambert — 解决”光如何减弱”
  • HG 相位函数 — 解决”散射往哪个方向”

有了这个基础,可以继续扩展:加入时间变量让云飘动、实现大气散射、支持彩霞日落等效果。

项目信息

参考资料