Ray Tracing In One Weekend – 走进面向对象的时代

写在前面

本部分涉及到的智能指针部分,原文做了讲解,我就不详细地跟着再解释一遍了,还是建议先把 C++ 基本知识过了一遍再来学习该教程,个人觉得这样的话就不会对语法产生疑惑,带着自己的理解来敲一遍代码比不懂 C++ 去跟着敲,应该是事半功倍的。

这次重看 Ray Tracing In One Weekend 我有了很多的新的感悟,这些感悟也被我融入到了标题和小标题中,可以说我是标题党。但如果你真的看懂了这个教程,应该能体会到我起的这些标题实际上是对每节代码的巧妙比喻。

每个球都有一颗玻璃心

光线与物体表面相交时,我们需要计算并保存必要的信息。因此可以将碰撞点的信息构造为一个结构体 hit_record,其中包括了点坐标 point、法向量 normal 和碰撞时间 time

//hittable.h
#pragma once
#include "ray.h"
struct hit_record {
    point3 point;
    vec3 normal;
    double time;
};
复制代码

所有能与光线碰撞的物体都应该实现可碰撞接口 hittable,以及接口中判断光线与该物体能否发生碰撞的方法 virtual bool hit()。因此我们需要在碰撞方法中传入要检测的光线 const ray& r、碰撞时间和记录碰撞点信息的结构体 hit_record& rec,当发生碰撞时不仅要返回 true,还要将碰撞信息写入碰撞点信息结构体。

//hittable.h
class hittable {
public:
    virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec)const = 0;
};
复制代码

球都有对象了

已知球体的中心点坐标 point3 center 和球半径 double radius ,根据公式,我们就可以得到一个球体 sphere 的所有表面点坐标。在球体类型中继承 hittable 结构并声明必须实现的虚函数 hittable

//sphere.h
#pragma once
#include "hittable.h"
#include "vec3.h"
class sphere : public hittable{
public:
    sphere(point3 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:
    point3 center;
    double radius;
};
复制代码

接下来要实现 hittable 接口的 hit() 方法,我们需要将在 main.cpp 实现的两个方法移植到球体的碰撞判断函数中。前部分依旧是原方法的内容,将光线和球表面坐标公式联立后得到的二元一次方程式解出,判别式 discriminant 若小于 0 则说明无交点,返回 false。若光线与球体表面发生碰撞的时间 root 不符合我们的预期,也返回 false。最后筛选到符合的碰撞情形后,将碰撞点信息写入结构体实例 rec

//sphere.h
bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec)const {
    vec3 oc = r.origin() - center;
    double a = r.direction().length_squared();
    double half_b = dot(oc, r.direction());
    double c = oc.length_squared() - radius * radius;
    double discriminant = half_b * half_b - a * c;
    if (discriminant < 0) 
            return false;
    double sqrtd = sqrt(discriminant);
    double 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;
            }
    }
    rec.time = root;
    rec.point = r.at(rec.time);
    rec.normal = (rec.point - center) / radius;
    return true;
}
复制代码

穿心的丘比特之箭

当光线从外部击中球体时,交点处的法线 normal 方向实际上是向外的,也就是说与光线投射过来的方向总体是相反的。但当光线从球体内部射向球面时,法向量就朝向了球心,我们可以做一个修正,让交点处的法向量永远是向外的。这样做的好处是,不需要在后续的计算中,再用点乘判断光线是从正面还是反面射中的球体了。

//hittable.h
struct hit_record {
    point3 point;
    vec3 normal;
    double time;
    bool front_face;
    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;
    }
};
复制代码

步入殿堂

场景中可能会存在很多个、很多种的可碰撞物体,因此我们可以将存放物体的集合、遍历方法封装为一个实现了 hittable 接口的类型

//hittable.h
#pragma once
#include "hittable.h"
#include <memory>
#include <vector>
class hittable_list : public hittable {
public:
    hittable_list(std::shared_ptr<hittable> object) {add(object);}
    void clear() {objects.clear();}
    void add(std::shared_ptr<hittable> object) {objects.emplace_back(object);}
    virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const override;
public:
    std::vector<std::shared_ptr<hittable>> objects;
};
复制代码

场景类型的 hit 函数实现中需要将其维护的物体集合 objects 遍历,依次检查光线是否击中 object->hit() 该物体,如果击中了,需要将击中该物的时间 temp_rec.time 更新为最近时间 closest_so_far。如果后面检查的物体在最小时间 t_min 和最大时间 t_max 内也被击中了,说明该光线被后面遍历到的物体所遮挡。需要所有的物体都被遍历后,我们才能该光线最终的碰撞信息。

//hittable.h
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;
    double 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.time;
            rec = temp_rec;
        }
    }
    return hit_anything;
}
复制代码

万事俱备只欠东风

准备一个头文件命名为 rtweekend.h,用来存放公共的常量和工具函数。

//rtweekend.h
#pragma once
#include <cmath>
#include <limits>
#include <memory>
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;
inline double degrees_to_radians(double degrees) {
    return degrees * pi / 180.0;
}
复制代码

ray_color 函数中判断是否击中物体并返回对应颜色的逻辑,也需要修改了。

//main.cpp
color ray_color(const ray& r, const hittable& world) {
    hit_record rec;
    if (world.hit(r, 0, infinity, rec)) {
        return 0.5 * (rec.normal + color(1.0, 1.0, 1.0));
    }
    vec3 unit_direction = unit_vector(r.direction());
    double t = 0.5 * (unit_direction.y() + 1.0);
    return (1.0 - t) * vec3(1.0, 1.0, 1.0) + t * vec3(0.5, 0.7, 1.0);
}
复制代码

孤单世界中成双成对

准备工作都完成后,这次让我们来渲染两个球体。首先实现了 hittable 接口的 hittable_list 类型和 sphere 类型的实例创建。这里我们保持一开始的球不变,然后再增加一个半径为 100 的大球作为我们场景的“大地”。在 main() 函数中合适的位置添加一个 World 部分的代码:

//main.cpp/main()
//World
hittable_list world;
world.add(std::make_shared<sphere>(point3(0.0, 0.0, -1.0), 0.5));
world.add(std::make_shared<sphere>(point3(0.0, -100.5, -1.0), 100));
复制代码

Render 部分我们的 ray_color 函数多了一个参数 world,要补上。

//main.cpp/main()
//Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = image_height - 1; j >= 0; --j) {
    std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
    for (int i = 0; i < image_width; ++i) {
        double u = double(i) / (image_width - 1);
        double v = double(j) / (image_height - 1);
        ray r(origin, lower_left_corner + u * horizontal + v * vertical - origin);
        color pixel_color = ray_color(r, world);
        write_color(std::cout, pixel_color);
    }
}
std::cerr << "\nDone.\n";
复制代码

完成以上的代码后进行编译,重定向输出为 PPM 格式的图片,打开后即可看到如下的渲染结果:

image.png

看到更清晰的世界

欲先善其事必先利其器

为我们的工具类中添加随机数生成函数。由于 rand() 函数是取一个 032767RAND_MAX 范围内的数,因此 rand() / (RAND_MAX + 1) 是一个永远取不到 1 的、取值范围为 [0,1) 的双精度浮点数。我们需要一个自定义其实范围的随机数,因此对随机数生成函数进一步封装,最大值和最小值的区间是我们需要取得随机数范围,将二者之间得差值乘以一个 [0,1) 的随机数,得到的结果再加上最小值就得到了我们想要的随机数,其范围是 [min,max)

//rtweekend.h
inline double random_double() {
    return rand() / (RAND_MAX + 1);
}
inline double random_double(double min, double max) {
    return min + (max - min) * random_double();
}
复制代码

clamp() 函数用于防止数值溢出我们想要的范围。

//rtweekend.h
inline double clamp(double x, double min, double max) {
    if (x < min) return min;
    if (x > max) return max;
    return x;
}
复制代码

组装一个简单的相机

将主函数中散落的摄像机相关的变量,诸如相机位置 origin、视口大小 aspect_ratio、焦距 focal_length、投射而出的光线起始位置 lower_left_corner 等等,都封装到 camera 类型中,并将从相机出发投射光线的生成也封装为 get_ray() 函数,移植到相机类中。

//camera.h
#pragma once
#include "vec3.h"
#include "rtweekend.h"
#include "ray.h"
class camera {
private:
    point3 origin;
    point3 lower_left_corner;
    vec3 horizontal;
    vec3 vertical;
public:
    camera() {
        const double aspect_ratio = 16.0 / 9.0;
        double viewport_height = 2.0;
        double viewport_width = aspect_ratio * viewport_height;
        double focal_length = 1.0;
        origin = point3(0.0, 0.0, 0.0);
        horizontal = vec3(viewport_width, 0, 0);
        vertical = vec3(0, viewport_height, 0);
        lower_left_corner = origin - horizontal / 2 - vertical / 2 - vec3(0.0, 0.0, focal_length);
    }
    ray get_ray(double u, double v)const {
        return ray(origin, lower_left_corner + u * horizontal + v * vertical - origin);
    }
};
复制代码

封装后我们就可以将 Camera 部分简化为:

//main.cpp/main()
//Camera
camera cam;
复制代码

做一个简单的抗锯齿

如果我们对一个像素进行多次采样,但每次采样都稍微偏移一点随机距离 i + random_double(),然后将 samples_per_pixel 次数的采样颜色加在一起,最后输出时再将颜色值整体除以 samples_per_pixel,对于颜色变化不大的区域自然是没什么影响,但对于颜色发生剧烈变化的区域,我们相当于对着色点周围的颜色进行了采样取平均,那么我们就能得到一个反走样的、模糊化的边缘的球体。

Image 部分增加一个新的常量 samples_per_pixel

//main.cpp/main()
//Image
const int samples_per_pixel = 100;
复制代码

Render 部分增加多次采用的遍历逻辑,并注意修改后的函数参数变化。

//main.cpp/main()
//Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = image_height - 1; j >= 0; --j) {
    std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
    for (int i = 0; i < image_width; ++i) {
        color pixel_color(0.0, 0.0, 0.0);
        for (int s = 0; s < samples_per_pixel; ++s) {
            double u = (i + random_double()) / (image_width - 1);
            double v = (j + random_double()) / (image_height - 1);
            ray r = cam.get_ray(u, v);
            pixel_color += ray_color(r, world);
        }
        write_color(std::cout, pixel_color, samples_per_pixel);
    }
}
std::cerr << "\nDone.\n";
复制代码

修改 color.h 中的 write_color() 函数,我们需要将多次采样的颜色值结果除以 samples_per_pixel,并且为了防止最终得到的值溢出,要保证平均后的 r,g,br,g,b 值在 [0,1][0,1] 的范围内。

//color.h
void write_color(std::ostream& out, color pixel_color, int samples_per_pixel) {
    double r = pixel_color.x();
    double g = pixel_color.y();
    double b = pixel_color.z();
    double scale = 1.0 / samples_per_pixel;
    r *= scale;
    g *= scale;
    b *= scale;
    out << static_cast<int>(255.999 * clamp(r, 0.0, 0.999)) << ' '
        << static_cast<int>(255.999 * clamp(g, 0.0, 0.999)) << ' '
        << static_cast<int>(255.999 * clamp(b, 0.0, 0.999)) << '\n';
}
复制代码

完成以上的代码后进行编译,重定向输出为 PPM 格式的图片,将原渲染结果和经过多次采样的渲染结果进行对比可以看到:

image.png

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享