关于《Ray Tracing in One Weekend》

Ray Tracing in One Weekend Book Series

《Ray Tracing in One Weekend》是一个广受好评的关于光线追踪的入门实践教程,教程手把手教你仅用几百行C++代码逐步搭建一个软件光线追踪渲染器。

这是一个系列教程,包括

  1. 《Ray Tracing in One Weekend》
  2. 《Ray Tracing: The Next Week》
  3. 《Ray Tracing: The Reset of Your Life》

关于文本

本文对该系列的第一本书《Ray Tracing in One Weekend》的最终代码进行总结,并非原文中文翻译,并且略掉教程中代码迭代过程,系个人学习笔记,建议在阅读原文后再阅读本文。

关于PPM格式

该教程采用PPM格式来输出图片,本人暂时没有找到比较合适的适用于Windows下轻量干净的PPM图片浏览器,在这推荐一个在线PPM图片网页浏览器:

https://www.cs.rhodes.edu/welshc/COMP141_F16/ppmReader.html

另外如果写过软光栅tinyrenderer的话也可以把里面的tgaimage库搬来使用。

射线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ray {
public:
// 射线出发点
point3 orig;
// 射线方向
vec3 dir;

ray() {}
ray(const point3& origin, const vec3& dircetion) :orig(origin), dir(dircetion){}

point3 origin() const { return orig; }
vec3 direction() const { return dir; }

// 射线从发射点向射线方向出发,经过时间t后的位置点
point3 at(double t) const {
return orig + t * dir;
}
};

实现“光线追踪”我们需要一个射线类去模拟光线投射,例如从眼睛(摄像机)到场景中的物体的投射,以及击中物体后光线的再投射(反射、折射等),ray类就是上述射线类的实现,实现很简单,包含一个射线出发点成员和射线方向成员,另外还有函数at,传入参数t可模拟射线沿着目标方向运动。而一个物体与射线求交点,就是该物体与ray关于t的求解。

物体

1
2
3
4
5
// 场景中任意可接收光线的物体(抽象类、接口)
class hittable {
public:
virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;
};

把任意可被光线击中的东西抽象为hittable类,hittable类要求子类实现hit函数,hit函数接受光线ray参数,以及光线的t范围,可向外输出hit_record击中信息,函数需求根据输入的ray参数计算出击中结果,返回值表示是否击中此物体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct hit_record {
// 光线击中的位置点
point3 p;
// 击中点的法线
vec3 normal;
// 击中物体的材质
shared_ptr<material> mat_ptr;
// 击中时ray的t参数
double t;
// 是否击中外表面(用于区分击中的是物体外表面还是内表面,在处理类似玻璃球的物体的时候需要用到)
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;
}
};

hit_record可以理解为一个数据集合,可以把击中后需要取得的一些信息记录在这里面,包括击中位置点、法线、击中物材质等。

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
// 球体
class sphere : public hittable {
public:
sphere() {}
sphere(point3 cen, double r) : center(cen), radius(r) {};
sphere(point3 cen, double r, shared_ptr<material> m)
: center(cen), radius(r), mat_ptr(m) {};


virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

public:
point3 center;
double radius;
shared_ptr<material> mat_ptr;
};

bool sphere::hit(const 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);

// 找符合[t_min,t_max]范围的根
// Find the nearest root that lies in the acceptable range.
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;
}

// 若有解,记录相关信息到hit_record
rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);
rec.mat_ptr = mat_ptr;

return true;
}

sphere球体类是本课程中唯一一个具体实现的hittable类,类内用球中心点位置以及半径两个成员来描述球体的几何形状,hit函数则实现实现与球体相交的求解,以及hit_record的记录。

至于射线与球体怎么求相交,我们首先来复习一下球的公式:

当我们有一个球心在原点,且半径为r的球体

给定点(x,y,z)在球体表面上,则有x2+y2+z2=r2给定点(x,y,z)在球体表面上,则有x^2+y^2+z^2=r^2
给定点(x,y,x)在球体里面,则有x2+y2+z2<r2给定点(x,y,x)在球体里面,则有x^2+y^2+z^2<r^2
给定点(x,y,x)在球体外面,则有x2+y2+z2>r2给定点(x,y,x)在球体外面,则有x^2+y^2+z^2>r^2

设球心坐标为(Cx,Cy,Cz),则有(xCx)2+(yCy)2+(zCz)2=r2设球心坐标为(C_x,C_y,C_z),则有(x - C_x)^2 + (y - C_y)^2 + (z - C_z)^2 = r^2

复习完球体的公式后,我们来看看球与射线交点的求解
假设有球体中心点C(Cx,Cy,Cz),球表面上一个点P(x,y,z),向量从点C到点P(PC)(PC)的模为球体的半径r假设有球体中心点C(C_x,C_y,C_z),球表面上一个点P(x,y,z),向量从点C到点P为(P-C),(P-C)的模为球体的半径r,
结合向量点乘的性质,一个向量和本身进行点积的结果,是该向量的模的平方,可得出等式

(PC)(PC)=r2(P-C) \cdot (P-C) = r^2
求射线是否击中球体,我们可以假设射线经过P点,射线公式为P(t)=A+tb,其中A为射线起始点,b为射线方向,把P(t)代入点P,可得求射线是否击中球体,我们可以假设射线经过P点,射线公式为P(t) = A + tb, 其中A为射线起始点,b为射线方向,把P(t)代入点P,可得
(P(t)C)(P(t)C)=r2(P(t)-C) \cdot (P(t)-C) =r^2

把P(t)完整公式代入:

(A+tbC)(A+tbC)=r2(A+tb-C)\cdot(A+tb-C)=r^2
再把公式展开:

t2bb+2tb(Ac)+(AC)(Ac)r2=0t^2b \cdot b + 2tb \cdot (A-c)+(A-C)\cdot(A-c)-r^2=0

可以发现,上述等式中只有t是我们不知道的,对等式中的t进行求解,若t有解,则射线与球体相交,t怎么求解?我们来复习一下一元二次方程:

当我们碰到这种形式的式子的时候:
ax2+bx+c=0(a0)ax^2 + bx + c = 0(a \neq 0)
可以这样求解:
x1,2=b ± b24ac2a\Large x_{1,2}=\frac {-b \pm \sqrt {b^2-4ac}}{2a}

另外有判别式,Δ=b24ac另外有判别式,\Delta = b^2-4ac

{Δ>0方程有两个不相等的实数根Δ=0方程有两个相等的实数根(一个实数根)Δ<0方程无实数根\begin{cases}\Delta > 0 & 方程有两个不相等的实数根\\\Delta = 0 & 方程有两个相等的实数根(一个实数根)\\\Delta <0 & 方程无实数根\end{cases}

我们可以把上面等式写成下面的形式,让他看起来更像一元二次方程的一般式
(bb)t2+(2b(AC))t+(AC)(AC)r2(b \cdot b)t^2 +(2b \cdot(A-C))*t + (A-C)\cdot(A-C)-r^2

然后我们代入一元二次方程的公式进行求解,注意原公式的A、b、C和一元二次方程公式的a、b、c是两个东西,不要混淆,下面用粗体来区分

a=(bb)\mathbf a = (b \cdot b)
b=2b(AC)\mathbf b=2b \cdot (A-C)
c=(AC)(AC)r2\mathbf c= (A-C) \cdot (A-C) - r^2
x=t\mathbf x = t

在代码表示中,b为射线方向r.direction(),A为射线出发点r.origin(),c为center,r为球体半径radius,那么求解过程如下所示

1
2
3
4
5
6
7
8
9
10
11
double result;
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;// 判别式b^2 - 4ac
if (discriminant < 0) {// 判别式小于0,不相交
result = -1.0;
} else {// 判别式大于等于0,求解t
result = (-b - sqrt(discriminant) ) / (2.0*a);
}

另外,我们还可以简化一下计算过程,假设b=2h

b ± b24ac2a\Large \frac {-b \pm \sqrt {b^2-4ac}}{2a}
=2h ± (2h)24ac2a\Large =\frac {-2h \pm \sqrt {(2h)^2-4ac}}{2a}
=2h ± 2h2ac2a\Large = \frac {-2h \pm 2\sqrt {h^2-ac}}{2a}
=h ± h2aca\Large = \frac {-h \pm \sqrt {h^2-ac}}{a}

最终便有这样的代码形式:

1
2
3
4
5
6
7
8
9
 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;

材质

1
2
3
4
5
6
7
// 材质
class material {
public:
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const = 0;
};

把材质抽象成一个类,材质最后会作为具体物体类(继承自hittable)的一个成员,材质决定了物体如何与光线交互的,包括如何反射、折射光线、光线的吸收衰减等。

material类需要实现scatter函数,函数接受一个ray类型参数,表示入射光线;一个hit_record类型参数,表示入射光线若击中物体后的击中信息;一个color类型参数,表示射线击中位置点的颜色;另一个ray类型参数,表示入射光线击中后,根据具体材质特性产生散射的光线,用于计算后续间接光对颜色的影响。

这里attenuation和scattered两个参数是用作向外输出信息的。

漫反射材质

没有自发光的漫反射物体只会呈现自己颜色和周围环境颜色的结合,可以认为漫反射表面反射的光的方向是随机的。

漫反射取法向量加上单位球体表面上的随机点后的方向作为反射方向,实现如下:

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
// 漫反射材质
class lambertian : public material {
public:
lambertian(const color& a) : albedo(a) {}

virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
// 在法线方向上加上随机单位向量反射
auto scatter_direction = rec.normal + random_unit_vector();

//如果随机到的单位向量正好与法向量相反,会导致散射方向为零向量
// Catch degenerate scatter direction
if (scatter_direction.near_zero())
scatter_direction = rec.normal;

scattered = ray(rec.p, scatter_direction);
// 自身颜色
attenuation = albedo;
return true;
}

public:
color albedo;
};

最终效果:

金属材质

金属材质会产生镜面反射,对于光滑的金属,会表现出纯镜面反射,但不是所有金属都是光滑的,大部分金属没有镜子那么清晰,而是略带模糊的,所以在金属材质的实现上,我们还需要实现在镜面反射基础上的模糊(粗糙)效果。

对于镜面反射,我们首先要实现一个函数,实现给出指定入射方向,和法线方向,返回出射方向。

复习一下点积的几何意义——投影:
当一个单位向量和a^另一个长度不限的向量b进行点积,可以得到ba^方向上的有符号投影长度当一个单位向量和\hat{a}另一个长度不限的向量b进行点积,可以得到b在\hat{a}方向上的有符号投影长度
也就是v与n的点积可以得到B的长度,而B的长度与法向量N相乘即是B向量,根据上图可以看出,红色线条出射方向为V+2B,但注意,我们得到的投影长度是有符号长度,因为我们定义v的方向是从光源到目标点的方向,得到的长度方向与上图是反的,这里我们需要加一个负号,最终实现如下:

1
2
3
4
5
6
vec3 reflect(const vec3& v, const vec3& n) {
// v为入射光线,n为法向量(单位向量)
// dot(v, n)取入射光线在法向量方向上的投影长度
// 出射方向为v+2b,因为设定入射方向的方向是从光源到目标点,因为方向问题,要加个负号,最后结果是v-2b
return v - 2 * dot(v, n) * n;
}

为了产生模糊效果,我们需要对反射方向加上一个随机方向,随机方向为单位球内生成的随机方向,并乘以一个系数,以便控制这个金属材质的模糊(粗糙)程度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 金属材质
class metal : public material {
public:
metal(const color& a, double f) : albedo(a), fuzz(f < 1 ? f : 1) {}

virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
// 通过入射方向和法线方向,计算出反射方向
vec3 reflected = reflect(unit_vector(r_in.direction()), rec.normal);
// 反射方向为 镜面反射方向+单位球体内随机方向*模糊系数
scattered = ray(rec.p, reflected + fuzz * random_in_unit_sphere());
// 自身颜色
attenuation = albedo;
// 若最终生成的反射方向是往里面反射(dot<0),我们认为其吸收,不再产生散射
return (dot(scattered.direction(), rec.normal) > 0);
}

public:
color albedo;
double fuzz;
};

最终效果:
左边和右边的球为金属材质,中间的球为漫反射材质,其中左边的球fuzz为0.3,右边的球fuzz为1.0。

绝缘体材质

像水、钻石、玻璃之类透明的材料都是绝缘体,当一条光线击中这些物体时,会分为两条光线,一条折射,一条反射,在课程实现中,我们按每条光线击中绝缘体时,会随机生成一条反射或折射光线来处理这个问题。

要实现折射,我们先复习一下中学学过的斯涅尔定律(Snell’s law)

ηsinθ=ηsinθ\large \eta \cdot \sin\theta = \eta' \cdot \sin\theta'

θθ是入射光线与折射光线与法线的夹角,ηη是两个介质的折射率\theta 和 \theta' 是入射光线与折射光线与法线的夹角,\eta 和 \eta' 是两个介质的折射率

为了计算折射光线的方向,我们需要计算出sinθ:为了计算折射光线的方向,我们需要计算出 \sin \theta':

sinθ=ηηsinθ\large \sin\theta' = \frac{\eta}{\eta'} \cdot \sin\theta
在折射面有射线R与法向量N,它们的夹角为θ,我们可以把射线R分解成垂直和水平两个部分:在折射面有射线R' 与法向量N',它们的夹角为\theta' ,我们可以把射线R' 分解成垂直和水平两个部分:
R=R+R\mathbf{R'} = \mathbf{R'}_{\bot} + \mathbf{R'}_{\parallel}

分别解出这两个垂直和水平分量,则有:

R=ηη(R+cosθn)\mathbf{R'}_{\bot} = \frac{\eta}{\eta'} (\mathbf{R} + \cos\theta \mathbf{n})

R=1R2n\mathbf{R '}_{\parallel} = -\sqrt{1 - |\mathbf{R'}_{\bot}|^2} \mathbf{n}

推导过程不详细说明。

我们仍然需要解出cosθ,我们可以回忆一下点乘公式我们仍然需要解出\cos\theta,我们可以回忆一下点乘公式
ab=a b cosθ\large \mathbf{a} \cdot \mathbf{b} = |\mathbf{a}| |\mathbf{b}| \cos\theta
如果我们限制ab是单位向量,则有:如果我们限制\mathbf{a}和\mathbf{b}是单位向量,则有:
ab=cosθ\large \mathbf{a} \cdot \mathbf{b} = \cos\theta
所以R可以写作:所以\mathbf{R'}_{\bot}可以写作:
R=ηη(R+(Rn)n)\mathbf{R'}_{\bot} = \frac{\eta}{\eta'} (\mathbf{R} + (\mathbf{-R} \cdot \mathbf{n}) \mathbf{ n})

1
2
3
4
5
6
vec3 refract(const vec3& uv, const vec3& n, double etai_over_etat) {
auto cos_theta = fmin(dot(-uv, n), 1.0);
vec3 r_out_perp = etai_over_etat * (uv + cos_theta * n);
vec3 r_out_parallel = -sqrt(fabs(1.0 - r_out_perp.length_squared())) * n;
return r_out_perp + r_out_parallel;
}

把以上公式结合,可以得出上面代码,我们可以通过入射单位向量、法向量和折射率求出折射方向。

另外我们需要注意一个问题,当光线从高折射率介质射入低折射率介质时,斯涅尔定律(Snell’s law)可能没有实解,这个时候不会发生折射,我们回顾一下前面的公式:

sinθ=ηηsinθ\large \sin\theta' = \frac{\eta}{\eta'} \cdot \sin\theta

如果射线从玻璃内部出发,投射向玻璃外的空气介质(玻璃的折射率为1.5,空气的折射率为1.0),那么有:

sinθ=1.51.0sinθ\large \sin\theta' = \frac{1.5}{1.0} \cdot \sin\theta

而又因为sinθ不会大于1,所以一旦发生以下情况:而又因为\sin\theta'不会大于1,所以一旦发生以下情况:

1.51.0sinθ>1.0\large \frac{1.5}{1.0} \cdot \sin\theta > 1.0

则无解,所以这种情况下玻璃不能折射,只能反射。这种情况通常在实心物体内部发生,我们称这种情况为“全内反射”,这也是为什么当我们在水底下观察水面时,水与空气交界处看上去像一面镜子的原因。

还有一个光学现象是我们需要关注的,就是现实中的玻璃的反射率是会随着入射角度变化而变化的,例如我们从一个非常小的角度斜着去看玻璃窗的时候,他的反射率会接近一面镜子。这个现象的真实公式非常复杂,我们一般使用Schlick近似:

1
2
3
4
5
6
static double reflectance(double cosine, double ref_idx) {
// Use Schlick's approximation for reflectance.
auto r0 = (1 - ref_idx) / (1 + ref_idx);
r0 = r0 * r0;
return r0 + (1 - r0) * pow((1 - cosine), 5);
}

结合以上所有步骤,我们可以得写出绝缘体材质完整代码:

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
// 绝缘体材质
class dielectric : public material {
public:
dielectric(double index_of_refraction) : ir(index_of_refraction) {}

virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
attenuation = color(1.0, 1.0, 1.0);
double refraction_ratio = rec.front_face ? (1.0 / ir) : ir;

vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = sqrt(1.0 - cos_theta * cos_theta);

// 当光线从高折射率介质到低折射率介质时,sin_theta无解,这时候我们认为光线不发生折射,转而发生了反射
bool cannot_refract = refraction_ratio * sin_theta > 1.0;
vec3 direction;

if (cannot_refract || reflectance(cos_theta, refraction_ratio) > random_double())
direction = reflect(unit_direction, rec.normal);
else
direction = refract(unit_direction, rec.normal, refraction_ratio);

scattered = ray(rec.p, direction);
return true;
}

public:
double ir; // Index of Refraction

private:
static double reflectance(double cosine, double ref_idx) {
// Use Schlick's approximation for reflectance.
auto r0 = (1 - ref_idx) / (1 + ref_idx);
r0 = r0 * r0;
return r0 + (1 - r0) * pow((1 - cosine), 5);
}
};

注意这里衰减率为1,玻璃表面不吸收任何光。

最终效果:

其中左侧球体是绝缘体材质,有个技巧,这里其实有两个球心相同的绝缘体材质的球体,其中一个采用负半径,可以使得表面法线指向内部,这样可以制造出空心玻璃球的效果,原本因为折射造成的画面倒置效果也因为负负得正抵消了。

场景

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
// 用于储存场景中所有可接收光线的物体
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(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

public:
std::vector<shared_ptr<hittable>> objects;
};

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;
auto closest_so_far = t_max;

// 遍历每一个物体,测试当前光线是否击中任意物体
for (const auto& object : objects) {
// 若击中多个物体,用closest_so_far筛选出最近的一个物体
if (object->hit(r, t_min, closest_so_far, temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
// 传出hit_record
rec = temp_rec;
}
}

return hit_anything;
}

用hittable_list类来场景储存所有hittable物体,他自身也是一个hittable物体,会实现hit方法,hit内部逻辑为把传入的射线与hittable_list储存的所有物体测试是否击中,若有多个击中,则取最近的一个,最后返回击中信息。

摄像机

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
class camera {
public:
camera(
point3 lookfrom,
point3 lookat,
vec3 vup,
double vfov, // vertical field-of-view in degrees
double aspect_ratio,
double aperture,
double focus_dist
) {
// 摄像机为右手坐标系

// 摄像机垂直角度
auto theta = degrees_to_radians(vfov);
// 默认viewport在距离原点z=-1的平面,也就是摄像机到viewport的距离为1,则viewport半高是h = tan(theta / 2)
auto h = tan(theta / 2);
auto viewport_height = 2.0 * h;
// aspect_ratio为宽高比,利用宽高比和高度,计算得出宽
auto viewport_width = aspect_ratio * viewport_height;

// 摄像机后方
w = unit_vector(lookfrom - lookat);
// 摄像机右方
u = unit_vector(cross(vup, w));
// 摄像机上方
v = cross(w, u);

// 摄像机位置(眼睛位置)
origin = lookfrom;
// 水平方向和垂直方向带长度的向量,focus_dist是焦距,乘以focus_dist是因为为了焦距不影响实际画面范围,viewport长宽需要受focus_dist影响
horizontal = focus_dist * viewport_width * u;
vertical = focus_dist * viewport_height * v;
// viewport左下角坐标因为w是指向摄像机后方,而viewport在前方,需要focus_dist * w是负号
lower_left_corner = origin - horizontal / 2 - vertical / 2 - focus_dist * w;
// 透镜半径是光圈大小的一半
lens_radius = aperture / 2;
}

ray get_ray(double s, double t) const {
// s和t值的区间为[0,1]

// 焦散模糊通过透镜大小控制光线出发点的随机位移来实现
// random_in_unit_disk 在一个单位圆里随机一个点
vec3 rd = lens_radius * random_in_unit_disk();
vec3 offset = u * rd.x() + v * rd.y();

return ray(
origin + offset,
lower_left_corner + s * horizontal + t * vertical - origin - offset
);
}

private:
point3 origin;
point3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
vec3 u, v, w;
double lens_radius;
};

以上是摄像机完整代码,下面逐步分解

创建摄像机需要好些参数,其中lookfrom、lookat、vup决定了摄像机的方向,vfov和aspect_ratio决定了摄像机的视野范围,而aperture、focus_dist用于控制焦散模糊(景深)效果。

在光线追踪的成像方式中,我们从摄像机(眼睛)位置,向正前方的一个viewport矩形范围内投射多束光线,每一束初始光线可以对应一个像素(也可以多个光线对应一个像素,例如抗锯齿的时候,后面提到),光线在场景内经过多次散射后取得的颜色即为屏幕像素最后形成的颜色,分辨率越高,同一个viewport区域内投射的光线密度就越大,形成的图片精度就越高。

我们首先需要计算viewport的大小和方向。

1
2
3
4
5
6
7
8
9
// 摄像机为右手坐标系

// 摄像机垂直角度
auto theta = degrees_to_radians(vfov);
// 默认viewport在距离原点z=-1的平面,也就是摄像机到viewport的距离为1,则viewport半高是h = tan(theta / 2)
auto h = tan(theta / 2);
auto viewport_height = 2.0 * h;
// aspect_ratio为横纵比,利用横纵比和高度,计算得出宽
auto viewport_width = aspect_ratio * viewport_height;

我们先认为摄像机到viewport平面的距离为1(也可以是其他数字,只要h与其保持比例即可),通过fov(fov是指视锥体在竖直方向上的展开角度)求出viewport的高度,然后结合纵横比求出viewport的宽度。

1
2
3
4
5
6
// 摄像机后方
w = unit_vector(lookfrom - lookat);
// 摄像机右方
u = unit_vector(cross(vup, w));
// 摄像机上方
v = cross(w, u);

利用传入参数lookfrom和lookat,其中lookfrom为摄像机位置,lookat为目标位置,可以确定摄像机的平面朝向,但我们还需要确定摄像机在它视野方向上的旋转,我们需要确定一个摄像机坐标系的正上方方向,也就是参数中的vup(up vector),通常是使用(0, 1, 0)。这样我们就可以利用叉乘把剩下的u、v两个方向向量确定下来。

1
2
3
4
5
6
7
8
9
// 摄像机位置(眼睛位置)
origin = lookfrom;
// 水平方向和垂直方向带长度的向量,focus_dist是焦距,乘以focus_dist是因为为了焦距不影响实际画面范围,viewport长宽需要受focus_dist影响
horizontal = focus_dist * viewport_width * u;
vertical = focus_dist * viewport_height * v;
// viewport左下角坐标因为w是指向摄像机后方,而viewport在前方,需要focus_dist * w是负号
lower_left_corner = origin - horizontal / 2 - vertical / 2 - focus_dist * w;
// 透镜半径是光圈大小的一半
lens_radius = aperture / 2;

为了实现焦散模糊(景深)效果,我们需要模拟一个透镜,原本是从摄像机一个点发射光线,改为从一个透镜范围内随机发射出光线,焦散模糊效果实际由焦距参数focus_dist和光圈大小参数aperture决定。

注意这里horizontal和vertical乘上了focus_dist,因为我们定义上只让focus_dist影响焦散效果,而focus_dist焦距的调整会影响摄像机到viewport的距离,进而影响摄像机所看见的视野范围,所以这里可以认为把viewport也放大焦距大小,从而抵消其影响。

lower_left_corner则为viewport左下角坐标,取其左下角坐标方便我们以uv坐标形式(左下角为原点(0,0),两个轴最大值均为1)来计算投射光线。

1
2
3
4
5
6
7
8
9
10
11
12
13
ray get_ray(double s, double t) const {
// s和t值的区间为[0,1]

// 焦散模糊通过透镜大小控制光线出发点的随机位移来实现
// random_in_unit_disk 在一个单位圆里随机一个点
vec3 rd = lens_radius * random_in_unit_disk();
vec3 offset = u * rd.x() + v * rd.y();

return ray(
origin + offset,
lower_left_corner + s * horizontal + t * vertical - origin - offset
);
}

外部调用摄像机时,主要是获取某个该摄像机某个屏幕位置的射线,传入参数s,t分别表示以左下角为原点,在水平方向和竖直方向上的归一化坐标,为了模拟焦散模糊效果,我们把原本固定的射线出发点改为一个随机的出发点,随机范围为透镜大小范围,然后再朝viewport上对应的位置方向出发。

成像

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
// Image
// 摄像机比例
const auto aspect_ratio = 16.0 / 9.0;
// 输出图片宽度
const int image_width = 800;
// 输出图片高度
const int image_height = static_cast<int>(image_width / aspect_ratio);
// 每个像素采样次数
const int samples_per_pixel = 500;
// 光线计算深度
const int max_depth = 50;

// World
hittable_list world = random_scene();

// Camera
point3 lookfrom(13, 2, 3);
point3 lookat(0, 0, 0);
vec3 vup(0, 1, 0);
auto dist_to_focus = 10.0;
auto aperture = 0.1;

camera cam(lookfrom, lookat, vup, 20, aspect_ratio, aperture, dist_to_focus);


// 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);
// 每个像素采样数,用于抗锯齿
for (int s = 0; s < samples_per_pixel; ++s) {
//random_double 随机取值[0,1)
//为啥不是[-0.5,0.5]?因为原本的写法是取像素左下角的位置随机加上[0,1)的值,恰好在[0,1)范围内
auto u = (i + random_double()) / (image_width - 1);
auto v = (j + random_double()) / (image_height - 1);
ray r = cam.get_ray(u, v);
pixel_color += ray_color(r, world, max_depth);
}
write_color(std::cout, pixel_color, samples_per_pixel);
}
}

定义好屏幕分辨率、摄像机、场景物体等参数后,我们通过遍历每个像素,把像素坐标转换成uv坐标,然后调用摄像机的get_ray方法,取得当前像素对应的射线,然后调用ray_color获得通过这个射线计算出来的这个像素的颜色。

上文代码中可以看到,我们并没有直接取像素坐标计算uv坐标,而是在像素坐标基础上加入了一个随机量,这是因为要做一个抗锯齿处理,因为我们图像显示最小精度是一个像素,但图像中一个像素可能是覆盖了实际场景一个区域的显示结果,我们只取单个点的结果会产生锯齿现象,所以我们对一个像素点进行多次取样,取样次数为samples_per_pixel,并且每次取样加上一个随机位置偏移,并使得位置点加上随机偏移后,仍然在这个像素范围内,我们把这个像素多次采样的颜色值加在一起最后再除以采样次数,这样得到的颜色就是结合了像素区域内多次取样的结果,使得物体的边界会更加平滑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
color ray_color(const ray& r, const hittable& world, int depth) {
hit_record rec;

// If we've exceeded the ray bounce limit, no more light is gathered.
if (depth <= 0)
return color(0, 0, 0);

// 这里射线从0.001开始,避免因为浮点数精度问题导致物体散射光线与自身相交
if (world.hit(r, 0.001, infinity, rec)) {
ray scattered;
color attenuation;
if (rec.mat_ptr->scatter(r, rec, attenuation, scattered))
return attenuation * ray_color(scattered, world, depth - 1);
return color(0, 0, 0);
}
// 天空背景颜色
vec3 unit_direction = unit_vector(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);
}

射线最终是通过ray_color取得最终颜色,上面是ray_color的具体实现,ray_color会把传入的射线参数与传入的hittable参数(这里其实传入的都是hittable_list,包含场景所有物体),首先会把射线与场景所有物体求交,若有相交,取与射线出发点最近的相交点,获取该点颜色,并通过hit_record拿到其材质等数据,调用材质的scatter方法,若有散射,则继续用散射的射线调用ray_color获取散射后的光线击中的颜色,这是一个递归的过程,最终得到的结果是从第一束射线到最后结束的射线的颜色结合,若没击中场景物体,则取天空颜色,由于天空颜色是取射线方向归一化后的y轴数值来计算,所以天空整体在横竖方向上都有渐变效果。

有两点值得注意的地方,一是由于ray_color是一个递归的过程,递归是需要结束条件的,我们设定了depth,depth决定了光线最大散射次数,次数越大,其对间接光线的计算就越准确;二是为了避免由于浮点数精度问题导致求相交时,散射的射线与物体自己相交,所以射线从0.001开始而不是从0开始,稍微离开物体本身,避免阴影痤疮(shadow ance)问题。

最终效果