既然涉及到空间的概念,必然会牵扯到向量和空间坐标。在我们开始射出我们的第一根光线之前,我们需要打好地基,把一些最抽象的概念定义出来。
无论是三维空间中的点,向量或者是颜色,都是用(x,y,z)格式表示的,当然,如果是齐次坐标或者是带透明度的颜色就需要第四个维度,但是三维向量现在来看是足够了。
类内代码
创建vec3.h类,敲入以下代码:
//防止被重复引用,用ifndef去包裹所有代码。
#ifndef VEC3_H
#define VEC3_H
//需要一些cmath类里面的数学函数
#include <cmath>
#include <iostream>
//精确引用,只需要开方函数就只引入最小的命名空间
using std::sqrt;
class vec3 {
public:
// 空构造,默认构造一个(0,0,0)向量
vec3() : e{0,0,0} {}
// 传入三个参数的构造。
vec3(double e0, double e1, double e2) : e{e0, e1, e2} {}
//定义x,y,z分量。这样就可以用 向量.x() 直接拿取x分量。
double x() const { return e[0]; }
double y() const { return e[1]; }
double z() const { return e[2]; }
//下面进行运算符重载。
//单目 '-' 运算符 ,会对三维向量的每一维取反。
vec3 operator-() const { return vec3(-e[0], -e[1], -e[2]); }
// '[]' 运算符。为啥有俩?这俩啥区别呢?见解析。
double operator[](int i) const { return e[i]; }
double& operator[](int i) { return e[i]; }
// '+='运算符,const保护参数不被修改,返回引用允许操作符嵌套。
vec3& operator+=(const vec3 &v) {
e[0] += v.e[0];
e[1] += v.e[1];
e[2] += v.e[2];
return *this;
}
// '*='运算符
vec3& operator*=(const double t) {
e[0] *= t;
e[1] *= t;
e[2] *= t;
return *this;
}
// '/='运算符,直接使用*=去定义'/='。
vec3& operator/=(const double t) {
return *this *= 1/t;
}
//模相关。
// 模的平方。
double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}
// 模
double length() const {
return sqrt(length_squared());
}
public:
// 向量中的数组也直接暴露出去了,这里是float也没问题,看你的喜好。
double e[3];
};
// 给vec3类多起几个名字。
using point3 = vec3; // 3D point,在指定三维空间中的点的时候使用这种别名。
using color = vec3; // RGB color,在指定颜色的时候使用这种别名。
#endif
我们在定义这种底层类的时候要特别小心,遵循的准则就是“不开放不必要的属性”,能const的东西统统const。
可能你会对[]运算符重载有些许疑惑,为什么需要两个函数呢?可以看看下面的测试,或许你会对为什么这么写印象深刻一些。
测试方括号运算符重载
‘[]’运算符使用两种参数分别对应常量向量和非常量向量,具体看如下,我们修改一下运算符重载函数,再在main函数里进行测试:
//添加一些标准输出,当然你也可以通过打断点的方式知道到底进入了哪个函数。
double operator[](int i) const {
std::cout << "我是右值" << std::endl;
return e[i];
}
double& operator[](int i) {
std::cout << "我是左值" << std::endl;
return e[i];
}
在main函数中敲入如下代码,注意用return或者注释去隔离之前的代码,然后直接在ide里运行试试,如下:
#include "vec3.h"
int main() {
//定义两个常量。
vec3 common_vec3;
const vec3 const_vec3;
common_vec3[1] = 1; //这一行会输出“我是左值”。
//const_vec3[1] = 1; //这一行会报错"表达式必须是可以修改的左值"。
double d = const_vec3[1]; //这一行会输出“我是右值”。
return 1; //隔开老代码。
// 图片的长宽,我们先用一张256×256的图片来试试水。
const int image_width = 256;
...
明白了吗?这两个函数是为常量和非常量各自准备的,对它们做分别处理。
你可能会问“分别处理了什么?这俩函数都只有一个语句return e[i];
而已啊?”那你一定没有看到非常量版本的函数中返回值中的引用符号&。
这个&符号意味着这个返回值是可以被修改的,如果你把这个&删掉试试,上述代码中的第7行立马就报错了。
现在可以删掉上面的测试代码了,不要让测试的丑陋代码破坏了整个框架的统一性。
类外代码补充
我们还缺少什么?只用上面的代码你无法进行向量之间的加减操作对吧?没错,我们还需要二元运算符。我们打算把它们写在类外,这样看起来会更清楚一些。纯个人喜好,如果你想把它写到类内,也没有问题。
以下代码直接敲在vec3.h中vec3类的类外,注意时刻保持#ifndef的框架框住整体代码。
// vec3 类外函数
//重载输出流符号"<<"
inline std::ostream& operator<<(std::ostream &out, const vec3 &v) {
//当用户使用 cout << vec3的时候,输出vec3中的各个分量值。
return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
}
//重载+运算符,注意,它的返回值不是引用,这很合理,我们永远不会把 “a + b”这样的东西放在赋值符号左侧
inline vec3 operator+(const vec3 &u, const vec3 &v) {
return vec3(u.e[0] + v.e[0], u.e[1] + v.e[1], u.e[2] + v.e[2]);
}
//重载-运算符
inline vec3 operator-(const vec3 &u, const vec3 &v) {
return vec3(u.e[0] - v.e[0], u.e[1] - v.e[1], u.e[2] - v.e[2]);
}
//重载*运算符,向量*向量
inline vec3 operator*(const vec3 &u, const vec3 &v) {
return vec3(u.e[0] * v.e[0], u.e[1] * v.e[1], u.e[2] * v.e[2]);
}
//重载*运算符,标量*向量
inline vec3 operator*(double t, const vec3 &v) {
return vec3(t*v.e[0], t*v.e[1], t*v.e[2]);
}
//还是*运算符,但这次参数中标量和向量的顺序是反过来的。
inline vec3 operator*(const vec3 &v, double t) {
return t * v;
}
//用*去定义/
inline vec3 operator/(vec3 v, double t) {
return (1/t) * v;
}
//向量点乘,计算方法严格遵循数学定义。
inline double dot(const vec3 &u, const vec3 &v) {
return u.e[0] * v.e[0]
+ u.e[1] * v.e[1]
+ u.e[2] * v.e[2];
}
//向量叉乘,计算方法严格遵循数学定义。
inline vec3 cross(const vec3 &u, const vec3 &v) {
return vec3(u.e[1] * v.e[2] - u.e[2] * v.e[1],
u.e[2] * v.e[0] - u.e[0] * v.e[2],
u.e[0] * v.e[1] - u.e[1] * v.e[0]);
}
// 单位化这个向量,就是把它的各个分量除以它的长度,正好,我们可以用上面刚刚写完的/运算符去定义它。
inline vec3 unit_vector(vec3 v) {
return v / v.length();
}
抽离与简化
好,到此为止,我们完成了vec3.h文件的编写,你可以自行在main中测试这些类外的函数是否完美的生效了。
现在我们要把目光拉回上一节中的渲染循环,这一块的很多代码我们之后一直用到,不妨把它抽离出来,简化它。你要注意,这一切都基于一个思想:main函数是程序中最表层的代码,我们应该让他简化简化再简化,就像是我们精心制作一款产品,要让我们的用户收到无微不至的关怀那样。
注意,之后我们会把颜色分量放在[0,1]区间处理(这有无限多的优势,你会慢慢体会到),所以,在我们渲染循环中的这部分代码就是一定不会改动的:
int ir = static_cast<int>(255.999 * r);
int ig = static_cast<int>(255.999 * g);
int ib = static_cast<int>(255.999 * b);
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
你总要把[0,1]上的颜色映射到[0,255]区间里,并且输出到ppm文件里,因为我们已经默认ppm文件的颜色区间为[0,255]。好,我们接下来就把碍眼的它从main中抽离出去,创建color.h文件,敲入如下代码:
#ifndef COLOR_H
#define COLOR_H
#include "vec3.h"
#include <iostream>
//一个全局的函数,接受一个输出流参数,和一个color参数
void write_color(std::ostream &out, color pixel_color) {
//把之前的代码两步并作一步,直接转到[0,255]区间然后直接输出出去。
out << static_cast<int>(255.999 * pixel_color.x()) << ' '
<< static_cast<int>(255.999 * pixel_color.y()) << ' '
<< static_cast<int>(255.999 * pixel_color.z()) << '\n';
}
#endif
对于这部分代码有以下几个要点:
- 把输出流当作函数参数传入,这是一个常用的技巧。可以把cout操作从函数外转移到函数内部,上面vec3类外函数中对<<运算符的重载就是一样的技巧。
- 嘿,color类是什么东西?你一定忘记了我们对vec3类起了俩个小名,其实它就是vec3类。注意,这不是累赘的操作!当别人第一眼看到你的这个函数的时候,可以瞬间明白:“哦,它需要一个颜色,而不是一个3D坐标或者女生三围之类的其他玩意。”
好!我们用它来简化我们的main函数,代码如下:
#include "color.h"
#include "vec3.h"
#include <iostream>
int main() {
const int image_width = 256;
const int image_height = 256;
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) {
//直接调用vec3类有参构造构造一个对象
color pixel_color(double(i)/(image_width-1), double(j)/(image_height-1), 0.25);
//写颜色!如此的简单!
write_color(std::cout, pixel_color);
}
}
std::cerr << "\nDone.\n";
}
这一章很枯燥吧,你没有看到新的漂亮的图形,但是,这一章中介绍的vec3.h类的写法,可以说是大多数工具类写法的典范了。通过对这个类的学习, 你能学到底层数学相关类的定义方式。
下一章开始,我们就要射出我们的第一根光线啦,在那之后,你会看到“蓝天”。
参考文献
https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第3节