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

Multiple Importance Sampling

Monte Carlo Path Tracing

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

$$ I = \int_D f(x) \mathrm dx \approx \dfrac{1}{N} \sum_{i = 1}^N \dfrac{f(X_i)}{p(X_i)} $$

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

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

$$ 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 = \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})} $$

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

$$ \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 $$

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

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

$$ \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} $$

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

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

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

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

$$ w_1(\omega_{i1}) = \dfrac{p_1(\omega_{i1})}{p_1(\omega_{i1}) + p_2(\omega_{i1})} $$$$ w_2(\omega_{i2}) = \dfrac{p_2(\omega_{i2})}{p_1(\omega_{i2}) + p_2(\omega_{i2})} $$

然后计算 exitant radiance

$$ \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 形式是 $p_2(\omega)$,也就是说这个 PDF 只有在立体角下才有意义。而光源采样是在光源的表面上进行的,采样的是点 $x'$,其 PDF 是 $p(x')$。

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

$$ \mathrm d\omega = \dfrac{|n_{x'} \cdot \omega|}{\|x - x'\|^2} \mathrm dA $$

两者之间需要进行转换:

$$ 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,每次估计仅随机选择一种采样方法并且仅采样一次。首先固定 $n_1 = n_2 = \cdots = n_m = 1$。记第 $i$ 种估计 $F_i(x) = \dfrac{w_i(x) f(x)}{p_i(x)}$,$F$ 为最终估计,$c_1, c_2, \dots, c_m$ 为选择各种采样方法的概率,则有

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

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

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

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

$$ \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 的 $w_i(x)$ 选择上,Balanced Heuristic 是方差最小的。

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

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

MIS 的应用

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

Next Event Estimation

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

$$ \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'_{\omega_i}, x)$ 表示 $x'_{\omega_i}$ 和 $x$ 是否直接可见。此时第二项和第三项可以分开计算。

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

使用 NEE 的 Path Tracing 则变为:

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

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

那么为什么上文提到 Next Event Estimation 是 MIS 的权重分配的一种方法呢?在实际的场景中,光源是少数。尽管每一种材料都有 $L_e$ 项,但非发光材料的 $L_e$ 项都是 $0$。而在许多实现中,光源是追踪的终点,光源只贡献 $L_e$ 而不贡献 $L_s$,这与非发光材料相反。因此所有的点要么贡献 $L_e$,要么贡献 $L_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,因为对光源采样的权重永远是 $0$。对于间接光照,仍然使用 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
    }

    // ...
}

参考文献