Whitted-Style 光线追踪的问题

Whitted-Style 风格的光线追踪特点:

  • 总是计算镜面反射和折射。
  • 在漫反射表面上光线不会反弹。

但这些假设是有问题的,因此需要引入路径追踪。

The Utah teapot

左边像镜子,右边像磨砂金属。

左边是完全的镜像反射 Mirror reflection,右边是 Glossy reflection

Cornell box

一根光线从上面打到了一个漫反射物体表面,光线就不再弹射了,这导致如图左侧所示的黑色阴影区域。

如右图物体表面呈现红色区域,说明红色的墙面的颜色跑到了物体表面上,这种现象称之为color bleeding。这是因为光线打到了墙面后,光线又反弹到了物体表面上,说明了全局光照会反弹不止一次。

如果没有全局光照,天花板就是黑的,物体侧面就是黑的。

Whitted-Style 光线追踪的错误

alt text

所以 Whitted-Style 风格的光线追踪是错误的,渲染方程是正确的。

如果需要正确计算物体表面上一个点打到的光线,则需要求解渲染方程。

但是求解它,需要计算半球上的积分,这是一个递归的执行流程。

这个积分式可以使用蒙特拉洛积分的方式来求解。

蒙特拉洛方式求解渲染方程

直接光照的情形

假设我们想只用直接光照要渲染一个像素点

alt text

如图上面是一个面光源,左侧有一个摄像机,右侧有一个漫反射物体。w0w_0是着色点发出光线到摄像机的方向,wiw_i是着色点接收到来自四面八方光线的方向。

alt text

假设着色点没有自发光项,那么渲染方程就变为反射方程

这仍然是需要求解一个球面上的积分,所以我们可以使用蒙特拉洛积分的方式来求解。

计算 p 点到相机的 radiance(亮度):

Lo(wo)=Ω+Li(p,wi)fr(p,wi,wo)(nwi)dwiL_o(w_o) = \int_{\Omega+} L_i(p, w_i) f_r(p, w_i, w_o) (n \cdot w_i) d w_i

根据蒙特卡洛积分公式:

abf(x)dx1Nk=1Nf(Xk)p(Xk),Xkp(x)\int_{a}^{b} f(x) d x \approx \frac{1}{N} \sum_{k=1}^{N} \frac{f(X_k)}{p(X_k)} , X_k \sim p(x)

其中的 f(x)f(x) 对应上面的渲染方程中的被积函数:

f(wi)=Li(p,wi)fr(p,wi,wo)(nwi)f(w_i) = L_i(p, w_i) f_r(p, w_i, w_o) (n \cdot w_i)

概率密度函数 p(x)p(x) 为:

p(wi)=12πp(w_i) = \frac{1}{2\pi}

假设我们是在半球面上均匀地采样,一个球体的表面积是 4π4 \pi,半球面是 2π2 \pi。所以概率密度函数为 12π\frac{1}{2\pi}

所以最终,渲染方程的蒙特拉洛近似为:

Lo(p,wo)1Nk=1NLi(p,wik)fr(p,wik,wo)(nwik)p(wik)L_o(p, w_o) \approx \frac{1}{N} \sum_{k=1}^{N} \frac{L_i(p, w_{i_k}) f_r(p, w_{i_k}, w_o) (n \cdot w_{i_k})}{p(w_{i_k})}

alt text

写成算法伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
shade(p, w_o) {
L_o = 0.0 // 输出亮度
for k = 1 to N {
w_i = sampleHemisphere() // 在半球面上均匀采样服从w_i ~ pdf(w_i)的方向
r = ray(p, w_i) // 从p点沿着w_i方向发射一条光线
if (r.hit(light)) { // 如果光线击中了光源
L_i = r.radiance(light) // 获取光源的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
L_o += (1 / N) * L_i * f_r * dot(n, w_i) / pdf(w_i) // 累加亮度
}
}
return L_o
}

全局光照的情形

对于全局光照的情形,着色点 p 点的入射光 Li(p,wi)L_i(p, w_i) 也可能来自其他物体 q 的反射过来的光,所以需要递归地计算。

alt text

如图所示,p 点接收到来自 q 点的反射光,就好像是相机在 q 点拍摄到的来自 q 点的直接光照的光线一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
shade(p, w_o) {
L_o = 0.0 // 输出亮度
for k = 1 to N {
w_i = sampleHemisphere() // 在半球面上均匀采样服从w_i ~ pdf(w_i)的方向
r = ray(p, w_i) // 从p点沿着w_i方向发射一条光线
if (r.hit(light)) { // 如果光线击中了光源
L_i = r.radiance(light) // 获取光源的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
L_o += (1 / N) * L_i * f_r * dot(n, w_i) / pdf(w_i) // 累加亮度
} else if (r.hit(object)) { // 如果光线击中了其他物体
q = r.hitPoint() // 获取击中的点 q
w_o_new = -w_i // 反转方向作为 q 点新的出射方向
L_i = shade(q, w_o_new) // 递归计算 q 点的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
L_o += (1 / N) * L_i * f_r * dot(n, w_i) / pdf(w_i) // 累加亮度
}
}
return L_o
}

问题: 光线弹射指数级爆炸

alt text

如图一份光线打到一个着色点,反射出 100 条光线,每条光线又打到另一个着色点,又反射出 10000 条光线,继续反射下一次将会是 100 万条光线,这样指数级增长的光线数量是无法承受的。

当 N=1 时,1 的任何次方都是 1,所以每次只是用一根光线进行采样,就不会出现指数级爆炸的问题。

伪代码编写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
shade(p, w_o) {
w_i = sampleHemisphere() // 在半球面上均匀采样服从w_i ~ pdf(w_i)的方向
r = ray(p, w_i) // 从p点沿着w_i方向发射一条光线
if (r.hit(light)) { // 如果光线击中了光源
L_i = r.radiance(light) // 获取光源的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
return L_i * f_r * dot(n, w_i) / pdf(w_i)
} else if (r.hit(object)) { // 如果光线击中了其他物体
q = r.hitPoint() // 获取击中的点 q
w_o_new = -w_i // 反转方向作为 q 点新的出射方向
L_i = shade(q, w_o_new) // 递归计算 q 点的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
return L_i * f_r * dot(n, w_i) / pdf(w_i)
}
}

N=1时,算法就叫做路径追踪(Path Tracing)。
N>1时,算法就叫做分布式路径追踪(Distributed Path Tracing)。

alt text

问题: 采样噪点

但是 N=1 时,图像会非常噪点,因为每个像素只采样了一条光线。所以一种解决方案是对每个像素进行多次采样,然后对这些采样结果进行平均,即为Ray Generation

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ray_generation(camera_pos, pixel) {
// 在一个像素内均匀随机选取N个采样点
pixel_radiance = 0.0 // 像素亮度
for i = 1 to N {
sample_point = samplePixel(pixel) // 在像素内采样一个点
w_o = computeRayDirection(camera_pos, sample_point) // 计算从相机位置到采样点的光线方向
r = ray(camera_pos, w_o) // 从相机位置沿着w_o方向发射一条光线
if (r.hit(object)) { // 如果光线击中了物体
p = r.hitPoint() // 获取击中的点 p
L_o = shade(p, -w_o) // 计算 p 点的亮度
pixel_radiance += (1 / N) * L_o // 累加像素亮度
}
}
return pixel_radiance
}

问题: 递归终止条件

shade 函数是递归调用的,如果没有终止条件,递归会一直进行下去,直到栈溢出。

如果直接暴力限制光线是一个固定的弹射次数,比如 5 次,那么光线在 5 次弹射后就会直接终止,这样会导致光线在某些情况下过早终止,将会损失能量,渲染结果是不对的。

在真实情况下,光线会弹射无数次,但是在计算机中无法做到这一点。

俄罗斯轮盘赌 Russian Roulette(RR) 方法

一种解决方案是俄罗斯轮盘赌(Russian Roulette)方法,简称 RR 方法。

俄罗斯轮盘赌核心与概率有关。若存活概率为 $ 0 < P < 1 $,则有机会安全;若概率为 $ 1 - P $,则会面临危险情况。比如,当左轮手枪中有两颗子弹时,存活概率 $ P = 4/6 $。

此前,我们总是向着色点发射一条光线,以获取着色结果 $ L_o $。
假设我们手动设置一个概率 $ P 0 < P < 1 $):

  • 以概率 $ P $,发射一条光线,并返回着色结果除以 $ P $,即 $ L_o / P $;
  • 以概率 $ 1 - P $,不发射光线,结果为 $ 0 $。

通过这种方式,期望结果仍为 $ L_o $,计算式为:

$ E = P \times (L_o / P) + (1 - P) \times 0 = L_o $。

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
shade(p, w_o) {
P_RR = 0.8 // 随便设定一个存活概率参数
ksi = random(0, 1) // 生成一个0到1之间的随机数
if (ksi > P_RR) {
return 0.0 // 光线终止,返回0亮度
}
w_i = sampleHemisphere() // 在半球面上均匀采样服从w_i ~ pdf(w_i)的方向
r = ray(p, w_i) // 从p点沿着w_i方向发射一条光线
if (r.hit(light)) { // 如果光线击中了光源
L_i = r.radiance(light) // 获取光源的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
return L_i * f_r * dot(n, w_i) / pdf(w_i) / P_RR
} else if (r.hit(object)) { // 如果光线击中了其他物体
q = r.hitPoint() // 获取击中的点 q
w_o_new = -w_i // 反转方向作为 q 点新的出射方向
L_i = shade(q, w_o_new) // 递归计算 q 点的亮度
f_r = brdf(p, w_i, w_o) // 计算 BRDF
return L_i * f_r * dot(n, w_i) / pdf(w_i) / P_RR
}
}

问题: 渲染效率较低

alt text

现在我们已经得到了一个正确的路径追踪算法了,但是它并不高效。

如图,当 SPP(Samples Per Pixel)较小时,图像会非常噪点;当 SPP 较大时,图像才会变得平滑。

对光源采样

一种提高效率的方式是对光源进行采样。

alt text

当在着色点对半球进行均匀采样时,如图可能每 5 条光线、500 条光线、50000 条光线里,才会有 1 条光线能击中光源。这就导致大量光线被“浪费”,这种采样方式效率低下。