对于表面法向量的设计决策,有两个要考虑的问题:
1、是否要用单位长度
“首先是这些法线是否为单位长度。这对于阴影很方便,所以我会说yes,但我不会在代码中执行它。这可能会出现一些细微的bug,所以要注意这是个人偏好,就像大多数类似的设计决策一样。”
2、是法向量是否要指向球外。
“目前,找到的法线总是在中心向交点的方向(法线指向外)。如果射线从外部与球面相交,法线与射线相交(the normal points against the ray)。如果射线从内部与球面相交,法线(法线总是指向外的)与射线相交(points with the ray)。或者,我们可以让法线总是指向射线。如果射线在球面外,法线指向外,但如果射线在球面内,法线指向内。”
5.1 用表面法向量着色
为了着色,我们先找到表面法向量。对于一个球体,要着色的点上的表面法向量为:射线与球的交点P减去球心C:P-C。
我们现在还没有灯光等东西,所以用颜色图来着色法向量。发现可视化的常用技巧是:假设法向量是单位长度(每个分量在-1和1之间),在将其映射到0到1之间,用x、y、z的值分别表示r、g、b的值。
对于求解法向量,我们除了知道是否击中球外,还要知道击中的位置。这里假设击中的点是t(较小的一个)对应的点,接下来可视化这些点:
// 我的代码 main.cc |
// 返回击中的点对应的t值 double hit_sphere(const ray& r, const point3& center, double radius) { vec3 oc = r.origin() - center; auto a = dot(r.direction(), r.direction()); auto b = 2.0 * dot(r.direction(), oc); auto c = dot(oc, oc) - radius * radius; auto deta = b * b - 4 * a * c; if (deta >= 0) { return (-b - sqrt(deta)) / (2.0 * a); } else { return -1; } }
color ray_color(const ray& r) { double t = hit_sphere(r, point3(0, 0, -1), 0.5); if (t > 0.0) { // 获得击中点处向外的单位法向量 vec3 unit_normal = unit_vector(r.at(t) - point3(0, 0, -1)); // 将向量各分量映射到 [0,1] return (0.5 * color(unit_normal.x() + 1, unit_normal.y() + 1, unit_normal.z() + 1)); } // 获得r的方向向量并转化为单位向量(此时向量的范围为[-1.0, 1.0]) vec3 unit_direction = unit_vector(r.direction()); // 将r的y方向上的向量归一化到[0.0, 1.0],用以线性插值 t = 0.5 * (unit_direction.y() + 1.0); // 利用线性插值函数: // blendedValue = (1−t)⋅startValue + t⋅endValue // 从 t = 0.0 输出白色,渐变到 t = 1.0输出蓝色 return (1.0 - t) * color(1.0, 1.0, 1.0) + t * color(0.5, 0.7, 1.0); } |
5.2 简化射线-球面的相交代码
注意到b的方程中有一个因子2。考虑b = 2h时的二次方程:
所以我们可以将求交代码简化为:
// 我的代码 main.cc |
// 返回击中的点对应的t值 double hit_sphere(const ray& r, const point3& center, double radius) { vec3 oc = r.origin() - center; auto a = dot(r.direction(), r.direction()); auto half_b = dot(r.direction(), oc); auto c = dot(oc, oc) - radius * radius; auto deta = half_b * half_b - a * c; if (deta >= 0) { return (-half_b - sqrt(deta)) / a; } else { return -1; } } |
5.3 Hittable对象的抽象
对渲染许多个球体,一个非常简洁的方法是为每条光线可能的命中对象创建一个抽象类hittable(或者命名为object类可能更合适,如果我们不说这是“object oriente”),并创建一个球体和一个球体列表。
hittable抽象类里面有一个hit方法,该方法的参数包括一个ray,t的范围:t_max和t_min,和用来记录射线交点结果的hit_recoder结构。
- 当射线击中较近物体时,我们将停止搜索,只计算最近的交点处的法线。
- 只有当求解出的t 在t_min到t_max范围内时,才算hit到目标,否则无效
- hit_recoder里记录的是:射线与表面的交点p,
交点处单位法向量normal,
交点对应的t。
代码如下:
// 网站上的代码 hittable.h #ifndef HITTABLE_H
#include "ray.h"
struct hit_record { point3 p; vec3 normal; double t; };
class hittable { public: virtual bool hit(const ray& r, double t_min, double t_max, hit_record & rec) const = 0; };
#endif // !HITTABLE_H |
这里还有一个sphere类,继承了hittable类,有center球心属性和radius半径属性。
利用上面的公式,重写hit方法,代码如下:
// 我的代码 sphere.h |
#pragma once #ifndef SPHERE_H #define SPHERE_H
#include "hittable.h" #include "vec3.h"
class sphere : public hittable { public: sphere() {}; 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;
private: point3 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 = dot(r.direction(), r.direction()); auto half_b = dot(r.direction(), oc); auto c = dot(oc, oc) - radius * radius; auto deta = half_b * half_b - a * c;
if(deta > 0) { // 命中 // 先判断较小的t是否命中,如果不命中就判断大的t auto sqrt_deta = sqrt(deta); double t = (-half_b - sqrt_deta) / a; if (t < t_max && t > t_min) { // 有效 rec.t = t; rec.p = r.at(t); rec.normal = (rec.p - center) / radius; return true; } t = (-half_b + sqrt_deta) / a; if (t < t_max && t > t_min) { // 有效 rec.t = t; rec.p = r.at(t); rec.normal = (rec.p - center) / radius; return true; } } // 超出范围,无效 或 没有命中 return false;
}
#endif // !SPHERE_H |
5.4 正面和背面
我们要从上面的球面法向几何的可能方向中找到一个,因为我们最终要确定射线来自曲面的哪一边。这对于在不同面上渲染的情况不同得到物体很重要,如双面纸上的字、玻璃球等。
如果我们决定让球面法向始终指向外面,我们就要确定上色时光线在哪边。我们可以通过比较射线和法线算出来。如果射线和法向面方向相同,射线在物体内部,如果射线和法向面方向相反,射线在物体外部。这可以通过取这两个向量的点积来确定,如果它们的点积是正的,射线就在球面内。
// [sphere.h] Comparing the ray and the normal
if (dot(ray_direction, outward_normal) > 0.0) { |
If we decide to have the normals always point against the ray,我们就不能用点积来确定射线在曲面的哪一边。相反,我们需要存储这些信息:
// [sphere.h] Remembering the side of the surface |
bool front_face; |
(直译)我们可以让法线总是指向外,或者总是指向入射光线。这个决定取决于您是想在几何相交时确定曲面的边(the side of the surface),还是在着色时确定曲面的边。在这本书中,我们的材料类型多于几何类型,所以我们选择做更少的工作,并在几何相交时确定曲面的边。这只是一个偏好问题,您将在文献中看到这两种实现。
我们在front_face中加入了bool型变量front_face到hit_record中,并且还添加了一个内联函数set_face_normal计算normal。
// [hittable.h] The hittable class with time and side #ifndef HITTABLE_H #include "ray.h" struct hit_record { bool front_face; inline void set_face_normal(const ray& r, const vec3& outward_normal) { }; class hittable { #endif | |
// 我的代码 hittable.h #pragma once #ifndef HITTABLE_H
#include "ray.h"
struct hit_record { point3 p; // 交点 vec3 normal; // 交点处单位法向量 double t; // 交点对应的射线的t值(网站上的代码好像漏了) bool front_face; // 射线是否在物体外部,true = 是,false = 否
inline void set_face_normal(const ray & r, const vec3& outward_normal) { front_face = dot(r.direction(), outward_normal); normal = front_face ? outward_normal : -outward_normal; } };
class hittable { public: virtual bool hit(const ray& r, double t_min, double t_max, hit_record & rec) const = 0; };
#endif // !HITTABLE_H |
然后我们把surface side的确定加入到类中:
// 我的代码 sphere.h |
bool sphere::hit(const ray& r, double t_min, double t_max, hit_record & rec) const { vec3 oc = r.origin() - center; auto a = dot(r.direction(), r.direction()); auto half_b = dot(r.direction(), oc); auto c = dot(oc, oc) - radius * radius; auto deta = half_b * half_b - a * c;
if(deta > 0) { // 命中 // 先判断较小的t是否命中,如果不命中就判断大的t auto sqrt_deta = sqrt(deta); double t = (-half_b - sqrt_deta) / a; if (t < t_max && t > t_min) { // 有效 rec.t = t; rec.p = r.at(t); vec3 outward_normal = (rec.p - center) / radius; rec.set_face_normal(r, outward_normal); return true; } t = (-half_b + sqrt_deta) / a; if (t < t_max && t > t_min) { // 有效 rec.t = t; rec.p = r.at(t); vec3 outward_normal = (rec.p - center) / radius; rec.set_face_normal(r, outward_normal); return true; } } // 超出范围,无效 或 没有命中 return false; } |
5.5 可命中对象的列表
我们已经拥有了通用的hittable对象用来计算和记录射线和物体相交的结果,现在添加一个类hittable_list,用来存储一系列的hittable。
Hittable_list类继承hittable类,包含的属性是一个存放hittable类型的对象的向量vector<shared_ptr<hittable>> objects,拥有的方法是:
- 默认初始化,带参数的初始化
- 清空object里的元素clear()
- 向object里添加元素add(…)
- 判断光线是否击中物体hit(…)
- 设置标志位hit_anything记录objects里的物体是否被击中。
- 记录到目前为止最近的交点的t的值:closest_so_far
- 遍历objects里的物体,判断是否有交点,有的话将hit_anything设置为true,并记录交点信息到rec中,更新t_max为closest_so_far。
- 返回hit_anything
代码如下:
#pragma once #ifndef HITTABLE_LIST_H #define HITTABLE_LIST_H
#include "hittable.h"
#include <memory> #include <vector>
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); } // 判断光线是否击中物体,是的话返回的rec是最近的交点的信息 virtual bool hit(const ray&r, double t_min, double t_max, hit_record & rec) const;
private: std::vector<shared_ptr<hittable>> objects; };
bool hittable_list::hit(const ray&r, double t_min, double t_max, hit_record & rec) const { bool hit_anything = false; double closest_so_far = t_max; hit_record temp_rec;
for (const auto& object : objects) { if(object->hit(r,t_min, closest_so_far, temp_rec)){ // 击中物体 closest_so_far = temp_rec.t; rec = temp_rec; hit_anything = true; } }
return hit_anything; }
#endif // !HITTABLE_LIST_H |
5.6 一些新的c++特性
上面的hittable_list使用了两个C++新特性:vector和shared_ptr。
vector是一个可以存放任何类型的类数组集合,它的长度随元素的个数自动增长。
shared_ptr是一个指向某些已分配类型的指针,具有引用计数语义。每次将其值赋给另一个共享指针(通常使用简单的赋值)时,引用计数都会递增。当共享指针超出范围(比如在块或函数的末尾)时,引用计数就会递减。一旦计数变为零,对象就会被删除。(直译)
5.7 常用常量和实用函数
我们需要一些数学常数,可以方便地将他们定义在头文件中。
现在我们在头文件rtweekend.h中定义:
- Infinity
- Pi
- 角度转弧度函数:degrees_to_redians
#ifndef RTWEEKEND_H #include <cmath> // Usings using std::shared_ptr; // Constants const double infinity = std::numeric_limits<double>::infinity(); // Utility Functions inline double degrees_to_radians(double degrees) { // Common Headers #include "ray.h" #endif |
利用我们写的类,接下来重写一下我们的main函数。我们再来回顾一下ray tracing的思路。
1、设置图片宽高
2、设置屏幕宽高、水平分量和垂直分量
3、在场景中添加物体
4、遍历从相机到图片上每一个像素的射线,计算与物体的相交情况,并着色(用某种规则将向量映射到RGB上),最后打印颜色。
现在可以开始重写了:
// 我的代码 main.cc #include "rtweekend.h" #include "hittable_list.h" #include "sphere.h"
color ray_color(const ray& r, const hittable& world) { // 记录射线与物体的相交结果 hit_record rec; if (world.hit(r, 0 , infinity, rec)) { // 将向量各分量映射到 [0,1] return (0.5 * (color(1,1,1) + rec.normal)); }
// 获得r的方向向量并转化为单位向量(此时向量的范围为[-1.0, 1.0]) vec3 unit_direction = unit_vector(r.direction()); // 将r的y方向上的向量归一化到[0.0, 1.0],用以线性插值 auto t = 0.5 * (unit_direction.y() + 1.0); // 利用线性插值函数: blendedValue = (1−t)⋅startValue + t⋅endValue // 从 t = 0.0 输出白色,渐变到 t = 1.0输出蓝色 return (1.0 - t) * color(1.0, 1.0, 1.0) + t * color(0.5, 0.7, 1.0); }
int main() { // 设置图片宽高比,宽度、高度 const auto aspect_ratio = 16.0 / 9.0; const int image_width = 384; const int image_height = static_cast<int>(image_width / aspect_ratio);
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n"; // 设置屏幕宽、高、深 auto screen_height = 2.0; auto screen_width = screen_height * aspect_ratio; auto deep = 1.0;
// 左下角origin、u、v point3 origin = point3(0, 0, 0); vec3 horizontal = vec3(screen_width, 0, 0); vec3 vertical = vec3(0, screen_height, 0); point3 lower_left_corner = origin - horizontal / 2 - vertical / 2 - vec3(0, 0, deep);
// 在场景中添加物体 hittable_list world; world.add(make_shared<sphere>(point3(0, 0, -1), 0.5)); world.add(make_shared<sphere>(point3(0, -100.5, -1), 100));
// 遍历射线,打印颜色 for (int j = image_height - 1; j >= 0; j--) { std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush; for (int i = 0; i < image_width; i++) { // 从相机到屏幕上每个像素的射线pixel_pos = u + v + lower_left_corner auto pixel_pos = horizontal * (double(i) / (image_width - 1)) + vertical * (double(j) / (image_height - 1)) + lower_left_corner; color pixel_color = ray_color(ray(origin, pixel_pos),world); out_color(std::cout, pixel_color); } std::cerr << "\nDone.\n"; } } |
锵锵!结果是这样: