我们花了两章来搞定漫反射材质,还好结果很令人满意。光线在场景中随机弹射,给我们带来磨砂质感和阴影,你应该通过上一章的习题玩的不亦乐乎了吧。
我们的旅程还远远没有结束,别忘记了我们在上一章中为了更快达成目标污染了表层文件,我们需要让我们的框架重回严谨,再继续制作其他的功能。
首先,我们需要一个材质类。

材质类

我们为什么需要材质类,而不把物体对待光线的方式这些逻辑直接写到物体类里?因为我们要做到物体和物体对待光线的方式分离,比如在未来,我们需要一个漫反射球,或者我需要一个金属方块。我们不希望球这种东西只能是漫反射的,或者只有方块才能是金属制品。我们必须得对材质有一个抽象,它可以是物体的某种属性(成员变量),让我们可以随便更换,绝对不能是一开始就决定好的。
材质类应该干什么?应该能产生反射光线,并且记录诸如光线衰减信息(上一章中的对半衰减)等,总结一句话,它得通过入射光线,得知反射光线的方向和能量大小。
我们创建material.h文件,并敲入如下“代码”:

  1. #ifndef MATERIAL_H
  2. #define MATERIAL_H
  3. class material{
  4. ⠀⠀⠀⠀⠀⠀⠀⠀⣔⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠐⠦⠀⠀⠀⠀⠀⠀⠀⠀⠀
  5. ⠀⠀⠀⠀⠀⠀⠀⠰⠸⠄⠀⣀⡀⠤⢄⢄⠤⠤⡘⡄⠀⢣⠀⡀⡀⠀⠀⠀⠀⠀
  6. ⠀⠀⠀⠀⠀⠀⣀⠇⠠⢐⠍⢌⢒⠅⢅⢂⠊⢔⢐⠀⠀⠠⢇⢕⠨⡢⠀⠀⠀⠀
  7. ⠀⠀⠀⢀⢄⠠⠠⡐⠁⠢⠡⡑⡁⡪⢂⠂⠕⡐⠄⢕⠌⠈⠀⡐⠌⡐⢥⠀⠀⠀
  8. ⠀⠀⠀⠎⡠⠑⢐⠀⠌⢌⢂⠚⡔⢀⢃⠌⠌⠄⢅⢲⡁⣴⡒⣺⡪⡐⠄⣇⠀⠀
  9. ⠀⠀⢸⠨⢨⠁⢃⠌⢌⢂⠢⠡⢙⠢⢂⠮⡨⠨⢂⠌⡢⠓⡥⣇⢟⠔⡡⡚⡄⠀
  10. ⠀⠀⠨⠨⡘⡷⠠⡑⡐⡢⠁⢇⠅⠌⡂⡂⠌⠌⠄⠕⢄⢕⢈⡷⡌⠔⡐⡌⡇⠀
  11. ⠀⠀⢸⠨⠠⣍⢌⢂⣖⣔⢲⣠⠣⡡⡌⠢⢊⢯⡳⠕⢑⠜⠤⡯⣻⡨⡐⢕⠇⠀
  12. ⠀⠀⠈⡎⢌⠰⣣⠑⢤⠘⢜⠑⠀⠈⠈⠀⡀⠅⠝⣘⠄⠌⢬⢯⣗⢧⢪⢪⡃⠀
  13. ⠀⠀⠀⠇⡂⡇⡿⡜⡄⠢⡀⠐⡤⡰⠁⡀⠄⠂⢈⠔⡌⡼⠊⣗⢯⢟⢌⢆⠃⠀
  14. ⠀⠀⠀⠠⢣⢪⢯⠏⠚⠕⠬⣺⢬⢭⢠⢀⡖⣊⢍⠁⠀⠀⢀⢯⠛⡜⡜⠼⠀⠀
  15. ⠀⠀⢀⠇⡇⡽⡝⠇⠀⢀⣀⢗⣝⢞⠤⠣⡪⠂⠀⡅⠀⠀⠈⠁⠀⢇⢇⡇⠀⠀
  16. ⠀⢀⢎⢇⠏⠀⠁⠀⠰⠁⠀⠈⠪⠄⠭⡯⠁⠀⡜⠀⠀⠀⠀⠀⠀⠘⡢⡏⡄⠀
  17. ⡠⢣⠚⠁⠀⠀⠀⠀⠨⢤⣀⡂⢶⠁⢠⠁⠄⡕⠁⠀⠀⠀⠀⠀⠀⠀⠱⡱⣙⢄
  18. };
  19. #endif
  1. 画一只晚晚是没办法让材质类正常运作的,我也并不是想和你开玩笑,我只是想陈述一个你或许不知道的小知识,那就是我们的程序并没有因为荒谬的材质类而无法编译,我们依然可以生成解决方案,并且运行成功。<br />这是为什么呢?很容易想到是因为虽然我们把材质类当画板使用,但是,总程序压根没有把这个新文件包含进去,因为咱们的main函数和这个材质类没有任何关系。对于我们最后生成的总程序来说,它优化掉了材质类,因为没用上,所以它压根不需要去检查材质类的语法。<br />注意,一般来说,ide对头文件(.h文件)的处理方式如上,但是对于cpp文件,可能就不一样了。<br />这个小知识在之后我们优化代码的时候很管用,现在暂时先不用管,我们给出材质类的正确的代码:
#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,这一章中,我们要把各个颜色分量给区分开,以创造出更绚烂的色彩变化。<br />函数的返回值是bool,我们留下一个标记,在函数出问题的时候让我们有能力进行追踪。

材质类的加入所引发的变化

材质是物体的一种特性,逻辑上讲,材质应该是物体的成员变量。而且物体的材质作为一个重要的信息,应该随着hit_record让光线带回。我们得把这一切都安排妥当,hittable.h产生如下的变化。
注意,这样的设计是纯属主观爱好,你完全可以使用其他的方式安排材质类,只要你最终可以让main函数平稳且简洁的运行起来就行:

#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) {
        ...
    }
};
这时候你应该会发现,程序开始报一些莫名其妙的错误了。<br />这是因为material.h和hittable.h文件互相包含了。我的main函数开始链接各个文件的时候,采取的策略是见一个就包含一个,并且包含所包含文件的头文件。互相包含问题会导致我们的main函数怎么努力包含都无法包含完这些文件,所以直接摆烂了。<br />遇到这种情况怎么办呢?我们观察到,hittable.h中虽然定义了一个material的智能指针,但是,它从来没有试图访问过这个指针所指向的对象。我们完全不需要让它包含material.h文件,我们只需要告诉它:“我提到的这个material这个东西,它是一个类哈,至于它具体是什么样的类,你去最终链接的那些个文件里,拿着这个名字去找,你一定能找到,所以你不要给我报错了!一切我都安排妥当了!”
// #include "material.h"
class material;
struct hit_record { ... }

因为material类是包含了hittable类的,最终我们的main函数所在文件只要包含material.h头文件,就代表了一定包含了hittable类,就可以立马找到这个字段”material”的真正含义。
sphere.h文件也产生了一些变化,具体如下:

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;
}

你可能认为材质应该是hittable类的属性,应该把它抽象到父类中。这样想当然也没错,我们把它仅仅当作球类的成员变量而不是物体类的成员变量的原因纯属个人偏好,或许我们以后会引入“不需要材质的物体”也说不定呢!

磨砂材质类

磨砂材质作为我们要实现的第一个材质是非常容易的,我们只需要模仿main函数中已经写过一遍的代码即可。我们可以把这个类直接写在material.h文件中,这种设计的方式是“一档多类”。它对于多个短小的类来说很有用,它可以防止文件数量过多带来的不便。

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等等奇怪的结果(即便现在没有不代表未来不会有)。
从逻辑上来说,我们的材质会反射多少光线,吸收多少光线,应该是由albedo变量全权决定,而产生的零反射向量会导致吸收的光线比我们预想的要更多。
所以我们要剔除零反射向量。先在vec3.h中写一个判断一个向量是否是0向量的函数:

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);
    }
    ...
};

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

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的可能性很低,所以我们就从简处理了。
            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中定义数个智能指针指向我们新创建的材质对象,再把原本两个参数的球构造函数改成三个参数的。当然最重要的部分还是我们的光线取色函数中和材质类的交互,看代码:

...

#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))

    ...

}
我们得到了彩色的球,当然如果你已经完成了上一章的习题,那你并不会感到有多新鲜...<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/25639626/1642823139577-f51e615d-6960-4f7d-ae27-af196b388eb5.png#clientId=u705f890d-bb30-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=248&id=u54e95225&margin=%5Bobject%20Object%5D&name=image.png&originHeight=484&originWidth=839&originalType=binary&ratio=1&rotation=0&showTitle=false&size=100458&status=done&style=none&taskId=u4f273401-1d55-4184-9722-ec1725ffdc6&title=&width=430.475341796875)

利用链接的特性再度简化材质类

写完材质类,你有没有发现材质类并不是一个很独立的模块。碰撞点信息里包含物体材质信息,而材质类依托碰撞点信息(如法线)才能计算反射方向。
我们设计的材质类是一个自创造出来就和hittable交杂耦合的类。从各个角度来看,这种耦合都是不可解的。
我们从学习代码最初就被叮嘱要进行代码解耦,但是,我们要搞清楚,解耦并不代表着类与类之间一定不能有半点交际。材质和物体本就是关系极为紧密的两个事物。只有有了物体,我们才会讨论物体的材质,而且没了材质,我们也就无法知晓物体的外观。我们之所以把它们分成两个类,是为了能更好的实现代码复用,是为了以后有了多种物体和多种材质之后,能由我们自己随心所欲的进行“连连看”配对。
既然我们知道了,物体类一定会与材质类一起被使用,我们不妨让材质类更加放飞自我。

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

// 其实这句代码也可以不要,但为了可读性,我们还是给自己留一条后路。
struct hit_record;
我们在磨砂材质类中调用了hit_record的成员变量,如`rec.normal`,如果我们不包含hittable.h文件,只是告诉编译器这个字段是一个结构体,它理应找不到其中的成员变量。但是,如果我们坚信它们一定会被同时使用,那我们就可以放心的进行省略了,因为最终这个文件一定会和hittable.h文件链接在一起,我们一定可以得知hit_record的具体内容。<br />那如果最终的程序没有包含material.h和hittable.h呢?比如我们在main函数中只画了蓝天。又或者我们使用了老版本的ray_color函数,并没有访问material类中的内容,还用老一套的代码去画“元气弹”,然后在头文件中去掉了material.h文件呢?<br />它依然不会报错!在之前我们已经证明过,就算你画一只小向晚都不会报错。<br />这个简化其实更多的是想透过这个现象去展示C++编译和链接的一些特性,你要说它真能给代码提提速吗?它可能并没有什么卵用...

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第9.1节到9.3节。