前言

体积光(Volumetric Lighting),也称为”God Rays”或”丁达尔效应”,是指光线穿过有雾气、灰尘或水汽的空间时,光束本身变得可见的现象。这是游戏和电影中常用的视觉效果,能够极大地增强场景的真实感和氛围感。

今天我们用C++实现这个效果,不依赖任何图形库,纯CPU渲染。

效果展示

体积光对比

  • 左图:普通渲染(只有表面光照)
  • 中图:体积光渲染(光束清晰可见)
  • 右图:差异热力图(差异均值39.33)

可以看到,光束从右上方的光源穿过三个球体之间的缝隙,形成了经典的”God Rays”效果。

技术原理

什么是体积光?

在真实世界中,我们之所以能看到光束,是因为:

  1. 散射(Scattering):光子碰到空气中的微粒(雾气、灰尘、水汽)后向各个方向散射
  2. 吸收(Absorption):部分光能被微粒吸收
  3. 方向性:散射后的光子可能射向我们的眼睛,让我们”看到”光束

Ray Marching算法

实现体积光的关键是Ray Marching(光线步进)

1
2
3
4
5
6
7
1. 从相机发射一条射线
2. 沿着射线以固定步长前进
3. 在每个采样点:
a. 检查该点是否能看到光源(阴影测试)
b. 如果可见,计算该点的散射光强度
c. 累积到最终颜色
4. 返回累积的颜色

用公式表示:

$$
L_{total} = \sum_{i=0}^{N} L_{scatter}(p_i) \cdot T(p_i) \cdot \Delta t
$$

其中:

  • $L_{scatter}(p_i)$:采样点 $p_i$ 的散射光强度
  • $T(p_i)$:透射率(考虑遮挡和衰减)
  • $\Delta t$:步长
  • $N$:步进次数

核心代码实现

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
30
Vec3 volumetric_light(const Ray& ray, const Scene& scene, double max_dist) {
const int NUM_STEPS = 60; // 步进次数
const double SCATTERING = 0.03; // 散射系数(雾的密度)

double step_size = max_dist / NUM_STEPS;
Vec3 accumulated(0, 0, 0);

// 沿着射线步进
for (int i = 0; i < NUM_STEPS; i++) {
double t = (i + 0.5) * step_size;
Vec3 sample_pos = ray.at(t);

// 计算该点到光源的方向和距离
Vec3 to_light = scene.light_pos - sample_pos;
double light_dist = to_light.length();
Vec3 light_dir = to_light.normalize();

// 阴影测试:检查是否被遮挡
if (!scene.is_occluded(sample_pos, light_dir, light_dist)) {
// 距离平方衰减
double atten = 1.0 / (1.0 + 0.02 * light_dist * light_dist);

// 累积散射光
double scatter = SCATTERING * step_size * atten;
accumulated = accumulated + scene.light_color * scatter;
}
}

return accumulated.clamp();
}

2. 阴影测试(遮挡检测)

1
2
3
4
5
6
7
8
9
10
11
bool is_occluded(const Vec3& point, const Vec3& light_dir, double light_dist) const {
Ray shadow_ray(point, light_dir);
double t;
Vec3 dummy_color, dummy_normal;

// 检查从采样点到光源的路径上是否有遮挡物
if (intersect(shadow_ray, t, dummy_color, dummy_normal)) {
return t < light_dist - 0.01; // 击中了遮挡物
}
return false; // 没有遮挡,可以看到光源
}

3. 主渲染循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
for (int j = 0; j < HEIGHT; j++) {
for (int i = 0; i < WIDTH; i++) {
// 生成射线
Vec3 ray_dir = /* 根据像素位置计算方向 */;
Ray ray(camera_pos, ray_dir);

Vec3 final_color(0, 0, 0);
double t;
Vec3 surface_color, normal;

if (scene.intersect(ray, t, surface_color, normal)) {
// 击中物体:渲染表面 + 体积光(相机到表面)
Vec3 hit_point = ray.at(t);
final_color = simple_shading(hit_point, normal, surface_color,
scene.light_pos, scene.light_color);
final_color = final_color + volumetric_light(ray, scene, t);
} else {
// 未击中物体:只渲染体积光
final_color = volumetric_light(ray, scene, 15.0);
}

pixels[j * WIDTH + i] = final_color.clamp();
}
}

关键参数调优

1. 散射系数(SCATTERING)

控制雾气的密度,越大光束越明显,但也容易过曝。

效果
0.01 微弱,几乎看不见
0.03 适中,清晰可见 ✅
0.08 较强,开始过亮
0.20 过强,图片接近全白

2. 步进次数(NUM_STEPS)

平衡性能和质量:

渲染时间 效果
32 0.7秒 有条纹感
60 1.4秒 平滑自然 ✅
100 2.3秒 提升不明显

3. 光照强度

1
light_color = Vec3(1.0, 0.95, 0.85) * 1.2;  // 1.2倍亮度

太强会导致过曝,太弱则光束不明显。

4. 衰减函数

1
2
3
4
5
// 距离平方衰减(物理准确)
double atten = 1.0 / (1.0 + 0.02 * dist * dist);

// 线性衰减(简单但不真实)
double atten = 1.0 / (1.0 + 0.1 * dist);

距离平方衰减更符合物理规律,但也需要更高的光强来补偿。

技术挑战与解决

挑战1:过曝问题

现象:初始渲染结果全白(RGB均值255)

原因

  • 散射系数过大(0.2)
  • 光照强度过高(3.0倍)
  • 累积的散射光超过了1.0的上限

解决

1
2
3
4
5
6
7
// 降低散射系数
SCATTERING: 0.20.080.03

// 降低光照强度
light_color: * 3.0 → * 1.2

// 结果:均值从255降到56.40

挑战2:性能优化

目标:保持效果的同时缩短渲染时间

方法

  1. 减少步进次数(80 → 60)
  2. 提前退出:如果透射率太低,停止累积
  3. 使用-O3编译优化

效果:1200×800分辨率,1.4秒完成

挑战3:平衡光束与场景

如果体积光太强,会掩盖场景本身的细节;太弱则看不出效果。

最终平衡

  • 表面光照均值:17.06(深色背景)
  • 体积光均值:56.40(光束清晰)
  • 差异均值:39.33(效果明显但不过分)

扩展思路

1. 彩色光源

1
2
3
4
5
// 暖色光源(日落)
Vec3 warm_light = Vec3(1.0, 0.7, 0.4) * 1.5;

// 冷色光源(月光)
Vec3 cool_light = Vec3(0.6, 0.7, 1.0) * 0.8;

2. 多光源混合

1
2
3
4
Vec3 total_scatter(0, 0, 0);
for (const auto& light : scene.lights) {
total_scatter += compute_scattering(sample_pos, light);
}

3. 时变效果

1
2
3
4
// 光束随时间移动
double time = /* 当前时间 */;
light_pos.x = 5.0 + 2.0 * sin(time * 0.5);
light_pos.y = 4.0 + 1.0 * cos(time * 0.3);

4. 后处理方法

除了Ray Marching,还可以用屏幕空间的径向模糊实现类似效果(性能更高,但不如Ray Marching准确)。

性能对比

方法 分辨率 时间 质量
Ray Marching (60步) 1200×800 1.4s 高 ✅
Ray Marching (32步) 1200×800 0.7s
后处理径向模糊 1200×800 0.2s 中低

对于离线渲染(如本项目),Ray Marching是最佳选择。对于实时渲染(游戏),通常会用GPU实现Ray Marching或后处理方法。

数学推导(可选)

散射方程

体积渲染方程(简化版):

$$
L(s) = L_0 \cdot T(s) + \int_0^s \sigma_s L_i(t) T(t) dt
$$

其中:

  • $L(s)$:到达相机的光强
  • $L_0$:背景光
  • $T(s)$:透射率,$T(s) = e^{-\int_0^s \sigma_t dt}$
  • $\sigma_s$:散射系数
  • $\sigma_t$:消光系数(散射+吸收)
  • $L_i(t)$:入射光强

离散化后:

$$
L \approx \sum_{i=0}^N \sigma_s L_i(t_i) e^{-\sigma_t t_i} \Delta t
$$

这就是我们代码中的累积公式。

总结

通过这个项目,我们:

  1. 实现了真实的体积光效果(丁达尔效应/God Rays)
  2. 掌握了Ray Marching算法(光线步进采样)
  3. 理解了散射、遮挡、衰减的物理原理
  4. 学会了参数调优(散射系数、步进数、光照强度)
  5. 解决了过曝问题(从全白到合理的明暗对比)

最终效果

  • 差异均值:39.33(效果明显)
  • 渲染时间:1.4秒(1200×800)
  • 光束清晰可见,穿过球体间隙形成God Rays

项目信息

参考资料


下期预告:可能会尝试流体模拟(SPH)或布料模拟,敬请期待!