0%

八:磨砂(上)

之前我们一直在画一些看起来完全不真实的东西,但从现在开始我们可以来实现更加真实的物体了。

漫反射原理

例如土墙、手机壳、木制桌面等等这些表面有很多微小凹凸的材质叫做漫反射材质,它们会吸收一部分光线,并且把剩余的光线朝随机方向反射出去,因为光线被反射到了不同的方向,自然不会出现某一个方向能接收到很亮的光线,也就不会有高光点存在。它们一般会吸收特定颜色的光,比如红色的砖块,它会吸收不是红色的光线,而把红色的光线按照随机方向反弹回去。

编程思路

现在暂时还没有光源,我们不妨假设“蓝天”就是光源,光的能量都是从蓝天上来的。

我们再假设任何物体对各种颜色光线的吸收率都是一样的,是1:1。光线碰撞到物体后,都会吸收掉每种光的一半的能量,然后反射一半。

又因为我们是逆光路取色,有以下n种情况:

  1. 从相机射出一根光线,这根光线没有碰到任何物体,即它射中了“蓝天”,那逆光路顺过来看看这意味着相机直接望到了蓝天,蓝天发出的光没有经过任何弹射直接进入了相机。
  2. 从相机射出一根光线,这根光线碰到了一颗球,然后经过随机弹射之后,再也没有射中任何物体,朝无穷远处射出,即,它经过一次弹射之后射中了蓝天。逆光路顺过来看看这意味着光线从蓝天射出打到了物体上并且弹到我们的眼睛里。因为这个物体的能量吸收和反射的比率是1:1,所以这根光线只有一半的能量了。
  3. 我们从相机射出一根光线,这根光线碰到了一颗球,然后经过随机弹射之后,它又碰到了一颗球,逆光路顺过来看光线从蓝天射出打到了一颗球上,反射到了另外一颗球上,再反射到我们的眼睛里,没错,这根光线只剩四分之一的能量了。
  4. ……

那么光线在空间中弹射了n-1次,它的能量只剩$\frac{1}{2^{n-1}}$。

从相机中射出一根光线之后,只要碰撞到物体,就从这个碰撞点朝随机方向发射一根光线,然后把这根光线取到的颜色乘以0.5并返回。

我们的取色函数叫ray_color(const ray& r, const hittable& world),我们这个函数的返回值应该写什么呢?应该是return 0.5 * ray_color(newRay,world),没错,这是一个递归,函数会疯狂的调用自己,直到某根随机反射光线射中了“蓝天”,再一层一层地返回。

随机光线

之前遗留的最后一个问题是如何随机发射一根光线呢?

这个问题并不像表面看上去那么简单,使用什么模型可以更加真实的模拟漫反射呢?最简单的模型是单位球体积内随机选点模型,如图:

image-20220307211216029

碰撞点是P,法线为N(单位化),在以(P+N)这个点为球心的单位球内随机寻找一点S,然后以S减去P为光线的反射方向向量就是最终我们需要的向量。这个S点是P点坐标+N向量+一个由球心指向球内随机点的向量三部分组成。

r向量是相机观察方向,因为我们的漫反射和视角方向无关,不用去管它。

在vec3文件中写入:

1
2
3
4
5
6
7
8
9
10
class vec3 {
public:
...
inline static vec3 random() {
return vec3(random_double(), random_double(), random_double());
}

inline static vec3 random(double min, double max) {
return vec3(random_double(min,max), random_double(min,max), random_double(min,max));
}

这里函数都是static的,就表示它属于整个类而不属于某个特定的对象,我们可以使用上面的函数直接调用vec3类构造生成一个三个分量都在[0,1)或者[min,max)内随机的随机vec3。

random() 函数生成的vec3可不是在单位球内的,它的XYZ轴都是在[0,1)之间的,它是一个在单位立方体内的点或者向量,我们得做一个简单的处理,让它的随机值最终落于单位球内。

再在vec3.h文件中vec3的类外写一个全局函数,它只有四句代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
vec3 random_in_unit_sphere() {
while (true) {
// 先来个中心在原点,边长为2的立方体内的点。
auto p = vec3::random(-1,1);

// 如果发现这个vec3的长度(它离原点的距离)大于1,即表示它是落于立方体内且落于球外的。
// 直接让他暴力再随机一次。
if (p.length_squared() >= 1) continue;

// 返回一个位于单位球内的点。
return p;
}
}

这里用了一个很暴力的方法:拒绝算法(rejection method),直接让他疯狂的循环,只要点不落于单位球内,我们就让他一直随机到单位球内为止。

接着更新着色代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
color ray_color(const ray& r, const hittable& world) {
hit_record rec;

if (world.hit(r, 0, infinity, rec)) {

//由三部分组成的S点坐标。
point3 target = rec.p + rec.normal + random_in_unit_sphere();

//创造新的光线并开启下一轮递归。
return 0.5 * ray_color(ray(rec.p, target - rec.p), world);
}

vec3 unit_direction = unit_vector(r.direction());
auto t = 0.5*(unit_direction.y() + 1.0);
return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
}

至于为什么将它写在这里,有一个原则就是对于不自信的内容,先写到表层实现需求,再抽象到底层维护框架。所以这里不要在意它污染了main函数(对所有的物体都采用了漫反射)。

递归终止条件

到这里运行程序法线根本无法运行:

image-20220307213818888

之前设置的递归终止条件是光线射入蓝天,但是如果光线没有射到蓝天而是一直在夹缝里不停的弹跳,就会导致系统栈溢出。为此因为引入一个光线弹射次数的上限值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
color ray_color(const ray& r, const hittable& world, int depth) {
hit_record rec;

// 如果次数消耗殆尽,直接终止递归,我们的系统栈可耗不起了!
if (depth <= 0)
return color(0,0,0);

if (world.hit(r, 0, infinity, rec)) {
point3 target = rec.p + rec.normal + random_in_unit_sphere();
//每一轮新的递归,我们把光线可弹射次数减一。
return 0.5 * ray_color(ray(rec.p, target - rec.p), world, depth-1);
}

vec3 unit_direction = unit_vector(r.direction());
auto t = 0.5*(unit_direction.y() + 1.0);
return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
}

int main() {
...
const int samples_per_pixel = 100;

//给定光线最大弹射次数。
const int max_depth = 50;
...
for (...) {

...
//更改ray_color调用代码。
pixel_color += ray_color(r, world, max_depth);
}
}

至此我们应该可以得到一张带有漫反射材质的图了:
image-20220307214423049

非常的暗的图片,但确实可以看到漫反射的细节,如果我们只看上方小球的球顶,常识告诉我们,光线打到这里,很大概率能反弹到蓝天上,也就是说,它的颜色应该趋向于蓝天的颜色衰减了一半之后的某种蓝色。而图像并非如此。

另一种方法也可以佐证,用文本模式打开ppm文件,可以看到上边右图中的RGB值,它们都是很靠近黑色的值,我们的程序似乎就没生成过只弹射一次就碰到蓝天的光线,这个问题在下一章得到一个非常巧妙且完美的解决办法。

拓展

  1. 可以证明单位球体积随机选点模型的代码取到的点一定是随机的吗?

    首先如果没有单位球的限制,在单位立方体内部的点,它们必然是均匀的,因为随机的三个标量都足够均匀。

    现在加上单位球的限制,进行n次独立实验(n足够大),我们把这n次实验的结果按照随机几次才得出结果再分成m堆。

    首先是最大的那一堆,这一堆中的点都是只随机一次就落在了单位球内的,有$\frac{\frac{4}{3}\prod}{8}*n$个点在这个堆里(球的体积比上立方体体积)。这些点必然均匀。因为咱们的随机点肯定均匀分布于立方体,也必然均匀分布于立方体中的球内。

    接下来看看第二大的那个堆,这个堆里的点都是第一次随机到了球外,第二次随机到了球内的,这部分的点有$(\frac{\frac{4}{3}\prod}{8})(\frac{8-\frac{4}{3}\prod}{8})*n$个。如果只针对这一批点来说,肯定也是均匀分布于球内(因为我们采用的算法并没有改变)。

    以此类推我们就能得出整体必然均匀的结论。

  2. 如果光线是从球内打到球的内壁上,我们代码能正确的运转吗?

    答案见下章。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第8.1节到8.3节。