根据师兄推荐,打算从 Peter Shirley 的《Ray Tracing in OneWeekend》等系列图书入门光线追踪,学习过程中记录了一些经验总结笔记。这些笔记中包含了学习过程中遇到的一些知识理解以及编程相关的问题,如今记录下来,总结经验,加深印象。
第一章介绍了作者将会使用c++进行编码,可以使用轻量级的IDE,甚至包括Codeblocks,这里我使用了Clion(通过教育网邮箱登录可以免费使用),比较轻量,而且代码管理也很方便。安装好Clion后记得配置一下C++环境。
作者喜欢在敲代码的过程中学习这些知识,我也很赞同。但是当一些代码提供可用时,虽然不好理解,推荐大家可以毫不留情的测试使用,这也是学习的一个过程。
这一章主要教大家如何从无到有输入一幅rgb图像。图像采用ppm格式(wiki百科上有具体介绍,很详细,推荐看一下) 存储,构造过程和输出过程都十分简单。
比较有用的一点是,这里推荐了另外一种输出图像的方式(任意格式的图像),那就是使用stb_image_write第三方库,方法十分简单,所用代码如下:
#include "vec3.h" // 双引号应用的是程序目录的相对路径中的头文件
#include // 尖括号引用的是编译器类库路径中的头文件
#define STB_IMAGE_WRITE_IMPLEMENTATION // 使第三方库stb_image_write成为可执行的源码
#include "stb_image_write.h"
using namespace std;
int main() {
int nx = 200;
int ny = 100;
int channels = 3; // 代表rgb三通道,若为4则代表rgba四通道
unsigned char *data = new unsigned char[nx*ny*channels]; // 声明数组,用于存放像素rgb值
for(int j = ny -1; j >= 0; j--){
for(int i = 0; i < nx; i++){
vec3 col(float(i) / float(nx), float(j) / float(ny), 0.2);
int ir = int(255.99*col[0]);
int ig = int(255.99*col[1]);
int ib = int(255.99*col[2]);
data[(ny - j - 1)*nx*3 + 3 * i] = ir; // 计算出二维图像中的像素在一维数组中的对应位置,从第一行第一列开始
data[(ny - j - 1)*nx*3 + 3 * i + 1] = ig;
data[(ny - j - 1)*nx*3 + 3 * i + 2] = ib;
}
}
stbi_write_png("PNGOutput2.png", nx, ny, channels, data, 0); // 输出图像
return 0;
}
这部分创建了一个vec3向量类,用于创建、表示以及操作三维向量。
这部分通过描述了射线的定义并创建了射线ray类,定义一个摄像机,以及创建一个颜色函数color来初步构建了一个光线追踪器,具有发射光线,计算光线与像素碰撞点的颜色的功能(demo中显示出了背景的颜色)。个人认为需要注意的有以下几点:
// 返回插值后的颜色,从浅蓝色到白色之间进行插值;从上到下渐变的颜色同时对应了y从大到小(for循环规定的)的位置特性
return (1.0 - t)*vec3(1.0, 1.0, 1.0) + t*vec3(0.5, 0.7, 1.0);
// 特别注意,这个t和第五章中的t不是一回事;这里的t只代表插值参数,第五章中的t代表射线函数的中的一个变量(注意一下即可)
这一部分主要是在场景中定义了一个球体,然后判断射线与球体是否相交,如果相交了,将球体对应的颜色设置为红色。你可能对以下几个问题感到疑惑:
这一部分分两个片段分别介绍了一些内容。
第一个片段介绍了球面法线的定义,以及如何可视化法线(通过颜色标识)。
第二个片段实现了在场景中通过链表的形式创建多个物体,用到了c++面向对象的知识。
当前章节中还用到了多态的思想。指的是,声明hittable类型的链表,然后为该链表赋值为sphere类型的内容。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作(摘抄自百度百科)。
关于最后的结果图:在最后的结果图中,你可能会问,**为啥大球是一片均匀的绿色的呢?**我的理解是大球太大了,导致我们看到的球面法线变化值不大,所以映射到颜色后,显示一片均匀的绿色。至于为啥是绿色,那是因为x/y/z对应的r/g/b值中,y分量比较大,所以g值大,显示绿色(个人理解)。
这部分讲的是如何实现Antialiasing抗锯齿,也叫反走样。
Diffuse Materials 漫反射材质 让模型的视觉效果更逼真。
这部分开头作者做了一下声明,那就是作者将几何形状和材质分开了,而非一一对应,这会导致一些局限性。此外,漫反射材质本身不发光,只会反射周围的环境光,同时会将环境光混合调制成本身的色彩。光线在漫反射表面上的反射方向是随机的,如下图:
当然有些光线也可能会被吸收,表面越黑,吸收光线的能力越强。这也是黑色物体为啥黑的原因——因为它吸收光线,极少反射光线。
废话不多说,我们看看代码部分有哪些需要注意的。
代码中的MAXFLOAT表示单浮点数允许的最大值,这里博主没有找到它的头文件,不过博主在float.h中找到了FLT_MAX,可以用做替代。
Metal 金属,这里指金属镜面反射材质。
在本章开始,作者表明打算建立一个抽象类material用作所有材质类的基类,它含有一个虚函数scatter,意思是散射。
可能有同学会产生一个问题,为啥在hittable.h中添加了这么一句:“class material;”,很明显是为了创建一个material类型的指针,由于没有涉及到构造函数的调用,所以允许这样做!其实博主还想问,为啥不先创建好了material类,然后直接引入该类的头文件呢?可能是为了在后面的编程过程中避免重复引用头文件吧。
在这部分创建了sphere类(继承自hittable基类),并实现了hit函数的定义,用于判断射线与球面是否相交,以及计算交点对应的t值(射线上的t)、计算交点坐标、计算交点处的法线、获取material指针,并将上述信息存储到hit_record结构体中,用于后续的射线散射计算。
在这部分创建了material的子类lambertian(用于表现漫反射),实现了scatter散射函数的定义,主要用于在相交后计算漫反射材质对应的随机反射射线。
在这部分同样创建了material的子类metal(用于表现金属镜面反射),同样实现了scatter散射函数的定义,不过这里的反射射线不再像漫反射那样具有随机方向了,而是有一定的数学规律,只要掌握向量点乘(dot)的基本知识和简单的三角函数计算就能推导出来(参见对应章节的教材讲解),唯一可能需要注意的是教材中描述的N(即法向量,法线),是一个单位向量,根据向量点乘公式计算时,N的模为1,所以计算被简化了(你应该明白我在说什么)。
下面的总结可能会比较复杂,建议先看电子版课本教材,同时对派生有一定理解,再来看下面这段总结:
原文中有一句英文可能不太好理解,但是翻译过来的意思是:当射线与球体表面相交时,hit_record结构体中的material指针就会被赋值从而指向一个具体的material指针,这个material指针纸箱相交球体的material属性,我们在mian函数中创建这个球体的时候就设置好了它的material属性。英语原文如下:
When a ray hits a surface (a particular sphere for example), the material pointer in the hit_record will be set to point at the material pointer the sphere was given when it was set up in main() when we start.
其实上面做的好处在于,我们想要通过一个物体链表来控制所有的场景模型物体,这个列表是hittable类型的,实际存放的其实是hittable的子类对象,这里用到了派生的思想(前面也说过)。这样的话可以使用for循环,对链表里的物体进行遍历,并执行统一的操作,如调用子类的成员函数hit判断是否相交,利用自身结构体成员hit_record中的material类型指针来调用对应材质的scatter散射函数,以此判断反射射线的具体情况(反射or折射,具体怎么反射or折射等等)。听起来可能比较复杂,但是如果你对面向对象的派生比较熟悉,相信你理解起来也不会那么复杂。
最后一部分内容与第八章模拟随机反射中的随机点相似,可以搭配着进行理解。
这部分引入了电介质材质。介绍几个术语:
dielectric 电介质
reflect 反射
refract 折射
Snell’s law 斯内尔定律,也就是折射定律
refractive index 折射率
啥是电介质材质?水、玻璃、钻石都是电介质(教材原话,老实说水是电介质吗,电介质不是不导电的吗Peter Shirley这里是不是应该改成空气、玻璃、钻石???还有另外一种理解,即这里的介质是指光线能够透过的介质,这样水、玻璃、钻石这些物体属于“电介质”就比较好理解了)。
电介质材质有啥特点?当一条光线穿过电介质材质时,会产生两条光线,一条是反射光线,一条是折射光线。在这一章我们假定一条光线将会随机的生成一条反射光线或者折射光线,即反射光线和折射光线不会由同一条射线产生(仅仅是在这里假定的)。相比而言折射光线的计算比较困难。
这部分还需要注意一个特殊情况,全反射。全反射是指,当入射角大于某一个固定值时,入射光线全部产生了反射光线,不再产生折射光线,有关定理和证明可以参见百度百科。
这一部分的难点主要在于折射光线的推导(也就是折射函数refract是怎么定义的)上,下面我们根据折射函数refract的具体定义进行分析:
bool refract(const vec3& v, const vec3& n, float ni_over_nt, vec3& refracted) const {
vec3 uv = unit_vector(v); // 入射光线的单位方向
float dt = dot(uv, n); // 入射光线与法线夹角的cos值
float discriminant = 1.0 - ni_over_nt*ni_over_nt*(1-dt*dt); // 折射光线的判别式(判断其是否存在),如何推导过来的?
if (discriminant > 0) { // 存在折射光线
refracted = ni_over_nt*(uv - n*dt) - n*sqrt(discriminant); // 计算折射光线的方向
return true;
} else // 不存在折射光线
return false;
}
这部分的另外一个难点在于光线传播方向的判断,即光线是从空气射向物体,还是从物体内部射向空气,对应的代码及辅助理解的公式如下:
if (dot(r_in.direction(), rec.normal) > 0) { // 判断入射光线和法线之间的夹角角度,> 0 表示成锐角,从而推断出是从空气进入物体,还是从物体进入空气
outward_normal = -rec.normal; // 光线从物体准备射向空气
ni_over_nt = ref_idx; // 物体与空气的折射率之比
cosine = ref_idx * dot(r_in.direction(), rec.normal) / r_in.direction().length(); // 计算夹角的cos值
} else {
outward_normal = rec.normal; // 光线从空气准备射向物体
ni_over_nt = 1.0 / ref_idx; // 空气与物体的折射率之比
cosine = -dot(r_in.direction(), rec.normal) / r_in.direction().length(); // 计算夹角的cos值
}
本章最后几个问题:如何实现菲涅尔现象,schlick函数和反射系数在编码过程中的具体作用是啥?
另外一个问题,demo代码中cosine值需不需要乘上ref_idx?ref_idx是两种介质折射系数的比值,对于空气和水,这个比值为1.4,相对来说乘与不乘对整个函数的影响不是很大。总之这个问题目前博主还不太清楚,有待第二次阅读时进一步解决。
Positionable Camera可以定位的摄像机。
在这部分作者对摄像机进行了定义,其中vup、u、v、w大家应该很容易理解对应的含义。
只是,vfov和aspect需要解释一下。
Defocus Blur 焦散模糊,与摄影领域的景深有关,景深是指用摄像机进行摄影,对焦过后,焦点所在物体前后清晰的范围。通常来说,使用大光圈可以获得较小的景深,使用小光圈可以获得大景深。当物体位于景深范围外时,就会拍摄出模糊的照片,也就是焦散模糊,字面上看其实就是失焦导致的模糊(摄影也是博主的一门专业课)。
这部分留下了一个项目案例,那就是生成图书的封面图。在这里博主提高了画面的分辨率(1920*1080)和采样数(400),场景模型生成函数如下:
hittable *random_scene(){
int n = 500;
hittable **list = new hittable*[n+1];
list[0] = new sphere(vec3(0, -1000, 0), 1000, new lambertian(vec3(0.5, 0.5, 0.5)));
int i = 1;
for (int a = -11; a < 11; a++) {
for (int b = -11; b < 11; b++) {
float choose_mat = random_double();
vec3 center(a+0.9*random_double(), 0.2, b+0.9*random_double());
if((center-vec3(4, 0.2, 0)).length() > 0.9) {
if (choose_mat < 0.8) { // 漫反射
list[i++] = new sphere(center, 0.2,
new lambertian(vec3(random_double()*random_double(),
random_double()*random_double(),
random_double()*random_double())));
} else if (choose_mat < 0.95) { // metal
list[i++] = new sphere(center, 0.2,
new metal(vec3(0.5*(1 + random_double()),
0.5*(1 + random_double()),
0.5*(1 + random_double())),0.5*random_double()));
} else { // 玻璃
list[i++] = new sphere(center, 0.2, new dielectric(1.5));
}
}
}
}
list[i++] = new sphere(vec3(0, 1, 0), 1.0, new dielectric(1.5));
list[i++] = new sphere(vec3(-4, 1, 0), 1.0, new lambertian(vec3(0.4, 0.2, 0.1)));
list[i++] = new sphere(vec3(4, 1, 0), 1.0, new metal(vec3(0.7, 0.6, 0.5), 0.2)); // f表示模糊系数,取值[0, 1]
return new hittable_list(list, i);
}
摄像机位置和朝向点参数设置如下:
vec3 lookfrom(13, 2, 3); // 摄像机位置
vec3 lookat(0, 0, 0); // 朝向点
有几点可能对你的项目产生帮助:
std::cout << "Process Bar:" << (ny - j) / float(ny) * 100.0f << "% "; // Process Bar 进度条
至此,博主将《Raytracing In One Weekend》学习过程中遇到的问题和经验总结了一下,部分内容带有强烈的主观判断,希望各位观众老哥大胆批评指正!~ 谢谢!~