体积渲染(Volume Rendering),图形学渲染领域的又一座大山。在 NeRF 和 3DGS 还没有出现之前,体积渲染主要被用来处理各种参与介质,比如牛奶、火焰、云层这类“看得见内部结构”的东西。这些介质有一个共同点:它们内部都充满了大量微小粒子,光在其中传播时,会不断和这些粒子发生相互作用,比如吸收、散射,甚至发光。也正因为如此,参与介质的渲染要比普通表面渲染复杂得多。
描述这一过程的核心工具,就是看起来特吓人的辐射传输方程(Radiative Transfer Equation, RTE)。和常见的渲染方程相比,它要复杂不少,因为它本质上是一个积分—微分方程。而渲染方程则是一个积分方程。
不过虽然辐射传输方程看起来很复杂,但在实际计算时,我们通常可以借助一种非常经典的方法——光线步进(Ray Marching)——来一步一步地近似求解它。
这篇文章主要是关于体积渲染背后的理论部分,不会涉及具体的实现。
光线在参与介质中的行为
和求解渲染方程时类似,在体积渲染里,我们真正需要关心的,其实也是最终进入视线方向的光。换句话说,我们并不需要追踪所有光线的完整命运,而是重点关注:哪些光会到达观察者眼中,以及它们在传播过程中发生了什么变化。
当光线在参与介质中传播时,它的行为大致可以归纳为四种:吸收(Abortion)、外散射(Out-scattering)、内散射(In-scattering)和发光(Emission)。其中,吸收和外散射会让视线方向上的光逐渐减弱;而内散射和发光则会为这条光路“补充能量”,让最终到达视点的光变得更强。
吸收其实很好理解:光在介质中传播时,一部分能量会被介质中的粒子吸收掉,所以光会越来越弱。
外散射描述的是这样一种情况:原本沿着视线方向传播的光,在与介质中的粒子发生相互作用后,被“打散”到了其他方向上。这样一来,沿着视线方向继续前进的光就变少了,因此观察者接收到的光照强度也会减弱。
内散射则可以看作外散射的反方向:原本不在视线方向上的光,在与粒子相互作用后,被散射到了视线方向上。这样,观察者就会额外接收到一部分光能,因此看到的结果会更亮一些。
发光就更直观了。比如火焰这类介质,其中的粒子本身就会主动向外辐射光线,因此也会给视线方向贡献额外的能量。
吸收系数、散射系数和消光系数
为了描述前面提到的这几种行为,辐射传输方程引入了两个非常重要的量:吸收系数 σa(x) 和 散射系数 σs(x)。它们用来刻画光在介质中传播时,被吸收和被散射的强弱程度:数值越大,说明光每往前走一小段距离,就越容易被介质影响。
在均匀介质中,这两个系数通常可以近似看成常数;但在非均匀介质中,比如云层、烟雾这类内部密度不断变化的介质,它们往往需要写成关于位置 x 的函数。更一般一点,在少数更复杂的模型里,它们甚至还可能和方向 ω 有关,比较少见就是了。
吸收系数 σa(x) 和散射系数 σs(x) 的单位都是 m−1。如果把一束光近似看作由大量光子组成,那么也可以粗略地理解为:光子在介质中前进时,会以一定速率不断“损失”掉,其中一部分因为吸收而消失,另一部分则因为散射而偏离原来的传播方向。
如果将光线用一系列光子替代,它们的具体含义可以理解成经过单位长度后,还有多少光子没有被吸收/散射。 例如这里由 1000 个光子传播了一个单位长度,若σa=0.4,则说明在这一个单位长度中,有 400 个光子被吸收了。随后剩下的 600 个光子又传播了,一个单位长度,这次只有 600×0.4=240 个光子被吸收。下图展示了σa=0.4时,光子数量随着路径长度增加而被吸收的过程。
例如,假设一束光由 1000 个光子组成,并且只考虑吸收。若 σa=0.4,那么可以粗略地理解为:当光传播一个单位长度时,会有一部分光子被吸收。用离散的方式去近似这个过程,可以想成第一段路程中约有 1000×0.4=400 个光子被吸收,于是还剩下 600 个光子;接着再传播一个单位长度时,被吸收的就不再是原来的 1000 个,而是剩余光子中的 600×0.4=240 个。也就是说,随着光不断传播,剩余光子数会越来越少,因此后续被吸收的数量也会逐渐减少。
上面这个描述其实并不准确,但是已经足够说明问题了,光线能量随着传播距离呈现指数衰减:一开始下降得更快,之后逐渐放缓。这个过程并非上述的离散的过程,而是一个连续进行的。
为了描述这个连续过程,我们需要引入微分。若只考虑吸收,那么当光沿方向 ω 传播一小段距离 ds 时,其亮度的变化可以写成:
dL(x+sω,ω)=−σa(x+sω)L(x+sω,ω)ds
这里的负号表示:随着传播距离增加,光的能量在不断减少。
这个微分方程其实是可以直接求解的。借助一点微积分,我们就能把“光在传播过程中不断被吸收”这件事,写成一个非常漂亮的解析形式:
L(x+dω,ω)=exp(∫0d−σa(x+sω)ds)L(x,ω)
这个式子的含义很直观:光从 x 沿着方向 ω 传播一段距离 d 之后,最终剩下来的能量,等于初始能量乘上一个衰减因子。而这个衰减因子,就是体积渲染中常说的透射率(transmittance)。
在这里只考虑吸收、不考虑散射和发光,所以透射率的意义非常直接:它表示一束光在介质中传播了一段距离之后,仍然没有被吸收掉的那部分比例。它的取值范围在 [0,1] 之间,通常记作 τa(x,x′):
τa(x,x′)=exp(∫0d−σa(x+sω)ds)
其中,
x′=x+dω
也就是说,我们是在从 x 到 x′ 的这段路径上,把吸收系数累积起来,再通过指数函数把它变成最终的透射率。
如果吸收系数 σa(x) 是常数,也就是介质处处都一样,那么这个式子就会进一步简化成我们最熟悉的形式:
τa(x,x′)=e−σad
其中,
d=∥x′−x∥
表示光传播的路径长度。
这就是大名鼎鼎的 Beer–Lambert Law(比尔—朗伯定律)。它告诉我们:光在介质中的衰减并不是线性的,而是随着传播距离呈指数下降。也正因为如此,光一开始衰减得最快,越往后变化越慢。
事情还没结束。前面我们处理了吸收,但对于参与介质来说,另一种同样会让光线衰减的机制——外散射——也不能忽略。
所谓外散射,可以简单理解为:原本沿着视线方向传播的光,在与介质中的粒子相互作用之后,被“打散”到了别的方向上。这样一来,继续留在视线方向上的光就变少了,因此从观察者的角度看,光线也被削弱了。
从数学形式上看,外散射和吸收的处理方式几乎是完全一样的。
如果我们只考虑外散射,不考虑吸收和发光,那么对应的透射率可以写成:
τs(x,x′)=exp(−∫0dσs(x+sω)ds)
如果散射系数
σs(x)
是常数,这个式子就会进一步简化为:
τs(x,x′)=e−σsd
这里的含义和吸收时完全类似:光沿路径传播的过程中,会不断因为外散射而“损失”掉一部分仍然留在当前方向上的能量。
正因为吸收和外散射在数学表达上如此相似,辐射传输方程通常会把它们合并起来,用一个统一的量来描述,这就是消光系数(extinction coefficient):
σt(x)=σa(x)+σs(x)
它表示的是:光在介质中传播时,由于吸收和外散射共同作用而产生的总衰减强度。
于是,最终的透射率就可以统一写成:
τ(x,x′)=exp(−∫0dσt(x+sω)ds)
如果介质是均匀的,也就是 σt 为常数,那么它就会退化成最熟悉的形式:
τ(x,x′)=e−σtd
很重要的一条式子,因为它告诉我们:在参与介质中,是由吸收和外散射共同决定的指数衰减过程。
相位函数与内散射
相位函数
吸收和外散射会让光线不断变暗,而内散射和发光则会为视线方向补充新的亮度。这一小节我们将讨论内散射是如何为视线方向上的光线补充亮度的。
上图展示的就是一个典型的内散射过程。可以把它理解成一个和 BSDF 有些相似的机制:当来自某个光源方向的入射光到达介质中的某个位置 x 时,这里的粒子会把其中一部分光散射到观察方向,也就是视线方向上。
从这个角度看,内散射确实和表面渲染里的反射 / 折射有一点像:它们都在回答同一个问题——来自某个方向的入射光,有多少会被分配到另一个方向上。
只不过,BSDF描述的是表面上的光线分布,而这里讨论的相位函数描述的是介质内部的散射行为。
为了刻画这种分配关系,体积渲染里会定义一个函数,叫做相位函数(phase function),通常记作fp(x,ωi,ωo)。
它表示在位置 x 处,来自方向 ωi 的入射光,有多少会被散射到方向 ωo 上。
相位函数通常需要满足两个重要性质。
第一是可逆性(reciprocity):
fp(x,ωi,ωo)=fp(x,ωo,ωi)
这意味着,如果介质满足互易性,那么“从 ωi 散射到 ωo”和“从 ωo 散射到 ωi”这两个过程在数学上是对称的。
第二是归一性(normalization):
∫S2fp(x,ωi,ωo)dω=1
这里的积分区域是整个单位球面 S2。
可以看到虽然表达的含义与BSDF类似,但是BSDF并不满足归一性,这是因为BSDF在推导的过程中已经考虑了吸收项(即菲涅尔项)。而在辐射传输方程中,吸收、外散射造成的整体衰减,则需要通过前面介绍的吸收系数、散射系数和透射率来单独处理。
进一步地,如果相位函数与位置 x 无关,并且介质的散射特性只取决于入射方向和出射方向之间的夹角,那么问题就会简单很多。
这时,我们不再需要分别关心 ωi 和 ωo 这两个具体方向本身,而只需要关心它们之间的夹角 θ。换句话说,散射结果只由“光拐了多大一个弯”决定,而不取决于它发生在什么位置、朝向哪个绝对方向。
在这种情况下,相位函数就可以进一步简化为只关于夹角的函数:fp(θ)
其中,θ 就是方向 ωi 和 ωo 之间的夹角。
这个简化非常常见,也非常有用。因为一旦相位函数只和 θ 有关,我们在分析和计算散射时,就不必总是处理两个完整的方向变量,而只需要研究“不同散射角度下,光更倾向于往哪里走”即可。很多经典的相位函数模型,都是在这个假设下建立起来的。
最简单的相位函数,当然就是各向同性相位函数(isotropic phase function)。它的含义很好理解:无论入射光从哪个方向来,发生散射之后,光都会以完全均匀的方式分配到球面上的所有方向。某种意义上,它和表面渲染里的漫反射有点像,都是一种“不偏不倚、平均分配”的模型。
它的表达式非常简单,且只有唯一一种表达:
fp(x,ωi,ωo)=4π1.
除了各向同性相位函数之外,还有很多各向异性相位函数。这类相位函数会更偏向某些特定方向进行散射。通过调节参数,我们可以得到更偏向前向散射或者后向散射的效果。
其中一个非常常用的模型,就是 Henyey–Greenstein 相位函数:
fp(x,θ)=4π1(1+g2−2gcosθ)231−g2.
这里的 g 被称为不对称因子(asymmetry parameter),它的取值范围是 [−1,1]。这个参数决定了散射更偏向哪个方向:
- 当 g=0 时,相位函数就退化为各向同性散射;
- 当 g>0 时,散射更偏向前向散射;
- 当 g<0 时,散射更偏向后向散射。
直观来看,g 越接近 1,光就越倾向于沿着原来的方向继续前进;而 g 越接近 −1,光就越倾向于被“反弹”回去。
有了相位函数之后,我们就可以把内散射写成一个明确的数学表达式了。它表示的是:在介质中的某个位置,会有来自各个入射方向的光,被散射一部分到当前的观察方向上,从而为这条视线“补充亮度”。
对应的微分形式可以写成:
dL(x′,ω)=σs(x′)∫s2fp(x′,ωi,ω)L(x′,ωi)dωids,
其中,
x′=x+sω.
这个式子的含义可以这样理解:
- L(x′,ωi) 表示在位置 x′ 处,来自入射方向 ωi 的光;
- fp(x′,ωi,ω) 决定这部分入射光有多少会被散射到观察方向 ω;
- σs(x′) 则表示在这个位置上,介质发生散射的强度;
- 对整个球面 S2 积分,意味着我们要把所有可能的入射方向都累加起来;
- 最后再乘上 ds,表示这只是沿光路上一小段距离内产生的亮度增量。
发光
发光的作用和内散射类似,都会为观察方向“补充”新的亮度。只不过,内散射是把其他方向上的光重新分配到视线方向,而发光则更直接:介质本身就在向外发射光。
因此,发光项的微分形式很好写:
dL(x′,ω)=Le(x′,ω)ds
这里,Le(x′,ω) 表示介质在位置 x′ 处,沿观察方向 ωo 的发光强度。也就是说,当光线沿路径前进一小段距离 ds 时,这一小段介质会主动向视线方向贡献一部分新的亮度。
辐射传输方程
现在,我们已经分别得到了吸收、外散射、内散射和发光这四部分的微分形式。到了这里,我们已经集齐了体积渲染里的所有核心拼图。接下来要做的事情就很自然:把这四种素材 Overlay,写成体积渲染中最核心的方程——辐射传输方程(Radiative Transfer Equation, RTE)。
它的形式可以写成:
∂s∂L(x′,ω)=−σt(x′)L(x′,ω)+σs(x′)∫S2fp(x′,ωi,ω)L(x′,ωi)dωi+Le(x′,ω)
其中,
x′=x+sω
表示沿着方向 ω 前进距离 s 后到达的位置。
这个方程其实并没有看起来那么吓人。它只是把光在线路传播时可能发生的几件事,一股脑写在了一起:
-
第一项表示光在传播过程中因为吸收和外散射而不断衰减;
−σt(x′)L(x′,ω)
-
第二项表示来自其他方向的光经过内散射后,被“搬运”到了当前方向上;
σs(x′)∫S2fp(x′,ωi,ω)L(x′,ωi)dωi
-
第三项则表示介质本身的发光贡献。
Le(x′,ω)
如果觉得中间那一大坨积分看起来有点重,我们也可以把它单独记成一个“散射光项”:
Ls(x′,ω)=∫S2fp(x′,ωi,ω)L(x′,ωi)dωi
这样一来,辐射传输方程就可以写成一个更紧凑的形式:
∂s∂L(x′,ω)=−σt(x′)L(x′,ω)+σs(x′)Ls(x′,ω)+Le(x′,ω)(1)
这么看就清楚多了:左边描述的是光沿路径传播时的变化率,右边则是在回答“它为什么会变”——要么因为衰减而减少,要么因为散射和发光而增加。
这也正是辐射传输方程最核心的思想:光在参与介质中的传播,本质上就是一个不断损失、又不断补充的动态平衡过程。
在前面的推导中,我一直使用的是 x′ 而不是直接写 x。这样做其实是有意的,因为这里我们讨论的并不是“空间中某个固定点上的普通变化”,而是沿着方向 ω 前进时,光线随路径长度 s 的变化。
更具体地说,我们有
x′=x+sω
也就是说,x′ 表示的是:从起点 x 出发,沿着方向 ω 走了距离 s 之后到达的位置。
因此,当我们写
∂s∂L(x′,ω)
时,它表达的是一个非常明确的含义:辐亮度沿着方向 ω 前进时,对路径长度 s 的变化率。
如果这里直接把 x′ 写回 x,虽然形式上看起来更简洁,但就会把这层“沿着特定方向求导”的含义冲淡。这样一来,式子看上去就像是在对一个普通空间位置求变化,而不再强调“这是沿着光线传播方向发生的变化”。
当然,如果你觉得一直写 x′ 还是有些累赘,还有一种在文献里非常常见、也更紧凑的写法,那就是把“沿着方向 ω 的变化率”直接写成方向导数:
(ω⋅∇x)L(x,ω)=−σt(x)L(x,ω)+σs(x)Ls(x,ω)+Le(x,ω)
这里的
ω⋅∇x
可以理解为:沿着方向 ω 对空间位置 x 求导。
它和前面写的 ∂s∂ 本质上表达的是同一件事,只是记号更紧凑,也更适合放在正式的数学推导或者论文里。
换句话说,这两种写法只是形式不同,强调的核心其实完全一样:我们关心的并不是光在任意方向上的变化,而是它沿着当前传播方向的变化。
现在,我们只需要沿着光线路径对公式(1)两侧分别积分,就能得到辐射传输方程的积分形式:
L(x,ω)=∫x0xτ(ξ,x)Le(ξ,ω)dξ+∫x0xτ(ξ,x)σs(ξ)∫S2f(ξ,ωi,ω)L(x,ωi)dωidξ+τ(x0,x)L0(x0,ω),
Scratchapixel的 Volume Rendering 章节有这个式子的详细推导过程。虽然推导很麻烦,但是最终表达的含义却非常直观。它表达的是:最终到达视线方向的辐亮度,可以看成三部分贡献的总和。
第一项是发光项。沿着这条视线分布在不同位置的发光粒子,都会朝观察方向贡献一部分辐亮度。不过,这些光并不是一产生就能毫发无损地到达观察者,它们还需要继续穿过剩下的介质,因此会受到吸收和外散射的影响。也正因为如此,这一项前面要乘上透射率 τ(ξ,x),用来描述这部分光从位置 ξ 传播到 x 之后还能剩下多少。
第二项是内散射项。它表示来自其他方向的入射光,在介质内部的某个位置被散射到了当前视线方向上。换句话说,本来并不朝着我们眼睛传播的光,在途中拐了个弯,于是也成了最终成像的一部分。同样,这部分光在被散射到视线方向之后,还要继续传播到观察点,所以也要乘上对应的透射率,表示它在后续路程中的衰减。下面是一张单次散射的示意图,多次散射的情况会更加复杂。
最后一项是边界项,也可以理解成背景光项。它表示光线在进入这段介质之前,原本就携带着一部分辐亮度 L0(x0,ω)。这部分光在穿过整段介质之后,也会因为吸收和外散射而逐渐减弱,最终只剩下 τ(x0,x)L0(x0,ω) 这一部分能够到达观察者。
这样一来,积分形式的辐射传输方程就很好理解了:我们看到的光,一部分来自介质本身的发光,一部分来自途中被散射进视线的光,剩下的一部分则来自背景光在穿过介质后残留下来的贡献。
以上就是本篇的全部内容。折腾了半天,这块总算是理地差不多了。下一篇终于可以愉快地啃 BSSDF 了。
参考文献
Efficient simulation of light transport in scenes with participating media using photon maps
PBRT:Volume Scattering Processes
Scratchapixel:Volume Rendering