0%

继续冲

介电质材料

乍一听可能感觉很厉害,但实际上水,玻璃,钻石一类的材料都属于介电质,他们通常有以下特点:

  • 不导电:但会光会与该材料发生相互作用,主要表现有 反射与折射
  • 折射率:是材料特有的属性,决定光线进入材料时的弯曲程度,不同的材料有不同的折射率

当光线击中他们时,会分成反射光线和折射(透射)光线。我们将通过随机选择的反射换和折射来处理这个情况,并确保每次交互只生成一个光线。

当光线从一种材料的周围环境进入该材料本身,如(玻璃和水)时,会发生折射,光线会弯曲。折射光线的弯曲程度由材料的折射率决定。通常,折射率n是一个描述光线从真空进入材料时弯曲程度的单一值。当一种透明材料嵌入另一种透明材料中时,可以用相对折射率来描述折射:物体的折射率/环境的折射率。如渲染一个水下的玻璃,那么其有效折射率为玻璃的折射率/水的折射率

斯涅尔定律

折射一般由斯涅尔定律来描述:

image.png

这里的符号我不好用Latex打出来,所以折射率用eta来描述,并且这里结合一张图来描述:

img

为了确定折射光线的方向,我们需要解出sin(theta’):

1
sin(theta0) = eta/eta0 * sin(theta)

我们可以将折射光线分解成垂直法线方向和平行法线方向:

1
2
3
R = R_parallel + R_vertical
后续简写成:
R = R_p + R_v

根据斯涅尔定理,折射光线的垂直分量与入射光线的垂直分量也成比例:

1
R_v0 = eta/eta0 * R_v

所以可以计算出

1
2
3
4
R_p = (R*n)*n 	//这里的n已经单位化了,所以不用除|n|,直接*n即可
R_v = R - R_p = R - (R*n)*n
R*n = -cos(theta0)
R_v0 = eta/eta0 * (R + cos(theta)*n)

由于单位向量下,|R|^2 = |R_p|^2 + |R_v|^2,我们有:

1
2
3
4
R_p0 = -sqrt(1 - |R_v0|)*n
//最终合并得到R0
R_v0 = eta/eta0 * (R + (-R*n)*n) //这里替换掉cos方便计算
R0 = R_p0 + R_v0

我们可以写出向量的折射函数:

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

在此基础上,我们可以创建出我们的介电质材料类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class dielectric : public material{
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}

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

vec3 unit_direction = unit_vector(r_in.direction());
vec3 refracted = refract(unit_direction,refracted,ri);

scattered = ray(rec.p,refracted);
return true;
}
private:
double refraction_index; //在真空中的折射率,或者材料的折射比例
};

然后我们更改main函数重新渲染看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8),0.3);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2),0.0);
auto material_front = make_shared<dielectric>(1.50);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
world.add(make_shared<sphere>(point3(0.0,-0.2,-1.0),0.3,material_front));
...
}
image.png

可以看到中间一个怪怪的就是我们的玻璃球体,现在它只有折射属性,所以看起来怪怪的,中间的小黑点则是因为其光线追踪到的未被遮挡的阴影。

全反射

如果介电材料只是折射到话,也会遇到一些问题,对于一些光线角度,它的计算并不符合斯涅尔定理。如果光线以较大的入射角进入交界处,可能会以大于90°的角度折射出来,这显然是不可能的,所以这里我们将要用到我们高中所学的知识——全反射,来解决这个问题。

至于判断什么时候计算,我们计算一下折射角度的sin值,如果sin值大于1,就说明发生全反射。我们就不用折射函数来计算,用反射函数来计算出射光线。我们可以写出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

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

bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;

if(cannot_refract)
direction = reflect(unit_direction,rec.normal);
else
direction = refract(unit_direction,rec.normal,ri);

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

我们现在采用这个新的dielectric::scatter()函数渲染先前的场景,会发现没有任何变化。这是因为给定一个折射率大于空气的材料的球体,不存在入射角会导致全反射。这是由于球体的几何形状,入射和出射的角度经过两次折射还是一样的。

所以我们这里模拟空气的折射率和水一样,然后把球体的材料改为空气,所以我们设置它的折射系数为index of refraction of air / index of refraction of water,然后我们渲染试试:

image.png

Schlick近似

实际生活中,光线击中透明材质表面时,一部分光会反射,还有一部分光会折射,这个比例取决于入射角和两种介质的折射率。这个现象叫做菲尼尔效应,其严格的计算十分复杂,但好在我们有一个名为Schlick近似的计算方式,它是简化的替代方案,而且十分简便。我们可以看下它的内容:

image.png

我们在此基础之上可以改进我们的dielectric类:

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 dielectric : public material{
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}

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

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

bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;

//如果发生折射,还要根据Schlick近似,判断是否反射,如果反射率大于随机值则反射
if(cannot_refract || reflectance(cos_theta,ri) > random_double())
direction = reflect(unit_direction,rec.normal);
else
direction = refract(unit_direction,rec.normal,ri);

scattered = ray(rec.p,direction);
return true;
}
private:
double refraction_index; //在真空中的折射率,或者材料的折射比例

static double reflectance(double cosine,double refraction_index){
//使用Schlick近似计算反射率
auto r0 = (1 - refraction_index) / (1 + refraction_index);
r0 = r0*r0;
return r0 + (1-r0)*std::pow((1 - cosine),5);
}
};

对比一下:

image.png

左边确实更加逼真了。

建模一个空心玻璃球

我们建模一个空心玻璃球。这是一个由厚度的球体,里面有一个空气球。光线穿过这个物体,先击中外球,然后折射,然后穿过球内的空气。然后又击中球的内表面,折射,再击中外球的内表面,最后返回场景大气中。

我们设置外球,使用标准玻璃建模,折射率为1.50/1.00(从空气射入玻璃)。内球不同,内球使用空气建模,折射率设置为1.00/1.50(从玻璃射入空气)

我们设置一下main函数,再次渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.50);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2),0.0);
auto material_bubble = make_shared<dielectric>(1.00/1.50);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.0), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.4, material_bubble));
...
}
image.png

感觉不错啊,今天就到此为止吧

继续图形学的学习,我打算在下一周左右结束图形学的学习,因为要期中考试了(晕),现在赶赶进度

金属

一个材质的抽象类

如果想让不同的物体拥有不同的材质,我们可以设置一个通用的材质类,具有许多参数。或者我们可以有一个抽象的材质类,封装特定材质的独特行为,这里我们使用第二种方式,因为这样便于我们更好的组织代码,设置这么一个类,对于不同的材质,我们需要做两件事:

  • 产生一个散射光线(或者吸收入射光线)
  • 如果散射了,光线应该怎么衰减

在此基础之上,我们可以定义出我们的抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef RENDER_C___MATERIAL_H
#define RENDER_C___MATERIAL_H

#include "hittable.h"

class material{
public:
virtual ~material() = default;

virtual bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const{
return false;
}
};

#endif //RENDER_C___MATERIAL_H

描述光线-物体交点的数据结构

hit_record的设置目的是为了避免一大堆的参数,所以我们设置一个封装的类型,将信息参数放入其中。当然,由于hittable.hmaterials.h需要在代码中能够引用对方的类,为了避免他们的循环依赖,我们向hittable文件头中添加class material来指定类的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class material;

class hit_record{
public:
point3 p;
vec3 normal;
shared_ptr<material> mat; //附带了击中材料的材质的信息
double t;
bool front_face;

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将一堆参数放入类中,然后作为一个组合发送。当光线击中一个表面是,hit_record中的材料指针被设置为球体在main中给定的指针材料。且当ray_color()获取hit_record时,它可以调用材料指针的成员函数来找出散射(如果有的话)

现在我们需要设置球体sphere类:

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
class sphere: public hittable{
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {

}

bool hit(const ray& r,interval ray_t,hit_record& rec) const override{
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;

auto discriminant = h*h - a*c;
if(discriminant < 0.0){
return false;
}

//解t并进行判断
auto sqrtd = std::sqrt(discriminant);
auto root = (h - sqrtd) / a;
if(!ray_t.surrounds(root)){
root = (h + sqrtd) / a;
if(!ray_t.surrounds(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);
rec.mat = mat;

return true;
}

private:
point3 center;
double radius;
shared_ptr<material> mat;
};

光线散射与反射率的建模

反射率是我们接下来需要关注的一件事情,反射率和材质 颜色有关,也会随入射光线方向变化

而我们先前的Lambertian 反射,它的反射有三种情况:

  • 根据反射率R,总是散射光线并衰减光线。
  • 光线有(1-R)的概率散射后不衰减
  • 综上两个情况

这里为了程序的简易性,我们选择第一种情况,来实现我们的Lambertian 材料:

1
2
3
4
5
6
7
8
9
10
11
12
13
class lambertian: public material{
public:
lambertian(const color& albedo) : albedo(albedo) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
auto scatter_direction = rec.normal + random_unit_vector();
scattered = ray(rec.p,scatter_direction);
attenuation = albedo;
return true;
}
private:
color albedo;
};

如果你仔细阅读会发现,我们使用的是random_unit_vector来生成一个随机的向量,这个向量可能和法线是等大反向的,从而导致零散射方向向量的情况,导致后许发生各种不良的情况。所以我们需要拦截这种可能性

所以我们创建一个新的向量方法——该方法返回一个布尔值,判断各个方向的维度会不会趋近0:

1
2
3
4
5
bool near_zero() const {
//判断各个分量是不是趋近于0
auto s = 1e-8;
return (std::fabs(e[0]) < s) && (std::fabs(e[1]) < s) && (std::fabs(e[2]) < s);
}

然后更新我们的Lambertian反射:

1
2
3
4
5
6
7
8
9
10
11
bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
auto scatter_direction = rec.normal + random_unit_vector();

//拦截危险向量
if(scatter_direction.near_zero())
scatter_direction = rec.normal;

scattered = ray(rec.p,scatter_direction);
attenuation = albedo;
return true;
}

镜像反射

对于抛光的金属,光线不会随机的散射,而是对称的反射:

image.png

这个过程我们可以用向量来实现计算:

  • 首先我们计算出向量v在n上的投影长度,然后取相反数得到b
  • 然后通过v+2b,来计算出反射后的光线

我们可以写出一下程序来计算反射函数:

1
2
3
4
5
//vec3.h
inline vec3 reflect(const vec3& v, const vec3& n){
//点积是v在n上的投影长度,即b的长度,*-2n则是为了校准方向
return v - 2* dot(v,n)*n;
}

然后我们用这个函数实现我们的金属材质:

1
2
3
4
5
6
7
8
9
10
11
12
13
class metal: public material{
public:
metal(const color& albedo) : albedo(albedo){}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
vec3 reflected = reflect(r_in.direction(),rec.normal);
scattered = ray(rec.p,reflected);
attenuation = albedo;
return true;
}
private:
color albedo;
};

我们接下来修改ray_color()函数以应用更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
ray scattered;
color attenuation;
if(rec.mat->scatter(r,rec,attenuation,scattered))
return attenuation* ray_color(scattered,depth-1,world);
return {0,0,0};
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后接着更行sphere的构造函数以初始化材质指针mat:

1
2
3
4
5
class sphere : public hittable {
public: sphere(const point3& center, double radius, shared_ptr<material> mat)
: center(center), radius(std::fmax(0,radius)), mat(mat) {}
...
};

金属球体在场景中

现在我们向场景中添加我们的金属球体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
hittable_list world;

auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8));
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2));

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
...
cam.render(world);
}

成功渲染了出来,看:

image.png

模糊反射

我们的金属球体太过完美,但是现实中我们往往也会看到各种磨砂材质的金属,所以在这里我们要引入一个新的变量fuzz,来随机化反射方向。我们使用一个随机点,以原始起点为中心,来模糊反射的光线。

image.png

我们用fuzz来决定模糊球体的大小,模糊球体越大,反射效果就越明显。当模糊球体较大或光线几乎平行于表面时(称为掠射光线),计算出的反射光线可能会指向物体内部,即在物体表面下方。对于这种情况,可以选择简单地吸收这些光线,即认为它们没有从物体表面反射出去。

为了确保模糊球体相对于反射光线的尺度是一致的,需要对反射光线进行归一化处理,即调整其长度为1。这样做可以确保无论反射光线的长度如何变化,模糊球体的尺度都是相对于光线方向的,而不是其长度。

于是我们可以更改以下程序以实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class metal: public material{
public:
metal(const color& albedo) : albedo(albedo){}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
vec3 reflected = reflect(r_in.direction(),rec.normal);
reflected = unit_vector(reflected) + (fuzz*random_unit_vector());
scattered = ray(rec.p,reflected);
attenuation = albedo;
return (dot(scattered.direction(),rec.normal) > 0);
}
private:
color albedo;
double fuzz;
};

然后调整一下我们的main函数:

1
2
3
4
5
6
7
8
int main(){
...
world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
...
}

渲染出来的是这样的:

image.png

可以看到左边有明显的模糊感

中途歇了一段时间写物理作业,然后昨天学了下垃圾回收。现在继续开始我们的图形学学习。

漫反射材质

现在我们有了物体和每个像素的多个光线,现在我们可以尝试制作更加逼真的材质了。我们先从我们的漫反射材质开始(也称为哑光材质)开始。不过这里我们将几何体和材质分开使用,这使得我们可以将一种材质应用于多种物体,或者将多种材质应用于一种物体。这样分开使用的方法更加灵活也更加容易拓展,所以我们选择这种方式来实现我们的材质。

一个简单的漫反射材质

一个漫反射的物体不会发出自己的光,它会吸收周围的环境的颜色,然后通过自己固有的颜色来调节。从扩散表面反射的光线方向是随机的,我们向两个漫反射材质之间发射三束光线,他们的行为会有所不同

image.png

当然,他们有可能会被吸收,也有可能会被反射。表面越暗,说明光线被吸收的可能性更大(黑色说明光线被完全吸收了)。实际上我们我们可以用随机化方向的算法产生看起来哑光的材质,最简单的实现就是:一个光线击中表面,有相等的机会向任何方向弹射出去

image.png

这样的漫反射材质是最简单的,我们需要实现一些随机反射光线的方法,我们向vec类中再额外实现几个函数,以完成能够生成任意随机的向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class vec3 {
public:
...

double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}

static vec3 random() {
return vec3(random_double(), random_double(), random_double());
}

static vec3 random(double min, double max) {
return vec3(random_double(min,max), random_double(min,max), random_double(min,max));
}
};

现在我们需要操作这个随机向量,以确保我们的向量生成在外半球。我们用最简单的方法来实现这个过程,即随机重复生成向量,直到生成了满足我们需求的标准样本,然后采用它,实现它的具体方法如下:

  • 在该点的单位球体内生成一个随机向量
  • 将这个向量单位化,以确保指向球面
  • 如果这个向量单位化后,不在我们想要的半球,将其反转

我们开始这个算法的具体实现,首先在包围单位球体的立方体内随机生成一个点(即x,y,z都在[-1,+1]内)。如果这个点在单位球体之外,则重新生成一个点,直到找到一个在单位球体内的一个点:

image.png

然后我们将其单位化

image.png

我们先实现这个功能吧:

1
2
3
4
5
6
7
8
9
10
//vec3.h
inline vec3 random_unit_vector(){
while(true){
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
if(lensq <= 1){
return p/ sqrt(lensq);
}
}
}

实际上这里还会有一点小问题,我们需要直到。由于浮点数的精度是有限的,一个很小的数在平方后可能会向下溢出到0。也就是说,如果三个坐标都足够小(非常接近球心),向量在单位化操作下可能会变成[+-无穷,+-无穷,+-无穷]。为了解决这个问题我们需要设置一个下限值,由于我们使用的是double,所以在这里我们可以支持大于1e-160的值,所以我们更新一下程序:

1
2
3
4
5
6
7
8
9
10
11
//vec3.h
inline vec3 random_unit_vector(){
while(true){
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
//1e-160太极限了,所以放宽一点
if(lensq > 1e-100 && lensq <= 1){
return p/ sqrt(lensq);
}
}
}

现在我们计算得到了单位球面上的随机向量,需要将其与表面法线比较,以判断其是否为位于正确的半球

image.png
1
2
3
4
5
6
7
8
//vec3.h
inline vec3 random_on_hemisphere(const vec3& normal){
vec3 on_unit_sphere = random_unit_vector();
if (dot(on_unit_sphere,normal) > 0.0)
return on_unit_sphere;
else
return -on_unit_sphere;
}

接下来我们需要将其应用到上色函数中,这里的话我们还需要注意一点,就是光线颜色的反射率,如果反射率为100%,那么我们看到的都是白色的,如果光线反射率是0%,那么光线都被吸收,我们看到的物体是黑色的。这里我们设置我们的光线反射率为50%,并将其应用到我们的ray_color()中:

1
2
3
4
5
6
7
8
9
10
11
12
color ray_color(const ray& r, const hittable& world) const {
hit_record rec;

if (world.hit(r, interval(0, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后我们可以看到渲染出来的图片:

image.png

限制光线的反射次数(递归深度)

注意,我们的ray_color函数是递归的,我们却不能确保它何时停止递归,也就是它不再击中任何东西的时候。当情况比较复杂的时候可能会花费很多时间或者是栈空间,为了防止这种情况我们需要限制这个程序的最大递归深度,我们将其作为camera类的一个属性,并且更改我们的ray_color()函数:

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
class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

void render(const hittable& world){
initialize();

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
color pixel_color(0,0,0);
for(int sample = 0;sample < samples_per_pixel; sample++){
ray r = get_ray(i,j);
pixel_color += ray_color(r,max_depth,world);
}
write_color(std::cout,pixel_color*pixel_samples_scale);
}
}
std::clog << "\rDone. \n";
}
...
private:
...

color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth - 1,world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};

同时我们可以通过更新main函数来更改我们的深度限制:

1
2
3
4
5
6
7
8
9
int main(){
...
cam.aspect_radio = 16.0/9.0;
cam.image_width = 800;
cam.samples_per_pixel = 100;
cam.max_depth = 50;

cam.render(world);
}

渲染出来的效果差不多

image.png

小阴影块

我们这里还需要解决一个问题,当我们计算光线与表面的交点时,计算机会尝试准确的计算出它的交点,但是由于浮点数的误差,我们很难准确的计算出来,导致交点会略微偏移,这其中就有一部分的交点会在表面之下,会导致从表面随机散射的光线再次与表面相交,可能会解出t = 0.000001,也就是在很短的距离内,再次击中表面。

所以为了解决这个问题,我们需要设置一个阈值,当t小于一定程度的时候,我们不将视作有效的命中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth - 1,world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

我们在此基础上再尝试渲染一次,看看效果:

image.png

右边是修复后的效果,我们可以明显感受到其变得更加明亮,更加真实

Lambertian反射

我们先前用的是随机的光线来实现漫反射,这样的话会虽然可以正确的渲染出我们的图像,但是缺少了一点真实感,在实际的漫反射中,反射光线遵循一定的规律(应该是在图形学中通常使用Lambertian定理来实现漫反射)

Lambertian反射的核心思想是反射光的亮度和观察的方向无关,而是和入射光线和表面法线的夹角有关,实际上他们之间存在I = I0*cos(/theta)的关系。

为了在光线追踪中模拟Lambertian分布,我们可以通过移动单位球的方式以实现这个过程,我们向随机生成的单位向量添加一个法向量,来实现这个移动。这么说可能比较抽象,可以看下以下图片:

image.png

我们将单位球沿着法线方向移动生成一个新的单位球,此时我们随机分布的点位是大致符合Lambertian反射的,具体的内容可以自己去尝试Lambertian分布的搜索。

这个过程看起来很复杂,实际上实现起来十分简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
//我们只需要在这里添加一个法向量即可
vec3 direction = rec.normal + random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth - 1,world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后我们再次尝试渲染:

image.png

右图是经过Lambertian反射后生成的渲染图像,可以注意到经过Lambertian反射的设置之后,右图的阴影变得更加明显。这是因为散射的光线更多的指向法线,使其更加击中,对于交界处的光线,更多的直接向上反射了,所以在球体下方的颜色会更暗淡。

伽马矫正以实现准确的色彩强度

大多数显示设备(如显示器、电视和投影仪)的亮度输出与输入信号的关系是非线性的。这种非线性关系称为伽马(gamma),通常在2.2左右。这意味着,如果直接将线性空间(即未进行伽马校正的空间)的像素值发送到显示设备,显示出来的图像会显得比预期的要暗。

人眼对亮度的感知也是非线性的。我们的眼睛对暗部细节比亮部细节更敏感。通过伽马校正,可以使图像的亮度分布更符合人眼的感知特性,从而在视觉上获得更好的效果。

这里我们需要使用一个简单的gamma矫正模型,

image.png

我们编写我们的write_color()函数以实现从线性空间到伽马空间的变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
inline double linear_to_gamma(double linear_component){
if(linear_component >0)
return std::sqrt(linear_component);
return 0;
}

void write_color(std::ostream& out,const color& pixel_color){
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();

//从线性空间到伽马空间的转换
r = linear_to_gamma(r);
g = linear_to_gamma(g);
b = linear_to_gamma(b);

//使用区间RGB[0,1]计算RGB值
static const interval intensity(0.000,0.999);
int rbyte = int (256*intensity.clamp(r));
int gbyte = int (256*intensity.clamp(g));
int bbyte = int (256*intensity.clamp(b));

out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

现在我们对我们的write_color()进行了伽马矫正,现在我们再来看看效果:

image.png

左边的是经过伽马矫正之后的图片,确实更好看了一点,哈哈。

大佬的技术博客,拜读了一下,理解的七七八八,做一个记录和研究吧。

文章地址:Baby’s First Garbage Collector – journal.stuffwithstuff.com

简易的垃圾回收机制

在计算机运行的过程中,会不断的分配和释放内存。尤其是在一些低级的语言中,内存的分配和释放都需要程序员手动分配。如果不对内存加以分配和管理,就会导致系统资源耗尽,从而发生内存泄露。或者如果错误的释放了正在使用的内存空间,那么又会导致系统崩溃。所以一个合理的垃圾回收机制,可以自动识别并回收不再使用的内存,减轻程序员的负担。

垃圾回收的几种实现

  • 引用计数:为每个对象维护一个引用计数器,初始化为1,每当有一个新的引用指向它,计数器就+1。每当一个引用失效,则将计数器-1。当计数器为0时,说明对象不再被引用,就被垃圾回收器回收了。
  • 标记-清除算法:从一个”根对象”开始,通过引用关系遍历所有可以到达的对象,并将其标记为“存活”。然后回收器再扫描一遍内存空间,找出没有被标记的”垃圾”,并将其回收
  • 复制算法:将内存分为两块,分别是“从空间”和“到空间”,再内存分配时,只使用从空间,当从空间被填满时,回收器开始工作,其从根对象开始遍历所有的可达对象,并将其复制到到空间。然后将从空间和到空间的身份进行互换,继续使用从空间进行内存的分配
  • 标记-整理算法:首先从根对象开始遍历所有可达对象,然后移动存活对象到内存空间的另一端,并更新他们的引用。最后回收器,回收所有没有使用的内存碎片。相当于复制算法和标记-清除算法的结合版

这里我们将要使用的垃圾回收机制是 标记-清除算法 ,现在我们需要明确什么是垃圾什么是被使用中的内存空间。

垃圾,指的是之前分配但现在不再使用的内存。使用中的定义则较为复杂,有以下几点:

  • 任何处于作用域中的变量所引用的对象都是处于使用中的
  • 任何被另一个正在使用中的对象所引用的对象都是处于使用中的(注意理解,这是一条递归规则)

所以我们需要从变量开始遍历对象,以达到所有可以到达的对象,对于不可到达的对象,将其收回。

标记与清除

关键在于对对象的遍历和标记,其原理十分的简单:

  • 从根部开始遍历整个对象图。每到达一个对象,就将其上的”标记”设置为true
  • 完成后,遍历查找所有的未被设置的对象,并将其删除

对象

垃圾回收器常常被用于各种编程语言中,但是我们这只是演示其功能,我们就只用基本的数据类型,和简单的虚拟机来实现这个功能并创建一些可回收的垃圾。我们用一个枚举变量来标识对象的类型:

1
2
3
4
typedef enum{
OBJ_INT,
OBJ_PAIR
} ObjectType;

这里定义了两种数据类型,一个是数据类型,一个是成对类型。这个对可以是各种组合,可以是一对数,或者一对对对象,或者一个数和一个对对象。这样的话可以实现对象之间的相互引用,这样的话就可以得到一个连续的引用对象的树,我们我们可以定义出它:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct sObject{
ObjectType type;
union{
// OBJ_INT
int value;

//OBJ_PAIR
struct {
struct sObject * head;
struct sObject * tail;
};
};
} Object;

type字段来标识值的类型——int/pair,然后用联合体来存储

小小的虚拟机

现在我们可以在虚拟机种使用这个数据类型,我们的虚拟机拥有一个栈,用来存储当前作用域中的变量。

1
2
3
4
typedef struct {
Object * stack[STACK_MAX];
int stackSize;
} VM;

这个结构体中包含两个部分:

  • 一个是一个栈空间,用来存储栈中存储的对象的地址
  • 一个是当前栈的大小

我们在此基础之上继续编写一个创建并初始化虚拟机的函数:

1
2
3
4
5
VM * newVM(){
VM * vm = malloc(sizeof (VM));
vm->stackSize = 0;
return vm;
}

然后我们添加能对虚拟机的栈进行操作的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void push(VM * vm,Object * value){
if(vm->stackSize > STACK_MAX){
fprintf(stderr,"Stack overflow");
exit(0);
}
vm->stack[vm->stackSize++] = value;
}
Object * pop(VM * vm){
if(vm->stackSize < 0){
fprintf(stderr,"Stack underflow");
exit(0);
}
return vm->stack[--vm->stackSize];
}

我们现在可以存放变量了,那么我们也需要一个创建变量的程序:

1
2
3
4
5
Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
return object;
}

上面的辅助函数实现了对变量内存的分配,和内存类型的设定,我们在此基础上编写指定的数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void pushInt(VM *vm,int intValue){
Object* object = newObject(vm,OBJ_INT);
object->value = intValue;
push(vm,object);
}
Object * pushPair(VM * vm){
Object* object = newObject(vm,OBJ_PAIR);
/*
原文这里用的是pop,但我发现使用pop会导致栈上数量减少,从而导致在标识环节无法为所有变量打上标记
所以改成相对索引,才能达到我们想要的效果
*/
object->tail = vm->stack[vm->stackSize-2];
object->head = vm->stack[vm->stackSize-1];

push(vm,object);
return object;
}

这就是我们的小虚拟机,如果我们真的有一个调用这些函数的解析器和解释器,那么我们手上就真的有一门语言了。如果我们有足够的内存,它甚至可以运行真正的程序。

标记

我们可以在之前的基础上开始我们的垃圾回收,第一个阶段就是标记。我们需要遍历所有可以到达的对象并设置他们的标记位,现在我们需要为之前的Object结构设置一个标记位。且当我们创建一个新的对象时,我们修改newObject()以初始化marked为零。

1
2
3
4
5
6
7
8
9
10
typedef struct sObject{
unsigned char marked;
...
} Object;
Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
object->marked = 0;
return object;
}

接下来我们开始准备标记所有可到达的函数,我们先从内存中的变量开始(即遍历栈),不过首先我们需要实现我们的标记函数,然后再遍历标记中调用:

1
2
3
4
5
6
7
void mark(Object * object){
object->marked = 1;
}
void markAll(VM * vm){
for(int i=0;i<vm->stackSize;i++)
mark(vm->stack[i]);
}

不过这还远远不够,我们标记的对象本身确实是可以到达的,但是我们还有一种成对类型。在这里,我们需要意识到可达性是传递的,我们可以用递归实现这个过程:

1
2
3
4
5
6
7
8
9
void mark(Object * object){
//这里是用于检测当前对象是否被遍历过,避免两个对象互相引用的情况
if(object->marked) return;
object->marked = 1;
if(object->type == OBJ_PAIR){
mark(object->head);
mark(object->tail);
}
}

现在我们的标记已经完成了,我们可以使用markAll()来实现对内存空间的标记

清除

现在最最最重要的一个环节到了,我们需要遍历我们分配的所有对象,并释放那些未被标记到的对象,但是这里有一个问题,按照定义,未标记的对象对我们而言是不可到达的。

VM已经实现了对对象的引用予以,所以哦我们只将对象的指针存储在变量和成对变量中。一旦对象不再被其他变量所引用,虚拟机就完全失去了它,这就发生了内存泄露。为了解决这个问题,VM需要有自己的对象引用,这些引用和用户可见的语义是不同的,换而言之,我们可以自己跟踪它们。

实现的方式也很简单,我们创建一个链表,用来加入我们分配过的所有对象,我们在Object中完成这个过程:

1
2
3
4
5
typedef struct sObject{
// 用来指向对象列表中的下一个对象
struct sObject* next;
...
} Object;

虚拟机则负责跟踪该列表的头部:

1
2
3
4
5
typedef struct {
// 对象列表中的首指针
Object * firstObject;
...
} VM;

newVM()中,我们将头指针初始化为NULL。每次创建对象时,我们都将其添加到列表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VM * newVM(){
VM * vm = malloc(sizeof (VM));
vm->firstObject = NULL;
vm->stackSize = 0;
return vm;
}
Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
object->marked = 0;

//将声明的变量加入对象列表中
object->next = vm->firstObject;
vm->firstObject = object;

return object;
}

现在我们想要删除未标记的对象,只需要遍历列表即可,我们进行清除函数的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void sweep(VM * vm){
Object ** object = &vm->firstObject;
while(*object){
if(!(*object)->marked){
//如果对象未被标记,从链表中移除并释放
Object * unreached = * object;
*object = unreached->next;
free(unreached);
}else{
//如果对象被标记了,就移除标记(为之后的GC准备),然后进行下一步
(*object)->marked = 0;
object = &(*object)->next;
}
}
}

这个代码看起来确实抽象,但是其逻辑实际上十分简单。它遍历整个链表。每当遇到未标记的对象,就释放内存,并将其从链表中移除。执行这个过程中后,我们就删除了所有不可到达的对象。(这里的二重指针真搞人心态,实际上这个object指向当前对象的next地址)。

OK至此为止,我们的垃圾回收器已经完成了,我们将其简单的合并起来:

1
2
3
4
void GC(VM * vm){
markAll(vm);
sweep(vm);
}

但是不止如此,由于内存是有限的,我们需要设置一个对象数量的阈值,一旦超过这个数量就启动GC对内存进行垃圾回收,我们可以通过拓展VM来计算和设置数量:

1
2
3
4
5
6
7
typedef struct {
//当前分配的对象的总数
int numObjects;
//触发GC的对象的总数
int maxObjects;
...
} VM;

然后再VM的创建中初始化他们:

1
2
3
4
5
6
VM * newVM(){
...
vm->numObjects = 0;
vm->maxObjects = INITIAL_GC_THRESHOLD;
return vm;
}

其中INITIAL_GC_THRESHOLD是第一次启动垃圾回收的对象的数量,这个可以自行调整。

当我们每创建一个对象时,我们都需要增加numObject的数量并进行判断,当其到达最大值时进行垃圾回收:

1
2
3
4
5
6
Object * newObject(VM * vm,ObjectType type){
...
//增加对象数量
vm->numObjects++;
return object;
}

当然每当我们使用sweep释放一个对象,我们也应该在程序中减少当前对象的数量:

1
2
3
4
5
6
7
8
9
10
11
12
void sweep(VM * vm){
Object ** object = &vm->firstObject;
while(*object){
if(!(*object)->marked){
...
free(unreached);
vm->numObjects--;
}else{
...
}
}
}

最后我们需要修改GC()来更新最大值:

1
2
3
4
5
void GC(VM * vm){
markAll(vm);
sweep(vm);
vm->maxObjects = vm->numObjects*2;
}

每次回收后我们的剩余活动对象数量会动态更新GC的触发值,如果活动对象变多,就会扩大。如果大量的活动对象被释放,就会自动缩小。

演示

本来是想演示一下这个程序怎么使用的,感觉还是算了,因为这个过程并不是很直观,所以就不在此演示了,不过如果能一直看到这里,想必整个过程始终是心里有数的了,就不在此过多概述。

好吧我还是让AI写了一个测试集:

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
int main() {
// 创建一个新的虚拟机实例
VM * vm = newVM();

// 向虚拟机中压入一些整数
pushInt(vm, 10);
pushInt(vm, 20);
pushInt(vm, 30);

// 创建一个对子对象,它包含两个整数对象
Object * pair1 = pushPair(vm);

// 再次压入一些整数
pushInt(vm, 40);
pushInt(vm, 50);

// 创建另一个对子对象,它包含两个整数对象
Object * pair2 = pushPair(vm);

// 弹出一些对象,模拟使用后不再需要的场景
pop(vm);
pop(vm);

// 打印当前虚拟机中的对象数量
printf("Objects before GC: %d\n", vm->numObjects);

// 触发垃圾回收
GC(vm);

// 打印垃圾回收后的对象数量
printf("Objects after GC: %d\n", vm->numObjects);

// 清理虚拟机占用的内存
sweep(vm); // 再次调用 sweep 以确保所有对象都被释放

// 释放虚拟机实例占用的内存
free(vm);

return 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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include <stdio.h>
#include <stdlib.h>

#define STACK_MAX 256
#define INITIAL_GC_THRESHOLD 10

//数据结构定义
typedef enum{
OBJ_INT,
OBJ_PAIR
} ObjectType;

typedef struct sObject{
// 用来指向对象列表中的下一个对象
struct sObject* next;
unsigned char marked;
ObjectType type;
union{
// OBJ_INT
int value;

//OBJ_PAIR
struct {
struct sObject * head;
struct sObject * tail;
};
};
} Object;

typedef struct {
//当前分配的对象的总数
int numObjects;
//触发GC的对象的总数
int maxObjects;
// 对象列表中的首指针
Object * firstObject;
Object * stack[STACK_MAX];
int stackSize;
} VM;

//虚拟机的基本操作与实现
VM * newVM(){
VM * vm = malloc(sizeof (VM));
vm->firstObject = NULL;
vm->stackSize = 0;
vm->numObjects = 0;
vm->maxObjects = INITIAL_GC_THRESHOLD;
return vm;
}

void push(VM * vm,Object * value){
if(vm->stackSize > STACK_MAX){
fprintf(stderr,"Stack overflow");
exit(0);
}
vm->stack[vm->stackSize++] = value;
}
Object * pop(VM * vm){
if(vm->stackSize < 0){
fprintf(stderr,"Stack underflow");
exit(0);
}
return vm->stack[--vm->stackSize];
}

Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
object->marked = 0;

//将声明的变量加入对象列表中
object->next = vm->firstObject;
vm->firstObject = object;

//增加对象数量
vm->numObjects++;
return object;
}

void pushInt(VM *vm,int intValue){
Object* object = newObject(vm,OBJ_INT);
object->value = intValue;
push(vm,object);
}
Object * pushPair(VM * vm){
Object* object = newObject(vm,OBJ_PAIR);
object->tail = vm->stack[vm->stackSize-2];
object->head = vm->stack[vm->stackSize-1];

push(vm,object);
return object;
}

//垃圾回收机制函数
void mark(Object * object){
if(object->marked) return;
object->marked = 1;
if(object->type == OBJ_PAIR){
mark(object->head);
mark(object->tail);
}
}
void markAll(VM * vm){
for(int i=0;i<vm->stackSize;i++)
mark(vm->stack[i]);
}
void sweep(VM * vm){
Object ** object = &vm->firstObject;
while(*object){
if(!(*object)->marked){
//如果对象未被标记,从链表中移除并释放
Object * unreached = * object;
*object = unreached->next;
free(unreached);
vm->numObjects--;
}else{
//如果对象被标记了,就移除标记(为之后的GC准备),然后进行下一步
(*object)->marked = 0;
object = &(*object)->next;
}
}
}

void GC(VM * vm){
markAll(vm);
sweep(vm);
vm->maxObjects = vm->numObjects*2;
}

int main(){

}

上一章完成了相机类的实现,对之前所学的内容进行了封装与整理,现在要学习新的内容。

抗锯齿

我们放大之前渲染的图片,往往会发现我们渲染的图像边缘有尖锐的”阶梯”性质。这种阶梯状被称为”锯齿”。当真实的相机拍照时,边缘通常没有锯齿,这是因为真实的边缘时前景和背景颜色的混合,而不是简单的二值化。而且真实的世界是连续的,而我们渲染的图像是离散的,也就是说真实世界具有无限的分辨率 ,而我们的=图像的分辨率是有限的。我们可以通过对每个像素进行多次采样并取平均值,来近似实现此效果。

我们目前采用的采样方式被称为”点采样“,即在每个像素的中心发射一条射线来进行采样,但是同时也面临一个问题,当我们对较远的地方的图像进行采样时,可能会出现”非黑即白”的情况。但实际上我们应该看到的是灰色,而不是黑白分明的点。这是我们的眼睛很自然的对远处的图形进行了处理,而这种处理正是我们想要的效果。

所以为了解决这个问题,我们采用”多采样”的方式,来对我们的图片实现采样。我们需要对像素周围的光线进行采样,然后对采样的结果进行整合,以近似真实世界的连续效果。

为了实现这种效果,我们采用最为简单的方式,我们对以像素为中心的正方形区域进行采样,这个正方形区域会延伸到每个相邻的像素的一般位置。这个方法可能效果一般,但是便于实现,具体的内容可以参考文献像素不是一个小方块,下面是一个多采样草图

image.png

数学工具:随机数生成

为了实现函数的多采样,我们需要一个能够返回真实随机数的随机数生成器。这个函数为我们返回一个(0,1)的随机数,我们可以使用标准库<cstdlib>中的std::rand()函数,这个函数会返回一个[0,RAND_MAX]之间的随机整数。我们通过以下改动,可以得到真正的随机数函数,我们写在rtweekend.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <limits>
#include <memory>
...
//实用函数
inline double degree_to_radius(double degrees){
return degrees * pi / 180.0;
}
inline double random_double(){
//返回[0,1)的数
return std::rand() / (RAND_MAX + 1.0);
}
inline double random_double(double min,double max){
//返回[min,max)的数
return min + (max - min)*random_double();
}

不过由于rand()具有随机性较差等特点,所以在C++11标准下有其他的随机数函数写法:

1
2
3
4
5
6
7
8
9
...
#include <random>
...
inline double random_double(){
static std::uniform_real_distribution<double> distribution(0.0,1.0);
static std::mt19937 generator;
return distribution(generator);
}
...

不过看不太懂就是了

使用多采样式生成像素

对于由多个样本组成的像素,我们将从像素周围的区域选择样本,并将颜色(光值)平均在一起

我们需要更新我们的write_color()函数以考虑我们的样本数量,不过在此之前,我们需要添加一个用于区间判断的辅助函数interval::clamp(x),以确保最终的结果的颜色分量保持在正确的[0,1]范围:

1
2
3
4
5
6
7
8
9
class interval{
public:
...
//包含
double clamp(double x) const{
if(x < min) return min;
if(x > max) return max;
return x;
}

接下来我们更新write_color()函数,其包含区间的限制功能:

1
2
3
4
5
6
7
8
9
10
11
  void write_color(std::ostream& out,const color& pixel_color){
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();
//使用区间RGB[0,1]计算RGB值
static const interval intensity(0.000,0.999);
int rbyte = int (256*intensity.clamp(r));
int gbyte = int (256*intensity.clamp(g));
int bbyte = int (256*intensity.clamp(b));
out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

这样保证了我们的的rgb分量不会超出[0,1]的范围,这样更加安全。

接下来我们需要更新相机类,以定义和使用一个新的camera::get_ray(i,j)函数,该函数将为每个像素生成不同的样本。这个函数将使用一个新的辅助函数sample_squred(),该函数在以原点为中心的正方形内生成一个随机样本点。然后我们将这个正方形中的随机样本转换为我们当前正在采样的特定像素。

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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

#ifndef RENDER_C___CAMERA_H
#define RENDER_C___CAMERA_H

#include "hittable.h"

class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数

void render(const hittable& world){
initialize();

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
color pixel_color(0,0,0);
for(int sample = 0;sample < samples_per_pixel; sample++){
ray r = get_ray(i,j);
pixel_color += ray_color(r,world);
}
write_color(std::cout,pixel_color*pixel_samples_scale);
}
}
std::clog << "\rDone. \n";
}

private:
int image_height; //渲染图像的高度
double pixel_samples_scale; //每次采样的颜色权重
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值

void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = point3 (0,0,0);

//确认视窗的设置
auto focal_length = 1.0; //焦距设置
auto viewport_height = 2.0;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//视图边缘的向量计算
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}

ray get_ray(int i,int j){
//构造一个从原点开始的随机采样射线,指向(i,j)像素

auto offset = sample_square();
auto pixel_sample = pixel00_loc + ((i+offset.x())*pixel_delta_u) + ((j+offset.y())*pixel_delta_v);

auto ray_origin = camera_center;
auto ray_direction = pixel_sample - ray_origin;

return ray(ray_origin,ray_direction);
}

vec3 sample_square() const {
//返回一个一个随机的点,在[-0.5,-0.5]~[+0.5,+0.5]的单位矩阵内
return {random_double() - 0.5, random_double() - 0.5,0};
}

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,interval(0,infinity),rec)){
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}
};

#endif //RENDER_C___CAMERA_H

这是新的camera,我们更新了get_ray()sample_square(),还有新的公有私有属性

接下来设置一下主函数的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

int main(){
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));

camera cam;

cam.aspect_radio = 16.0/9.0;
cam.image_width = 400;
cam.samples_per_pixel = 100; // 设置采样次数

cam.render(world);
}
image.png

这里可以看到左图的锯齿明显减少了,我们的抗锯齿设置的十分成功,今天就到此为止了

Camera类

我们之前学了很多的图形学知识和相关的程序,现在我们停下脚步,来好好整理一下我们学习的内容,我们将之前的视口代码和渲染代码合并到一个新的单类camera.h,这个类主要负责两项任务:

  • 构建并发射光线到世界中
  • 使用光线的信息来构建渲染图像

这次的重构,我们收集以下几个功能:

  • ray_color()
  • 图像设置
  • 相机设置
  • 渲染

新的相机类将包含两个公有方法:initialize()render() 以及两个私有辅助方法 get_ray()ray_color()

相机类的设计应该遵循尽可能的简单的方式,让我们在后续使用时操作尽可能的简单,使用默认构造函数,且避免复杂的初始化过程。同时允许用户通过直接赋值改变公共变量,避免复杂的setter方法。并且在渲染函数开始时,自动的调用initialize()操作,避免用户操作的复杂性。

现在我们先搭建其camera类的框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

#ifndef RENDER_C___CAMERA_H
#define RENDER_C___CAMERA_H
#include "hittable.h"
class camera{
public:

//这里设置公有的属性

void render(const hittable& world){
}
private:

//这里放置私有的属性

void initialize(){
}

color ray_color(const ray&r,const hittable& world) const{
}
};
#endif //RENDER_C___CAMERA_H

然后一一将我们的方法和属性完善首先是将main中的上色部分ray_color移动到里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class camera {
...
private:
...
color ray_color(const ray& r, const hittable& world) const {
hit_record rec;

if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#ifndef RENDER_C___CAMERA_H
#define RENDER_C___CAMERA_H

#include "hittable.h"

class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数

void render(const hittable& world){
initialize();

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r,world);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

private:
int image_height; //渲染图像的高度
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值

void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

camera_center = point3 (0,0,0);

//确认视窗的设置
auto focal_length = 1.0; //焦距设置
auto viewport_height = 2.0;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//视图边缘的向量计算
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,interval(0,infinity),rec)){
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}
};

#endif //RENDER_C___CAMERA_H

我们使用新的类来实现对main函数的简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "rtweekend.h"

#include "camera.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"

int main(){
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));

camera cam;

cam.aspect_radio = 16.0/9.0;
cam.image_width = 800;

cam.render(world);
}

这样的操作极大的简化了后续我们的图形的渲染,你看这是渲染出来的放大版:

image.png

那么这一章就到此为止啦

感觉学的还是有点慢啊,刚刚往后看了一点,感觉后面的知识越来越难,我得赶快点学。

法向量和多物体处理

根据法向量进行表面着色

我们需要计算出表面法线进而进行着色。(法线是垂直于交点处的表面的向量)

法向量的计算使用决策有以下两种情况:

  • 法向量可以有任意长度,这些长度可以附带额外的信息,比如反射啥的
  • 法向量全部进行单位化,此时法向量仅仅代表其法线方向

我们可以看到法线的单位化过程中有开方操作,这个操作可能会花费较多的时间,但是我们仍然保留它,理由有三:

  • 你不应该等到要用的时候再进行单位化,设置条件可能会导致程序更加复杂
  • 我们会经常用到单位化的法向量
  • 法向量的单位化比想象中的简单,根据特定的几何类可以定制不同的单位化策略,如在球体中可以通过除以radius实现法向量的单位化

综上考虑,我们对所有的法向量都进行单位化。

球体的外法线的计算方式也很简单:

1
2
vec(d) = point(P) - point(C)
unit(vec(d)) = vec(d)/radius
image.png

由于我们的单位向量的大小是1也就是说,单位法向量在其他任意方向的分量范围都在[-1,1]内,所以我们可以将其每个分量都映射到[0,1]的区间内,然后将(x,y,z)映射到 (red,green,blue)

我们需要计算出t从而计算出法向量的信息,我们确保球体是在摄像机的前方,所以不必考虑t的负值。当t有多个解的时候,我们只考虑离我们最近的交点(最小的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
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
//判断delta值,并计算t(这里计算的是较小的t)
if(discriminant < 0.0){
return -1.0;
}else{
return (-b - std::sqrt(discriminant)) / (2.0*a);
}
}

color ray_color(ray & r){
auto t = hit_sphere(point3(0,0,-1),0.5,r);
//根据t的分量进行上色
if(t > 0.0){
vec3 N = unit_vector(r.at(t) - point3(0,0,-1));
return 0.5*color(N.x()+1,N.y()+1,N.z()+1);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们将程序修改后运行渲染得到了新的图片:

image.png

光线计算的简化

我们查看原本的交点计算方程:

1
2
3
4
5
6
7
8
9
10
11
12
13
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;

if(discriminant < 0.0){
return -1.0;
}else{
return (-b - std::sqrt(discriminant)) / (2.0*a);
}
}

我们注意到b中有一个-2的因子,那么我们考虑将其分离出来,令b = -2h

于是方程可以进行以下变换实现简化:

image.png

同时我们知道向量的自己和自己的点积,等同于向量的平方dot(vec(v),vec(v)) = vec(v)^2

所以我们的程序可以改进成以下形式(没看出改进太多啊):

1
2
3
4
5
6
7
8
9
10
11
12
13
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;
auto discriminant = h*h - a*c;

if(discriminant < 0.0){
return -1.0;
}else{
return (h - std::sqrt(discriminant)) / a;
}
}

可被(光线)击中类的抽象

接下来我们需要考虑当有多个球体的情况,你可能觉得很简单,可以创建一个数组来实现,但实际上有更好的选择——创建一个抽象类,这里我们称其为hittable类。

这个抽象类将接受ray作为参数。同时我们添加一个有效的命中区间(t_min到t_max),这样便于我们对t的计算,只有在有效命中区内,t才被采用,命中才计数。同时我们还要考虑一个问题,那就是我们只需要考虑并计算离我们较近的东西的法线,所以我们需要采取一些方案来计算他们,以下是一个抽象类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#ifndef RENDER_C___HITTABLE_H
#define RENDER_C___HITTABLE_H

#include "ray.h"

//用于存储光线与物体相交的重要信息
class hit_record{
public:
point3 p;
vec3 normal;
double t;
};

class hittable{
public:
virtual ~hittable() = default;
//纯虚函数,用来实现函数的多态性
virtual bool hit(const ray& r, double ray_min,double ray_max,hit_record& rec) const = 0;
};

#endif //RENDER_C___HITTABLE_H

在此基础上实现一个球体的类:

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

#ifndef RENDER_C___SPHERE_H
#define RENDER_C___SPHERE_H

#include "vec3.h"
#include "hittable.h"

class sphere: public hittable{
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {}
//fmax返回两个浮点数参数较大的一个,fmin同理
bool hit(const ray& r, double ray_min,double ray_max,hit_record& rec) const override{
vec3 oc = center - r.origin(); // override 重写基类的虚函数
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;

auto discriminant = h*h - a*c;
if(discriminant < 0.0){
return false;
}

//解t并进行判断
auto sqrtd = std::sqrt(discriminant);
auto root = (h - sqrtd) / a;
if(root <= ray_min || ray_max <= root){
root = (h + sqrtd) / a;
if(root <= ray_min || ray_max <= root)
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
rec.normal = (rec.p - center) / radius;

return true;
}

private:
point3 center;
double radius;
};


#endif //RENDER_C___SPHERE_H

前表面与后表面

我们先前提到了关于法线的几个决策,现在我们需要决定法线是否应该始终指向外面。当然,我们可以利用法线始终指向外面的方式来进行光线来向的判断,并帮助我们区分前后表面。

image.png

以这张图为例,我们可以通过计算光线方向和法线(始终朝外)的点积,并判断它的正负来实现其方向的判断。如果其点积为正,说明光线与法线同向,照射到的是内表面。如果点积为负,说明光线与法线反向,照射到的是外表面。我们可以通过以下程序实现判断:

1
2
3
4
5
6
7
8
9
10
bool front_face;
if(dot(ray_direction,outward_normal) > 0.0){
// 光线在球体内部
normal = -outward_normal; //实际的法线方向,需要翻转
front_face = false;
}else{
// 光线在球体外部
normal = outward_normal;
front_face = true;
}

我们将其添加到我们的hittable.hsphere中:

1
2
3
4
5
6
7
8
9
10
11
12
13
class hit_record{
public:
point3 p;
vec3 normal;
double t;
bool front_face;

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;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class sphere : public hittable {
public:
...
bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const {
...

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;
}
...
};

可击中对象的列表

我们之前创建了一个抽象类hittabel.h,用来描述光线可以相交的物体。现在我们再创建一个类来存储具有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

#ifndef RENDER_C___HITTABLE_LIST_H
#define RENDER_C___HITTABLE_LIST_H

#include "hittable.h"

#include <memory>
#include <vector>

using std::make_shared;
using std::shared_ptr;

class hittable_list : public hittable {
public:
std::vector<shared_ptr<hittable>> objects;
// 创建了一个可击中对象的数组
hittable_list(){}
hittable_list(shared_ptr<hittable> object) {add(object);}

void clear() {objects.clear();}

void add(shared_ptr<hittable> object) {
objects.push_back(object);
}

bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const override{
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_tmax;

for(const auto& object : objects){
if(object->hit(r,ray_tmin,closest_so_far,temp_rec)){
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
};

#endif //RENDER_C___HITTABLE_LIST_H

这里有些地方用到了C++的一些特性,需要特别强调一下。

shared_ptr<> 是一个智能指针,被包含在<memory>中,用来指向已经分配的类型,它可以自动管理内存,当对象不再被任何shared_ptr引用时,自动释放内存。我们可以使用make_shared<thing>(...)为数据类型thing创建一个实例,并返回一个shared_ptr<thing>例如:

1
2
3
auto double_ptr = make_shared<double>(0.41);
auto vec3_ptr = make_shared<vec3>(1.1,1.2,1.3);
auto sphere_ptr = make_shared<sphere>(point3(0,0,0),1.0)

还有一个需要注意的用到的特性是std::vector,其被包含在<vector>中,其用来生成一个动态数组,并且支持对其集合仅从指定的操作。例如:

1
2
3
4
5
6
7
8
9
std::vector<shared_ptr<hittable>> objects;
//添加一个值
objects.push_back(object);
//删除所有值
objects.clear();
//数组大小
objects.size();
//访问
objects[]

大概涉及到这些吧,剩下的之后再慢慢了解

常用常量和实用函数

我们设置一个头文件,我们放置一些常用的数学常量和一些常用的头文件,这样方便我们的调取

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

#ifndef RENDER_C___RTWEEKEND_H
#define RENDER_C___RTWEEKEND_H

#include <cmath>
#include <iostream>
#include <limits>
#include <memory>

using std::make_shared;
using std::shared_ptr;

//常量设置
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;

//实用函数
inline double degree_to_radius(double degrees){
return degrees * pi / 180.0;
}

//常用文件头
#include "color.h"
#include "ray.h"
#include "vec3.h"

#endif //RENDER_C___RTWEEKEND_H

现在我们的rtweekend.h里面已经包含了大多数常用的文件头,

我们现在利用上述新创建文件头,写一个新的main函数:

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
//这里我们修改了一下头文件还有ray_color函数
#include "rtweekend.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"

#include <iostream>

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,0,infinity,rec)){ //只有t在(0,+无穷)的时候才成立
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

int main(){
auto aspect_radio = 16.0/9.0; //长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//球体列表
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));

//确保视口的宽高比和图像的宽高比一样
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0,0,0);

//设置视口向量与单位长度
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
auto pixel_delta_u = viewport_u/image_width;
auto pixel_delta_v = viewport_v/image_height;

//计算像素点位
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
auto pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);


//渲染器
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r,world);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

这个是我们渲染出来的新图片,我们设置了一个大的球体在下面作为一个地面。

image.png

区间类

我们在判断是否击中的hit()函数中我们经常设置一个区间(tmin,tmax),为了方便之后的使用,我们将这个区间类给抽象出来:

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

#ifndef RENDER_C___INTERVAL_H
#define RENDER_C___INTERVAL_H

#include "rtweekend.h"

class interval{
public:
double min,max;
//默认区间是空的
interval() : min(+infinity),max(-infinity) {}

interval(double min,double max): min(min),max(max) {}

double size() const{
return max - min;
}
//闭区间
bool contains(double x) const {
return min <= x && x <= max;
}
//开区间
bool surrounds(double x) const{
return min < x && x < max;
}

static const interval empty,universe;
};

const interval interval::empty = interval(+infinity,-infinity);
const interval interval::universe = interval(-infinity,+infinity);

#endif //RENDER_C___INTERVAL_H

我们现在可以用这个头文件去更新之前用到了区间的程序

1
2
3
4
5
6
//hittable.h
class hittable {
public:
...
virtual bool hit(const ray& r, interval ray_t, hit_record& rec) const = 0;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//hittable_list.h
class hittable_list : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_t.max;

for (const auto& object : objects) {
if (object->hit(r, interval(ray_t.min, closest_so_far), temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}

return hit_anything;
}
...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//sphere.h
class sphere : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
...

auto root = (h - sqrtd) / a;
if (!ray_t.surrounds(root)) {
root = (h + sqrtd) / a;
if (!ray_t.surrounds(root))
return false;
}
...
}
...
};
1
2
3
4
5
6
7
8
9
10
color ray_color(const ray& r, const hittable& world) {
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

以上就是需要更新的地方啦

这一篇的内容比较多,写了差不多两天,得好好消化一下啦。

学到一半区研究旋转立方体去了,确实好玩,受益匪浅啊,刚好搞明白了上一章相机的设置和视图的投影,现在继续学习。

添加一个球

我们现在向我们的光线追踪器中添加一个物体。我们从添加一个球体开始(因为它是最容易分析实现的)

射线-球面的交汇

这一部分将有大量的公式,但是我现在还没有搞明白Latex的渲染问题(这个太麻烦了,我已经试过几次了),可能会用比较丑陋方式来表达或者直接上截图

我们知道球体的表达式:

1
x^2 + y^2 + z^2 = r^2

当球心为C(C_x,C_y,C_z)时,我们有:

1
(C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 = r^2

但是这个是数值上的运算,我们需要想办法将它转换成向量vec3的形式,这里观察可得,实际上我们可以用一点P(x,y,z)来表示球体上的任意一点,也就是说从CP向量,可以用point(C)-point(P)来表示,现在我们可以用向量的概念去理解这个式子:

1
(point(C)-point(P))*(point(C)-point(P)) = (C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 = r^2

现在将射线的路径和球体的方程式联立起来:

1
2
3
4
5
6
P(t) = Q + td
(point(C)-point(P))*(point(C)-point(P)) = r^2

联立得:

t^2*vec(d)*vec(d) - 2*t*(point(C)-point(Q)) + (point(C)-point(Q))*(point(C)-point(Q)) - r^2 = 0

这是一个关于t的一元二次方程,我们可以根据医院二次方程的求解方程式来计算t,并得到射线与球体的相交情况(注意以下的乘法都是点乘):

1
2
3
4
5
6
求解方程式:
x = (-b +- sqrt(b^2 - 4*a*c))/(2*a)
根据联立方程式得到的数值:
a = vec(d)*vec(d)
b = -2*vec(d)*(point(C)-point(Q))
c = (point(C)-point(Q))*(point(C)-point(Q)) - r^2

带入以上的数据就可以解出t,同时可以判断光线与球体的相交情况

image.png

创建一个带有小球的光追图像

我们现在将刚刚计算得到的数学公式硬编码进入我们的程序,并设置球体的中心在(0,0,-1),半径为0.5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin(); //计算(point(C)-point(Q))
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
return (discriminant >= 0);
}

color ray_color(ray & r){
if(hit_sphere(point3(0,0,-1),0.5,r))
return {1,0,0};

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们将这一部分添加进入先前的代码可以得到我们渲染出来的图像,中间的红色是我们添加的球体。

image.png

不过此时程序仍然存在一个问题,我们并不能区分摄像机前后的物体,由于射线的对称性,当球体位于(0,0,1)的时候,存在t的解为负数,导致渲染出相同的图片。

同时,我们现在还不能对物体进行前后的判断,还有阴影,反射光线等功能还有多个物体的共存,我们在接下来的学习中,慢慢尝试解决这个问题,今天就到此为止啦。

看到一个视频,对其内容比较感兴趣,刚好今天没什么事,我们来深入解析一下这个项目

视频链接:终端字符旋转立方体

参考链接:Donut math: how donut.c works

这是我们最终想要实现的效果(一个旋转的立方体):

image.png

旋转

怎么让一个立方体转起来,我们是怎么实现的?实际上这里用到的是线性代数的几何原理。

这里直接给出实现方法,通过三个旋转矩阵对向量进行三次线性变换,从而实现对点的旋转效果:

  • 我们以正方体的几何中心作为原点,此时正方体表面的任意一点可被表示为向量(i, j, k)
  • 现在使用通过绕X轴旋转的旋转矩阵,来实现变换image.png
  • 想要对于Y轴和Z轴的旋转的话,乘以对应的旋转矩阵即可实现image.png

这样我们就通过矩阵乘法实现了向量旋转的效果,我们可以得到旋转后的点的位置

这里我们使用wiki百科中的内旋矩阵来实现我们的计算image.png

接下来我们可以写出以下代码来计算我们的在任意时刻的点位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double A,B,C;
float calculateX(int i,int j,int k){
return i*cos(A)cos(B) +
j*cos(A)*sin(B)*sin(C) - j*sin(A)*cos(C) +
k*cos(A)*sin(B)*cos(C) + k*sin(A)*sin(C);
}
float calculateY(int i,int j,int k){
return i*sin(A)*cos(B) +
j*sin(A)*sin(B)*sin(C) + j*cos(A)*cos(C) +
k*sin(A)*sin(B)*cos(C) - k*cos(A)*sin(C);
}
float calculteZ(int i,int j,int k){
return -i*sin(B) +
j*cos(B)*sin(C) +
k*cos(B)*cos(C);
}

现在我们对立方体的模拟已经完成了,接下来我们需要想办法将其投影到我们的视图上面。

投影

我们根据这张图来进行解释image.png

这个object就是我们模拟出来的物体,也就是我们的正方体。screen是我们的屏幕,我们现在根据投影将物体投射在我们的屏幕上呈现出我们看到的效果。

这里可以看到得到等式关系y/z = y0/z0也就是说y0 = y*(z0/z)其中z0的值是保持不变的,我们将其设置为K1。我们就可以用等式xp = K1*ooz*x*2;yp = K1*ooz*y来表示它的坐标(这里xp需要乘2是因为终端中的字符有一定的长宽比,如果不乘以2,则投影出来的效果会比较窄)。

所以我们就可以写出我们的投影函数了:

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
// 变量
int idx;
int xp,yp;
float ooz;
float cubeWidth = 16; //正方体大小
int width = 160,height = 44; //视口大小
int distanceFromCamera = 60; //视口距离物体几何中心的距离
int K1 = 40; //相机到视口的距离
float zbuffer[160*44];
char buffer[160*44];

void calculateForSurface(float cubeX,float cubeY,float cubeZ,char ch){
//原点(0,0,0)实际上是相机的位置(即视点)
x = calculateX(cubeX,cubeY,cubeZ);
y = calculateY(cubeX,cubeY,cubeZ);
z = calculateZ(cubeX,cubeY,cubeZ) + distanceFromCamera;

ooz = 1/z; //one over zero
xp = (int)(width/2 + K1*ooz*x*2);//这里乘2平衡字符的宽高比
yp = (int)(height/2 + K1*ooz*y);

idx = xp + yp*width; //计算在数列中的位置
if(idx >= 0 && idx < width*height){
// z缓冲
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}

以上就是我们的投影函数了,你可能会注意到这一部分:

1
2
3
4
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}

这就是大名鼎鼎的Z缓冲技术(Z-buffering),如果没有这个程序可能我们投影出来的效果就会是透视的,我们看物体的前面却能看到物体的背面,这是因为后面的字符覆盖到了前面的字符。所以在这里我们需要对每个点进行深度检测,即计算他们的ooz,如果z越大说明离得越远,反之则说明离得近。每次进行缓冲区的覆写之前我们都先对其做一次判断,如果当前Z的深度大于先前的点,则忽略。

这样的设计确保只有最近的表面可见,避免远处的物体错误的覆盖近处的物体。

展示

现在我们已经完成了我们的模拟函数和渲染和函数,接下来就需要进行一系列的初始化,让正方体动起来

我们直接将main函数给出,然后进行解释:

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
float zbuffer[160*44];
char buffer[160*44];
int backgroundASCIIcode = ' ';
float increamentSpeed = 0.5;
int main(){
printf("\x1b[2j"); //cls的转义序列,用于清屏
while(1){
memset(buffer,backgroundASCIIcode,width*height);//设置背景为' '
memset(zbuffer,0,width*height*4); //设置深度为无限深度
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}
printf("\x1b[H"); //将光标置于左上角的转义序列,确保打印的视图稳定完整
printf("\x1b[?25l");//隐藏光标的转义序列,保证观看的优美
fflush(stdout);
//用于打印缓冲图像,并且用三元运算符号判断换行时机
for(int k=0 ;k< width*height;k++){
putchar(k%width ?buffer[k] : 10);
}
//设置转动
A += 0.04;
B += 0.04;
C += 0.04;
usleep(8000);
}
}

你可能会对这一部分代码感到困惑:

1
2
3
4
5
6
7
8
9
10
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}

实际上这是对正方体的初始化,和对其点位的跟踪计算,我们将正方体的几何中心视作原点(0,0,0),那么根据这个原点建系。Z轴指向垂直向内,Y轴指向竖直向上,X轴指向水平向右。那么我们就可以确定正方形六个面的位置了:

固定坐标 变化的坐标 几何意义
前面 Z = -10 XY 靠近屏幕的面(Z最小)
后面 Z = +10 XY 远离屏幕的面(Z最大)
右面 X = +10 YZ 立方体右侧的面(X最大)
左面 X = -10 YZ 立方体左侧的面(X最小)
顶面 Y = +10 XZ 立方体顶部的面(Y最大)
底面 Y = -10 XZ 立方体底部的面(Y最小)

至于CubeX和CubeY的值则是用来确定位置,以确保方向一定。

这么解释理解起来比较麻烦,实际上你仍然可以理解成一个矩阵变换的过程:

  • 我们设当前面上一点的位置为(x,y,z)
  • 那么右面上的点的表示即为,绕Y轴旋转90度的旋转矩阵的线性变换
  • 其他五个面同理,我们可以得到以下的线性关系:
面上一点的表示
前面 (x,y,z)
后面 (-x,y,-z)
右面 (-z,y,x)
左面 (z,y,-x)
顶面 (x,z,-y)
底面 (-x,z,y)

然后依次进行计算即可,简而言之这是一个将正方体内的坐标系转换到3D世界中的坐标系的一个过程

至此我们的程序就完成了,效果见下图:

image.png

源代码

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
68
69
70
71
72
73
74
75
76
77
78
79
#include<stdio.h>
#include<math.h>
#include<unistd.h>

int idx;
int xp,yp;
float ooz;
float x,y,z;
double A,B,C;
float cubeWidth = 16;
int width = 160,height = 44;
int distanceFromCamera = 60;
int K1 = 40;
float zbuffer[160*44];
char buffer[160*44];
int backgroundASCIIcode = ' ';
float increamentSpeed = 0.5;

float calculateX(float i,float j,float k){
return i*cos(A)*cos(B) +
j*cos(A)*sin(B)*sin(C) - j*sin(A)*cos(C) +
k*cos(A)*sin(B)*cos(C) + k*sin(A)*sin(C);
}
float calculateY(float i,float j,float k){
return i*sin(A)*cos(B) +
j*sin(A)*sin(B)*sin(C) + j*cos(A)*cos(C) +
k*sin(A)*sin(B)*cos(C) - k*cos(A)*sin(C);
}
float calculateZ(float i,float j,float k){
return -i*sin(B) +
j*cos(B)*sin(C) +
k*cos(B)*cos(C);
}
void calculateForSurface(float cubeX,float cubeY,float cubeZ,char ch){
x = calculateX(cubeX,cubeY,cubeZ);
y = calculateY(cubeX,cubeY,cubeZ);
z = calculateZ(cubeX,cubeY,cubeZ) + distanceFromCamera;

ooz = 1/z;
xp = (int)(width/2 + K1*ooz*x*2);
yp = (int)(height/2 + K1*ooz*y);

idx = xp + yp*width;
if(idx >= 0 && idx < width*height){
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}


int main(){
printf("\x1b[2j");
while(1){
memset(buffer,backgroundASCIIcode,width*height);
memset(zbuffer,0,width*height*4);
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}
printf("\x1b[H");
printf("\x1b[?25l");
fflush(stdout);
for(int k=0 ;k< width*height;k++){
putchar(k%width ?buffer[k] : 10);
}
A += 0.04;
B += 0.04;
C += 0.04;
usleep(8000);
}
}

昨天休息了一天,今天继续图形学的学习

向场景发射光线

现在我们我们准备做一个光线追踪器。其核心在于,光线追踪程序通过每个像素发送光线。这意味着对于图像中的每个像素点,程序都会计算一天从观察者出发,穿过该像素的光线。并且计算这个光线的方向上所看到的像素的颜色。其步骤为以下几点:

  • 计算从“眼睛”发出的通过像素的光线
  • 确定光线与物体的交汇
  • 计算交点像素的颜色和性质

设置一个摄像机

除了设置渲染图像的像素维度,我们还需要一个虚拟视口,通过这个视口传递场景光线。这个视口是3D世界中的一个虚拟矩形,其中包含图像像素网格的位置。如果像素在水平方向和垂直方向上的距离相同(正方形像素),那么用于生成这些像素的视口也将具有具有相同的渲染比例。两个相近的像素之间的距离称之为像素间距,通常被单位化,用于计算。

首先我们随意设置一个为2的视口高度,并将视口宽度缩放,以获得我们需要的宽高比例,下面是渲染图像的设置:

1
2
3
4
5
6
7
8
9
10
auto aspect_radio = 16.0/9.0;	//长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//确保视口的宽高比和图像的宽高比一样
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);

这里之所以没有使用aspect_radio来计算视口宽度是因为,为了确保其比例更加的真实。因为aspect_ratio是一个理想的比例,但是并不真实。图像的宽高比可能会在四舍五入的过程中丢失精度,所以此时的aspect_ratio并不准确,要得到真正的宽高比,我们直接使用图像的宽高比来进行计算。

接下里我们需要定义摄像机的中心:一个3D空间中的点,所有的场景光线都将从这个点出发(这个点通常也被称之为视点)。从相机中心到视口中心的向量将与视口垂直。我们将视口到视点的距离看作一个单位。这个距离我们称其为焦距。

我们可以通过这张图理解视点和视口的关系,但是忽略图上的方向,我们只需要知道Z轴是从视点指向视口的方向即可

image.png

实际上我们将视口的左上角定为(0,0),然后扫描像素时从左上角开始,逐行从左到右扫描,然后逐行从上往下扫描。为了方便导航像素网格,我们设置从左往右的向量u⃗和从上往下的向量v⃗ 。然后我们根据像素间距,将像素视口均匀的分成了高x宽网格空间

image.png

下面我们写一个实现相机的代码,我们创建一个函数ray_color(const ray& r),它返回给定场景中的射线的颜色——我们先设置为总返回黑色。

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
#include "color.h"
#include "vec3.h"
#include "ray.h"

#include <iostream>

color ray_color(const ray& r){
return {0,0,0};
}

int main(){
auto aspect_radio = 16.0/9.0; //长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//确保视口的宽高比和图像的宽高比一样
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0,0,0);

//设置视口向量与单位长度
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
auto pixel_delta_u = viewport_u/image_width;
auto pixel_delta_v = viewport_v/image_height;

//计算像素点位
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
auto pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);


//渲染器
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

以上就是一个摄像机的实现了,接下来我们用它来实现背景的渲染。

渲染背景

我们接下来想要渲染一个随y变化的由蓝变白的背景,这就需要我们修改ray_color(ray)函数,从而实现一个简单的梯度函数,这个函数将根据y值来按线性规则混合白色和蓝色。

我们使用一个标准的图形技巧来实现线性混合:

blendValue = (1 − a) * startValue + a * endValue

当a接近0时,颜色趋近为起始颜色。当a接近1时,颜色接近目标颜色。

我们修改函数ray_color(ray& r)实现这个功能:

1
2
3
4
5
color ray_color(ray & r){
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们编译执行,得到了渲染后的图片:

image.png

今天就先到这里啦。