background

光线追踪中的 Multiple Importance Sampling

本文主要介绍多重重要性采样(Multiple Importance Sampling)及其在光线追踪中的典型应用。

Table of Contents

Multiple Importance Sampling

Monte Carlo Path Tracing

在 Ray Tracing 中,最核心的技术就是 Monte Carlo Estimation。通过使用 Monte Carlo Estimation,我们可以求解 Rendering Equation。对于 Monte Carlo Estimation 来说,一个合适的采样方法可以极大提升收敛的速度,具体来说,对于以下积分及其 Estimator

I=∫Df(x)dx≈1N∑i=1Nf(Xi)p(Xi) I = \int_D f(x) \mathrm dx \approx \dfrac{1}{N} \sum_{i = 1}^N \dfrac{f(X_i)}{p(X_i)}

XiX_i 的最佳的采样分布应该满足 p(Xi)∝f(Xi)p(X_i) \propto f(X_i)。

然而对于 Rendering Equation 来说,这个条件难以满足,因为其被积函数包含了 cosine-weighted BSDF 和 radiance 两部分因子,后者更是需要递归求解的未知项,显然没有办法进行完美的采样。

Lo(x,ωo)=Le(x,ωo)+∫Ω+fs(x,ωo,ωi)Li(x,ωi)(n⋅ωi)dωi L_o(x, \omega_o) = L_e(x, \omega_o) + \int_{\Omega^+} f_s(x, \omega_o, \omega_i) L_i(x, \omega_i) (n \cdot \omega_i) \mathrm d\omega_i

一般情况下,我们只能选择让采样分布于其中一部分因子的形状近似,也就产生了两种采样——BSDF 采样(包括了余弦项)和光源采样(光源贡献的 radiance 应该比非直接的更大)。

Multiple-Sample Model

当存在多种采样方法时,可以将它们结合在一起,每种方法采样若干个样本,最后在用一定的权重进行组合。这就是 MIS 中的 Multiple-Sample Model:

I=∫Df(x)dx≈∑i=1m1ni∑j=1niwi(Xij)f(Xij)pi(Xij) I = \int_D f(x) \mathrm dx \approx \sum_{i = 1}^{m} \dfrac{1}{n_i} \sum_{j = 1}^{n_i} w_i(X_{ij}) \dfrac{f(X_{ij})}{p_i(X_{ij})}

其中 nin_i 为使用第 ii 种方法采样的个数,pip_i 为第 ii 种采样方法的 PDF。第 ii 种方法得到的 Xi1,Xi2,…X_{i1}, X_{i2}, \dots 独立同分布(在实际实现中已经满足)。wi(X)w_i(X) 为权重,需要满足以下条件:

∀x(f(x)≠0),∑i=1mwi(x)=1∀i,x(pi(x)=0),wi(x)=0 \forall x(f(x) \ne 0), \sum_{i = 1}^m w_i(x) = 1 \\ \forall i, x(p_i(x) = 0), w_i(x) = 0

这些条件可以推出 ∀x(f(x)≠0)\forall x(f(x) \ne 0),至少有一种采样方法可以采样到 xx,也就是所有采样方法的并可以覆盖整个积分区域。

只要满足条件,那么 II 就是无偏的

E(I)=E(∑i=1m1ni∑j=1niwi(Xij)f(Xij)pi(Xij))=∑i=1m1ni∑j=1niE(wi(Xij)f(Xij)pi(Xij))=∑i=1mE(wi(Xi)f(Xi)pi(Xi))=∑i=1m∫Dpi(x)wi(x)f(x)pi(x)dx=∫D(∑i=1mwi(x))f(x)dx=∫Df(x)dx \begin{aligned} \mathbb E(I) & = \mathbb E\left(\sum_{i = 1}^{m} \dfrac{1}{n_i} \sum_{j = 1}^{n_i} w_i(X_{ij}) \dfrac{f(X_{ij})}{p_i(X_{ij})}\right) \\ & = \sum_{i = 1}^{m} \dfrac{1}{n_i} \sum_{j = 1}^{n_i} \mathbb E\left(w_i(X_{ij}) \dfrac{f(X_{ij})}{p_i(X_{ij})}\right) \\ & = \sum_{i = 1}^{m} \mathbb E\left(w_i(X_i) \dfrac{f(X_i)}{p_i(X_i)}\right) \\ & = \sum_{i = 1}^{m} \int_{D} p_i(x) w_i(x) \dfrac{f(x)}{p_i(x)} \mathrm dx \\ & = \int_{D} \left(\sum_{i = 1}^{m} w_i(x)\right) f(x) \mathrm dx \\ & = \int_{D} f(x) \mathrm dx \end{aligned}

实际运用中,wi(x)w_i(x) 的选择有几种方案:

  • 加权平均:wi(x)w_i(x) 为常数,实际效果不好,以简单平均为例 wi(x)=1n,ni=1w_i(x) = \dfrac{1}{n}, n_i = 1,最终只能使方差 D(I′)=1nD(I)\mathbb{D}(I') = \dfrac{1}{n} \mathbb{D}(I)。
  • 划分积分区域:假设积分区域可以划分为不相交的 Ω1,Ω2,…,Ωn\Omega_1, \Omega_2, \dots, \Omega_n,然后分别采样,最终再把每个区域上的积分相加起来,实际上就是定义权重 wi(x)=I{x∈Ωi}w_i(x) = \mathcal{I}\{x \in \Omega_i\}。典型的应用就是 Next Event Estimation,其他情况很少见,因为大多数情况没有办法进行这样的划分。
  • Balanced Heuristic:wi(x)=pi(x)∑k=1npk(x)w_i(x) = \dfrac{p_i(x)}{\sum_{k = 1}^n p_k(x)},非常优秀的权重分配方式,最优秀的权重分配方式也不会在方差上显著小于 Balanced Heuristic。
  • Power Heuristic:wi(x)=(pi(x))β∑k=1n(pk(x))βw_i(x) = \dfrac{(p_i(x))^\beta}{\sum_{k = 1}^n (p_k(x))^\beta},如果每种采样方式在自己对应理想情况下已经可以达到很低的方差,那么 Power Heuristic 相比 Balanced Heuristic 会强化每种采样方法在理想情况的效果。一般情况下 β=2\beta = 2。

MIS 可以应用到几乎各个地方,只要原本的采样空间可以用多个采样分布来覆盖,每个分布在特定空间表现更优,就可以使用 MIS 进行组合。

具体到 Path Tracing 中,对于每一条光线,其到达某表面某点 xx 时,就可以使用 cosine-weighted BSDF 采样 p1(ω)p_1(\omega) 和光源采样 p2(ω)p_2(\omega) 分别得到样本 ωi1,ωi2\omega_{i1}, \omega_{i2},然后使用 Balanced Heuristic 分配权重

w1(ωi1)=p1(ωi1)p1(ωi1)+p2(ωi1) w_1(\omega_{i1}) = \dfrac{p_1(\omega_{i1})}{p_1(\omega_{i1}) + p_2(\omega_{i1})}

w2(ωi2)=p2(ωi2)p1(ωi2)+p2(ωi2) w_2(\omega_{i2}) = \dfrac{p_2(\omega_{i2})}{p_1(\omega_{i2}) + p_2(\omega_{i2})}

然后计算 exitant radiance

Lo(x,ωo)≈w1(ωi1)fs(x,ωo,ωi1)Li(x,ωi1)(n⋅ωi1)p1(ωi1)+w2(ωi2)fs(x,ωo,ωi2)Li(x,ωi2)(n⋅ωi2)p2(ωi2) \begin{aligned} L_o(x, \omega_o) & \approx \dfrac{w_1(\omega_{i1}) f_s(x, \omega_o, \omega_{i1}) L_i(x, \omega_{i1}) (n \cdot \omega_{i1})}{p_1(\omega_{i1})} \\ & + \dfrac{w_2(\omega_{i2}) f_s(x, \omega_o, \omega_{i2}) L_i(x, \omega_{i2}) (n \cdot \omega_{i2})}{p_2(\omega_{i2})} \end{aligned}

从理论上来说,这样计算的结果是正确的,但是每一次递归产生的分支数就会倍增,复杂度是指数级的,解决方法可以使用接下来的 Single-Sample Model 或者 Next Event Estimation。

需要注意这里对光源采样的 PDF 形式是 p2(ω)p_2(\omega),也就是说这个 PDF 只有在立体角下才有意义。而光源采样是在光源的表面上进行的,采样的是点 x′x',其 PDF 是 p(x′)p(x')。

对于顶点在 xx、指向 x′x' 的立体角 ω\omega,有

dω=∣nx′⋅ω∣∥x−x′∥2dA \mathrm d\omega = \dfrac{|n_{x'} \cdot \omega|}{\|x - x'\|^2} \mathrm dA

两者之间需要进行转换:

p2(ω)=p(x′)dAdω=∥x−x′∥2∣nx′⋅ω∣p(x′) p_2(\omega) = \dfrac{p(x') \mathrm dA}{\mathrm d\omega} = \dfrac{\|x - x'\|^2}{|n_{x'} \cdot \omega|}p(x')

Single-Sample Model

对于 Path Tracing 这样需要递归计算的问题,为了防止指数爆炸,Single-Sample Model 可以用于替代 Multiple-Sample Model,每次估计仅随机选择一种采样方法并且仅采样一次。首先固定 n1=n2=⋯=nm=1n_1 = n_2 = \cdots = n_m = 1。记第 ii 种估计 Fi(x)=wi(x)f(x)pi(x)F_i(x) = \dfrac{w_i(x) f(x)}{p_i(x)},FF 为最终估计,c1,c2,…,cmc_1, c_2, \dots, c_m 为选择各种采样方法的概率,则有

P(F(X)=Fi(X)ci)=ci \mathbb{P}\left(F(X) = \dfrac{F_i(X)}{c_i}\right) = c_i

定义随机变量 II, P(I=i)=ci\mathbb{P}(I = i) = c_i,则联合分布 (I,X)(I, X) 有 PDF

p(i,x)=P(I=i)p(x∣I=i)=cipi(x) p(i, x) = \mathbb{P}(I = i) p(x \mid I = i) = c_i p_i(x)

前面的 F(X)F(X) 就可以更准确地表示为 F(I,X)=FI(X)cIF(I, X) = \dfrac{F_I(X)}{c_I},计算其期望

E(F(I,X))=∑i=1m∫Dp(i,x)Fi(x)cidx=∑i=1m∫Dcipi(x)Fi(x)cidx=∑i=1m∫Dwi(x)f(x)dx=∫D(∑i=1mwi(x))f(x)dx=∫Df(x)dx \begin{aligned} \mathbb{E}(F(I, X)) & = \sum_{i = 1}^m \int_D p(i, x) \dfrac{F_i(x)}{c_i} \mathrm dx \\ & = \sum_{i = 1}^m \int_D c_i p_i(x) \dfrac{F_i(x)}{c_i} \mathrm dx \\ & = \sum_{i = 1}^m \int_D w_i(x) f(x) \mathrm dx \\ & = \int_D \left(\sum_{i = 1}^m w_i(x) \right) f(x) \mathrm dx \\ & = \int_D f(x) \mathrm dx \end{aligned}

Single-Sample Model 的 wi(x)w_i(x) 选择上,Balanced Heuristic 是方差最小的。

在 Multiple-Sample Model 中的 Lo(x,ωo)L_o(x, \omega_o) 计算可以修改为随机选择前一项或后一项计算,再除以选择这一项的概率,这样就避免了指数爆炸。

尽管 Single-Sample Model 一次只采样一次,但这并不代表我们对于每个像素只采样一次。每个像素同样有多个光线样本,每个光线计算光照时只采样一个方向,随着 SPP 增多,每个像素的结果也会正确收敛。

MIS 的应用

其实在上文介绍 MIS 的原理时,已经涉及到了一些基本的应用,下文的应用则是更进一步的结合。

Next Event Estimation

再次回顾 Rendering Equation,并进行拆分,可以发现 Lo(x,ωo)L_o(x, \omega_o) 由三部分组成:自身直接发光、其他点的自身直接发光、光线在其他点的散射

Lo(x,ωo)=Le(x,ωo)+Ls(x,ωo)=Le(x,ωo)+∫Ω+fs(x,ωo,ωi)Li(x,ωi)(n⋅ωi)dωi=Le(x,ωo)+∫Ω+fs(x,ωo,ωi)Le(xωi′,−ωi)V(xωi′,x)(n⋅ωi)dωi+∫Ω+fs(x,ωo,ωi)Ls(xωi′,−ωi)(n⋅ωi)dωi \begin{aligned} L_o(x, \omega_o) & = L_e(x, \omega_o) + L_s(x, \omega_o) \\ & = L_e(x, \omega_o) + \int_{\Omega^+} f_s(x, \omega_o, \omega_i) L_i(x, \omega_i) (n \cdot \omega_i) \mathrm d\omega_i \\ & = L_e(x, \omega_o) \\ & + \int_{\Omega^+} f_s(x, \omega_o, \omega_i) L_e(x'_{\omega_i}, -\omega_i) V(x'_{\omega_i}, x) (n \cdot \omega_i) \mathrm d\omega_i \\ & + \int_{\Omega^+} f_s(x, \omega_o, \omega_i) L_s(x'_{\omega_i}, -\omega_i) (n \cdot \omega_i) \mathrm d\omega_i \\ \end{aligned}

“其他点的自身直接发光”就是直接光照,用第二项表示,“其他点的散射”就是间接光照,用第三项表示。V(xωi′,x)V(x'_{\omega_i}, x) 表示 xωi′x'_{\omega_i} 和 xx 是否直接可见。此时第二项和第三项可以分开计算。

Next Event Estimation 的思想就是在计算 Lo(x,ωo)L_o(x, \omega_o) 时就提前计算其他点 x′x' 对 xx 产生的直接光照 Le(xωi′,−ωi) L_e(x'_{\omega_i}, -\omega_i),而不是在递归计算 Li(x,ωi)=Lo(xωi′,−ωi)L_i(x, \omega_i) = L_o(x'_{\omega_i}, -\omega_i) 才把直接光照加上去。在没有使用 NEE 时,所有的 radiance 的贡献归根结底都只能在光线与发光材料(Le>0L_e > 0)相交时才能计算,在大多数场景下,光源只是场景中的一小部分,采样不太可能选取光源方向。使用 NEE 后,直接光照计算被独立出来,也就可以仅对光源方向进行采样。而对于间接光照,影响更显著的是 xx 处的 BSDF,使用 BSDF 采样计算间接光照则比计算所有光照更加合适。

使用 NEE 的 Path Tracing 则变为:

  • 对于每一条光线,其到达表面 xx 点时,分别根据 BSDF 和光源采样方向 ωi1,ωi2\omega_{i1}, \omega_{i2}。
  • ωi1\omega_{i1} 方向的光线按照 Path Tracing 的步骤继续递归计算,但是要注意,递归计算的是间接光照,因此下一个交点的 LeL_e 不能再计入结果。
  • ωi2\omega_{i2} 方向的光线只需要判断是否与光源上采样的点直接相交,如果相交则加上采样点的 LeL_e。无论是否相交,这个光线都无需递归计算,这样的光线也因此被称为 Shadow Ray。
  • 光线的第一个交点处的 LeL_e 并没有在前一个交点被提前计算(因为并没有前一个交点),需要特殊判断,加入到最终结果中。第一层递归计算的是对像素的所有光照,而不是间接光照。

这个方法与上文描述的 Multiple-Sample Model 的 MIS 非常相似,不同之处在于上文的方法没有划分出直接光照和间接光照,无论是哪一种方法采样得到的方向,都使用相同的 Path Tracing 步骤继续递归计算 LoL_o。而 NEE 因为明确划分了光照的类型,递归计算的是 LsL_s,使用 Shadow Ray 计算的是 LeL_e,两种类型的光线有很大的差别。

那么为什么上文提到 Next Event Estimation 是 MIS 的权重分配的一种方法呢?在实际的场景中,光源是少数。尽管每一种材料都有 LeL_e 项,但非发光材料的 LeL_e 项都是 00。而在许多实现中,光源是追踪的终点,光源只贡献 LeL_e 而不贡献 LsL_s,这与非发光材料相反。因此所有的点要么贡献 LeL_e,要么贡献 LsL_s,这本质上就划分了区域,所以把 NEE 视为划分区域的权重分配方法。

NEE + Balanced Heuristic

朴素的 NEE 能够取得较好的效果有一个前提条件,那就是 BSDF 的形状较为均匀、没有显著的方向性。否则在计算直接光照时,光源采样分布与 BSDF 严重不匹配,NEE 的效果就会大打折扣,最极端的情况就是镜面反射,其 BSDF 是 δ\delta 函数,光源采样完全失效。一种临时解决方法是对于这一类镜面反射/折射材料,不再使用 NEE,而是使用普通 Path Tracing 计算所有光照。但对于 Microfacet Surface 材料,无论是朴素 NEE 还是 朴素的 Path Tracing 都不是好的方案。

解决的方法在于对直接光照计算使用 MIS,即采样 Shadow Ray 方向时使用 Single-Sample Model + Balanced Heuristic 同时考虑光源采样和 BSDF 采样,这样就可以实现对 BSDF 自适应,降低直接光照的方差。对于镜面反射/折射材料,不使用 MIS,因为对光源采样的权重永远是 00。对于间接光照,仍然使用 BSDF 采样。

有的 Shadow Ray 实现中,从一个点发射多条 Shadow Ray,再取每条 Shadow Ray 的平均。根据对 MIS 权重分配的介绍,这种方法实际上是加权平均,较好的情况下,这种方法对于提升效果没有很大作用,方差没有从数量级上减少,运行时间却大幅增加。

以下则介绍 NEE + Balanced Heuristic 在我的 Fractured-Ray Raytracer 中的实现(使用 Rust 编写)。首先是一些基本的 trait 定义,shade() 方法用于计算 intersection 处向 ray 方向的 radiance。

// src/domain/material/def/material.rs
pub trait Material: Any + Debug + Send + Sync + 'static {
    fn shade(
        &self,
        context: &mut RtContext<'_>,
        state: RtState,
        ray: Ray,
        intersection: RayIntersection,
    ) -> Contribution;
    
    // ...
}

pub trait BsdfMaterial: Material + BsdfSampling {
    fn bsdf(
        &self,
        dir_out: UnitVector,
        intersection: &RayIntersection,
        dir_in: UnitVector,
    ) -> Vector;
}
// src/domain/sampling/coefficient/bsdf.rs
pub trait BsdfSampling: Debug + Send + Sync {
    fn sample_bsdf(
        &self,
        ray: &Ray,
        intersection: &RayIntersection,
        rng: &mut dyn RngCore,
    ) -> BsdfSample;

    fn pdf_bsdf(&self, ray: &Ray, intersection: &RayIntersection, ray_next: &Ray) -> Val;
}

对于各种 BSDF 材料,可以定义共同的实现,shade_light() 和 shade_scattering() 分别用于计算直接光照和间接光照:

// src/domain/material/def/ext.rs
pub trait BsdfMaterialExt: BsdfMaterial {
    fn shade_light(
        &self,
        context: &mut RtContext<'_>,
        ray: &Ray,
        intersection: &RayIntersection,
    ) -> Contribution {
        const SAMPLE_LIGHT_PROB: Val = Val(0.5);
        // Single-Sample Model MIS
        if Val(context.rng().random()) <= SAMPLE_LIGHT_PROB {
            let radiance = self.shade_light_using_light_sampling(context, ray, intersection);
            radiance * SAMPLE_LIGHT_PROB.recip()
        } else {
            let radiance = self.shade_light_using_bsdf_sampling(context, ray, intersection);
            radiance * (Val(1.0) - SAMPLE_LIGHT_PROB).recip()
        }
    }

    fn shade_scattering(
        &self,
        context: &mut RtContext<'_>,
        state_next: RtState,
        ray: &Ray,
        intersection: &RayIntersection,
    ) -> Contribution {
        let renderer = context.renderer();

        // Sample from BSDF
        let sample = self.sample_bsdf(ray, intersection, *context.rng());
        if sample.pdf() == Val(0.0) {
            return Contribution::new();
        }

        // coefficient = bsdf * cos / pdf
        let coefficient = sample.coefficient();
        let ray_next = sample.into_ray_next();
        let radiance = renderer.trace(context, state_next, ray_next, DisRange::positive());
        coefficient * radiance
    }

    // ...
}

如果是从光源采样来计算直接光照,则需要测试与光源的可见性,如果可见,则计算光照和权重:

// src/domain/material/def/ext.rs
pub trait BsdfMaterialExt: BsdfMaterial {
    fn shade_light_using_light_sampling(
        &self,
        context: &mut RtContext<'_>,
        ray: &Ray,
        intersection: &RayIntersection,
    ) -> Contribution {
        let scene = context.scene();
        let lights = scene.get_lights();

        // Sample from a light source
        let res = lights.sample_light(intersection, *context.rng());
        let Some(sample) = res else {
            return Contribution::new();
        };
        if sample.pdf() == Val(0.0) {
            return Contribution::new();
        }

        // Test light source visibility
        let (ray_next, distance) = (sample.ray_next(), sample.distance());
        let range = (Bound::Excluded(Val(0.0)), Bound::Included(distance));
        let res = scene.test_intersection(ray_next, range.into(), sample.shape_id());
        let (intersection_next, light) = if let Some((intersection_next, id)) = res {
            let id = id.material_id();
            let material = scene.get_entities().get_material(id).unwrap();
            if material.kind() == MaterialKind::Emissive {
                (intersection_next, material)
            } else {
                return Contribution::new();
            }
        } else {
            return Contribution::new();
        };

        // Balanced Heuristic
        let pdf_light = sample.pdf();
        let pdf_bsdf = self.pdf_bsdf(ray, intersection, ray_next);
        let weight = pdf_light / (pdf_light + pdf_bsdf);

        let bsdf = self.bsdf(-ray.direction(), intersection, ray_next.direction());
        let cos = intersection.normal().dot(ray_next.direction());
        let coefficient = bsdf * cos / pdf_light;

        let ray_next = sample.into_ray_next();
        // No more recursion here
        let radiance = light.shade(context, RtState::new(), ray_next, intersection_next);
        weight * coefficient * radiance
    }

    // ...
}

如果是从 BSDF 采样直接光照方向,则在确定方向后寻找最近交点,并判断是否是光源:

// src/domain/material/def/ext.rs
pub trait BsdfMaterialExt: BsdfMaterial {
    fn shade_light_using_bsdf_sampling(
        &self,
        context: &mut RtContext<'_>,
        ray: &Ray,
        intersection: &RayIntersection,
    ) -> Contribution {
        let scene = context.scene();
        let lights = scene.get_lights();

        // Sample direction from BSDF
        let sample = self.sample_bsdf(ray, intersection, *context.rng());
        if sample.pdf() == Val(0.0) {
            return Contribution::new();
        }

        // Find intersection and check if it is a light source
        let ray_next = sample.ray_next();
        let res = scene.find_intersection(ray_next, DisRange::positive());
        let (intersection_next, light) = if let Some((intersection_next, id)) = res {
            let id = id.material_id();
            let material = scene.get_entities().get_material(id).unwrap();
            if material.kind() == MaterialKind::Emissive {
                (intersection_next, material)
            } else {
                return Contribution::new();
            }
        } else {
            return Contribution::new();
        };

        // Balanced Heuristic
        let pdf_bsdf = sample.pdf();
        let pdf_light = lights.pdf_light(intersection, ray_next);
        let weight = pdf_bsdf / (pdf_light + pdf_bsdf);

        let coefficient = sample.coefficient();
        let ray_next = sample.into_ray_next();
        // No more recursion here
        let radiance = light.shade(context, RtState::new(), ray_next, intersection_next);
        weight * coefficient * radiance
    }

    // ...
}

最后对于各种材料,添加实现(以 Glossy 为例):

impl Material for Glossy {
    fn shade(
        &self,
        context: &mut RtContext<'_>,
        state: RtState,
        ray: Ray,
        intersection: RayIntersection,
    ) -> Contribution {
        let light = self.shade_light(context, &ray, &intersection);
        // With `skip_emissive == true`, no radiance would be added if the
        // next ray intersects a light source (`Emissive` material)
        let state_next = state.with_skip_emissive(true);
        let mut res = self.shade_scattering(context, state_next, &ray, &intersection);
        res.add_light(light.light());
        res
    }

    // ...
}

参考文献