既然涉及到空间的概念,必然会牵扯到向量和空间坐标。在我们开始射出我们的第一根光线之前,我们需要打好地基,把一些最抽象的概念定义出来。
无论是三维空间中的点,向量或者是颜色,都是用(x,y,z)格式表示的,当然,如果是齐次坐标或者是带透明度的颜色就需要第四个维度,但是三维向量现在来看是足够了。

类内代码

创建vec3.h类,敲入以下代码:

  1. //防止被重复引用,用ifndef去包裹所有代码。
  2. #ifndef VEC3_H
  3. #define VEC3_H
  4. //需要一些cmath类里面的数学函数
  5. #include <cmath>
  6. #include <iostream>
  7. //精确引用,只需要开方函数就只引入最小的命名空间
  8. using std::sqrt;
  9. class vec3 {
  10. public:
  11. // 空构造,默认构造一个(0,0,0)向量
  12. vec3() : e{0,0,0} {}
  13. // 传入三个参数的构造。
  14. vec3(double e0, double e1, double e2) : e{e0, e1, e2} {}
  15. //定义x,y,z分量。这样就可以用 向量.x() 直接拿取x分量。
  16. double x() const { return e[0]; }
  17. double y() const { return e[1]; }
  18. double z() const { return e[2]; }
  19. //下面进行运算符重载。
  20. //单目 '-' 运算符 ,会对三维向量的每一维取反。
  21. vec3 operator-() const { return vec3(-e[0], -e[1], -e[2]); }
  22. // '[]' 运算符。为啥有俩?这俩啥区别呢?见解析。
  23. double operator[](int i) const { return e[i]; }
  24. double& operator[](int i) { return e[i]; }
  25. // '+='运算符,const保护参数不被修改,返回引用允许操作符嵌套。
  26. vec3& operator+=(const vec3 &v) {
  27. e[0] += v.e[0];
  28. e[1] += v.e[1];
  29. e[2] += v.e[2];
  30. return *this;
  31. }
  32. // '*='运算符
  33. vec3& operator*=(const double t) {
  34. e[0] *= t;
  35. e[1] *= t;
  36. e[2] *= t;
  37. return *this;
  38. }
  39. // '/='运算符,直接使用*=去定义'/='。
  40. vec3& operator/=(const double t) {
  41. return *this *= 1/t;
  42. }
  43. //模相关。
  44. // 模的平方。
  45. double length_squared() const {
  46. return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
  47. }
  48. // 模
  49. double length() const {
  50. return sqrt(length_squared());
  51. }
  52. public:
  53. // 向量中的数组也直接暴露出去了,这里是float也没问题,看你的喜好。
  54. double e[3];
  55. };
  56. // 给vec3类多起几个名字。
  57. using point3 = vec3; // 3D point,在指定三维空间中的点的时候使用这种别名。
  58. using color = vec3; // RGB color,在指定颜色的时候使用这种别名。
  59. #endif

我们在定义这种底层类的时候要特别小心,遵循的准则就是“不开放不必要的属性”,能const的东西统统const。
可能你会对[]运算符重载有些许疑惑,为什么需要两个函数呢?可以看看下面的测试,或许你会对为什么这么写印象深刻一些。

测试方括号运算符重载

‘[]’运算符使用两种参数分别对应常量向量和非常量向量,具体看如下,我们修改一下运算符重载函数,再在main函数里进行测试:

  1. //添加一些标准输出,当然你也可以通过打断点的方式知道到底进入了哪个函数。
  2. double operator[](int i) const {
  3. std::cout << "我是右值" << std::endl;
  4. return e[i];
  5. }
  6. double& operator[](int i) {
  7. std::cout << "我是左值" << std::endl;
  8. return e[i];
  9. }
  1. main函数中敲入如下代码,注意用return或者注释去隔离之前的代码,然后直接在ide里运行试试,如下:
  1. #include "vec3.h"
  2. int main() {
  3. //定义两个常量。
  4. vec3 common_vec3;
  5. const vec3 const_vec3;
  6. common_vec3[1] = 1; //这一行会输出“我是左值”。
  7. //const_vec3[1] = 1; //这一行会报错"表达式必须是可以修改的左值"。
  8. double d = const_vec3[1]; //这一行会输出“我是右值”。
  9. return 1; //隔开老代码。
  10. // 图片的长宽,我们先用一张256×256的图片来试试水。
  11. const int image_width = 256;
  12. ...

明白了吗?这两个函数是为常量和非常量各自准备的,对它们做分别处理。
你可能会问“分别处理了什么?这俩函数都只有一个语句return e[i];而已啊?”那你一定没有看到非常量版本的函数中返回值中的引用符号&。
这个&符号意味着这个返回值是可以被修改的,如果你把这个&删掉试试,上述代码中的第7行立马就报错了。
现在可以删掉上面的测试代码了,不要让测试的丑陋代码破坏了整个框架的统一性。

类外代码补充

我们还缺少什么?只用上面的代码你无法进行向量之间的加减操作对吧?没错,我们还需要二元运算符。我们打算把它们写在类外,这样看起来会更清楚一些。纯个人喜好,如果你想把它写到类内,也没有问题。
以下代码直接敲在vec3.h中vec3类的类外,注意时刻保持#ifndef的框架框住整体代码。

  1. // vec3 类外函数
  2. //重载输出流符号"<<"
  3. inline std::ostream& operator<<(std::ostream &out, const vec3 &v) {
  4. //当用户使用 cout << vec3的时候,输出vec3中的各个分量值。
  5. return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
  6. }
  7. //重载+运算符,注意,它的返回值不是引用,这很合理,我们永远不会把 “a + b”这样的东西放在赋值符号左侧
  8. inline vec3 operator+(const vec3 &u, const vec3 &v) {
  9. return vec3(u.e[0] + v.e[0], u.e[1] + v.e[1], u.e[2] + v.e[2]);
  10. }
  11. //重载-运算符
  12. inline vec3 operator-(const vec3 &u, const vec3 &v) {
  13. return vec3(u.e[0] - v.e[0], u.e[1] - v.e[1], u.e[2] - v.e[2]);
  14. }
  15. //重载*运算符,向量*向量
  16. inline vec3 operator*(const vec3 &u, const vec3 &v) {
  17. return vec3(u.e[0] * v.e[0], u.e[1] * v.e[1], u.e[2] * v.e[2]);
  18. }
  19. //重载*运算符,标量*向量
  20. inline vec3 operator*(double t, const vec3 &v) {
  21. return vec3(t*v.e[0], t*v.e[1], t*v.e[2]);
  22. }
  23. //还是*运算符,但这次参数中标量和向量的顺序是反过来的。
  24. inline vec3 operator*(const vec3 &v, double t) {
  25. return t * v;
  26. }
  27. //用*去定义/
  28. inline vec3 operator/(vec3 v, double t) {
  29. return (1/t) * v;
  30. }
  31. //向量点乘,计算方法严格遵循数学定义。
  32. inline double dot(const vec3 &u, const vec3 &v) {
  33. return u.e[0] * v.e[0]
  34. + u.e[1] * v.e[1]
  35. + u.e[2] * v.e[2];
  36. }
  37. //向量叉乘,计算方法严格遵循数学定义。
  38. inline vec3 cross(const vec3 &u, const vec3 &v) {
  39. return vec3(u.e[1] * v.e[2] - u.e[2] * v.e[1],
  40. u.e[2] * v.e[0] - u.e[0] * v.e[2],
  41. u.e[0] * v.e[1] - u.e[1] * v.e[0]);
  42. }
  43. // 单位化这个向量,就是把它的各个分量除以它的长度,正好,我们可以用上面刚刚写完的/运算符去定义它。
  44. inline vec3 unit_vector(vec3 v) {
  45. return v / v.length();
  46. }

抽离与简化

好,到此为止,我们完成了vec3.h文件的编写,你可以自行在main中测试这些类外的函数是否完美的生效了。
现在我们要把目光拉回上一节中的渲染循环,这一块的很多代码我们之后一直用到,不妨把它抽离出来,简化它。你要注意,这一切都基于一个思想:main函数是程序中最表层的代码,我们应该让他简化简化再简化,就像是我们精心制作一款产品,要让我们的用户收到无微不至的关怀那样。
注意,之后我们会把颜色分量放在[0,1]区间处理(这有无限多的优势,你会慢慢体会到),所以,在我们渲染循环中的这部分代码就是一定不会改动的:

  1. int ir = static_cast<int>(255.999 * r);
  2. int ig = static_cast<int>(255.999 * g);
  3. int ib = static_cast<int>(255.999 * b);
  4. std::cout << ir << ' ' << ig << ' ' << ib << '\n';
  1. 你总要把[0,1]上的颜色映射到[0,255]区间里,并且输出到ppm文件里,因为我们已经默认ppm文件的颜色区间为[0,255]。好,我们接下来就把碍眼的它从main中抽离出去,创建color.h文件,敲入如下代码:
  1. #ifndef COLOR_H
  2. #define COLOR_H
  3. #include "vec3.h"
  4. #include <iostream>
  5. //一个全局的函数,接受一个输出流参数,和一个color参数
  6. void write_color(std::ostream &out, color pixel_color) {
  7. //把之前的代码两步并作一步,直接转到[0,255]区间然后直接输出出去。
  8. out << static_cast<int>(255.999 * pixel_color.x()) << ' '
  9. << static_cast<int>(255.999 * pixel_color.y()) << ' '
  10. << static_cast<int>(255.999 * pixel_color.z()) << '\n';
  11. }
  12. #endif

对于这部分代码有以下几个要点:

  1. 把输出流当作函数参数传入,这是一个常用的技巧。可以把cout操作从函数外转移到函数内部,上面vec3类外函数中对<<运算符的重载就是一样的技巧。
  2. 嘿,color类是什么东西?你一定忘记了我们对vec3类起了俩个小名,其实它就是vec3类。注意,这不是累赘的操作!当别人第一眼看到你的这个函数的时候,可以瞬间明白:“哦,它需要一个颜色,而不是一个3D坐标或者女生三围之类的其他玩意。”

好!我们用它来简化我们的main函数,代码如下:

  1. #include "color.h"
  2. #include "vec3.h"
  3. #include <iostream>
  4. int main() {
  5. const int image_width = 256;
  6. const int image_height = 256;
  7. std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
  8. for (int j = image_height-1; j >= 0; --j) {
  9. std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
  10. for (int i = 0; i < image_width; ++i) {
  11. //直接调用vec3类有参构造构造一个对象
  12. color pixel_color(double(i)/(image_width-1), double(j)/(image_height-1), 0.25);
  13. //写颜色!如此的简单!
  14. write_color(std::cout, pixel_color);
  15. }
  16. }
  17. std::cerr << "\nDone.\n";
  18. }

这一章很枯燥吧,你没有看到新的漂亮的图形,但是,这一章中介绍的vec3.h类的写法,可以说是大多数工具类写法的典范了。通过对这个类的学习, 你能学到底层数学相关类的定义方式。
下一章开始,我们就要射出我们的第一根光线啦,在那之后,你会看到“蓝天”。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第3节