前言

前文介绍了基于物理渲染的基础知识,包括辐射度量学、BRDF以及渲染方程,但并没有给出解渲染方程的方法,或者说如何通过该渲染方程计算出屏幕上每一个坐标的像素值。

本文就介绍通过蒙特卡洛路径追踪来解渲染方程的方法。

 

蒙特卡洛积分(Monte Carlo Integration)

首先看下面的图,如何去求曲线在a、b之间围成的面积:

求曲线在a、b区域内面积也就是求曲线的函数在[a, b]内的定积分,但对于这样一个曲线,很难用一个数学函数去表示,因此无法用一般解析的方法直接求得积分值,可以采用黎曼积分的思路。

也就是对上图这个图形纵向均匀地切割,分成多个宽度相同的条形,那么这些条形就可以近似为长方形,取条形中线的高度作为近似长方形的高度,将这些近似小长方形的面积全部加起来就能得到整个图形近似的面积。

显然切割的数量越多也就是采样越多,得到的结果越逼近真实的数值。

而相比起黎曼积分的均匀采样,蒙特卡洛积分可以指定一个随机分布来对被积分的值进行采样,因此更加通用,定义如下:

如图所示,我们希望求出一个函数f(x)在积分域[a,b]上的积分值,选定一个采样的分布p(x),通过对该分布来进行多次的函数值采样,最后近似的积分值如图中最下方式子所示。

注意公式中的1/N可以理解为不将图形分割,而是每次采样都近似为一个高为采样对应的函数值的长方形,将多次采样近似的长方形面积累加求均值,如下图是采样次数为4时的。

这里对前面的公式进行一个简单的推导,从概率论的角度理解,求均值的做法其实也是对期望的逼近,因此:

那么对于这样一个服从某一分布的期望的计算套公式直接计算得:

通过以上推导即可明白蒙特卡洛的近似正是对积分值的一个无偏估计,也就是采样次数越多,近似值与积分值越接近。

为了方便,一般都使用均匀采样,因此很容易推出:

因此,蒙特卡洛就是对函数值进行多次采样求均值,从而帮助求得困难积分值的近似的方法

除了上述的期望法以外,蒙特卡洛还可以用投点法计算定积分。

向区域内随机投下大量的点,整个区域的面积 x 图形内的点占全部点的比例得到的就是近似积分值,从概率论的角度很好理解,同样是投点越多,结果越准确。

 

蒙特卡洛路径追踪(Monte Carlo Path Tracing)

回顾一下前文中所得到的渲染方程:

要想解出以上方程的解主要有两个难点:

  1. 积分的计算
  2. 递归形式

对于这种复杂积分的计算自然就要利用前面所提到的蒙特卡洛积分方法了。

不过在进入具体计算之前,先对渲染方程做出一点修改,舍弃自发光项(因为除了光源其他物体不会发光),从而方便进行计算推导:

修改过之后的方程其实就只是一个单纯的积分计算了,其物理含义为着色点p到摄像机或人眼的Radiance值。

从具体例子出发,首先仅仅考虑表面一点p的直接光照:

其中ω0为眼睛的观察方向,ωi则是各个方向弹射到点p的光线(这里箭头方向与光线实际方向相反是因为渲染方程中所有方向都以表面向外为正方向)。

回想前面所提到的,对于一个困难积分只要选定一个被积分变量的采样分布即可通过蒙特卡洛的方法得到积分结果的近似值,而此时的被积分值为入射光线方向ωi,选定采样分布p(ωi)为在p点的半球方向内对ωi进行均匀取样(只考虑从平面上方入射的光线,半球的立体角为),不难得出积分近似结果如下:

因为只考虑直接光照,所以只有当采样的方向ωi击中光源的时候,光源才会对该着色点有贡献,计算伪代码如下:

shade(p, wo)
    Randomly choose N directions p(ωi)
    Lo = 0.0
    For each wi
        Trace a ray r(p, wi)
        If ray r hit the light
            Lo += (1 / N) * L_i * f_r * cosine / p(ωi)     //L_i代表光源亮度,f_r代表BRDF
    Return Lo

显而易见的,单独仅仅考虑直接光照自然是不够的,还需要间接光照,即当采样的ωi方向碰撞到了别的物体,如下图所示:

此时采样的光线ωi碰撞到了另一个物体的Q点,如果这条光线在Q点反射后没有碰到光源,这条路径自然没有贡献;如果光线在Q点反射后碰到了光源,那么该条路径对着色点P的贡献是多少呢?

此时可以理解为ωi方向为Q点的观察方向,求Q点到ωi方向的Radiance值,也就是光源在点Q的直接光照再乘上Q点发射到ωi方向的比例。

显然这是一个类似光线追踪的递归过程,不同点是该方法通过对光线方向的采样从而找出一条条可以连接眼睛和光源的的光线路径,这也正是为什么叫路径追踪的原因,伪代码如下:

shade(p, wo)
    Randomly choose N directions p(ωi)
    Lo = 0.0
    For each wi
        Trace a ray r(p, wi)
        If ray r hit the light
            Lo += (1 / N) * L_i * f_r * cosine / p(ωi)
        Else If ray r hit an object at q
            Lo += (1 / N) * shade(q, -wi) * f_r * cosine / p(ωi)
    Return Lo

至此,我们成功通过蒙特卡洛的方式解出(近似)了渲染方程的积分值,也通过考虑直接光照与间接光照解决了递归的问题。但该方法有一个非常致命的缺陷:

我们通过每次对光线方向的采样从而解出方程,假设每次采样100条,那么从人眼出发的第一次采样就是100条,在进行第二次反射之后就是10000条,依次类推,反射越多次光线数量便会指数级爆炸增长,计算量完全无法接受,那么如何才能使得光线数量不爆炸增长呢?

唯有每次只采样一个方向!N = 1。(N = 1时就是通常所说的路径追踪,作为区分N != 1时,一般称为分布式路径追踪。)

shade(p, wo)
    Randomly choose ONE direction p(wi)
    Trace a ray r(p, wi)
    If ray r hit the light
        Return L_i * f_r * cosine / p(wi)
    Else If ray r hit an object at q
        Return shade(q, -wi) * f_r * cosine / p(wi)

如果只采样一个方向那么所带来的问题也是显而易见的,积分计算的结果会非常的随机,虽然蒙特卡洛积分是无偏估计,但样本越少显然偏差越大。

但该问题也好解决,如果每次只去寻找一条路径结果不准确,那么就去寻找多条路径!如下图所示:

从摄像机向每个像素的区域内发射多条路径,追踪每条路径的反射并计算,最后将多条路径的结果求平均即可。

改良之后的Path Tracing伪代码如下:

ray_generation(camPos, pixel)
    Uniformly choose N sample positions within the pixel
    pixel_radiance = 0.0
    For each sample in the pixel
        Shoot a ray r(camPos, cam_to_sample)
        If ray r hit the scene at p
            pixel_radiance += 1 / N * shade(p, sample_to_cam)
    Return pixel_radiance

通过对经过单个像素的光线多次重复采样,每次在反射的时候只随机选取一个方向,解决了对经过单个像素的光线采样一次,而对反射光线按分布多次采样所导致的光线爆炸问题。

但问题还没有彻底解决,因为shade函数的递归没有出口,永远不会停下。 但这里并不会采用光线追踪算法中设定最大反射次数的方式,而是非常精妙的采用了俄罗斯轮盘赌(Russian Roulette)

一把弹仓6发的左轮手枪,随机填充两发子弹,按下扳机时,目标有4/6的概率活下来,这就是俄罗斯轮盘赌的概念。

将其应用在路径追踪当中,首先设定一个概率P, 每次光线反射时有P的概率光线会继续递归并设置返回值为Lo/P,有1-P的概率光线停止递归,并返回0。这样巧妙的设定之下光线一定会在某次反射之后停止递归,并且计算的结果依然是无偏的,因为从概率论的角度来看,Radiance的期望Lo不变,证明如下:

shade函数的伪代码变更如下,使得可以停止递归了:

shade(p, wo)
    Manually specify a probability P_RR
    Randomly select ksi in a uniform dist. in [0, 1]
    If (ksi > P_RR) 
        return 0.0;

    Randomly choose ONE direction p(wi)
    Trace a ray r(p, wi)
    If ray r hit the light
        Return L_i * f_r * cosine / p(wi) / P_RR
    Else If ray r hit an object at q
        Return shade(q, -wi) * f_r * cosine / p(wi) / P_RR

至此,路径追踪算法已经完成大半,只差最后一个小问题!

现在的路径追踪效率非常的低下,如图所示:

在每次计算直接光照的时候,通过均匀采样随机方向打出光线,但只有极少数的光线方向可以碰到光源,尤其当光源越小的时候,这种现象越明显,大量采样的光线都是无效浪费的。

因此在计算直接光照的时候可以改进为直接对光源进行采样!这样所有采样的光线都一定会击中光源(如果中间没有别的物体遮挡),没有光线路径就不会被浪费了。假设光源的面积为A,那么对光源进行的采样分布p(A) = 1/A ,但原始的渲染方程:

很明显是对光线方向ωi进行积分的,如果想要对光源进行采样并依然使用蒙特卡洛的方法,那么一定要将其修改为对光源面积 dA 的积分(改变积分域),换言之就是需要找到 dA 的关系。如下图所示:

dA 的关系如下

关系式中的 cosθ’ 是为了计算出光源上微分面积元正对半球的面积,之后再按照立体角的定义dω = dA / rr,除以着色点x与光源采样点 x’ 距离的平方即可。

于是根据图中二者的关系可将渲染方程改写如下:

这样便成功从对 ωi 的积分转到了对光源面积 A 的积分,然后就可以利用蒙特卡洛的方法对光源进行采样从而计算直接光照的积分值了,对于间接光照,依然采用先前的方法进行光线方向的均匀采样。最终伪代码如下,分直接光照和间接光照两部分计算:

shade(p, wo)
    # Contribution from the light source.
    Uniformly sample the light at x’ (p(light) = 1 / A)
    L_dir = L_i * f_r * cos θ * cos θ’ / |x’ - p|^2 / p(light)

    # Contribution from other reflectors.
    L_indir = 0.0
    Test Russian Roulette with probability P_RR
    Uniformly sample the hemisphere toward wi (p(wi) = 1 / 2pi)
    Trace a ray r(p, wi)
    If ray r hit a non-emitting object at q
        L_indir = shade(q, -wi) * f_r * cos θ / p(wi) / P_RR

    Return L_dir + L_indir

计算直接光照的时候还需要判断光源与着色点之间是否有物体遮挡,该做法也很简单,只需从着色点 x 向光源采样点 x’ 发出一条检测光线判断是否与光源之外的物体相交即可,如图所示:

# Contribution from the light source.
L_dir = 0.0
Uniformly sample the light at x’ (p(light) = 1 / A)
Shoot a ray from p to x’
If the ray is not blocked in the middle
    L_dir = …

到这一步,路径追踪才真正完整了,最后看一下真实照片和路径追踪效果的对比,感受一下照片级真实(Photo-Realistic)。

 

本文参考自闫令琪老师的《GAMES101-现代计算机图形学入门》和孙晓磊的计算机图形学系列笔记,感谢。


人们之间谈话失败,
并不是因为缺乏智慧,而是因为自负。
每一个人都希望谈论自己,
或是自己感兴趣的话题。

——托尔斯泰