有了基本工具类,现在我们可以回顾图形学中的知识,从最简单的渲染一个球体开始,逐渐熟悉光线追踪的实现。
1 渲染一个球体 在光线追踪中如果投射出的光线碰到了物体,就计算该点的颜色作为像素值,那么我们从最简单的情形开始,渲染一个球体,我们在 z = -1 处放置一个球体,然后计算投射出的每一条光线和该球体是否有交点,如果有的话我们将该像素设置为一个定值,这样就可以在屏幕上显示出这个球体了。
计算光线和空间中球体是否有交点我们在图形学中已经学过,非常简单,对于射线 $P(t)$ 和一个空间中球心在 $C$ ,半径为 $r$ 的球体,如果射线上的点在球面上,则满足: $$ (P(t)−C)⋅(P(t)−C)=r^2 $$ 将 $P(t) = A + t \vec b$ 代入得: $$ (A+t\vec b−C)⋅(A+t\vec b−C)=r^2 $$ 再展开即可得到关于 $t$ 的一元二次方程: $$ t^2 \vec b⋅\vec b+2t\vec b⋅(A−C)+(A−C)⋅(A−C)−r^2=0 $$ 只需要判断这个一元二次方程有没有实数根即可。于是我们可以写出判断光线是否和球体相交的函数:
1 2 3 4 5 6 7 8 9 bool hit_sphere (const point3& center, double radius, 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 ); }
然后在之前的 ray_color
函数中加上判断光线是否和球体相交的代码:
1 2 3 4 5 6 7 8 9 10 color ray_color (ray& r) { if (hit_sphere (point3 (0 , 0 , -1 ), 0.5 , r)) return color (1 , 0 , 0 ); vec3 unit_dir = normalize (r.direction ()); auto t = 0.5 * (unit_dir.y () + 1.0 ); return (1.0 - t) * color (1.0 , 1.0 , 1.0 ) + t * color (0.5 , 0.7 , 1.0 ); }
效果如下:
需要注意的是,现在我们只考虑了是否有实数根,并没有考虑 t 的正负,这会导致即使把球体放到 z = 1 处,也能得到和上图相同的结果,这相当于我们看到了在相机后面的物体,这个问题之后我们会解决。
2 表面法线 2.1 可视化物体表面法线 我们计算光线与物体交点的光照时首先需要知道该交点的法线,对于一个球体来说,表面上任意一点的法线方向就是该点和球心连线的方向并从球心向外指。因此我们只要计算出投射的光线和球体的交点就可以得到该点的法向量,由于法向量是单位向量,每个分量范围都在 [-1, 1] ,因此我们可以将每个分量都映射到 [0, 1],作为颜色值显示出来。
为此我们先修改刚才的 hit_sphere
使其返回交点的 t 值,由于我们的球体放在 z = -1 处,所以两个交点都一定是正实数,我们返回较小的那个一根即可,即: $$ t_{return} = \frac{-b - \sqrt{b^2-4ac} }{2a} $$ 因为只能看到离我们最近的点,修改后的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 double hit_sphere (const point3& center, double radius, 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); } }
同时修改 ray_color
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 color ray_color (ray& r) { auto t = hit_sphere (point3 (0 , 0 , -1 ), 0.5 , r); if (t > 0 ) { vec3 normal = normalize (r.at (t) - vec3 (0 , 0 , -1 )); return (normal + vec3 (1 , 1 , 1 )) * 0.5 ; } vec3 unit_dir = normalize (r.direction ()); t = 0.5 * (unit_dir.y () + 1.0 ); return (1.0 - t) * color (1.0 , 1.0 , 1.0 ) + t * color (0.5 , 0.7 , 1.0 ); }
渲染效果如下:
2.2 代码优化 上面的 hit_sphere
函数有一些可以优化的地方,首先两个相同向量的点乘,可以通过我们在 vec3
类中定义的 length_squared()
方法得到,另外考虑方程的求根公式: $$ \frac{-b \pm \sqrt{b^2-4ac} }{2a} $$ 如果把 b 替换成 2h,可以得到: $$ \frac{-2h \pm \sqrt{(2h)^2-4ac} }{2a} $$ 展开整理得: $$ \frac{-h \pm \sqrt{h^2-ac} }{a} $$ 于是我们只需要计算 b 的一半即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 double hit_sphere (const point3& center, double radius, ray& r) { vec3 oc = r.origin () - center; auto a = r.direction ().length_squared (); auto h = dot (oc, r.direction ()); auto c = dot (oc, oc) - radius * radius; auto discriminant = h * h - a * c; if (discriminant < 0 ) { return -1.0 ; } else { return (-h - sqrt (discriminant)) / a; } }
3 多个物体 3.1 实现物体类 在实际场景中我们不可能只有一个物体,并且物体也不可能只是球体,还可能有有各种各样的模型,因此为了能让所有模型都可以放置到场景中并计算我们投射光线和物体的交点,我们可以先定义一个可计算交点的抽象物体类 hittable
,这个类定义一个纯虚函数 hit
用来判断物体和光线的交点,然后再以该抽象类为基类,实现各种物体类即可,这样一来,不同的物体的 hit
函数有可以有不同的实现了。
hit
函数也和我们上面写的稍有不同,除了要接收一根光线作为参数外,还要有一个限定范围 $t_{min}$ 和 $t_{max}$,只有当交点的 t 在这个范围内才会与物体相交,这部分也在之前的图形学中也有学过,同时这个范围也可以用于后面计算多个物体中最近的交点。
此外,光线可能和物体有多个交点,我们需要取在限定范围内的离我们最近的交点,把该交点及其法线等属性记录下来,因此还要定义一个存储交点属性的结构体。
最后还需要考虑一个问题,交点是物体的正面还是背面?这对我们渲染来说非常重要,尤其是一些双面不同的物体。这可以通过光线和法线的点乘来判断,如果光线和交点的法线反向,这说明交点在物体的正面,因为法线都是从物体中心指向表面的;相反如果光线和交点的法线同向,这说明交点在物体的背面。同向和反向实际上是两个向量的夹角,因此点乘即可。如果交点在物体背面,那么计算光照时用到的法线应该是这个点表面法线的反方向,所以要对原法线取反。如下图:
现在我们可以开始实现上面的思路了,首先是抽象类和结构体的定义:
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 #pragma once #ifndef HITTABLE_H #define HITTABLE_H #include "utilities.h" struct hit_record { point3 p; vec3 normal; double t; bool front_face; inline void set_face_normal (ray& r, vec3& outward_normal) { front_face = dot (r.direction (), outward_normal) < 0 ; normal = front_face ? outward_normal : -outward_normal; } }; class hittable {public : virtual bool hit (ray& r, double t_min, double t_max, hit_record& rec) const = 0 ; }; #endif
接下来实现一个球体类:
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 47 48 49 50 51 52 #pragma once #ifndef SPHERE_H #define SPHERE_H #include "hittable.h" class sphere : public hittable {public : sphere () {} sphere (point3 cen, double r) : center (cen), radius (r) {}; virtual bool hit (ray& r, double t_min, double t_max, hit_record& rec) const override ; public : point3 center; double radius; }; bool sphere::hit (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 ; } rec.t = root; rec.p = r.at (rec.t); vec3 outward_normal = (rec.p - center) / radius; rec.set_face_normal (r, outward_normal); return true ; }; #endif
3.2 实现物体列表类 现在我们已经有了可以与光线相交的物体的基类 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 44 45 46 47 48 #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); } virtual bool hit (ray& r, double t_min, double t_max, hit_record& rec) const override ; public : std::vector<shared_ptr<hittable>> objects; }; bool hittable_list::hit (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
3.3 关于智能指针 上面的物体列表类中使用了智能指针,在之后的代码中我们也会经常使用,智能指针能帮助我们自动管理内存,防止内存泄漏,一般来说初始化一个智能指针可以使用如下形式的代码:
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 );
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 );
声明后就可以像正常指针一样使用了。更多关于智能指针的内容可以查看 C++ 与 STL 部分的笔记。
4 常用常量和工具函数 接下来需要定义一些常用的常量以及角度转弧度等工具函数,并将一些类的头文件整合起来,使代码组织更整洁。
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 #pragma once #ifndef UTILITIES_H #define UTILITIES_H #include <cmath> #include <limits> #include <memory> using std::shared_ptr;using std::make_shared;using std::sqrt;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 ; } #include "ray.h" #include "vec3.h" #endif UTILITIES_H
5 再次可视化法线 使用上面实现的一系列代码,再次实现一个可视化法线的效果,首先修改 ray_color
函数:
1 2 3 4 5 6 7 8 9 10 11 color ray_color (ray& r, const hittable& world) { hit_record rec; if (world.hit (r, 0 , infinity, rec)) { return 0.5 * (rec.normal + color (1 , 1 , 1 )); } vec3 unit_direction = normalize (r.direction ()); auto t = 0.5 * (unit_direction.y () + 1.0 ); return (1.0 - t) * color (1.0 , 1.0 , 1.0 ) + t * color (0.5 , 0.7 , 1.0 ); }
然后是主函数:
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 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 #include <iostream> #include <string> #include "utilities.h" #define STB_IMAGE_WRITE_IMPLEMENTATION #include "stb_image_write.h" #include "hittable_list.h" #include "sphere.h" #include "color.h" int main () { std::string SavePath = "D:\\TechStack\\ComputerGraphics\\Ray Tracing in One Weekend Series\\Results\\" ; std::string filename = "WorldSphereNormal.png" ; std::string filepath = SavePath + filename; const auto aspect_ratio = 16.0 / 9.0 ; const int image_width = 400 ; const int image_height = static_cast <int >(image_width / aspect_ratio); const int channel = 3 ; auto viewport_height = 2.0 ; auto viewport_width = aspect_ratio * viewport_height; auto focal_length = 1.0 ; auto origin = point3 (0 , 0 , 0 ); auto horizontal = vec3 (viewport_width, 0 , 0 ); auto vertical = vec3 (0 , viewport_height, 0 ); auto lower_left_corner = origin - horizontal / 2 - vertical / 2 - vec3 (0 , 0 , focal_length); 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 )); unsigned char * odata = (unsigned char *)malloc (image_width * image_height * channel); unsigned char * p = odata; for (int j = image_height - 1 ; j >= 0 ; --j) { std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush; for (int i = 0 ; i < image_width; ++i) { auto u = double (i) / (image_width - 1 ); auto 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 (p, pixel_color); } } stbi_write_png (filepath.c_str (), image_width, image_height, channel, odata, 0 ); std::cerr << "\nDone.\n" ; }
渲染效果如下: