0%

【学习记录】Ray Tracing in One Weekend

image-20220909102129774

之前完成的光追是依靠GAMES101中闫老师提供的一个完成度非常高的框架实现的,自己也就补充完成了几个函数,虽然最后也成功渲染出了一张图,但是总觉得只是学会了光追的中的重要的几个点,而对全局的架构理解不深。这门课程虽然仅仅只是实现了最基础的光追,但却是从零开始逐步完善整个项目工程,同样令我收获颇丰。

原文地址: https://raytracing.github.io/books/RayTracingInOneWeekend.html

一、原理概述

渲染主要考虑每一个物体对每一个像素点的影响,它主要包括两种普遍的方式:

1、物体顺序渲染(object-order rendering)

依次考虑每一个物体,并且对于每一个物体,我们会查找它影响了哪些像素的值并更新这些像素值

2、图像顺序渲染(image-order rendering)

轮流考虑每一个像素,对于每一个像素,查找所有会影响它的物体,计算最终的像素值。

光线追踪就是一种根据图像顺序渲染的方式。

基本的光线追踪其包含三个部分:

  1. 光线生成 ray generation,基于相机几何学计算每一个像素上”光线“的起点和方向

  2. 光线求交 ray intersection,找到光线和最靠近物体的交点

  3. 着色 shading ,通过光线求交的结果和其它信息计算像素颜色

基本的光线追踪算法的算法流程为

1
2
3
4
对于每一个像素点:
从每一个像素发射一系列的采样光线,计算观察方向发出的光线
计算每一条采样光线在场景中与物体发生的折射反射,将颜色累加
将上述颜色取平均值作为一个像素对应的颜色值

具体执行的大致流程如下:

(1)设置一些参数,如像素宽高,每个像素采样次数,递归深度等

(2)对于每一个像素使用随机函数打出多条随即方向的光线

(3)每条光线都通过 ray_color() 这个函数与场景中的物体进行碰撞,发生反射、折射

(4)根据物体的材质计算反射光线,递归计算光线与物体碰撞得到的颜色

(5)将上述得到的颜色值累加起来写入当前像素的位置

(6)循环重复步骤(2) —(5)

(7)将颜色值除以光线个数取平均,有时还需用到Gamma矫正

二、教程的几个关键步骤

1、对于光线的定义

image-20220907232615527

  • 光线是一个三维向量
  • 为光线起始位置,是一个三维向量
  • 为光线起始位置到光线打到物体上的点的距离,是一维标量
  • 为光线的方向,是一个三维向量

2、光线与球体求交

该教程的场景全都是由球体构成,那些看起来像地面的物体是一个超大半径的球体,所以该教程与物体有关的碰撞求教基本上是与球体之间的碰撞。具体体现在如下函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
if (discriminant < 0)
{
return -1.0;
}
else
{
return (-b - sqrt(discriminant)) / (2.0 * a);
}
}

该函数接受三个参数

  • 与光线碰撞的球体的中心点 center
  • 与光线碰撞的球体的半径 radius
  • 光线 r

image-20220908003512493

教程中对于光线与球体是否有交点是根据二次方程是否有解来判断的,有三个注意点:如果两个解中更小的解在区间内,那么更小的解是交点,体现在:

1
return (-b - sqrt(discriminant)) / (2.0 * a);

1
2
3
4
5
6
7
auto root = (-half_b - sqrtd) / a;
if (root < t_min || t_max < root)
{
root = (-half_b + sqrtd) / a;
if (root < t_min || t_max < root)
return false;
}

否则,如果更大的解在区间内,那么更大的解是交点。否则没有交点。

3、对可命中对象进行抽象

建立一个名为hittable的抽象类

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

该类拥有一个 hit 函数,该函数接受如下参数:

  • 光线 r

  • 碰撞最短距离 t_min

  • 碰撞最长距离 t_max

  • 碰撞记录 rec

    碰撞记录存放着与光线碰撞的物体上的点的相关信息,包括碰撞处点的位置、法线(由球心指向外)、光线移动的距离。

1
2
3
4
5
6
struct hit_record
{
point3 p;
vec3 normal;
double t;
};

之后便可以让sphere类继承该碰撞类,并覆写 hit 函数

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

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

// 求出在位于区间内的最近的那个解
// 如果两个解中更小的解在区间内,那么更小的解是交点
// 否则,如果更大的解在区间内,那么更大的解是交点。
// 否则没有交点。
auto root = (-half_b - sqrtd) / a;
if (root < t_min || t_max < root) {
root = (-half_b + sqrtd) / a;
if (root < t_min || t_max < root)
return false;
}

// 若有交点,则将该交点记录保存在 hit_record中
rec.t = root;
rec.p = r.at(rec.t);
rec.normal = (rec.p - center) / radius; // 对法向量进行归一化

return true;
}

然后建立一个的列表类 hittable_list ,存储可命中对象

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

该类使用了智能指针(shared_ptr),会记录有多少个 shared_ptr 共同指向一个对象,一旦最后一个这样的指针被销毁,也就是一旦某个对象的引用计数变为0,这个对象会被自动删除。这在非环形数据结构中防止资源泄露很有帮助。具体可见 C++ 智能指针 shared_ptr 详解与示例

4、关于抗锯齿

主要方法:

1、在采样时使用随机函数

1
2
auto u = (i + random_double()) / (image_width - 1);
auto v = (j + random_double()) / (image_height - 1);

2、对颜色多次采样,最后取平均值

1
2
3
const int samples_per_pixel = 500;
...
auto scale = 1.0 / samples_per_pixel;

5、漫反射的材质

image-20220908004652800

上图表示两个单位球在 P 点处相切, 为表面法线。两个单位球的球心为 ()与 (),我们将球心为 ()的球视为表面外的球,在该单位求内随机取一点 ,从 P 点出发出一条光线,方向为 (),表示发生漫反射时随机反射出去的一条光线。

光线不可能无止境的递归反射下去,所以我们还需要确立一个最大反射次数用于终止递归

1
2
3
4
5
6
7
const int max_depth = 50;

color ray_color(const ray& r, const hittable& world, int depth) {
if (depth <= 0)
return color(0,0,0);
// ..............
}

示例效果如下:

image-20220908011724278

6、金属材质

对于光滑金属,光线不会随机散射

image-20220908010855254

1
2
3
4
// 上图对应的反射代码如下
vec3 reflect(const vec3& v, const vec3& n) {
return v - 2*dot(v,n)*n;
}

对于金属材质,创建对应的材质类:

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

virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override
{
vec3 reflected = reflect(unit_vector(r_in.direction()), rec.normal);
scattered = ray(rec.p, reflected);
attenuation = albedo;
// 若散射方向与法线夹角小于90°,说明发生了正确的反射
return (dot(scattered.direction(), rec.normal) > 0);
}

public:
color albedo;
};

示例效果如下:

image-20220908011704309

三、成果

格式问题:尝试了多种网页在线转换和python代码都没法把 .ppm 格式的文件转换成 .jpg,只好直接截图了。

按照教程上的代码渲染出的图,花费了一晚上才渲染好

image-20220909102013774

自己修改了一些球的材质位置,减少了球的数量,耗时15分钟左右

image-20220909102129774