我们在上一章中制作了一颗球,并且成功让光线与它碰撞,甚至使用了表面法线渲染出一颗彩色的球。这是了不起的成就,要知道,我们是在纯C++上搭建的框架,没有任何图形API的支持,一切底层的东西都是我们自己做的!
但是你也看到了制作球的代码把整个main函数所在文件弄得乱七八糟,我们绝对不应该把这种代码放在main函数里的,main函数是和用户最接近的地方,要是用户随便修改一下我们的参数,比如把Δ的表达式改变一下,整个系统都瘫痪了。
我们需要封装!需要把这些我们辛辛苦苦写出的代码隐藏起来!

小简化

在代码搬家之前,我们先尝试再简化一下我们的简易碰撞函数代码,我们注意到Δ和求根公式中有一些项前面有常数2或者4,我们可以耍一些小聪明把它消除掉,看下面的代码:

  1. double hit_sphere(const point3& center, double radius, const ray& r) {
  2. vec3 oc = r.origin() - center;
  3. auto a = r.direction().length_squared();
  4. //默认直接把2除掉
  5. auto half_b = dot(oc, r.direction());
  6. auto c = oc.length_squared() - radius*radius;
  7. //这个discriminant是之前的四分之一。
  8. auto discriminant = half_b*half_b - a*c;
  9. if (discriminant < 0) {
  10. return -1.0;
  11. } else {
  12. //分子分母都是简化前的一半。
  13. return (-half_b - sqrt(discriminant) ) / a;
  14. }
  15. }
  1. 这只是一步可能没多大用处的简化罢了,它会让你渲染每个像素快上大概0.000000000001秒(~~我胡扯的~~),但是如果我们有10000000000个像素(~~显然不可能~~),那它还是会让我们的渲染快上不少的。

小抽象

我们现在有了球,或许我们需要一个球类去描述它,但是未来可能还会有更多的其他种类的物体,最容易想到的方法是首先编写一个物体基类。
这个抽象基类需要抽象出所有物体的共性,它们分别是什么呢?

  1. 一个物体一定可以被光线感知到,它可以被光线照到,并且光线可以通过这次碰撞获取一些关于物体的信息。即,它需要一个抽象的碰撞函数,就和我们之前在main所在文件中写的球简易碰撞函数那样。这个函数的返回值我想设计成bool,即返回是否碰撞到。至于其他的碰撞信息,我们通过其他方式去返回。
  2. 想想看我们需要返回什么?首先毋庸置疑——t值。想想看我们当时在main函数中做的那样,检查t值,如果它满足条件我们才觉得光线和球撞到了,如果没有满足条件,就画蓝天去了。你可能会疑惑为什么我们已经通过返回值表示有没有被撞上,还需要这个t值呢?因为t值在之后会有更大的作用,可以说,t值是光线和物体碰撞中最重要的信息之一,我们应当让外界知道这个值以方便其他的运算。
  3. 我们还需要返回什么呢?法线。想想元气弹,我们是不是通过法线实现了一个很漂亮的效果,但是这还不是全部,法线的作用远比你想象的大得多,比如镜面反射,我们需要通过法线去计算反射方向。总之,我们需要返回法线信息。
  4. 我们还需要一个容易被忽略的东西——碰撞点坐标。你可能会说:“我都知道光线的信息,你也返回了t值了,用at函数不就可以直接算出来了吗?”确实,但是很多情况下,我们都需要用到p点坐标,我们不希望每次用到的时候都去计算一遍,而且我们理应把这些计算放在更底层的地方,要知道,现在发射光线的函数是main函数,我们不应该把这种底层计算放在用户看得到的地方。

创建hittable.h文件,敲入下面的代码,还有一些细节我们之后再说:

  1. #ifndef HITTABLE_H
  2. #define HITTABLE_H
  3. #include "ray.h"
  4. //我们把需要返回的数据封装成结构体,按照上面分析的,暂时我们需要这三个东西。
  5. struct hit_record {
  6. point3 p;
  7. vec3 normal;
  8. double t;
  9. };
  10. //可碰撞物体类。
  11. class hittable {
  12. public:
  13. //需要这样一个纯虚函数,所有继承自这个类的子类(如球),都需要实现这个函数。
  14. //看他的参数,是不是多出了什么东西?我们下面说。
  15. virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;
  16. };
  17. #endif

注意碰撞函数的参数,我们给定了t的min和max值,这些值表示了我们容许的t的范围,想想看我们上一课的习题里提到了一颗在相机后面的球,如果当时我们限定了t的范围,认为根为负的都该被剔除,我们就不会得到这样反常理的图像了。即:
六:物体类 - 图1
所有不满足条件的t都会被剔除,这种剔除不仅仅可以用在负值的剔除上,还会有其他的妙用,这个之后你会慢慢体会到。

小实现

我们现在可以基于这一个基类构建我们的球类代码,创建sphere.h文件,敲入如下代码:

  1. #ifndef SPHERE_H
  2. #define SPHERE_H
  3. #include "hittable.h"
  4. #include "vec3.h"
  5. class sphere : public hittable {
  6. public:
  7. //构造
  8. sphere() {}
  9. sphere(point3 cen, double r) : center(cen), radius(r) {};
  10. //声明要override纯虚函数
  11. virtual bool hit(
  12. const ray& r, double t_min, double t_max, hit_record& rec) const override;
  13. public:
  14. point3 center;
  15. double radius;
  16. };
  17. bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
  18. //不多赘述
  19. vec3 oc = r.origin() - center;
  20. auto a = r.direction().length_squared();
  21. auto half_b = dot(oc, r.direction());
  22. auto c = oc.length_squared() - radius*radius;
  23. auto discriminant = half_b*half_b - a*c;
  24. //发现方程没有根,直接退出。
  25. if (discriminant < 0) return false;
  26. auto sqrtd = sqrt(discriminant);
  27. //因为我们引入了tmax和tmin,所以这里还需要格外的运算。
  28. //检查较小的根
  29. auto root = (-half_b - sqrtd) / a;
  30. if (root < t_min || t_max < root) {
  31. //检查较大的根
  32. root = (-half_b + sqrtd) / a;
  33. if (root < t_min || t_max < root)
  34. return false;
  35. }
  36. //封入结构体
  37. rec.t = root;
  38. rec.p = r.at(rec.t);
  39. //我们不用麻烦使用unit_vector函数,我们可以直接利用已经存好的半径进行单位化,实现加速。
  40. rec.normal = (rec.p - center) / radius;
  41. return true;
  42. }
  43. #endif

第40行开始的if语句非常的巧妙,我们只有在较小的根不满足六:物体类 - 图2的时候,才会检查较大的根,换句话说,如果较小的根在我们的许可范围内,我们会直接采纳他。一般来说,较小的根是光线和物体的首次交汇点,我们的碰撞点都是指这一点, 所以优先返回较小根是非常合理的。
假设我们要剔除负值的t,并且球和光线有两个交点,较小的根是负值,较大的是正值,我们就会采用较大的根,你想想看这是一个什么样的3D场景——没错,我们把相机放在了球的内部!

小细节

到此为止,你有没有发现上面的代码有什么致命缺陷吗?嘿,我都在尽力提示你了——“相机放在球的内部”会产生什么样的问题呢?
法线不对了!我们计算法线的方式永远都是把碰撞点减去球心,但是如果光线是从内部打到球上,那么法线的方向就不对了!因为在我们的计算方式里,它永远是指向球外的。
这影响很大,假设我们制作一个玻璃球,光线会在球的内部弹射,如果法线一直向外,那我们根本无法让光线在球的内部弹射。
我们应该有这样一个结构:

  1. //我们通过光线方向和向外法线方向的点乘来判断光线是从外面射进来的,还是从内部射出去的。
  2. if (dot(ray_direction, outward_normal) > 0.0) {
  3. // 光线在球内!我们的向外法线是不对的,应该向内!
  4. ...
  5. } else {
  6. // 光线在球外,我们计算的法线是正确的!
  7. ...
  8. }

如果点乘为正,表示光线和向外法线是大致相同方向,即表示光线是从内部射到球面上的,所以我们应该对法线取反,让它指向球心。
我同样希望能把光线打到的是正面还是反面这个信息也存起来,对于所有的凸面体来说,我们知道光线在物体内还是物体外是很有用处的。看如下代码,我们在hittable.h中的hit_record结构体中加入如下代码去判断并更改法线的朝向:

  1. struct hit_record {
  2. point3 p;
  3. vec3 normal;
  4. double t;
  5. //光线打到的是不是物体的外面?
  6. bool front_face;
  7. //一个结构体内的函数,他判断法线的里外,并且在光线打到物体内面时取反法线。
  8. inline void set_face_normal(const ray& r, const vec3& outward_normal) {
  9. front_face = dot(r.direction(), outward_normal) < 0;
  10. normal = front_face ? outward_normal :-outward_normal;
  11. }
  12. };
  1. sphere.h中球类的hit函数中,修改并添加如下代码:
  1. bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
  2. ...
  3. rec.t = root;
  4. rec.p = r.at(rec.t);
  5. vec3 outward_normal = (rec.p - center) / radius;
  6. //用这一个函数设置hit_record中的法线和front_face
  7. rec.set_face_normal(r, outward_normal);
  8. return true;
  9. }

这一章全部都是代码的转移和优化,我们添加了一个物体基类并转移了球类的代码,并且对一些细节问题进行阐述,下一章,我们会处理一些更抽象的东西,建造我们自己的工具函数类。
或许你看见我们这几章都没有渲染出更多有趣的图像,觉得有些乏味,请坚持,黎明就在眼前。

小问题

请使用抽象后的代码,在main函数中实现元气弹。

参考文献

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