Files
XCEngine/docs/plan/毕设/一 体积渲染入门.md

905 lines
69 KiB
Markdown
Raw Normal View History

# 一 体积渲染入门
**原教程链接:** [https://www.scratchapixel.com/lessons/3d-basic-rendering/volume-rendering-for-developers/intro-volume-rendering.html](https://www.scratchapixel.com/lessons/3d-basic-rendering/volume-rendering-for-developers/intro-volume-rendering.html)
本课程聚焦于通过**光线步进ray-marching技术实现体绘制。虽然该方法未必过时但现代生产级渲染软件更倾向于使用delta tracking 方法**。不过,光线步进曾长期作为这些软件渲染体积的行业标准,因此将其作为入门学习内容并无不妥。我认为读者可以从此入手,因为它更简单易懂,能帮助大家更好地掌握后续高级方法所依赖的数学基础。
体绘制(严格来说,用 “参与介质” 替代 “体积” 这一术语更为准确)是一个与硬表面渲染同样庞大且复杂的领域。它拥有一套独立的方程体系,实际上可以看作是描述光线与固体物质相互作用方程的推广。对于不熟悉复杂数学公式的读者而言,这些内容可能会让人望而生畏。遵循 Scratchapixel 一贯的教学理念,我们采用**“自下而上” 的实践教学法**。也就是说,我们不会从复杂方程入手,而是先编写代码渲染一个简单的**体积球体**,在过程中以直观易懂的方式讲解相关概念,最后在课程末尾对所学内容进行总结和形式化梳理。
![](images/2026/02/08/20260208_131118_296.png)
---
# 一、体积渲染
## 引言
本课程前三章目标是学习如何渲染一个球形体积,该体积由单个光源照射,背景为纯色。这将帮助我们建立对体积的直观认知,并引入用于渲染体积的光线步进算法。在本章中,我们将仅渲染密度均匀的基础体积,暂时忽略物体外部或体积内部投射的阴影,以及密度变化的体积渲染。这些内容将在后续章节中探讨。我们将跳过大量关于体积定义和渲染方程的背景知识,**直接从实践入手**,通过实现过程逐步构建对体绘制的系统性理解。
## 内部透射率、吸收、粒子密度与比尔定律
当物体反射的光或光源发出的光穿过充满粒子的空间时,其能量会被吸收。体积内的粒子越多,体积就越不透明。基于这一简单观察,我们可以确立体绘制的几个核心概念:**吸收、透射**,以及**体积不透明度**与**内部粒子密度**的关系。本章中,我们假设体积内的粒子密度是均匀的。
![](images/2026/02/08/20260208_131118_342.webp)
当光线穿过体积射向人眼(这正是我们能看到物体成像的原理)时,部分光会被体积吸收,这一现象称为**吸收**。目前我们关注的是背景光穿过体积后的透射量,这一指标被称为**内部透射率**,即光线穿过体积时被吸收后的剩余光量。内部透射率的取值范围为 0 到 10 表示体积完全阻挡光线1 表示在真空环境中光线完全透射)。
光线穿过体积的透射量遵循**比尔 - 朗伯定律**(简称比尔定律)。在该定律中,密度的概念通过**吸收系数**(后续章节还会引入散射系数)来体现。本质上,“体积密度越大,吸收系数越高”,而吸收系数越高,体积的不透明度也随之增加。比尔 - 朗伯定律的表达式如下:
![](images/2026/02/08/20260208_131118_385.webp)
该定律指出,光线穿过体积的内部透射率($T$)与体积的吸收系数(希腊字母$\sigma$)和**光线在介质中传播的距离(即路径长度)**的乘积呈指数关系。
> 注意这里的distance是光线穿过介质的距离
由此可得比尔定律的一个性质:**对于分段路径,总透射率等于各段透射率的乘积**。
具体来说,如果光线在介质中传播的总距离为 $\text{distance}_2 = \text{distance}_0 + \text{distance}_1$,那么根据比尔定律:
$$
T_2 = e^{-\sigma_a \cdot \text{distance}_2} = e^{-\sigma_a \cdot (\text{distance}_0 + \text{distance}_1)} = e^{-\sigma_a \cdot \text{distance}_0} \cdot e^{-\sigma_a \cdot \text{distance}_1} = T_0 \cdot T_1
$$
即总透射率 $T_2$ 等于两段透射率 $T_0$ 和 $T_1$ 的乘积。这体现了光线在介质中传播时,吸收效应具有累积性和可乘性。
另外这些系数的单位是**倒数距离**(如$m^{-1}$或$cm^{-1}$),仅涉及尺度差异。理解这一点很重要,因为它能帮助我们直观理解这些系数的物理意义:**吸收系数(以及后续的散射系数)可视为光子在任意位置或距离上发生随机事件(如被吸收或散射)的概率密度。**
> 注意这里是“概率密度”后面推导体积渲染方程的时候会再次提到这一点。因为这里实际上是假设了在distance长度的传输距离中吸收系数是恒定的所以才可以直接用区间长度乘以吸收系数等到了后面就会发现在非均匀参与介质中这一项实际上是需要积分的。
吸收系数和散射系数作为概率密度值是不限制必须小于1的这也取决于测量单位。例如某介质的吸收系数以毫米为单位时可能为0.2换算为厘米和米则分别为2和20。因此实际应用中使用大于1的值是完全合理的。
> 系数与平均自由程的关系吸收系数和散射系数的单位为倒数距离这一点至关重要——对系数取倒数1除以吸收系数或散射系数即可得到一个距离值这个距离被称为平均自由程它代表光子发生随机事件吸收或散射的平均传播距离
> $$ \text{平均自由程} = \frac{1}{\sigma} $$
> 该值在模拟参与介质中的多重散射时具有重要作用,想了解更多相关内容,可参考次表面散射和高级体绘制课程。
![](images/2026/02/08/20260208_131118_565.webp)
*图1传播距离越长或密度越大内部透射率越低。*
吸收系数或传播距离越大,内部透射率$T$的值越小。比尔-朗伯定律的计算结果始终在0到1之间当距离或吸收系数为0时结果为1当距离或吸收系数极大时$T$趋近于0。在固定距离下吸收系数增大减小在固定吸收系数下传播距离增加$T$同样减小。简言之光线在体积内传播越远被吸收的光越多体积内粒子越多光被吸收的程度越高。这一规律在图1中得到了直观体现。
> 比尔定律与宝石仅存在吸收作用的介质是透明(而非半透明)的,但会使透过的图像变暗,例如啤酒、葡萄酒、宝石和有色玻璃等。
## 在纯色背景上渲染体积
我们可以从简单场景开始实践。假设有一块厚度为10、密度为0.1的体积板背景颜色例如墙面反射的光为background_color那么透过体积板看到的背景颜色可通过以下方式计算
```hlsl
vec3 background_color {xr, xg, xb}; // 背景颜色
float sigma_a = 0.1; // 吸收系数
float distance = 10; // 光线穿过体积的距离
float T = exp(-distance * sigma_a); // 计算内部透射率
vec3 background_color_through_volume = T * background_color; // 透过体积的背景色
```
就是这么简单。
## 散射
到目前为止,我们都假设体积是黑色的,也就是说,我们只是在体积板覆盖的区域将背景色变暗。但体积并非只能是黑色,与固体物体类似,体积也能反射(更准确地说是**散射**)光线。这也是为什么在晴朗的日子里,我们能清晰看到云朵的轮廓,仿佛它是一个固体物体。体积还可以自行发光(例如烛火),这一点我们仅作提及,第一章暂不讨论发光效果。
> 反射是 “有规律的定向反弹”,散射是 “无规律的四处散开”,用日常例子就能懂:反射:光线(或声波等)碰到光滑、均匀的表面(比如镜子、平静的水面、抛光的金属),会沿着固定方向 “弹回去”,方向明确、有规律。比如照镜子能看清自己,就是光线反射的结果。散射:光线(或声波等)碰到粗糙表面、微小颗粒(比如墙面、空气分子、灰尘、雾滴),会被拆成无数条 “小光线”,向四面八方无规则散开,没有固定方向。比如天空是蓝色(阳光被空气分子散射)、教室里开灯能照亮整个房间(灯光被墙面 / 灰尘散射),都是散射的效果。
假设我们的体积板具有特定颜色**volume_color**,暂时先不深究该颜色的来源,后面会详细解释。我们可以先将其理解为体积物体“反射”(实际并非反射,此处暂以固体物体的反射概念类比)照射光线后呈现的颜色。此时,代码可修改为:
```cpp
vec3 background_color {xr, xg, xb}; // 背景颜色
float sigma_a = 0.1; // 吸收系数
float distance = 10; // 光线穿过体积的距离
float T = exp(-distance * sigma_a); // 内部透射率
vec3 volume_color {yr, yg, yb}; // 体积自身颜色
// 透过体积的最终颜色 = 透射的背景色 + 体积散射的颜色
vec3 background_color_through_volume = T * background_color + (1 - T) * volume_color;
```
这类似于Photoshop等软件中的图像混合Alpha混合操作。例如将图像B叠加到图像A上其中A是背景图如蓝色墙面B是带有透明通道的红色圆盘混合公式为
$$ \text{最终颜色} = \text{透明度} \times A + (1-\text{透明度}) \times B $$
此处的“透明度”对应透射率$T$而B则是体积物体的颜色即体积散射后射向人眼/相机的光线颜色)。我们将在讲解光线步进算法时再次回顾这一概念,现在请先记住这一混合逻辑。
## 渲染第一个体积球体
利用上述知识,我们可以渲染出第一幅三维图像。我们将渲染一个充满粒子的球形体积,并将其置于背景之上。原理非常简单:首先检测相机光线与球体是否相交。若不相交,则直接返回背景色;若相交,则计算光线进入和离开球体表面的交点,进而求出光线穿过球体的距离,再应用比尔定律计算光线透过球体后的透射量。这里我们暂时假设球体“反射”(散射)的光是均匀的,光照效果将在后续内容中探讨。
![](images/2026/02/08/20260208_131118_782.webp)
*图2穿过体积物体的相机光线*
![](images/2026/02/08/20260208_131118_926.webp)
*图3利用相机光线与体积物体的交点计算光线沿路径上体积的不透明度*
**实现细节**
从技术上讲,我们无需计算光线进出球体的具体交点,只需用光线与球体相交的参数距离(沿相机光线的参数化距离)相减即可得到穿过距离。以下示例中我们仍计算交点,是为了强调我们关注的是这两个点之间的距离。
```cpp
class Sphere : public Object {
public:
// 计算光线与球体的交点
bool intersect(vec3 ray_origin, vec3 ray_direction, float &t0, float &t1) const {
// 光线与球体相交检测的具体实现
}
float sigma_a{ 0.1 }; // 吸收系数
vec3 scatter{ 0.8, 0.1, 0.5 }; // 散射颜色
vec3 center{ 0, 0, -4 }; // 球体中心坐标
float radius{ 1 }; // 球体半径
};
// 光线追踪场景
vec3 traceScene(vec3 ray_origin, vec3 ray_direction, const Sphere *sphere) {
float t0, t1;
vec3 background_color { 0.572, 0.772, 0.921 }; // 背景色(浅蓝色)
if (sphere->intersect(ray_origin, ray_direction, t0, t1)) {
vec3 p1 = ray_origin + ray_direction * t0; // 光线进入球体的点
vec3 p2 = ray_origin + ray_direction * t1; // 光线离开球体的点
float distance = (p2 - p1).length();
// 光线穿过球体的距离也可直接用t1 - t0计算
float transmission = exp(-distance * sphere->sigma_a); // 计算透射率
// 最终颜色 = 透射的背景色 + 球体散射的颜色
return background_color * transmission + sphere->scatter * (1 - transmission);
}
else {
return background_color; // 无交点时返回背景色
}
}
// 渲染图像
void renderImage() {
Sphere *sphere = new Sphere;
// 遍历图像的每一行和每一列
for (int y = 0; y < image_height; ++y) {
for (int x = 0; x < image_width; ++x) {
vec3 ray_dir = computeRay(x, y); // 计算当前像素对应的光线方向
vec3 pixel_color = traceScene(ray_origin, ray_dir, sphere); // 追踪光线获取像素颜色
image_buffer[y * image_width + x] = pixel_color; // 将颜色存入图像缓冲区
}
}
saveImage(image_buffer); // 保存图像
// 其他清理工作...
}
```
![](images/2026/02/08/20260208_131119_130.webp)
很明显,随着密度(吸收系数$\sigma_a$的增加透射率逐渐趋近于0这意味着体积球体的颜色将逐渐盖过背景色。
从渲染结果可以看出,球体中心区域的不透明度更高,因为光线穿过该区域的距离最长;同时,随着吸收系数$\sigma_a$增大,整个球体的不透明度都会提升。太棒了!你已经成功渲染出第一个体积球体,距离成为体绘制专家又近了一步。
## 添加光照!内散射
目前我们已经得到了体积球体的基础渲染效果,但光照效果该如何实现呢?当光线照射到体积物体上时,直接暴露在光线下的部分会比阴影区域更亮。与固体物体一样,体积也会被光源照亮,我们该如何在渲染中体现这一点?
原理其实很简单。想象光源发出的光线穿过体积时,其强度会因吸收而衰减,而衰减程度同样遵循比尔定律。换句话说,若已知光线在体积内传播的距离,就能计算出该距离处的光线强度:
```cpp
float light_intensity = 10; // 光源强度(任意数值)
// 光线在体积内传播的距离乘以吸收系数,计算透射率
float T = exp(-distance_travelled_by_light * volume->absorption_coefficient);
float light_intensity_attenuation = T * light_intensity; // 衰减后的光线强度
```
正如我们之前所学,光线在体积内传播时,能量会按照比尔定律逐渐衰减,这一点不难理解。但除此之外,光线沿初始方向传播时,除了被吸收,部分光线还会被散射到其他方向。如果散射方向恰好与人眼观察方向相反,那么这部分光线就会进入人眼或相机传感器。这一过程与光线从固体表面反射类似,但存在一些有趣的差异。
由于云朵并非固体物体,光线照射到粒子上时会发生两种情况:
* 被吸收;
* 被散射。对于体积,我们通常用“散射”而非“反射”来描述这一过程。
如果光线没有击中体积内的粒子,自然会沿原方向继续传播。我们暂时无需关注光线被吸收和被散射的概率,目前需要重点关注的是:如果光线被散射到与人眼观察方向相反的方向,那么即使光源发出的初始光线并非朝向人眼,这部分散射光最终仍能进入人眼。这种现象被称为**内散射**,指的是**穿过体积的光线因散射事件而被重新定向至人眼方向的效果**。
图4展示了这一过程散射事件是光子与介质/体积中的粒子或原子相互作用的结果。原子不会吸收光子,而是将其“弹射”到与入射方向不同的方向(如果体积由水或烟雾等原子构成);或者,若烟雾中含有灰尘、煤烟、海盐等微小固体粒子,光线会从这些粒子表面反射(本质也是散射)。后续章节将深入探讨这一现象。
![](images/2026/02/08/20260208_131119_367.webp)
*图4透过体积物体看到的光线既来自背景物体图中蓝色部分也来自光源。尽管光源发出的初始光线并非射向人眼但部分光线在穿过体积物体时会因内散射效应被重新定向至人眼。*
观察图4可以发现进入人眼的光线图中蓝色相机光线由两部分组成一部分是来自背景的光线另一部分是光源发出后经内散射射向人眼的光线图中黄色光线
体积的外观(即从特定视角观察到的体积形态)是光线吸收与散射共同作用的结果——这里的光线既包括沿视线方向直接穿过体积的背景光,也包括场景中各方向光源照射体积的光线。因此,要生成逼真的云朵图像,必须同时考虑这两种效应。我们已经探讨了吸收现象,接下来需要研究如何在内渲染中加入内散射效果。
我们的目标是找到一种方法,测量因内散射效应而沿相机光线射向人眼的光量。但问题在于:**内散射可能发生在光线进出球体的两个交点($t_0$和$t_1$)之间的任意位置,这并非一个离散过程**(即光线粒子不会只在该线段的特定位置发生散射),而是沿整个线段持续发生的吸收与散射现象。那么,我们该如何测量沿一段距离内持续入射的光能呢?
在探讨解决方案之前,我们先统一**符号定义:用$L_s(\omega)$表示沿人眼方向(用希腊字母$\omega$表示)散射的光量,其单位为**辐射度**,其中$x$代表线段$t_0$到$t_1$上的任意一点。
![](images/2026/02/08/20260208_131119_566.webp)
*图5需要对相机光线穿过体积物体的线段上所有因内散射重新定向至人眼的光线进行积分计算。*
在物理学中,当需要表示沿某段距离或某个体积的能量流入时,我们通常使用**积分**来描述。换句话说,我们需要对函数$L_s(\omega)$在区间$t_0$到$t_1$上进行积分,所得结果即为我们要测量的总散射光量。如果你对积分概念不太熟悉,可以将其理解为“收集沿方向$\omega$的光线在参数距离$t_0$到$t_1$之间的所有散射光能”。若这些量是离散的,我们只需求和即可;但对于$L_s(\omega)$这类连续函数,就需要用积分来表达这一思想。该积分的数学表达式如下:
![](images/2026/02/08/20260208_131119_751.webp)
问题在于,我们需要计算的这个积分没有解析解。对于球体等简单形状和简单光照场景,或许存在解析解,但我们需要的是一种适用于任意体积形状(包括后续将介绍的非均匀密度体积)和任意光照场景的通用解决方案,因此解析解并非可行路径。那我们该怎么办?
我们可以采用实验物理学和计算机图形学(至少在影视或游戏图像制作中,计算结果无需达到科研级精度)中常用的方法——用**黎曼和**来近似计算这个积分。
黎曼和的思想很简单我们可以将曲线下方的面积分解为多个已知面积的简单图形如矩形见图7。通过在曲线上按固定间隔间隔宽度为$\Delta t$)采样$L_s(\omega)$的值,每个矩形的面积即可表示为采样值$L_s(\omega)$乘以间隔宽度$\Delta t$采样点取间隔中点。将所有矩形的面积求和就能得到曲线下方面积的近似值如图7所示。需要注意的是这一结果并非精确的曲线下方面积而是一个近似值从图中可以直观看出误差的存在。不难理解矩形的宽度$\Delta t$越小,近似结果就越接近真实面积,但同时计算耗时也会增加。
![](images/2026/02/08/20260208_131119_976.webp)
*图6沿光线按固定步长推进用黎曼和近似计算积分。*
![](images/2026/02/08/20260208_131120_212.webp)
*图7可以用黎曼和估算代表相机光线散射光量的曲线下方面积。核心思路是将曲线下的面积分解为多个小矩形每个矩形的高度为采样点的$L_s(\omega)$值,宽度为用户定义的步长$\Delta t$。*
将这一方法应用到体绘制中,具体该如何操作?后面我们将详细讲解,但可以先简单剧透一下:本质上,我们需要将相机光线穿过球体的线段,分割为多个长度为$\Delta t$的子线段;对于每个子线段,计算函数$L_s(\omega)$的值具体步骤包括见图5
* 从子线段**中点**$p$向光源方向发射一条光线,确定光线离开体积物体的位置,从而计算出光线从光源传播到点$p$时穿过体积的距离;
* 利用该距离,通过比尔定律计算光线传播至点$p$时**剩余的能量**
* 将计算得到的$L_s(\omega)$乘以步长$\Delta t$,并累加所有子线段的贡献值,即可得到近似的积分结果。
不过,细心的读者可能会发现两个我们尚未解答的问题:
* 我们现在已经知道了从光源传播到采样点$p$的光量,但这并不能告诉我们其中有多少比例的光会最终沿$\omega$方向散射。说得没错,你还没有完全理解$L_s(\omega)$的完整含义。换句话说,我们已经考虑了光的传播衰减,但尚未用到$\omega$变量——正如你所想,它在计算散射比例时起着关键作用。(剧透:这个问题是通过后面介绍的“相位函数解决”的)
* 沿$\omega$方向散射的光线,从点$p$传播到$t_0$(光线进入球体的点)的过程中,难道不会被再次吸收吗?也就是说,既然$p$位于体积内部,光线仍需在体积内传播一段距离才能离开并进入人眼,那么这段传播过程中光线应该也会被吸收。确实如此!
如果你能意识到这两个问题,那恭喜你,也说明我们的教学起到了效果。这意味着你已经准备好进入下一章,我们将在那里详细解答这两个问题。
---
# 二、光线步进算法
## 万能的光线步进算法
为了对沿光线传播路径、由内散射产生的入射光进行积分计算我们需要将光线穿过的体积分解为多个小体积元然后汇总每个小体积元对整个体积物体的贡献——这有点类似于在二维图像编辑软件如Photoshop将带有蒙版或Alpha通道通常表示物体不透明度的图像相互叠加。这也是我们在第一章中讨论Alpha合成方法的原因。每个小体积元都对应第一章中提到的黎曼和中的一个采样点。
![](images/2026/02/08/20260208_131120_416.webp)
*图1反向光线步进。沿光线从t1向t0方向以固定的小步长推进。*
算法的工作流程如下:
* 计算t0和t1的值即相机/人眼光线进入和离开体积物体的交点对应的参数距离。
* 将t0-t1定义的线段分割为X个大小相同的小线段。通常我们通过选择“步长”来实现这一点——步长就是一个定义小线段长度的浮点数。例如若t0=2.5、t1=8.3,步长=0.25则将t0-t1线段分割为(8.3-2.5)/0.25=23个小线段为简化理解暂不考虑小数细节
* 接下来沿相机光线推进X次可从t0或t1开始见第6点
![](images/2026/02/08/20260208_131120_609.webp)
*图2计算Li(x)需要向光源方向发射一条光线,以确定光束到达采样点需穿过体积的距离。*
5. 每推进一步,从该步的**中点(即采样点)向光源发射一条“光线”,计算这条光线与体积元的交点(离开体积的位置)。需注意,光源发出的光线在传播到采样点的过程中会被体积吸收,因此利用**比尔定律计算该采样点因内散射产生的贡献。这就是上一章提到的黎曼和中的Li(x)值。别忘了我们需要将这个值乘以步长对应黎曼和中的dx项即矩形的宽度。伪代码如下
```hlsl
// 计算当前采样点x的Li(x)
float lgt_t0, lgt_t1; // 光线与球体交点的参数距离
volumeSphere->intersect(x, lgt_dir, t0, lgt_t1); // 计算光线与球体的交点
color Li_x = exp(-lgt_t1 * sigma_a) * light_color * step_size; // step_size即我们的dx
...
```
如图2所示光线与球体交点检测中的t0应始终为0因为光线从球内部发出而t1是从采样点x到光线与球体交点的参数距离。因此我们可将该值代入比尔定律计算光线在体积物体中传播过程中被吸收的距离对应的衰减量。
6. 当然,在图一中,穿过小体积元(采样点)的光线也会被该体积元衰减。因此,我们以**步长**作为光束穿过该体积元的距离,利用比尔定律计算采样点的透射率,再用该透射率对(内散射产生的)光量进行衰减(相乘)。
7. 最后我们需要汇总所有采样点的贡献以得到体积物体的整体不透明度和“颜色”。实际上若从后往前考虑这一过程如图1所示第一个采样点从t1开始会被第二个采样点遮挡第二个又会被第三个遮挡依此类推直到最后一个采样点紧邻t0。从相机光线的视角看紧邻t1的采样点会被其前方所有采样点遮挡紧邻t0的采样点的下一个采样点会被这个紧邻t0的采样点遮挡以此类推。
“光线步进”的名称如今就很容易理解了我们沿光线推进以固定的小步长移动如图1所示为反向光线步进的示例。需注意光线步进算法并非必须使用固定步长也可采用非固定步长但为简化理解我们先使用固定步长Ken Musgrave称之为“跨度”。使用固定步长时我们称之为“均匀光线步进”区别于“**自适应光线步进”**)。
汇总采样点的方式有两种反向从t1到t0推进或正向从t0到t1推进。其中一种方式相对更有优势某种程度上。下面我们将分别说明它们的工作原理。
## 反向光线步进
反向光线步进中,我们沿光线**从后往前推进即从t1到t0**。这会改变我们汇总采样点以计算最终像素不透明度和颜色的方式。
很自然地,由于我们从体积物体的背面(球体的后半部分)开始,理论上可以将像素颜色(相机光线对应的最终颜色)初始化为背景色(浅蓝色)。但在我们的实现中,会在整个计算过程结束后(得到体积物体的颜色和不透明度后)再将两者混合——这类似于在二维编辑软件中合成两张图像。
我们从t1开始计算体积中第一个采样点设为X0的贡献然后以固定步长向t0推进依次计算后续采样点的贡献。
![](images/2026/02/08/20260208_131120_859.webp)
*图3计算一个采样点时需要考虑来自背面的背景光和来自光源的内散射光再考虑小体积元对这些光贡献的吸收。这可以理解为背景色与光源色分别乘以小体积元透明度后相加。*
那么,一个采样点的贡献如何计算?
我们先按照上述第6点的方法计算内散射贡献光源的贡献Li(X0)向光源方向发射一条光线然后利用比尔定律衰减光贡献以考虑光线从进入体积物体球体到采样点X0的过程中被吸收的量。
之后将这个光量乘以采样点的透明度代表该采样点吸收光的比例。采样点的透明度同样通过比尔定律计算步长即为光束穿过该采样点的距离图3
```cpp
...
color Li_x0 = exp(-lgt_t1 * sigma_a) * light_color * step_size; // step_size即我们的dx
color x0_contrib = Li_x0 * exp(-step_size * sigma_a);
...
```
我们已经计算出了第一个采样点X0的贡献。接下来处理第二个采样点X1但此时需要考虑两个光源第一个采样点X0发出的光即上一步的计算结果以及第二个采样点X1的内散射光。前者已计算完成后者的计算方法与X0一致。我们将两者相加再乘以第二个采样点的透射率得到新的结果。重复这一过程处理X2、X3……直至到达t0。最终结果即为体积物体对当前相机光线对应像素颜色的贡献。这一过程如下所示。
![](images/2026/02/08/20260208_131121_079.webp)
从上图可以看出,我们需要计算两个值:体积的**整体颜色**存储在result中和**整体透明度**。我们将整体透明度初始化为1完全透明然后在沿光线推进的过程中用每个采样点的透明度对其进行衰减相乘。最后**利用这个整体透明度将体积物体与背景色混合**,公式如下:
```cpp
color final = background_color * transmission + result;
```
在合成术语中“result”项已预先乘以了体积的整体透明度。若你对此感到困惑我们将在下一章详细说明目前无需过度关注。
另需注意上图和下方代码中采样点的衰减项始终相同exp(-step_size * sigma_a)。显然,这样的实现不够高效——我们应计算一次该值并存储在变量中重复使用。但我们的目标是清晰易懂,而非编写高性能代码。此外,目前沿光线推进过程中该值是恒定的,但在下一章中我们会发现,不同采样点的衰减项可能不同。
对应的代码实现如下:
```cpp
constexpr vec3 background_color{ 0.572f, 0.772f, 0.921f };
vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, ...) {
const Object* hit_object = nullptr;
IsectData isect;
for (const auto& object : objects) {
IsectData isect_object;
if (object->intersect(ray_orig, ray_dir, isectObject)) {
hit_object = object.get();
isect = isect_object;
}
}
if (!hit_object)
return background_color;
float step_size = 0.2;
float sigma_a = 0.1; // 吸收系数
int ns = std::ceil((isect.t1 - isect.t0) / step_size);
step_size = (isect.t1 - isect.t0) / ns;
vec3 light_dir{ 0, 1, 0 };
vec3 light_color{ 1.3, 0.3, 0.9 };
float transparency = 1; // 初始化透明度为1
vec3 result{ 0 }; // 初始化体积颜色为0
for (int n = 0; n < ns; ++n) {
float t = isect.t1 - step_size * (n + 0.5);
vec3 sample_pos= ray_orig + t * ray_dir; // 采样点位置(步长中点)
// 使用比尔定律计算采样点透明度
float sample_transparency = exp(-step_size * sigma_a);
// 用采样点透明度衰减全局透明度
transparency *= sample_transparency;
// 内散射:计算光线从光源传播到采样点穿过体积的距离,再应用比尔定律
IsectData isect_vol;
if (hitObject->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-isect_vol.t1 * sigma_a);
result += light_color * light_attenuation * step_size;
}
// 最终用采样点透明度衰减结果
result *= sample_transparency;
}
// 与背景色混合并返回
return background_color * transparency + result;
}
```
但请注意,这段代码尚未完全准确——它缺少一些我们将在下一章补充的项。目前,我们仅希望你理解光线步进的核心原理。即便如此,这段代码仍能生成具有说服力的图像。
![](images/2026/02/08/20260208_131121_318.webp)
本例中我们使用了一个沿y轴向上的顶视平行光球体的淡红色来自光源颜色。可以看到球体上半部分比下半部分更亮阴影效果已初步显现。
$$
\begin{array}{l} A &=& Li(X_0) * \color{red}{Att};\text{ // first iteration n = 0} \\ B &=& (A + Li(X_1)) * Att; \text{ // second iteration n = 1}\\ B &=& (Li(X_0) * Att + Li(X_1)) * Att;\\ B &=& (Li(X_0) * \color{red}{Att^2} + Li(X_1) * Att;\\ C &=& (B + Li(X_2)) * Att;\text{ // third iteration n = 2}\\ C &=& (Li(X_0) * Att^2 + Li(X_1) * Att + Li(X_2)) * Att;\\ C &=& (Li(X_0) * \color{red}{Att^3} + Li(X_1) * Att^2 + Li(X_2) * Att;\\ ... \end{array}
$$
观察循环过程中Li(X0)的变化可以发现它会被采样点衰减项的若干次方相乘。沿光线推进的步数越多指数越大从1到2再到3……结果就越小因为衰减项或采样点透明度小于1。换句话说第一个采样点对体积整体散射光的贡献会随着采样点的累积而逐渐减小。
## 正向光线步进
![](images/2026/02/08/20260208_131121_557.webp)
*图4正向光线步进。沿光线从t0向t1方向以固定的小步长推进。*
在计算Li(x)和采样点透射率方面正向光线步进与反向光线步进并无区别。两者的差异在于采样点的汇总方式——正向光线步进沿t0到t1方向从前到后推进。此时一个采样点的散射光贡献需要被当前已处理的所有采样点包括当前采样点的整体透射率透明度衰减Li(X1)会被采样点X0和X1的透射率衰减Li(X2)会被采样点X0、X1和X2的透射率衰减以此类推。算法描述如下
步骤1进入光线步进循环前初始化整体透射率透明度为1结果颜色变量存储当前相机光线对应的体积物体颜色为0`float transmission = 1; color result = 0;`
步骤2光线步进循环的每次迭代
* 计算当前采样点的内散射光Li(x)
* 将整体透射率乘以当前采样点的透射率,更新整体透射率:`transmission *= sample_transmission;`
* 将Li(x)乘以整体透射率(当前已处理的所有采样点会遮挡该采样点的散射光),并将结果累加到存储体积颜色的全局变量中:`result += Li(x) * transmission;`
对应的代码实现如下:
```cpp
...
vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, ...) {
...
float transparency = 1; // 初始化透明度为1
vec3 result{ 0 }; // 初始化体积颜色为0
for (int n = 0; n < ns; ++n) {
float t = isect.t0 + step_size * (n + 0.5); //关键区别
vec3 sample_pos = ray_orig + t * ray_dir;
// 当前采样点的透明度
float sample_attenuation = exp(-step_size * sigma_a);
// 用当前采样点的透射率衰减体积物体的整体透明度
transparency *= sample_attenuation;
// 内散射:计算光线从光源传播到采样点穿过体积的距离,再应用比尔定律
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-isect_vol.t1 * sigma_a);
// 用累积的整体透射率衰减内散射贡献
result += transparency * light_color * light_attenuation * step_size;
}
}
// 混合背景色和体积物体颜色
return background_color * transparency + result;
}
```
但请注意,这段代码尚未完全准确——它缺少一些我们将在下一章补充的项。目前,我们仅希望你理解光线步进的核心原理。即便如此,这段代码仍能生成具有说服力的图像。
此处无需展示图像——若实现正确,反向和正向光线步进应得到相同的结果。当然,我们知道你可能不会轻易相信,因此以下是两种方法的渲染结果。
![](images/2026/02/08/20260208_131121_801.webp)
## 为什么正向光线步进比反向光线步进“更好”?
因为当体积的透明度非常接近0时当体积足够大或散射系数足够高时可能出现这种情况我们可以**停止**光线步进——而这只有在正向推进时才可行。
目前,渲染我们的体积球体速度较快,但随着章节推进,你会发现渲染速度会逐渐变慢。因此,若我们能在沿光线推进过程中,当体积变得完全不透明、后续采样点不再对像素颜色产生贡献时停止计算,这将是一项有效的优化。
我们将在下一章实现这一优化思路。
## 步长的选择
![](images/2026/02/08/20260208_131122_008.webp)
*图5由于步长过大我们无法捕捉到体积中的细节。当然这个例子比较极端旨在帮助你理解核心思想。*
![](images/2026/02/08/20260208_131122_279.webp)
*图6这个例子也很极端2个采样点可能永远无法正确渲染体积物体的光照但可以看出由于步长过大我们无法捕捉到被固体物体遮挡的体积部分。我们需要小得多的步长。*
请记住我们沿光线从t0到t1以小步长推进、执行光线步进的目的是利用黎曼和方法近似计算积分沿相机光线向人眼散射的内散射光量。正如上一章和《着色的数学原理》课程中所解释的用于近似积分的矩形越大此处矩形宽度由步长定义近似精度越低反之矩形越小步长越小近似精度越高但计算耗时也会增加。目前渲染体积球体的速度较快但随着课程推进你会发现渲染速度会变得非常慢。因此**步长的选择是速度与精度之间的权衡**。
目前我们假设体积密度是均匀的。在下一章中我们将学习渲染云、烟雾等密度随空间变化的体积。这类体积既包含低频特征也包含高频细节。若步长过大可能无法捕捉到部分高频细节图5。这属于**滤波**问题,是一个重要但复杂的独立主题。
步长选择不当还可能引发另一个问题阴影。若小型固体物体在体积物体上投射阴影步长过大会导致我们错过这些阴影图6
以上内容并未告诉我们如何选择合适的步长。理论上没有固定的规则——你需要大致了解体积物体的尺寸。例如若体积是充满某种均匀介质的房间你需要知道房间的大小以及所使用的单位比如1单位=10厘米。因此若房间尺寸为100单位步长0.1可能过小而1或2可能是一个合适的起点。之后如前所述你需要通过调整找到速度与精度的最佳平衡点。
但这并非绝对。除了根据场景中物体的大小凭经验选择步长外,还有更合理的方法。一种可行的方法是:计算相机光线进入体积处的像素投影尺寸,将步长设置为该投影尺寸。实际上,作为离散单元的像素,无法呈现比自身尺寸更小的场景细节。我们在此不展开详细讨论(滤波值得单独开设一门课程),目前只需记住:合适的步长应接近相机光线与体积交点处的像素投影尺寸。这可以通过以下公式估算:
```cpp
float projPixWidth = 2 * tanf(M_PI / 180 * fov / (2 * imageWidth)) * tmin;
```
你可以根据需要优化该公式其中tmin是相机光线与体积物体交点的距离。类似地你也可以计算光线离开体积处的像素投影宽度然后在tmin和tmax处的投影宽度之间进行线性插值以设置沿光线推进过程中的步长。
## 继续前进前的其他注意事项!
编写生产级代码时,需要将光线的不透明度和颜色与光线数据一起存储。这样,我们可以先对固体物体执行光线追踪,再对体积物体执行光线步进,并在过程中汇总结果(类似于我们在示例中将背景色与体积球体混合的方式)。
请注意,相机光线的传播路径上可能存在多个体积物体。因此,需要沿路径存储不透明度,并在光线步进过程中汇总连续体积物体的不透明度和颜色。
一个体积物体可能由多个相互重叠的物体(如立方体、球体)组合而成。在这种情况下,我们可能需要将它们组合成某种聚合结构。对这类聚合体执行光线步进时,需要特别注意计算构成聚合体的各个物体的交界面。
## 接下来:补充缺失的项,得到物理准确的结果
在本课程的第三章(下一章)中,我们将为当前实现补充缺失的项,以得到(更)符合物理规律的结果。我们还将向你展示,掌握这些知识后,你将能够阅读和理解其他人编写的渲染器代码。准备好了吗?
## 源代码
重现前两章图像的源代码(含嵌入在文件中的编译说明)可在课程的最后一章下载(与往常一样)。请注意,该代码与本章展示的代码片段略有不同,差异将在下一章中解释。
---
# 三、光线步进:精准实现!
## 内散射与外散射
在前几章中,我们只考虑了光束与介质粒子之间的两种相互作用:吸收和内散射。但要得到准确的结果,我们需要考虑四种相互作用。这些相互作用可分为两类:一类会削弱光束穿过介质到达人眼过程中的能量,另一类则会增加光束的能量。
光束穿过介质到达人眼时,能量会因以下两种作用而损失:
* **吸收Absorption**:部分光能被介质粒子吸收。若你是这个粒子,可以这样理解:“有一些光线正朝着你(观察者)传播,但很抱歉,我吸收了一部分,所以你接收到的光会减少。”
* **外散射Out-Scattering**:正如上一章所提及的,光线会被粒子散射。这会使原本不朝向人眼传播的光线被重新定向到人眼方向,这就是我们上一章讨论的内散射效应。**但原本朝向人眼传播的光线,在传播过程中也可能被散射到其他方向——这意味着光线的能量也会因此损失,这种现象被称为外散射(顾名思义)。**若你是这个粒子,可以这样理解:“有一些光线正朝着你(观察者)传播,但我把一部分散射到了随机方向,所以你接收到的光会减少。”
光束穿过介质到达人眼时,能量会因以下两种作用而增加:
* **发射Emission**:我们在第一章中提到过这种效应,但也说明暂时会忽略它。例如,火焰会发出炽热的光。
* **内散射In-Scattering**:我们对这种效应已经很熟悉了。**部分原本不朝向人眼传播的光线,会通过散射被重新定向到人眼方向,这种效应即为内散射。**若你是这个粒子,可以这样理解:“我收集了从各个方向射向我的光线,并将一部分朝着你(观察者)的方向发射出去,所以你会接收到一些原本不打算朝向你的光线。” 内散射可以看作是外散射的结果——光线会(或多或少地,后续介绍相位函数时会详细说明)向所有方向散射,而其中一个方向恰好是人眼观察方向(相机光线方向)。
这些效应如下图所示。
![](images/2026/02/08/20260208_131122_481.webp)
在计算光束穿过介质到达人眼过程中的**能量损失**时,我们必须同时考虑**吸收**和**外散射**。外散射和内散射均由同一类光-粒子相互作用(散射)引起——在上一章中,我们用变量$\sigma$(希腊字母西格玛)来定义散射。因此,由于散射($\sigma$)也是导致光束穿过介质到达人眼时能量损失的原因,我们需要将其与吸收系数$\sigma_a$一起纳入比尔定律方程中。请记住,该方程既用于计算$Li(x)$项,也用于计算采样点的透射率。因此,我们的代码需要修改如下(红色部分为修改内容):
```cpp
...
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
// 计算采样点透射率
float sample_attenuation = exp(-step_size * (sigma_a + sigma_s));
transparency *= sample_attenuation;
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += ...;
}
...
```
有时你会看到$\sigma_a$和$\sigma_s$的和被称为消光系数extinction coefficient通常用$\sigma_t$西格玛t表示。
$$ \sigma_t = \sigma_a + \sigma_s $$
这个消光系数同样可以用概率密度来解释,它可以理解为光子沿其路径传播时,**在单位长度内发生任何相互作用(无论是被吸收还是被散射)的概率密度**。它代表了光子“消失”(吸收)或“偏离”(外散射)其原始路径的总概率。例如,$\sigma_{t}= 0.5 m^{-1}$意味着光子平均每传播 2 米1 / 0.5)就会发生一次吸收或外散射。
这个时候再来理解比尔定律,会发现实际上比尔定律描述的是透射率,它可以用泊松分布来建模。
![](images/2026/02/08/20260208_131122_704.webp)
![](images/2026/02/08/20260208_131122_902.webp)
满足:
> 独立性:事件发生的概率不受之前发生过的事件影响(比如 “上一分钟没接到电话”,不影响 “这一分钟接到电话” 的概率);平稳性:单位时间 / 空间内事件发生的平均概率是固定的(比如每天医院急诊人数的平均值稳定,不会突然翻倍);稀有性:在极短时间 / 极小空间内,事件发生 2 次及以上的概率几乎为 0比如同一瞬间接到两个电话的概率可忽略
此时**透射率**$T$也就是对于**每个粒子**在distance的传输距离中一次都未发生吸收或散射的概率是泊松分布中的**零事件概率**。而前面代码中的**step_size * (sigma_a + sigma_s)是可以近似看作是每个粒子在step_size距离内发生“消失”或“偏离”的概率由于这里是“单个粒子”因此也可以看作是该粒子在step_size距离内发生“消失”或“偏离”的**期望值。
而**exp(-期望)** :这正是**泊松分布中的零事件概率**,即光子“幸存”下来,没有发生任何吸收或散射事件的概率,即下面公式中$P(X=0)=e^{-\lambda}$。
关于散射项,我们还没有完全讨论完……**内散射产生的、朝向人眼的光量也与散射项成正比。因此,我们还需要将内散射的光贡献乘以$\sigma_s$变量**。代码修改如下(红色部分为修改内容):
```cpp
...
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
// 计算采样点透射率
float sample_attenuation = exp(-step_size * (sigma_a + sigma_s));
transparency *= sample_attenuation;
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * step_size;
}
...
```
详细的数学解释我们会在加入了相位函数之后说明这里暂时把sigma_s粗略地当成一依据概率的缩放因子即可。
## 密度项
我们将在下一章详细讨论这个术语。
到目前为止,我们假设用于控制体积“不透明度”的散射系数和吸收系数(请记住,这些系数的值越高,体积越不透明)在整个体积内是均匀的。在学术文献中,这通常被称为**均匀参与介质homogenous participating medium**。但现实世界中的“体积”(如云层、烟雾羽流)通常并非如此,它们的不透明度会随空间变化,这类介质被称为**非均匀参与介质heterogeneous participating medium**。
我们将在下一章中学习如何模拟密度变化的体积物体但目前我们先引入一个全局缩放散射系数和吸收系数的变量称之为密度density。我们将用它来缩放$\sigma_a$和$\sigma_s$,修改如下(红色部分为修改内容):
```cpp
...
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
float density = 1; // 密度
// 计算采样点透射率
float sample_attenuation = exp(-step_size * density * (sigma_a + sigma_s));
transparency *= sample_attenuation;
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * density * step_size;
}
...
```
请记住,$\sigma_s$在代码中被使用了两次。我们将在下一章中解释如何实现空间变化的密度。
现在请注意一个有趣的点当密度为0时不会向result变量中添加任何值。换句话说在没有体积的区域空空间或密度=0不应该有任何光的累积。这对于以下代码行至关重要
```cpp
// 与背景色混合并返回
return background_color* transparency + result;
```
如果在没有体积的区域result的值不为0例如我们在计算内散射时忘记将散射项乘以密度值那么我们会在不应该出现光的区域看到光result>0。这就是上一章中我们提到result已经“预先乘以不透明度”的原因——它已经乘以了自身的“不透明度蒙版”在密度/不透明度大于0的区域result大于0否则为0。
## 相位函数
还记得第一章结尾我们留下的一个问题吗?
> 我们现在已经知道了从光源传播到采样点p 的光量,但这并不能告诉我们其中有多少比例的光会最终沿$\omega$ 方向散射。说得没错,你还没有完全理解$L_s(\omega)$ 的完整含义。换句话说,我们已经考虑了光的传播衰减,但尚未用到$\omega$ 变量——正如你所想,它在计算散射比例时起着关键作用。
相位函数就是用来解决这个问题的,它在变量的基础上又多考虑了$\omega$。
**内散射**贡献应使用以下方程计算:
![](images/2026/02/08/20260208_131123_151.webp)
其中,$Li$是内散射(辐射度)贡献,$x$是采样点位置,$\omega$是人眼观察方向(相机光线方向)。通常,$\omega$的方向始终与辐射度传播方向一致,即从物体指向人眼。$\omega'$表示光源方向(且$\omega'$应从物体指向光源)。
我们用文字描述这个方程:符号$S^2$(在文献中你也可能看到写作$\Omega_{4\pi}$)表示的积分意味着,内散射贡献可以通过考虑整个方向球$S^2$上所有方向的入射光来计算。
此处的$L(x, \omega')$就是我们在代码中计算的光贡献或**入射辐射度**项,在我们的这个示例中,由于我们只考虑了**一个光源**,而且是**点光源**,因此它就是以下代码片段计算的值:
```cpp
...
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * density * step_size;
...
}
```
它表示在采样点$x$代码中的sample_pos来自特定光源方向$\omega'$代码中的light_dir的光量——该光线已穿过体积内的一定距离代码中的isect_vol.t1
但我们尚未引入积分符号后的项:$p(x, \omega', \omega)$。它被称为**相位函数**phase function本质上是**在球面立体角上归一化的条件概率密度函数**,它描述了在发生一次散射事件的**条件**下,光子从入射方向 ω' 被散射到出射方向 ω 的概率密度。我们接下来会解释其含义。
![](images/2026/02/08/20260208_131123_362.webp)
*图1各向同性光线在方向球上向所有方向均匀散射与各向异性相位函数光线在方向球上的分布不均匀。*
当光子与粒子相互作用时,它会被散射到粒子周围所有可能的方向上,且每个方向被选中的概率相同。在这种情况下,我们称之为**各向同性散射体积isotropic scattering volume**。但各向同性散射并非普遍情况,大多数体积倾向于在有限的方向范围内散射光线,这类介质被称为**各向异性散射介质anisotropic scattering medium**或体积。相位函数是一个简单的数学方程,用于描述特定方向组合(观察方向$\omega$和入射光方向$\omega'$下的散射光量其返回值范围为0到1。从数学角度来说相位函数用于模拟光线或辐射度的**角分布**。
相位函数有几个特性。首先,**它在其定义域(方向球$S^2$上的积分必然为1**。实际上,组成体积的粒子会受到来自所有可能方向的光束照射,而这些可能的方向可以看作是以粒子为中心的球体。因此,如果我们考虑粒子周围所有可能的入射光方向,那么该粒子散射出的总光量不会超过所有入射光的总和——这就是相位函数需要在方向球上归一化的原因:
![](images/2026/02/08/20260208_131123_576.webp)
如果相位函数未归一化,它会导致光的“增加”或“减少”。相位函数的另一个特性是互易性:如果交换方程中的$\omega$和$\omega'$项,相位函数返回的结果相同。
$$ f_p(x, \omega, \omega') = f_p(x, \omega', \omega) $$
![](images/2026/02/08/20260208_131123_790.webp)
*图2相位函数仅考虑光线方向与观察方向之间的夹角$\theta$。*
**相位函数仅取决于观察方向和入射光方向之间的夹角。因此,它通常用**角度$\theta$(希腊字母西塔)来定义,即两个向量之间的夹角(而非$\omega$和$\omega'$本身)。如果我们计算方向$\omega$(观察方向)和$\omega'$(入射光方向)的点积,$\cos\theta = \omega \cdot \omega'$的取值范围为[-1, 1],因此$\theta$本身的取值范围为[0, $\pi$],如下列图像所示。
![](images/2026/02/08/20260208_131124_012.webp)
> 总之,相位函数用于告知你:对于任意特定的入射光方向($\omega'$),有多少光可能被散射到观察者方向($\omega$)。
闲话少说,相位函数具体是什么样子的?
最简单的是各向同性体积的相位函数。由于来自方向球上所有方向的光线会被均匀地散射到方向球上的所有方向因此相位函数请记住其在球域上的积分需归一化为1可简单表示为
$$ f_p(x, \theta) = \frac{1}{4\pi} $$
请注意,该函数与观察方向和入射光方向无关。函数定义中虽包含$\theta$角,但等式右侧(等号右边)并未使用该角——这符合预期,因为散射光子的出射方向与入射光方向无关(两者之间没有依赖关系,因此$\theta$无需出现在方程中),且所有出射方向被选中的概率相同(这就是方程为常数的原因)。这个方程不难理解:球体的表面积为$4\pi$球面度steradians因此如果你从微分立体角的角度考虑入射方向那么所有入射方向覆盖的表面积就是$4\pi$,因此相位函数必须为$1/(4\pi)$才能满足归一化特性:所有入射方向覆盖的表面积除以$4\pi$等于1。这里值得一提的是**相位函数的单位是1/球面度**1/srsr代表球面度
各向同性体积的相位函数非常简单。让我们再看另一个相位函数——亨耶-格林斯坦相位函数Henyey-Greenstein phase function其表达式如下
![](images/2026/02/08/20260208_131124_273.webp)
![](images/2026/02/08/20260208_131124_481.webp)
*图3不同非对称因子gg=0.3、0.5、0、-0.3、-0.5)下,亨耶-格林斯坦相位函数在极坐标系中的图像。角度$\theta$的取值范围为[0, $\pi$]。*
显然,它比各向同性相位函数更复杂。如你所见,它包含另一个变量$g$称为非对称因子asymmetry factor其中$g \in [-1, 1]$。这个参数用于控制光线的散射方向是向前还是向后:当$g>0$时,光线主要向前散射;当$g<0$时,光线主要向后散射;当$g=0$时,该函数等于$1/(4\pi)$即各向同性体积的相位函数。图3展示了不同$g$值下该函数的图像。
还存在其他相位函数如施里克相位函数Schlick、瑞利散射相位函数Rayleigh或洛伦兹-米散射相位函数Lorenz-Mie。这些函数被设计用于拟合不同类型粒子的散射行为。例如当你试图渲染由微小粒子小于光波长组成的体积时使用瑞利函数效果更好而对于较大的粒子灰尘、水滴等米函数更合适。亨耶-格林斯坦相位函数常用于影视行业的生产级渲染,因为它计算速度快(其他函数可能较慢)且易于采样(例如,参见《蒙特卡洛模拟》课程)。
最后,以下是将亨耶-格林斯坦相位函数添加到代码中的实现(你也可以自由实现其他函数):
```cpp
// 亨耶-格林斯坦相位函数
float phase(const float &g, const float &cos_theta) {
float denom = 1 + g * g - 2 * g * cos_theta;
return 1 / (4 * M_PI) * (1 - g * g) / (denom * sqrtf(denom));
}
vec3 integrate(...) {
...
float g = 0.8; // 相位函数的非对称因子
for (int n = 0; n < ns; ++n) {
...
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float cos_theta = ray_dir * light_dir;
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color * step_size;
}
...
}
...
}
```
请注意,这与上述内散射项的正式数学定义更加接近。
![](images/2026/02/08/20260208_131124_796.webp)
上图展示了在两种不同的光照设置下,不同相位函数非对称因子$g$值对应的体积球体渲染结果:左侧为逆光(光线直接朝向相机),右侧为正光(光线和相机均直接朝向球体)。
亨耶-格林斯坦相位函数虽简单,但能很好地拟合真实世界的数据。例如,你可以通过组合$g=0.35$和$g$为负值或更大值的函数结果实现双瓣相位函数two-lobe phase function以获得更精确的拟合效果。请自由尝试。对于云层或薄雾等物体使用较大的$g$值约0.8)。课程末尾的参考资料部分提供了相关参考。
## 数学理解
如果继续从数学的角度来理解加入了相位函数之后的完整公式的话:
```cpp
result += density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color * step_size;
```
首先因为我们的示例中只有一个不考虑体积的点光源因此对相位函数不需要进行积分直接取入射方向的光即可。而由于相位函数本质上是概率密度函数因此需要phase(g, cos_theta)乘以单位立体角才能表示概率。而对于light_color这个入射辐射度而言它本身就是单位立体角上的光通量。因此二者相乘就把这个单位立体角消掉了。
而由于sigma_s是在该路径上发生散射的概率密度因此需要乘以step_size才是发生散射的概率。同时又由于这个相位函数它本质上是在发生散射的条件下的条件概率密度函数因此它还需要乘以条件概率也就是sigma_s * step_size。所以这样乘下来density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color实际上是step_size步长内的光贡献期望。最后用累加模拟积分即可。
> 同时这里可以发现在前面讨论比尔定律的时候我们用sigma_s乘以distance用来表示distance距离内发生光的外散射的期望。而这里我们用sigma_s相乘之后得到的是单位距离的光贡献期望。这是因为期望值的计算与我们关注的事件有关我们前面关注的是光因外散射发生能量损失这里关注的是光因内散射产生的光贡献。
## 采样点位置的抖动
![](images/2026/02/08/20260208_131125_081.webp)
到目前为止,我们始终将采样点定位在每个小线段的中点。使用均匀间隔的采样点类似于将体积切割成多个切片,这些切片可能会导致令人不适的条纹伪影(如上图所示,该效果经过人工放大)。为了“解决”这个问题,我们可以在每个小线段上随机选择一个位置放置采样点——换句话说,采样点可以位于小线段的任意边界范围内(当然,沿相机光线方向)。要实现这一点,我们需要将以下代码行:
```cpp
float t = isect.t0 + step_size * (n + 0.5);
vec3 sample_pos = ray_orig + t * ray_dir;
```
替换为:
```cpp
float t = isect.t0 + step_size * (n + rand());
vec3 sample_pos = ray_orig + t * ray_dir;
```
![](images/2026/02/08/20260208_131125_292.webp)
*图4为避免条纹伪影我们可以抖动采样点的位置而非使用均匀间隔的采样点。采样点可位于小线段的任意边界范围内。*
其中rand()是一个返回[0,1]范围内均匀分布随机数的函数。我们将这种方法称为随机采样stochastic sampling
随机采样是一种蒙特卡洛技术,在该技术中,我们在适当的非均匀间隔位置对函数进行采样,而非均匀间隔位置。
我们不能说这种方法“更好”(因此“解决问题”加上了引号),因为它用噪声替代了条纹,而噪声本身也是一个问题。尽管如此,从视觉效果来看,噪声比条纹更令人愉悦。你可以使用更复杂的“随机”数生成序列(例如拟蒙特卡洛方法)来减少噪声。然而,在本版本的课程中,我们将跳过这个主题——关于这个主题可以写一整本书(目前,你可以在《蒙特卡洛实践》课程中找到相关信息)。
## 体积不透明时退出光线步进循环(优化)
实际上如果你沿t0到t1距离的一半推进后体积的透明度例如低于1e-3你可能会认为计算剩余一半的采样点是不必要的如相邻图所示。你可以在检测到透明度变量低于这个最小阈值时直接退出光线步进循环见以下伪代码。考虑到光线步进是一种计算速度较慢的方法我们应该使用这种优化——尤其是当体积物体密度较大时密度越大透明度下降越快这将节省大量时间。我们在上一章中提到这是我们可能更倾向于使用正向积分而非反向积分的原因之一。
```cpp
...
float transparency = 1;
// 沿光线推进
for (int n = 0; n < ns; ++ns) {
...
if (transparency < 1e-3)
break;
}
```
![](images/2026/02/08/20260208_131125_507.webp)
*图5俄罗斯轮盘赌技术的可视化。当透明度低于某个阈值时我们退出光线步进循环但这意味着我们的结果被“截断”了。如何解决这个问题*
现在你可以在通过透明度测试时停止光线步进不再进行任何其他计算——但这在“统计上”是错误的会在渲染图像中引入偏差。通过查看xx图可以更轻松地理解这一点红线表示我们停止光线步进的阈值。如果我们在此处停止就相当于忽略了曲线下方和右侧沿x轴的体积贡献。当然这部分贡献在某种程度上是“可忽略的”——这也是我们最初决定实现这种截断方案的原因。然而如果你是一名试图模拟中子穿过板材过程的热核工程师这种方案是不可接受的。那么我们如何在利用这种优化的同时满足热核工程师的要求呢
我们将使用的方法称为俄罗斯轮盘赌Russian roulette——我们在专门介绍蒙特卡洛方法的课程中已经讨论过这种技术。其核心思想是当透明度值低于某个阈值例如1e-3应用俄罗斯轮盘赌技术。然后在[0,1]范围内随机选择一个均匀分布的数并测试该随机数是否大于1/d其中d是大于1的正实数可为整数但非必须。如果是则退出循环否则继续循环但将当前透明度值乘以d。此处的d表示通过测试的概率。例如当d=5时光线步进循环终止的“概率”为5分之4。
希望是合理的如果随机数小于1/d你可以认为光子被“杀死”了无法再进行任何处理。但作为“杀死”光子的交换我们会给存活下来的光子“增加能量”在我们的案例中实际上也就是增加透明度值——增加的比例与光子被杀死的概率成反比。以下是该思想的代码实现
```cpp
...
float transparency = 1;
// 沿光线推进
int d = 2; // d值越大退出步进循环的频率越高
for (int n = 0; n < ns; ++ns) {
...
if (transparency < 1e-3) {
if (rand() > 1.f / d) // 在此处停止
break;
else
transparency *= d; // 继续推进,但进行补偿
}
}
```
**俄罗斯轮盘赌Russian roulette**是一种蒙特卡洛技术,用于在模拟中无偏地终止采样路径。其核心思想是:
* 当某个事件(如光线继续传播)的概率很低时,可以以一定概率提前终止该事件,但需要对结果进行加权补偿,以保持期望值不变。
在体积渲染中用于优化光线步进过程当体积透明度低于某个阈值如1e-3它允许以一定的概率提前终止循环同时通过调整透明度值来保持统计无偏性。
* **无偏性保持**
* 假设当前光线透明度为$T$(已低于$\epsilon$),下一步的贡献(不考虑终止)为$C = T \times \Delta\mathbf{L}_{\text{next}}$$\Delta\mathbf{L}_{\text{next}}$是下一步的颜色贡献)。
* 以概率 $p = 1/d$ 继续循环,以概率 $1-p$ 终止循环。
* $\mathbb{E}[\text{贡献}] = p \times (T \times d \times \Delta\mathbf{L}_{\text{next}}) + (1-p) \times 0$
* 代入$p=1/d$$\mathbb{E}[\text{贡献}] = \frac{1}{d} \times (T \times d \times \Delta\mathbf{L}_{\text{next}}) = T \times \Delta\mathbf{L}_{\text{next}}$
* 这意味着在统计意义上,调整后的贡献值期望值等于原始值,因此最终颜色的期望值不变。
* **方差影响**
* 俄罗斯轮盘赌会增加结果的方差(因为引入了随机性),但避免了直接截断带来的系统性偏差。在蒙特卡洛渲染中,无偏性通常比方差更重要,因为方差可以通过增加采样数来减少。
## 阅读他人的代码!
本课程的前三章涵盖了开始渲染体积所需的全部知识。至此当你面对他人的代码时应该能够理解其核心逻辑。让我们一起进行这个练习我们将使用一个名为PBRT的开源项目并查看其体积渲染的实现——对你而言其中应该不再有任何秘密。
```cpp
Spectrum SingleScatteringIntegrator::Li(const Scene *scene,
const Renderer *renderer, const RayDifferential &ray,
const Sample *sample, RNG &rng, Spectrum *T,
MemoryArena &arena) const {
// [注释]
// 计算与体积物体的交界面t0, t1。如果光线未与体积物体相交
// 则将透射率设为1并返回颜色0。
// [/注释]
VolumeRegion *vr = scene->volumeRegion;
float t0, t1;
if (!vr || !vr->IntersectP(ray, &t0, &t1) || (t1-t0) == 0.f) {
*T = 1.f;
return 0.f;
}
// [注释]
// 如果存在交点将全局透射率透明度设为1
// 将存储最终颜色的变量此处命名为Lv设为0。
// 计算采样点数量,并相应调整步长。
// [/注释]
// 在_vr_中执行单散射体积积分
Spectrum Lv(0.);
// 准备体积积分的步进
int nSamples = Ceil2Int((t1-t0) / stepSize);
float step = (t1 - t0) / nSamples;
Spectrum Tr(1.f);
Point p = ray(t0), pPrev;
Vector w = -ray.d;
t0 += sample->oneD[scatterSampleOffset][0] * step;
// 计算单散射采样点的采样模式
float *lightNum = arena.Alloc<float>(nSamples);
LDShuffleScrambled1D(1, nSamples, lightNum, rng);
float *lightComp = arena.Alloc<float>(nSamples);
LDShuffleScrambled1D(1, nSamples, lightComp, rng);
float *lightPos = arena.Alloc<float>(2*nSamples);
LDShuffleScrambled2D(1, nSamples, lightPos, rng);
uint32_t sampOffset = 0;
// [注释]
// 正向光线步进:这是主循环,我们将遍历所有小线段,
// 计算每个采样点对最终体积透明度Tr和颜色Lv的不透明度和内散射贡献。
// [/注释]
for (int i = 0; i < nSamples; ++i, t0 += step) {
// 推进到t0处的采样点并更新_T_
// [注释]
// 更新采样点位置,然后评估该位置的体积密度。
// 我们尚未学习这部分内容,这是接下来两章的主题。
// 目前可将stepTau变量视为我们代码中的密度变量。
// 采样点位置经过抖动处理。
// 然后应用比尔定律用当前采样点的不透明度衰减全局透射率变量Tr
// [/注释]
pPrev = p;
p = ray(t0);
Ray tauRay(pPrev, p - pPrev, 0.f, 1.f, ray.time, ray.depth);
Spectrum stepTau = vr->tau(tauRay,
.5f * stepSize, rng.RandomFloat());
Tr *= Exp(-stepTau);
// [注释]
// 应用俄罗斯轮盘赌技术。
// [/注释]
// 如果透射率很小,可能终止光线步进
if (Tr.y() < 1e-3) {
const float continueProb = .5f;
if (rng.RandomFloat() > continueProb) {
Tr = 0.f;
break;
}
Tr /= continueProb;
}
// [注释]
// 采样点存活:计算该采样点...
// [/注释]
}
}
```
## 接下来是什么?
如果你能坚持到这里恭喜你你已经“毕业”了Scratchapixel将为你颁发虚拟荣誉证书。我们已经涵盖了这些算法的核心工作原理。剩余的章节主要是利用我们迄今为止所学和构建的知识享受乐趣并制作一些酷炫的图像。最后在最后一章中我们将汇总所有所学知识看看它们如何转化为描述光能穿过参与介质空气、烟雾、云层、水等并与之相互作用的通量的实际方程。