Ray Marching 体积云渲染:从物理原理到代码实现(C++)
前言
普通的 3D 渲染处理的是表面——三角形、球面、平面——光线打到表面就结束了。
但云、雾、火焰、体积光……这些东西没有明确的表面。光线穿进去,在里面反复散射,最终才到达眼睛。这类渲染统称体积渲染(Volume Rendering)。
今天我们用 C++ 从零实现一个体积云渲染器,效果如下:

可以看到云层有明显的厚薄变化、云底阴影、受光面亮背光面暗的立体感。
整个项目涉及 4 个核心技术,我们逐一讲清楚:
- Ray Marching — 如何穿越体积
- Perlin FBM — 如何生成云的形状
- Beer-Lambert — 如何模拟光被吸收
- Henyey-Greenstein 相位函数 — 如何模拟散射方向
一、Ray Marching:像走路一样穿越体积
原理
表面渲染里,光线打到三角形就有精确的交点。但体积没有”表面”,怎么求交?
Ray Marching 的答案很简单:沿光线方向一步一步走,每走一步就采样一次。
1 | 起点 ──→ ● ● ● ● ● ● ● → 终点 |
伪代码:
1 | t = t_start |
代码实现
1 | Vec3 raymarch(const Vec3& origin, const Vec3& dir) const { |
关键变量含义:
transmittance:光线到达当前位置还剩多少”透明度”。从1.0开始,穿过越多云就越小accColor:沿途每步散射进来的光的累积- 最后混合:透射率越低(云越厚),天空背景越看不见,云的颜色越突出
二、Perlin FBM:生成云的形状
原理:为什么用噪声?
云的形状是随机但有结构的——大块形状 + 中等卷曲 + 细小絮状。
单纯的随机数(白噪声)没有任何结构;纯几何体太规则。Perlin 噪声恰好在两者之间:连续、平滑,但有随机感。
更进一步,**FBM(分形布朗运动)**把多个不同频率的 Perlin 噪声叠加,产生分形感:
1 | FBM = 低频噪声×0.5 + 中频噪声×0.25 + 高频噪声×0.125 + ... |
频率越高振幅越小,这和自然界里”大波浪叠加小波纹”的规律一致。
代码实现
1 | float fbm(float x, float y, float z, int octaves = 6) const { |
把噪声变成云的密度
原始 FBM 输出 [-1, 1],需要处理成密度场:
1 | float density(const Vec3& p) const { |
参数调优直觉:
| 参数 | 作用 | 调大效果 |
|---|---|---|
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 | if (d > 0.002f) { |
直觉理解:
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 | // 计算光线方向和太阳方向的夹角 cosine |
直观效果:
- 当视线背对太阳时($\cos\theta \approx -1$),相位函数值小 → 云看起来更暗
- 当视线朝向太阳时($\cos\theta \approx 1$),相位函数值大 → 云边缘有光晕(”银边”效果)
五、自遮蔽阴影:云为什么有暗色底部?
原理
真实的云,底部比顶部暗。原因是:太阳光从上方照来,要穿过厚厚的云层才能到达云底,被吸收了很多,所以云底暗。
在 Ray Marching 中模拟这个效果的方法:从当前采样点额外向太阳方向再走一段,统计路径上的云密度,用 Beer-Lambert 计算有多少阳光能到达这里。
1 | 太阳 ↓↓↓↓↓↓ |
代码实现
1 | float lightTransmittance(const Vec3& pos) const { |
然后在主步进里,把阳光乘以这个透射率:
1 | float lightT = lightTransmittance(pos); // 阳光能到达这里多少? |
直观效果:
- 云的顶部:
lightT接近 1.0,很亮 - 云的底部:需要穿过大量云层,
lightT很小(接近 0),很暗
六、完整流程串联
把上面所有模块串起来,完整的 Ray Marching 流程是:
1 | 对每个像素: |
七、参数调优经验
这是整个项目最花时间的部分。主要遇到了以下问题:
问题1:云太均匀、看不出立体感
原因:密度阈值太低,整片空间都有薄薄的云,没有明确的”实心云团”。
解法:
1 | // ❌ 阈值太低:0.42,到处都是薄云 |
问题2:光照太平、云底不暗
原因:阴影采样的吸收系数太小,阳光能轻松穿透所有云层。
解法:
1 | // ❌ 吸收系数太小:1.8 |
问题3:视角太近,看不出宏大感
解法:相机后移 + 减小FOV(相当于”望远镜效果”):
1 | // ❌ 近距离,大视角 |
参数速查表
| 参数 | 当前值 | 调大效果 | 调小效果 |
|---|---|---|---|
| 密度阈值 | 0.46 | 云更少更扎实 | 云更多更蓬松 |
| 密度幂次 | 1.6 | 边缘更清晰 | 边缘更模糊 |
| 主步进吸收 | 1.8 | 云更不透明 | 云更透明 |
| 阴影吸收 | 3.0 | 云底更暗 | 云底更亮 |
| 步长 | 0.35 | 渲染更快但有噪点 | 更平滑但更慢 |
| 太阳 g 值 | 0.35 | 前向散射更强 | 更均匀散射 |
总结
体积云渲染的核心思路:把”穿越体积”分解成很多小步,每步采样密度、计算光照,最后累积。
4 个关键技术缺一不可:
- Ray Marching — 解决”如何遍历体积”
- Perlin FBM — 解决”云长什么样”
- Beer-Lambert — 解决”光如何减弱”
- HG 相位函数 — 解决”散射往哪个方向”
有了这个基础,可以继续扩展:加入时间变量让云飘动、实现大气散射、支持彩霞日落等效果。
项目信息
- 完整代码:GitHub - 03-05-Volume-Rendering-Ray-Marching
- 编译运行:
1
2
3g++ -std=c++17 -O3 main_v2.cpp -o volume_v2
./volume_v2
# 约 40 秒渲染完成(1200×675)










