Ray Tracing in One Weekend - 1中我们已经实现了一些基本类,例如vec3, ray等。并且对Camera geometry有了基本的了解,能定义camera并生成渐变的图像。接下来我们从为ray tracer加一个球体开始继续探索吧!

Adding a Sphere

让我们在ray tracer中添加一个物体。人们通常使用球体,因为计算光线是否击中球体相对直接简单。

Ray-Sphere Intersection

回想一下,位于原点的半径为的球体方程是。换句话说,如果给定的点(x, y, z)在球体上,那么;如果给定的点(x, y, z)在球体内,那么;如果给定的点(x, y, z)在球体外,那么

如果球心在上,表达式会丑一点,即:

在图形学中,你几乎总是希望使用向量来表达公式,这样所有的x, y, z相关的计算都可以在vec3类中进行。你可能已经注意到了从球心到点的向量是,按照向量的内积公式可得:

所以用向量表示的球面方程即:

我们可以理解为“满足此等式的任意一点p一定在球体上”。我们还想知道射线是否与球体相交。如果相交了,那么一定有一个值可以使得满足球体方程。所以我们来尝试计算使得方程成立:

或者将展开:

我们所需要的向量代数知识就到这里啦。如果我们展开表达式并且全都移项到左边就可以得到:

方程中的向量和都是已知的或是常数。是未知的,并且是一个关于的一元二次方程,就像你在高中数学里学到的那样。你可以用求根公式来判别交点个数,它可以是正数(表示有两个实数解),可以是负数(表示没有实数解),或者0(表示有一个实数解)。在图形学中,代数和几何紧密相关。我们通常会得到的是:

Figure 4: 光线与球体的相交结果

Creating Our First Raytraced Image

如果我们将这个数学计算编码到我们的程序中,我们可以通过在我们放置在z轴-1位置上的小球体上着红色的方式来进行测试。

1
2
3
4
5
6
7
8
bool hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = dot(r.direction(), r.direction());
auto b = 2.0 * dot(oc, r.direction());
auto c = dot(oc, oc) - radius * radius;
auto discriminant = b * b - 4 * a * c;
return (discriminant > 0);
}

我们将得到下图:

现在这里还缺很多东西,比如着色,反射光线和多个物体的处理,但比起刚刚开始,我们距离完成一半的工作已经更接近了!需要注意的一点是,我们测试了光线是否与球体相交,但是对的解也是有效的。如果你将球心改为,你将会得到之前一样的图像。这不是一个特性,我们会在之后解决这个问题。

Surface Normals and Multiple Objects

Shading with Surface Normals

首先让我们理解surface normal(表面法线)以便进行着色。surface normal是一个垂直于交点处所在的表面的向量。对于法线,我们有两个设计决策。第一个是这些法线是否是单位长度。对于着色来说,单位长度很方便,所以我会说“是”,但是我不会在代码里强行这么写。这可能会导致一些微妙的bug,因此请注意这是个人偏好,就像大多数设计决策一样。对于球体而言,outward normal(外向法线)的方向就是击中点减去球心的方向:

Figure 5: 球体表面法线几何

在地球上,这就意味着从地心指向你的向量垂直向上。现在让我把这个想法写进代码,并对其进行着色。现在还没有光源和其他东西,所以我们可以用color map来可视化法线。一个常见的可视化法线的技巧(因为假设表面法线 是一个单位长度向量是很容易且直观的,所以每个分量的取值范围从-1到1)是将每个分量映射到0到1的区间中,然后再将x/y/z映射到r/g/b。对于法线而言,我们需要击中点的信息,而不是仅仅知道是否击中。现在,场景中只有球体并且它在相机的正前方,所以先别担心t为负值的情况。我们只需要假定最近的击中点(即最小的)。根据这个思路我们可以计算并可视化,可以生成以下图像:

Simplifying the Ray-Sphere Intersection Code

让我们回顾一下光线与球体的方程式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = dot(r.direction(), r.direction());
auto b = 2.0 * dot(oc, r.direction());
auto c = dot(oc, oc) - radius * radius;
auto discriminant = b * b - 4 * a * c;

// calculate the smallest t
if (discriminant < 0.0) {
return -1.0;
}
else {
return (-b - sqrt(discriminant)) / (2.0 * a);
}
}

首先,回想一下,一个向量与它自己的点积等于它长度的平方。
第二,我们注意到在上述代码中变量b有一个常数项2。设,可以推得:

按照以上思路,我们可以将sphere-intersection的代码简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = r.direction().length_squared();
auto half_b = dot(oc, r.direction());
auto c = oc.length_squared() - radius * radius;
auto discriminant = half_b * half_b - a * c;

// calculate the smallest t
if (discriminant < 0.0) {
return -1.0;
}
else {
return (-half_b - sqrt(discriminant)) / (a);
}
}

An Abstraction for Hittable Objects

现在,我们考虑一下场景中有多个球体的情况。虽然使用一个球体数组的方案很直接诱人,但是一个非常清晰的解决方案是“为光线可能击中的任何物体创建一个抽象类,然后将球体和球体数组都视为可被击中的物体”。关于这个类叫什么名字,这有点困扰 - 如果不考虑”object oriented”的话,叫”object”可能很好;在我们出现要计算volumes的情况,叫”surface”也不太合适。”hittable”强调了将实体们统一起来的成员函数。以上名字我都不喜欢,但我会选择”hittable”。

hittable这个抽象类有一个接受光线的hit function。大多数ray tracer为了方便,都加入了一个区间来判断相交是否合法。对于一开始的光线来说,总是正的。但如我们所见,加入区间为代码实现提供了一些帮助。现在有个设计上的问题:是否在每一次击中时我们都需要计算法线。实际上我们可能在搜索过程中找到更近的击中点就行,并且我们只需要计算最近击中点的法线。我将给出一个简单的解决方案,并将一些需要计算的东西放进结构体里。

以下是hittable抽象类的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef HITTABLE_H
#define HITTABLE_H

#include "ray.h"
#include "vec3.h"

struct hit_record {
point3 p; // intersection point
vec3 normal; // the unit surface normal vector at the intersection point
double t; // parameter t at the intersection point
};

class hittable {
public:
virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;
};

#endif

以下是它的派生类sphere的代码:

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
#ifndef SPHERE_H
#define SPHERE_H

#include "hittable.h"
#include "vec3.h"

class sphere: public hittable {
public:
sphere() {};
sphere(vec3 cen, double r) : center(cen), radius(r) {};

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

public:
vec3 center;
double radius;
};

bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
vec3 oc = r.origin() - center;
auto a = r.direction().length_squared();
auto half_b = dot(oc, r.direction());
auto c = oc.length_squared() - radius * radius;

auto discriminant = half_b * half_b - a * c;
if (discriminant < 0) return false;
auto sqrtd = sqrt(discriminant);

// Find the nearest root that lies in the acceptable range.
auto root = (-half_b - sqrtd) / a;
if (root < t_min || root > t_max) {

// Find the farther one
root = (-half_b + sqrtd) / a;
if (root < t_min || root > t_max) return false;
}

rec.t = root;
rec.p = r.at(rec.t);
rec.normal = (rec.p - center) / radius;

return true;
}

#endif

Front Faces Versus Back Faces

第二个关于法线的设计决策是他们是否应该始终向外。到目前为止,法线的方向始终是从球心到intersection point(击中点)的方向,即法线指向外部。如果ray从外部与球体相交,则法线指向与ray 相反的方向。如果ray从内部与球体相交,则(始终向外的)法线与ray方向相同。另一种选择是,让法线始终指向射线的反方向。即如果ray从外部与球体相交,则法线指向外部;如果ray从内部与球体相交,则法线指向内部。

Figure 6: 球体表面法线几何的可能方向

注:以上两种方案,对于ray从外部与球体相交的情况是一致的,即与ray方向相反,而ray从内部与球体相交有向内向外两种选择。

我们需要在这些可能性中选择一种,因为我们最终希望确定光线来自表面的哪一侧。这对于在每一侧用不同方式渲染的对象非常重要,比如双面纸上的文字,或者像玻璃球一样具有内部和外部的对象。

如果我们决定让法线始终指向外部(方案一),那么在上色是我们需要确定ray在物体的哪一侧。我们可以通过ray和normal的方向来判断。如果ray和normal方向相同,那么ray位于物体内部;如果方向相反,那么ray位于物体外部。这可以通过两个向量的点积来判断,如果他们的点积为正,则射线在物体内部,反之在物体外部。这里使用了点积的另一条公式:

1
2
3
4
5
6
7
if (dot(ray_direction, outward_normal) > 0.0) {
// ray is inside the sphere
...
} else {
// ray is outside the sphere
...
}

如果我们决定让法线始终只想ray的反方向,就不能用点乘来判断了。相反,我们应该存储这些信息:

1
2
3
4
5
6
7
8
9
10
bool front_face;
if (dot(ray_direction, outward_normal) > 0.0) {
// ray is inside the sphere
normal = -outward_normal;
front_face = false;
} else {
// ray is outside the sphere
normal = outward_normal;
front_face = true;
}

注: front_face == true意味着surface normal指向外部,即正面;front_face == false意味着surface normal指向内部,即反面;

我们可以使法线始终指向外部,或者始终逆着入射光的方向。这个决策取决于你希望在几何相交阶段还是在着色阶段决定法线在表面的哪一侧。在本书中,我们拥有比几何类型更多的材质类型,因此我们将选择更少的工作量,并将这个决策放在几何相交阶段进行。这只是个简单的偏好问题,在文献中你会看到两种实现方式。

我们为hit_record结构体添加一个名为front_facebool值,当然我们也会加入一个函数为我们计算这个值。现在struct hit_record的实现如下:

1
2
3
4
5
6
7
8
9
10
11
struct hit_record {
point3 p; // intersection point
vec3 normal; // the unit surface normal vector at the intersection point
double t; // parameter t at the intersection point
bool front_face; // the direction of the normal

inline void set_face_normal(const ray& r, const vec3& outward_normal) {
front_face = dot(r.direction(), outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};

并在覆盖sphere::hit函数时,调用set_face_normal函数来计算并记录法线的方向。

1
2
3
4
rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);

A List of Hittable Objects

注:如果你不熟悉c++的一些特性例如vector或者shared_ptr,我推荐先看下一小节再倒回来看这一小节。

现在,我们有了一个名为hittable的抽象类,光线可以与之相交。现在我们来添加一个类以存储hittable的列表:

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
#ifndef HITTABLE_LIST_H
#define HITTABLE_LIST_H

#include "hittable.h"

#include <vector>
#include <memory>

using std::shared_ptr;
using std::make_shared;

class hittable_list : public hittable {
public:
hittable_list() {}
hittable_list(shared_ptr<hittable> object) { add(object); }

void clear() { objects.clear(); }
void add(shared_ptr<hittable> object) { objects.push_back(object); }

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

public:
std::vector<shared_ptr<hittable>> objects;
};

bool hittable_list::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = t_max;

for (const auto& object: objects) {
if (object->hit(r, t_min, closest_so_far, temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}

return hit_anything;
}

#endif

Some New C++ Features

如果你平时不是c++程序员,hittable类使用了两个可能会使你遇到困难的c++特性:vectorshared_ptr

shared_ptr<type>是指向某些已分配类型的指针,并具有引用计数语义。每当你将其赋值给另一个shared pointer(通常使用简单的赋值语句),引用计数会递增。当shared_ptr超出作用域(比如在block或是function的末尾),引用计数会减少。一旦计数变成零,对象将会被删除。

通常,可以通过新分配的对象来初始化一个shared_pointer,例如:

1
2
3
shared_ptr<double> double_ptr = make_shared<double>(0.37);
shared_ptr<vec3> vec3_ptr = make_shared<vec3>(1.414214, 2.718281, 1.618034);
shared_ptr<sphere> sphere_ptr = make_shared<sphere>(point3(0, 0, 0), 1.0);

make_shared<thing>(thing_constructor_params ...)按照constructor params为给定的thing类型分配一个新实例,并返回一个shared_ptr<thing>

由于类型可以通过make_shared<type>(...)返回类型的自动推导来确定,上面的代码可以通过auto类型来简化:

1
2
3
auto double_ptr = make_shared<double>(0.37);
auto vec3_ptr = make_shared<vec3>(1.414214, 2.718281, 1.618034);
auto sphere_ptr = make_shared<sphere>(point3(0, 0, 0), 1.0);

我们在代码中使用了shared pointer,因为它允许多个几何体共享一个公共实例(例如,一组使用相同纹理贴图材质的球体),并且它能自动内存管理且易于理解。

std::shared_ptr被包含在<memory>头文件中。

第二个你可能不太熟悉的特性是std::vector。这是一个类似数组的通用容器,可以容纳任意类型的元素。在上面的代码中,我们使用了一个包含指向hittable类指针的容器。vector会在添加更多元素时自动增长:(hitttable_list类中,)使用objects.push_back(object)可以将值添加到std::vector类型的成员变量objects的末尾。

std::vector被包含在<vector>头文件中。

最后,hittable_list.h在开头部分用using语法告诉编译器。shared_ptrmake_shared是来自std库的。这样,我们每次使用它就不需要加上std::前缀了。

Common Constants and Utility Functions

我们需要一些数学常量,所以为了方便,我们将他们定义在一个单独的头文件中。目前我们只需要inifinity(无穷大)。但稍后我们还会加入自己定义的圆周率pi。因为没有标准的跨平台的定义圆周率的方式,所以我们自己定义。我们将把常用的有用的常数和未来实用的函数放在rtweekend.h中,作为我们通用的主头文件。

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
#ifndef RTWEEKEND_H
#define RTWEEKEND_H

#include <cmath>
#include <limits>
#include <memory>

// Usings

using std::shared_ptr;
using std::make_shared;
using std::sqrt;

// Constants

const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;

// Utility Functions

inline double degrees_to_radians(double degrees) {
return degrees * pi / 180.0;
}

// Common Headers

#include "ray.h"
#include "vec3.h"

#endif

对应的,main.cc可以进行一些简化。
这样产生的图片实际上只是显示了球体的位置以及他们表面法线的可视化结果。这通常是查看模型缺陷和特征的好方法。

Figure 5: 用法线计算颜色渲染出的球体与地板的结果

Reference