写在前面
本部分涉及到的智能指针部分,原文做了讲解,我就不详细地跟着再解释一遍了,还是建议先把 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 格式的图片,打开后即可看到如下的渲染结果:
看到更清晰的世界
欲先善其事必先利其器
为我们的工具类中添加随机数生成函数。由于 rand()
函数是取一个 0
到 32767
即 RAND_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
,并且为了防止最终得到的值溢出,要保证平均后的 值在 的范围内。
//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 格式的图片,将原渲染结果和经过多次采样的渲染结果进行对比可以看到: