0%

十:材质类

在上一章由于急于实现功能而污染了表层文件,我们需要将它抽象出来,才能继续制作其他材质。

抽象材质类

材质类应该干什么?应该能产生反射光线,并且记录诸如光线衰减信息(上一章中的对半衰减)等,总结一句话,它得通过入射光线,得知反射光线的方向和能量大小。

创建material.h文件,写入”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef MATERIAL_H
#define MATERIAL_H

#include "rtweekend.h"
#include "hittable.h"

class material {
public:
//这个唯一的纯虚函数吃入射光线以及碰撞点信息(我们需要法线以及其他数据),吐物体颜色和反射光线。
virtual bool scatter(
const ray& r_in/*in*/, const hit_record& rec/*in*/,
color& attenuation/*out*/, ray& scattered/*out*/) const = 0;
};
#endif

物体颜色信息指的是物体对于各个红绿蓝三种光线的吸收率,在上一章中,一直使用的是各种光都对半吸收的参数0.5,这一章中,我们要把各个颜色分量给区分开,以创造出更绚烂的色彩变化。

函数的返回值是bool,留下一个标记,为了在函数出问题的时候让我们有能力进行追踪。

在hit_record里加入材质信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "rtweekend.h"
#include "material.h"

struct hit_record {

...
//依然使用智能指针去管理,这样如果你有多个物体像应用同一种材质,就不需要创建多个一样的材质了。
shared_ptr<material> mat_ptr;

inline void set_face_normal(const ray& r, const vec3& outward_normal) {
...
}
};

这时候我们会发现,程序开始报一些莫名其妙的错误了。

这是因为material.h和hittable.h文件互相包含了。main函数开始链接各个文件的时候,采取的策略是见一个就包含一个,并且包含所包含文件的头文件。互相包含问题会导致我们的main函数怎么努力包含都无法包含完这些文件,所以直接摆烂了。

遇到这种情况怎么办呢?我们观察到,hittable.h中虽然定义了一个material的智能指针,但是,它从来没有试图访问过这个指针所指向的对象。所以完全不需要让它包含material.h文件,只需要在使用它之前声明即可:

1
2
3
// #include "material.h"
class material;
struct hit_record { ... }

sphere.h文件也产生了一些变化,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class sphere : public hittable {
public:
sphere() {}
sphere(point3 cen, double r, shared_ptr<material> m)
: center(cen), radius(r), mat_ptr(m) {};

virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

public:
point3 center;
double radius;
//材质是球的属性,所以它理应是球的成员变量
shared_ptr<material> mat_ptr;
};

bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
...
//碰撞到的话,记得把球的材质也返回出去。
rec.mat_ptr = mat_ptr;
return true;
}

磨砂材质

我们只需要模仿main函数中已经写过一遍的代码就可以得到代码。可以把这个类直接写在material.h文件中,这种设计的方式是“一档多类”。它对于多个短小的类来说很有用,它可以防止文件数量过多带来的不便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class lambertian : public material {
public:
//只允许单参数构造,在创建磨砂材质的时候,请直接传入它的颜色。
lambertian(const color& a) : albedo(a) {}

//覆写scatter函数。
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
// 用真实兰伯特模型生随机点。
auto scatter_direction = rec.normal + random_unit_vector();
// 直接对传进来的引用赋值,传出反射光线方向。
scattered = ray(rec.p, scatter_direction);
// 直接对传进来的引用赋值,把本材质的albedo传出去。
attenuation = albedo;
return true;
}

public:
color albedo;
};

和main函数中唯一的不同就是我们可以指定材质的颜色了。

但作为底层代码,我们应该想到更多,比如在随机选点的时候,我们有可能会选到离碰撞点很近的点,然后进而导致生成的反射方向极度接近于0向量。

从代码鲁棒性的角度来看,这会导致我们在后续的某些计算中——比如球类中光线和球求焦点的代码中,除以一个和0很接近的数,这可能会导致我们最终的值变成infinities或者NaNs等等奇怪的结果(即便现在没有不代表未来不会有)。

所以我们要剔除零反射向量。先在vec3.h中写一个判断一个向量是否是0向量的函数:

1
2
3
4
5
6
7
8
9
10
class vec3 {
...
// 判断本向量是否是0向量。
bool near_zero() const {
// 如果三个分量都极接近于0,则返回true,否则返回false。
const auto s = 1e-8;
return (fabs(e[0]) < s) && (fabs(e[1]) < s) && (fabs(e[2]) < s);
}
...
};

修改磨砂材质类,消灭零反射向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class lambertian : public material {
public:
lambertian(const color& a) : albedo(a) {}

virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
auto scatter_direction = rec.normal + random_unit_vector();

//如果抓到零向量,直接生成一个指向法线方向的反射向量,由于概率不高,所以从简处理
if (scatter_direction.near_zero())
scatter_direction = rec.normal;

scattered = ray(rec.p, scatter_direction);
attenuation = albedo;
return true;
}

public:
color albedo;
};

成果

在main函数所在文件中使用磨砂材质类,除了要包含material头文件之外,还需要在main中定义数个智能指针指向新创建的材质对象,再把原本两个参数的球构造函数改成三个参数的。当然最重要的部分还是我们的光线取色函数中和材质类的交互,.cpp文件中代码修改为:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
...

#include "material.h"

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.001, infinity, rec)) {

// 两个用于接收结果的容器。
ray scattered;
color attenuation;
// 调用物体材质的scatter函数,传入入射光线和碰撞信息,用容器接收结果。
if (rec.mat_ptr->scatter(r, rec, attenuation, scattered))
// 使用颜色衰减和新光线进行递归。原来这里是0.5。
// 现在我们使用vec3的乘法,可以对三个通道分别指定衰减比率。
return attenuation * ray_color(scattered, world, depth-1);

// 如果scatter函数返回了false(目前看不可能),直接返回黑色。
return color(0,0,0);

}

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(){

...

hittable_list world;
//定义两个智能指针指向原地构造的匿名对象。
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.7, 0.3, 0.3));
//替换成三参数构造。
world.add(make_shared<sphere>(point3(0, 0, -1), 0.5, material_center));
world.add(make_shared<sphere>(point3(0, -100.5, -1), 100, material_ground))

...

}

就可以得到有颜色的磨砂材质了:

拓展

  1. 材质属性可以放在hittable中吗?

    我认为是可以的,但是这样做目前看来并没有什么好处。在没有出现其他形状的物体之前,把它放在球类中,我们可以清晰的看到该类的所有成员,是目前的最优解。

  2. 材质类与hitable可解耦吗?

    我认为是不可以的,材质类本就不是一个很独立的模块,碰撞点信息里包含物体材质信息,而材质类依托碰撞点信息(如法线)才能计算反射方向。我们从学习代码最初就被叮嘱要进行代码解耦,但是解耦并不代表着类与类之间一定不能有半点交际。材质和物体本就是关系极为紧密的两个事物。只有有了物体,我们才会讨论物体的材质,而且没了材质,我们也就无法知晓物体的外观。把它们分成两个类,是为了能更好的实现代码复用,是为了以后有了多种物体和多种材质之后,能由我们自己随心所欲的进行“连连看”配对。

参考文献

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

参考自《RayTracingInOneWeekend》第9.1节到9.3节。