0%

以前做的项目,复习一下以前写的框架,注:只针对代码讲解。

数据管理者

因为在游戏中我希望能将游戏的数据储存,或者在外部配置一些数据应用到游戏中,所以用到了json的数据存贮,这里我们直接基于Litjson开源项目来完成这个管理者类。其功能主要便是把数据序列化后储存到本地和将本地数据反序列化应用于游戏中。

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
using LitJson;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;

// 序列化和反序列化Json时 使用的是哪种方案
public enum JsonType
{
JsonUtlity,
LitJson,
}

// Json数据管理类 主要用于进行Json的序列化存储到硬盘和反序列化从硬盘中读取到内存中
public class JsonMgr
{
//单例模式,简单说就是将此脚本的代码暴露出去,供其他所有脚本使用,通常用在管理者脚本上
private static JsonMgr instance = new JsonMgr();
public static JsonMgr Instance => instance;

private JsonMgr() { }

//存储Json数据 序列化
public void SaveData(object data, string fileName, JsonType type = JsonType.LitJson)
{
//确定存储路径
string path = Application.persistentDataPath + "/" + fileName + ".json";
//序列化 得到Json字符串
string jsonStr = "";
switch (type)
{
case JsonType.JsonUtlity:
jsonStr = JsonUtility.ToJson(data);
break;
case JsonType.LitJson:
jsonStr = JsonMapper.ToJson(data);
break;
}
//把序列化的Json字符串 存储到指定路径的文件中
File.WriteAllText(path, jsonStr);
}

//读取指定文件中的 Json数据 反序列化
public T LoadData<T>(string fileName, JsonType type = JsonType.LitJson) where T : new()
{
//确定从哪个路径读取
//首先先判断 默认数据文件夹中是否有我们想要的数据 如果有 就从中获取
string path = Application.streamingAssetsPath + "/" + fileName + ".json";
//先判断 是否存在这个文件
//如果不存在默认文件 就从 读写文件夹中去寻找
if(!File.Exists(path))
path = Application.persistentDataPath + "/" + fileName + ".json";
//如果读写文件夹中都还没有 那就返回一个默认对象
if (!File.Exists(path))
return new T();

//进行反序列化
string jsonStr = File.ReadAllText(path);
//数据对象
T data = default(T);
switch (type)
{
case JsonType.JsonUtlity:
data = JsonUtility.FromJson<T>(jsonStr);
break;
case JsonType.LitJson:
data = JsonMapper.ToObject<T>(jsonStr);
break;
}

//把对象返回出去
return data;
}
}

UI管理者类

我们需要一个管理者类来控制和管理我们所有面板的显示以及隐藏,因为我们需要管理不同的面板,所以需要使用模板,此时我们制作面板基类的好处就是,可以通过它来统一管理所有面板,也符合面向对象设计原则。来看代码:

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UIManager
{
//单例模式
private static UIManager instance = new UIManager();

public static UIManager Instance { get => instance; set => instance = value; }

//用与显示的面板,每显示一个面板就将其加入字典中,以便于调用
private Dictionary<string, BasePanel> panelDic = new Dictionary<string, BasePanel>();

//得到自身的Transform,
private Transform canvasTrans;

//初始化
private UIManager()
{
//创建canvas空白画布
GameObject canvas = GameObject.Instantiate(Resources.Load<GameObject>("UI/Canvas"));
//把画布位置保存下来
canvasTrans = canvas.transform;

//防止场景加载的时候被销毁
GameObject.DontDestroyOnLoad(canvas);
}

//传入想调用的面板,来将它显示
public T showPanel<T>(bool isFade = true) where T:BasePanel
{
//type(T)获取该对象,并取得其名字
string panelName = typeof(T).Name;

//查看字典中有无该面板,如果有表明已经显示,直接return该面板,注意此处要将BasePanel重新转换为子类类型
if (panelDic.ContainsKey(panelName))
{
return panelDic[panelName] as T;
}

//显示面板 根据面板名字 动态创建预制体 设置父对象
GameObject panelObj = GameObject.Instantiate(Resources.Load<GameObject>("UI/" + panelName));
panelObj.transform.SetParent(canvasTrans, false);

//指向面板上 显示逻辑 并且把它保存起来
T panel = panelObj.GetComponent<T>();

//把这个面板脚本 存储在字典中方便之后的获取和隐藏;
panelDic.Add(panelName, panel);

panel.ShowMe(isFade);

return panel;
}

//隐藏面板
public void HidePanel<T>(bool isFade = true) where T:BasePanel
{
//根据泛型取得名字
string panelName = typeof(T).Name;

//如果存在,调用其基类的HideMe函数,并传入lambda让其释放内存并在字典中删除
if (panelDic.ContainsKey(panelName))
{
if (isFade)
{
panelDic[panelName].HideMe(() =>
{
//删除对象
GameObject.Destroy(panelDic[panelName].gameObject);
//删除字典里面储存 面板脚本
panelDic.Remove(panelName);
});

}
else
{
//删除对象
GameObject.Destroy(panelDic[panelName].gameObject);
//删除字典里面储存 面板脚本
panelDic.Remove(panelName);
}
}
}

//得到面板
public T GetPanel<T>() where T:BasePanel
{
string panelName = typeof(T).Name;
if (panelDic.ContainsKey(panelName))
{
return panelDic[panelName] as T;
}
else
{
return null;
}
}
}

有了这个脚本,我们就可以非常方便的调出和删除面板,所有的UI控制我们都可以使用此脚本来实现。

传统相机的局限

现实中的相机可无法把它获取的所有光线都汇聚到一点,通过逆光路模型来解释,就是它无法精准的控制发射光线的起点都重合在一起。

img

如上图所示,inside表示相机内部,outside是相机的外部。

相机通过镜头采集光线,镜头采集到的光线会被汇总,在镜头后的胶片上成像。在镜头之外,根据镜头凹透镜的焦距,在某个距离处采集到的光线都来自于一点,这个平面上的所有物体都处于完美对焦状态,而其他距离下的物体,都会因为光线无法精准落于一点而出现模糊。离完美对焦距离越远,就越模糊。

模拟散焦模糊

在《抗锯齿》那一章我们编写的多采样求平均的算法就可以完美模拟从镜头采集到成像的过程,现在,只需要改变我们光线的发射位置,假装光线是从镜头上任意一点发射的即可。

img

具体要怎么做呢?

1.要如何模拟镜头呢?

我们可以以原来的固定光线发射点为圆心,找一个圆片,这个圆片和虚拟视口所在平面平行,和相机正对方向垂直。然后在这个圆片上随机找一个点,以这个点为起点发射光线(这个圆片就是虚拟相机的镜头)。这个圆片的大小对应着真实相机中的镜头光圈大小,圆片越大,散焦模糊效果就越大。

2.要如何模拟光线聚焦效果呢?

光线是射向虚拟视口上的虚拟像素内的。在我们原来的设计里,虚拟视口和相机位置的距离始终为1,这意味着,我们的光线都会在镜头前方距离为1的平面上聚焦,如果我们需要让他能在任意位置聚焦,我们只需要改变虚拟视口距离相机的距离即可。

还记得是如何在代码中规定虚拟视口的位置的吗?

1
lower_left_corner = origin - horizontal/2 - vertical/2 - w;

如果我想把它推到和相机位置距离为2的平面上,仅仅是修改w的系数就可以了吗?比如:

1
lower_left_corner = origin - horizontal/2 - vertical/2 - 2*w;

这样就足够了吗?不行,看下图:

img

图上描绘的是相机空间下VoW面上的截面,假设中间的绿色竖线就是原先的虚拟视口,它离相机的距离为1,现在要把它移动到右边的蓝色平面上,根据相似三角形,如果它离相机的距离改成2,那它的高度会同步扩大为2,且因为像素数量没变,每个虚拟像素的大小也会增加为2。这意味着horizontal和vertical也得同步进行放大。

图中射向虚拟视口的光线假设命中了第x行,如果像素的大小扩大两倍,这根光线的延长线也会命中蓝色视口的第x行。如果像素没有放大,它将命中第2x行,这就不对了。

更改相机代码,现在我们可以传入焦距和光圈大小:

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
class camera {
public:
camera(
point3 lookfrom,
point3 lookat,
vec3 vup,
double vfov,
double aspect_ratio,
//光圈直径
double aperture,
//焦距
double focus_dist
) {
auto theta = degrees_to_radians(vfov);
auto h = tan(theta/2);
auto viewport_height = 2.0 * h;
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的距离的平面上。
//原本的距离是1,距离变成原来的focus_dist倍。
//所以,horizontal和vertical得同步乘以focus_dist倍。
horizontal = focus_dist * viewport_width * u;
vertical = focus_dist * viewport_height * v;

//距离不再是w,而是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 {
// 生成一个偏移值。
// 这个random_in_unit_disk()函数会产生一个XoY平面上的以原点为圆心的单位圆片内随机一点。
// 该函数之后给出。
vec3 rd = lens_radius * random_in_unit_disk();
// 把圆片从XoY平面,调整到uv平面上。
vec3 offset = u * rd.x() + v * rd.y();

return ray(
// 顶点加上偏移。
origin + offset,
// -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;
};

把random_in_unit_disk函数写在vec3.h类的类外,这个函数负责生成一个XoY面上以原点为圆心的圆片内的随机一点:

1
2
3
4
5
6
7
8
//XoY面上以原点为圆心的圆片内的随机一点。
vec3 random_in_unit_disk() {
while (true) {
auto p = vec3(random_double(-1,1), random_double(-1,1), 0);
if (p.length_squared() >= 1) continue;
return p;
}
}

还是使用上一章的场景,但要调整相机,给一个很大的光圈直径,并且把对焦平面(虚拟视口)放在画面正中心的小球上:

1
2
3
4
5
6
7
point3 lookfrom(3,3,2);
point3 lookat(0,0,-1);
vec3 vup(0,1,0);
auto dist_to_focus = (lookfrom-lookat).length();
auto aperture = 2.0;

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

​ 会得到这张图,这里的光圈很大,只要物体离虚拟视口有一点距离,就看不清了。

img

最终成果

现在来汇总在第一章中学到的所有知识,绘制一张图片,这将会是集大成之作,写入代码:

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
// 这个函数帮助生成一个丰富多彩的场景!!!
hittable_list random_scene() {
hittable_list world;

auto ground_material = make_shared<lambertian>(color(0.5, 0.5, 0.5));
//先来一个“地板”,它比我们之前创建过最大的球还要大十倍!!!
world.add(make_shared<sphere>(point3(0,-1000,0), 1000, ground_material));

//在这个循环里我们将生成数个小球!!!
for (int a = -11; a < 11; a++) {
for (int b = -11; b < 11; b++) {

//先进行一次随机采样,这次采样用来决定本轮循环生成的小球的材质!!!
auto choose_mat = random_double();
//在一个y=0.2这个平面上的一块方形区域里随机找一个点,作为这颗小球的球心!!!
point3 center(a + 0.9*random_double(), 0.2, b + 0.9*random_double());

//我们腾出一个位置,让生成的这些小球离(4, 0.2, 0)这个点远一点!!!
//因为这里我们要放置一个非常明显的大球,如果出现球与球重合就会影响美观了!!!
if ((center - point3(4, 0.2, 0)).length() > 0.9) {

//定义一个智能指针,但先别急着决定指向什么类型的材质对象!!!
shared_ptr<material> sphere_material;


if (choose_mat < 0.8) {//我们的材质有百分之八十的概率会是磨砂材质。
auto albedo = color::random() * color::random();
sphere_material = make_shared<lambertian>(albedo);
world.add(make_shared<sphere>(center, 0.2, sphere_material));
}
else if (choose_mat < 0.95) {//我们的材质有百分之十五的概率会是金属材质。
auto albedo = color::random(0.5, 1);
auto fuzz = random_double(0, 0.5);
sphere_material = make_shared<metal>(albedo, fuzz);
world.add(make_shared<sphere>(center, 0.2, sphere_material));
} else {//我们的材质有百分之五的概率会是玻璃材质。
sphere_material = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(center, 0.2, sphere_material));
}
}
}
}

//三颗站在C位的大球!!!
auto material1 = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(point3(0, 1, 0), 1.0, material1));

auto material2 = make_shared<lambertian>(color(0.4, 0.2, 0.1));
world.add(make_shared<sphere>(point3(-4, 1, 0), 1.0, material2));

auto material3 = make_shared<metal>(color(0.7, 0.6, 0.5), 0.0);
world.add(make_shared<sphere>(point3(4, 1, 0), 1.0, material3));

return world;
}

int main() {

//调整图片参数
const auto aspect_ratio = 3.0 / 2.0;
//分辨率调高点,玩票大的!!!
const int image_width = 1200;
const int image_height = static_cast<int>(image_width / aspect_ratio);
//拉高采样率会增加最终艺术品的成色!!!
const int samples_per_pixel = 500;
const int max_depth = 50;

//调用函数生成一个有着许多随机小球的场景!!!
auto world = random_scene();

// 放置相机
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);

// renderloop

std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";

for (int j = image_height-1; j >= 0; --j) {
...
}

image.png

可以给你的随机数生成器其他的种子,来改变小球的随机位置,直到你得到满意的结果。

到此为止,这本书的内容就结束喽!但这个渲染器并没有完成,接下来的内容在《Ray Tracing: The Next Week》中。

拓展

  1. 非常明显,高分辨率多个物体的图片是非常耗费时间的,想想如何充分利用CPU来给我们的程序提提速。

    答案见下章。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第12节。

球为什么会被拉长

在以前的图片中可以发现越靠近边缘位置越会被拉伸的厉害,现在我来尝试解释一下这个问题:

image-20220313131416140

上图是y = 0的情况下,场景的 XoZ面截面。O为相机位置,也是发射光线的原点,横线为Z = -1 位置上的虚拟视口,在空间中有三个大小一样的球,三个球的球心都在y = 0平面上,中间的那颗球处在虚拟视口的正中心,即它的位置是(0,0,-1)。现在开始往虚拟视口上发射光线,来看看每颗球x轴上的宽度是多大。

可以清楚的看到,A点虽然在虚拟视口上来看,和球还是有点距离,但是往A点发射光线来确定这一点的颜色的时候,发现它正好处于球的边界上,即在最终渲染的2D图片上,AB才是这颗球与x轴平行的直径,它显然比2D图片上处于中间的球的直径BD要长的多。

那如何对抗图片边缘变形问题呢?有两个解决方法:让虚拟视口变得非常小,或者让虚拟视口离相机位置足够远。

image-20220313131438632如图所示,把虚拟视口缩小,这样图中的三个球在最终显示的图片上来看就不会有非常明显的长度不一的问题了。因为视口变小,视口中的像素数量不变,所以每个像素在三维空间中的大小也变小了,所以图片的最终质量并不会有任何改变。

可调节视野

虚拟视口的长宽比不应该被限制,同时虚拟视口的高度也不应该恒定为2,假设有一个物体在我们的面前但是高度很高,比如它在(0,10,-1)的位置,它不会被相机捕捉到,我们得赋予它自由度,让它想看多高看多高。

对于方形的照片,在真实的摄影技术中,有一个数据叫视场(field of view),简写为fov,一般使用的是竖直fov,它表示相机处到视野最高处和视野最低处连线的夹角,如果是水平fov的话就是相机处和视野中最左最右处连线的夹角。如果知道长宽比的话,竖直和水平的fov就可以进行简单换算。

image-20220313132406568

如图所示,θ就是fov,我们就使用指定fov的方式来让视口高度变得可自定义。

还有一点纯属个人设计风格:传入角度值,再由类内转为弧度制进行后续计算。修改camera.h中的camera类代码如下所示:

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 camera {
public:
camera(double vfov /*竖直fov(vertical field of view)*/
,double aspect_ratio /*长宽比,把它暴露出去使之可自定义*/ ) {

//角度转弧度制,我们好久之前写的工具函数。
auto theta = degrees_to_radians(vfov);

//虚拟视口还是放在z = -1的地方。
auto focal_length = 1.0;

//通过换算得到上图中的h。
auto h = focal_length * tan(theta/2);

//这样虚拟视口就一步一步由fov换算得来了。
auto viewport_height = 2.0 * h;

auto viewport_width = aspect_ratio * viewport_height;
origin = point3(0, 0, 0);
horizontal = vec3(viewport_width, 0.0, 0.0);
vertical = vec3(0.0, viewport_height, 0.0);
lower_left_corner = origin - horizontal/2 - vertical/2 - vec3(0, 0, focal_length);
}

ray get_ray(double u, double v) const {
return ray(origin, lower_left_corner + u*horizontal + v*vertical - origin);
}

private:
point3 origin;
point3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
};

现在来测试一下,在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
int main() {
...

// 指定视场大小
double vfov = 10.0;

// 更改物体信息。
hittable_list world;

auto material_up = make_shared<lambertian>(color(1, 0, 0));
auto material_bottom = make_shared<lambertian>(color(0, 0, 1));

// 我们想创造两个球心在边界上的球。
auto R = tan(degrees_to_radians(vfov) / 2);
world.add(make_shared<sphere>(point3(0, R, -1), R, material_up));
world.add(make_shared<sphere>(point3(0, -R, -1), R, material_bottom));

// 定义一个vfov是10度的相机,并且传入我们之前定义在main中长宽比。
camera cam(vfov, aspect_ratio);

// 渲染循环
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for (int j = image_height-1; j >= 0; --j) {
...

会得到两个图片边缘的球,它们没有再被明显的拉长了,因为fov = 10° 的情况下,虚拟视口的大小比原来小得多(之前我们的高度h恒定为2的情况,fov按照反向换算应该为90°)。

image-20220313132539787

完全自由的相机

在视野大小和长宽比可以调整之后,相机身上的五个枷锁中的后两个已经解开,现在还有三个固定死的桎梏。接下来只要让相机的位置和朝向可以自由选定,就可以让相机彻底解放。

另外,虚拟视口大小可以通过fov自由控制之后,我们就没有必要再改变虚拟视口离相机的远近了,这不会给场景带来更多新的变化。可以缩小虚拟视口,实际上和把虚拟视口拉远得到的效果是完全一样的,所以固定死这个距离,假设这个距离恒定为1。

我们需要自由指定相机的位置,也就是说我们要暴露出一个point3(vec3的别名)变量来表示位置,这很容易想到。

那要如何指定相机看向的方向呢?有两个策略,给一个方向向量或者给定一个点表示看向的目标点,这样就可以通过和相机位置的连线得到同样方向向量,这里选择后者。

image-20220313132829324

相机放在lookfrom点上,看的目标点是lookat。

fov也已经有了,现在能开始构建相机了吗?不能,还有一个东西我们没法确定。想想看,你站在某个地方,望着桌子上的苹果,苹果一定会处在你视角的正中心,但是,我无法确定你有没有以你的鼻子为中心左右旋转你的头——在lookfrom所在的面和lookat-lookfrom向量垂直的向量有无数多条,我们不知道,相机的“头顶”是哪个方向。

这个问题,业界比较青睐的解决方案是确定一个vup(view up)方向,一般来说,只要不歪头,这个vup的值都是(0,1,0),即y轴正方向。但是搞不好就有一些特殊的要求,比如说,从墙角伸出一杆狙击枪,我们需要歪头去看瞄准镜,这时候,瞄准镜内的世界就是歪斜的,vup就会指向斜上方。

image-20220313133058687

看上左图,lookfrom、lookat和vup向量唯一确定了一个相机。

左图中的uv两个坐标轴,也就是我们的老相机里面的horizontal和vertical向量。

老相机是朝-z方向看的,而新的相机是朝-w(上右图中的向量)方向看的。右图中w,v和vup三个向量在同一个面上。最终相机代码如下:

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
class camera {
public:
camera(
// 相机位置。
point3 lookfrom,
// 相机看向的目标点。
point3 lookat,
// 相机正上方方向向量(通常为(0,1,0))。
vec3 vup,
// 视场大小。
double vfov,
// 长宽比。
double aspect_ratio
) {
auto theta = degrees_to_radians(vfov);
auto h = tan(theta/2);
auto viewport_height = 2.0 * h;
auto viewport_width = aspect_ratio * viewport_height;

// w向量是从lookat指向lookfrom的向量。
auto w = unit_vector(lookfrom - lookat);

// u向量与vup和w都垂直,我们可以直接叉乘得到它。
// 叉乘注意两个变量的前后顺序,注意叉乘结果向量的方向满足右手定则。
auto u = unit_vector(cross(vup, w));
// v向量与w及u向量都垂直,叉乘得到。
auto v = cross(w, u);

// 赋值原点。
origin = lookfrom;
// horizontal方向不再是(1,0,0),而是u。
horizontal = viewport_width * u;
// vertical方向不再是(0,1,0),而是v。
vertical = viewport_height * v;

// 虚拟视口左下角的坐标位置,原本减去vec3(0, 0, focal_length)的位置改成了w。
lower_left_corner = origin - horizontal/2 - vertical/2 - w;
}

ray get_ray(double s, double t) const {
return ray(origin, lower_left_corner + s*horizontal + t*vertical - origin);
}

private:
point3 origin;
point3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
};

虚拟视口离相机的距离由w决定,它是个单位向量,永远是1。我们不需要动他,改变fov能达到相同的效果。

在main函数中改变场景物体和相机参数,来测试一下这个自由的相机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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<dielectric>(1.5);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2), 0.0);

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.001, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(-1.001, 0.0, -1.0), -0.45, material_left));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));

camera cam(point3(-2,2,1), point3(0,0,-1), vec3(0,1,0), 90, aspect_ratio);

相机原点放在(-2,2,1)上,这离场景中的这些球很远,所以我们会得到:

image.png

fov为90的情况下,虚拟视口的高度为2,球处在我们图片的正中心,但很小.

如果想得到近景,只需要把fov改小,比如改成20,就可以得到清晰的细节,就好像使用了望远镜一样。

1
camera cam(point3(-2,2,1), point3(0,0,-1), vec3(0,1,0), 20, aspect_ratio);

image.png

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第11节。

斯涅尔定律

折射光线会遵循以下的法则进行折射,这个定律叫折射定律,或者是斯涅尔定律(Snell’s law):

image-20220311084827719

公式中,θθ′ 是入射光线和折射光线和法线的夹角,η 和 η′ 则是折射率,它表示入射光线所在的介质和折射光线所在的介质的一种性质,是光在真空中的传播速度与光在该介质中的传播速度之比(空气的折射率为1.00029,近似为1,玻璃是1.3-1.7,钻石则是2.4),如下图所示:

image-20220311084845727

折射方向向量

已知入射向量为R,如何求折射后向量R’呢?直接求是非常困难的,我们对它做一个分解,把目标向量分解到法线和与法线垂直方向这两个互相垂直的方向上,有

img

先求R’,它是R’中与法线方向垂直的分量。我们清楚的知道它的模是sinθ′​ ,但我们得找到一个和法线方向垂直的向量x(上图中指向正右方)作为它的向量表示,即最终的结果就是sinθ′ * unit_vector(x)。

我们可以从入射角那里找到一个这样的向量 x=R+cosθn,它是一个和同$R’⊥​向,并且模为sinθ的向量,我们要的向量的模是sinθ′ ,不过没关系,再利用斯涅尔定律转换一下就可以了,解析式如下:

img

到这一步还不够,我们已知的东西是入射向量和法线向量,θ不应该出现在最终的解析式里。因为R和法线向量n都是单位向量,我们可以用一个点乘去消灭这个碍事的cosθ,即:

img

知道R’⊥之后,R’∥可以通过一个简单的公式推算得到。首先,是和R’∥和n同向的,它的长度是cosθ′ ,通过三角函数中最基础的公式:

img

我们就可以得到如下最终解析式:

img

一切都准备妥当了,在vec3.h中创建一个类外函数,来描述折射这一过程:

1
2
3
4
5
6
7
8
9
//折射函数,吃单位化的入射和法线向量以及两种介质折射率的比值,吐折射方向向量。
vec3 refract(const vec3& uv/*入射向量*/, const vec3& n/*法线*/, double etai_over_etat/*η和η′的比值*/) {
auto cos_theta = dot(-uv, n);
//垂直于法线的分量
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;
}

总是折射的玻璃

有了折射函数,就先写一个总是折射的透明材质,看代码:

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
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);

//曾经记录过的看碰撞面是否是物体外面的bool变量>
//我们使用这个变量来控制物体射入和射出此材质时候η和η′分子分母调换。
double refraction_ratio = rec.front_face ? (1.0/ir) : ir;

//单位化入射方向。
vec3 unit_direction = unit_vector(r_in.direction());
//调用折射函数。
vec3 refracted = refract(unit_direction, rec.normal, refraction_ratio);
//制造光线。
scattered = ray(rec.p, refracted);
return true;
}

public:
double ir; // 这种透明材质的折射率。
};

main函数中看一下什么样吧

1
2
3
4
5
6
7
8
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_left = make_shared <metal> (color(0.8, 0.8, 0.8),0.3);
//玻璃的折射率在1.3-1.7之间。
auto material_right = make_shared<dielectric>(1.5);

world.add(make_shared<sphere>(point3(0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3(-0.5, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), 0.5, material_right));

image-20220311092348533

全内反射

上面的结果是不正确的,如果认真观察斯涅尔定律你会发现,这个公式是有失效的时候的。

光线从较高折射率的介质进入到较低折射率的介质时,如果入射角大于某一临界角θc(光线远离法线)时,折射光线将会消失,所有的入射光线将被反射而不进入低折射率的介质,这种现象叫做全内反射,或称全反射。

拿玻璃举例子,对于光线从玻璃中射入空气的情况,带入

img

得到:

img

对于如上的公式,当sinθ大于某特定角度时,sinθ′会大于1,但是sin函数是永远不会大于1的,也就是说,对于这样的角度来说,上述公式会失效。

也就是说,代码得甄别这样的情况,在这种情况下,我们得让光线反射而不是折射。也就是说会有如下的结构:

1
2
3
4
5
6
7
8
9
10
double cos_theta = dot(-unit_direction, rec.normal);
double sin_theta = sqrt(1.0 - cos_theta*cos_theta);

if (refraction_ratio * sin_theta > 1.0) {
// 折射
...
} else {
// 反射
...
}

那么只能能折射就折射的代码时是:

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
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);

//是否达到全反射的临界值。
bool cannot_refract = refraction_ratio * sin_theta > 1.0;
vec3 direction;
if (cannot_refract)
//全反射
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;
};

施利克近似

一个正确的透明材质会反射部分光线的,入射光线和面的夹角越大,它就越倾向于反射光线。这也就是为什么我们越从掠射角去观察窗户,我们越难看清窗外的景色,而越容易从其上看到自己的脸的原因。

入射光线和法线的夹角和反射率之间的关系是有一个巨大且丑陋的等式所决定的,几乎所有的人都会使用克里斯托弗·施利克(Christophe Schlick)的简单且令人惊讶的精确多项式去近似这个等式。

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
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());

//为了防止因为失误传入了非单位向量导致cosθ大于1,进而导致下面根号内有负值使程序崩溃,我们加一层保险。
double cos_theta = fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = sqrt(1.0 - cos_theta*cos_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;

private:
static double reflectance(double cosine, double ref_idx) {
// 使用施利克近似来计算反射概率。
auto r0 = (1-ref_idx) / (1+ref_idx);
r0 = r0*r0;
return r0 + (1-r0)*pow((1 - cosine),5);
}
};

施利克近似到底为何物,它到底是如何影响光线选择反射或折射的,先写下它的具体公式:

img

最终概率落于0到1之间。

公式中只有两个变量:1.两种物质的折射率之比,即img 2.夹角的余弦值cosθ。

它们是怎么影响结果的呢?首先,显而易见的是,θ越小,cosθ越大,反射的概率就越小。也就是说,反射率随着入射光线和法线的夹角增大而增大,这是符合规律的。

其次, 一个容易发现的规律——我们用img去代替公式中的img,最终结果不会有变化,也就是说,对于特定的两种介质,无论哪个是入射光线所在的介质,反射率都遵循一样的规律

还有一个规律,那就是如果img越靠近1,最终解析式中的第二项就越大。即,对于折射率越接近的两种介质,反射概率受θ的影响就越剧烈,反之,θ对于最终反射概率的影响就越小

最后一个规律,对于折射率差距巨大的两种介质img接近1,第二项接近0,光线极度倾向于反射,罕有折射

在我们的代码中,reflectance(cos_theta, refraction_ratio) 越大,则它越容易大于random_double(),即越容易反射,这是一个经典的利用已知概率和随机数配合进行随机采样的例子。你会得到:

image-20220311092852355

看起来不太像我们见到的玻璃,这是有多种原因决定的:

  1. 场景过于简单,在现实世界中,不可能处于这样的场景中,也就不存在对于这种环境下的玻璃材质的视觉直觉。

  2. 广角相机,与我们看到的不同,它把远离视角中心的一切物体都拉变形了。

空心玻璃

我们还实现一个空心的玻璃,不需要新的材质,空心玻璃的实现远比你想象的要简单。只需在玻璃球的同样位置放一个稍微小一点的,半径为负值的球即可。半径为负值代表着球的外表面的法线指向球心。通过已经实现的功能,做一个简单的改变就实现了一个玻璃球内“空气泡泡”。

1
2
3
4
5
world.add(make_shared<sphere>(point3(0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3(-0.5, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), 0.5, material_right));
// 负半径的玻璃球,作为空心球的内胆。
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), -0.4, material_right));

image-20220311093223889

拓展

1.空心玻璃球得到的太过简单梦幻,推导实现原理。

推导后发现,负半径使得这句代码vec3 outward_normal = (rec.p - center) / radius;得到的法线是向内的,之后经过rec.set_face_normal(r, outward_normal);函数后因为入射光与法线相反,rec结构体认为光线撞到了物体内部,于是把front_face设置为false,虽然依然得到了向外的法线,但是却为之后的材质判断埋下伏笔。

在材质判断中,是由double refraction_ratio = rec.front_face ? (1.0 / ir) : ir;这句代码判断内外的材质,而front_face为负也导致折射率之比为原来的倒数,则恰好构成由玻璃进入空气的情况。

2.注意到空心玻璃球左侧有一个小黑点,是如何来的呢?

我认为这主要是由于光线打到这个点上由于入射角很大而容易发生镜面反射,所以金属球上,而金属也会反射光线并吸收一部分能量,最后在多次弹射中耗尽了能量而呈现为黑色,可以看到在拉远它们之间的距离后就没有黑点了。

image-20220313131152062

  1. 事实上,玻璃也不可能完全透明,也就是说,光线碰撞到玻璃材质物体时,会即反射又折射,想想如何实现该材质。
  2. 了解光栅化中如何实现玻璃材质

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第10节。

https://en.wikipedia.org/wiki/Refraction

维基百科中对于折射相关的光学知识。

镜面反射

一个顺滑的金属的表面并不会随机反射射来的光线,而是会镜面反射光线。在现实中很难看到完美的金属,但金属的物质有一个基本的视觉直觉,那就是镜面反射可以制造物体的金属感。

要如何生成镜面反射的光线呢,在已知入射光线方向和法线方向之后,一个简单的向量运算即可得到反射光线方向。

image-20220311082447549如图是一个平面,光线从上方射到平面上,法线方向朝上,红色的向量即是我们要求的向量。我们可以把入射向量的起点放到碰撞点,这样不难看出向量之间的关系:V+ 2B 即是反射光线方向。B可以看作是V在N上的投影的逆向量,又N在我们的设计中是单位向量,我们可以在vec3.h中加入如下类外函数:

1
2
3
4
vec3 reflect(const vec3& v, const vec3& n) {
// 即 V - 2 *|B|* N
return v - 2*dot(v,n)*n;
}

金属类

在material.h中添加金属类,如下所示:

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

virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
//通过reflect函数生成反射方向,对v的单位化保证了最终生成的方向向量也是单位向量。
vec3 reflected = reflect(unit_vector(r_in.direction()), rec.normal);
//从碰撞点射出反射光线。
scattered = ray(rec.p, reflected);
attenuation = albedo;
//或许会有用处的保险。但是对于我们的球来说,反射光线和N的夹角不会大于90°。
return (dot(scattered.direction(), rec.normal) > 0);
}

public:
color albedo;
};

在世界中,我们就可以放一个金属球了

1
2
3
4
5
6
7
8
9
hittable_list world;

auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_left = make_shared<lambertian>(color(0.7, 0.3, 0.3));
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.5, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), 0.5, material_right));

image-20220311082857687

亚光金属

在现实世界中很多情况下无法从金属平面上看到自己清晰的脸的原因是金属会哑光,现代社会中为了防止光污染,很多金属表面都会做哑光处理。

哑光实际上就是把金属表面做成凹凸不平,一定程度上削弱镜面反射把所有光线都反射到同一个方向的讨厌特性,有效避免光污染。

在技术实现的角度上,我们可以通过对镜面反射的最终结果加上一个随机的向量来使光线变得更加混乱,从而模拟哑光的效果。

image-20220311082950838

这里使用之前的球面随机向量生成函数,来对最后的结果进行扰动。反映到最终的代码里,有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class metal : public material {
public:
// 增加一个变量f表示扰动的幅度,对成员变量fuzz进行参数列表赋值的时候,限制扰动程度的上限。
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);
// 对最终的反射光线加上一个球面随机向量,至于这个球的大小,由fuzz决定,它最大可以是1。
scattered = ray(rec.p, reflected + fuzz*random_in_unit_sphere());
attenuation = albedo;
return (dot(scattered.direction(), rec.normal) > 0);
}

public:
color albedo;
// 扰动幅度
double fuzz;
};

同样在世界中加入它,看看效果吧:

1
2
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.4, 0.2), 0.4);

image-20220311083217092

拓展

  1. 某些金属艺术品的表面是凹凸不平的鳞片状,这种形状的金属表面光线弹射的法则同样遵循镜面反射原则,但表面的法线会依据某种周期性进行变换,这样的金属材质,构造的时候除了颜色,哑光程度之外,还能改变鳞片的大小。image-20220311125157869

    代码为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Tmetal : public material {
public:
Tmetal(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(r_in.direction(), rec.normal);
attenuation = albedo;
//取法线的第一位小数后的部分,通过控制乘10还是乘更大的数可以控制鳞片大小
vec3 x = rec.normal * 10;
int a = x.x();
int b = x.y();
int c = x.z();
vec3 re = vec3(x.x() - a, x.y() - b, 0);
//将反射光线加上使用偏移量乘以上面得到的向量,周期为0.1(取决于前面法向量乘以的数)。
scattered = ray(rec.p, reflected + fuzz * vec3(re.x(), re.y(), re.z()));
return (dot(scattered.direction(), rec.normal) > 0);
}
public:
color albedo;
//光线偏向法线的程度,越大越偏离镜面反射。
double fuzz;
};
  1. 光栅化中有金属材质吗?它能够映射出其他物体吗?如果不能,游戏中的是如何实现的呢?

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第9.4节到9.6节。

在上一章由于急于实现功能而污染了表层文件,我们需要将它抽象出来,才能继续制作其他材质。

抽象材质类

材质类应该干什么?应该能产生反射光线,并且记录诸如光线衰减信息(上一章中的对半衰减)等,总结一句话,它得通过入射光线,得知反射光线的方向和能量大小。

创建material.h文件,写入”:

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

#include "rtweekend.h"
#include "hittable.h"

class material {
public:
//这个唯一的纯虚函数吃入射光线以及碰撞点信息(我们需要法线以及其他数据),吐物体颜色和反射光线。
virtual bool scatter(
const ray& r_in/*in*/, const hit_record& rec/*in*/,
color& attenuation/*out*/, ray& scattered/*out*/) const = 0;
};
#endif

物体颜色信息指的是物体对于各个红绿蓝三种光线的吸收率,在上一章中,一直使用的是各种光都对半吸收的参数0.5,这一章中,我们要把各个颜色分量给区分开,以创造出更绚烂的色彩变化。

函数的返回值是bool,留下一个标记,为了在函数出问题的时候让我们有能力进行追踪。

在hit_record里加入材质信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "rtweekend.h"
#include "material.h"

struct hit_record {

...
//依然使用智能指针去管理,这样如果你有多个物体像应用同一种材质,就不需要创建多个一样的材质了。
shared_ptr<material> mat_ptr;

inline void set_face_normal(const ray& r, const vec3& outward_normal) {
...
}
};

这时候我们会发现,程序开始报一些莫名其妙的错误了。

这是因为material.h和hittable.h文件互相包含了。main函数开始链接各个文件的时候,采取的策略是见一个就包含一个,并且包含所包含文件的头文件。互相包含问题会导致我们的main函数怎么努力包含都无法包含完这些文件,所以直接摆烂了。

遇到这种情况怎么办呢?我们观察到,hittable.h中虽然定义了一个material的智能指针,但是,它从来没有试图访问过这个指针所指向的对象。所以完全不需要让它包含material.h文件,只需要在使用它之前声明即可:

1
2
3
// #include "material.h"
class material;
struct hit_record { ... }

sphere.h文件也产生了一些变化,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class sphere : public hittable {
public:
sphere() {}
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 {
...
//碰撞到的话,记得把球的材质也返回出去。
rec.mat_ptr = mat_ptr;
return true;
}

磨砂材质

我们只需要模仿main函数中已经写过一遍的代码就可以得到代码。可以把这个类直接写在material.h文件中,这种设计的方式是“一档多类”。它对于多个短小的类来说很有用,它可以防止文件数量过多带来的不便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class lambertian : public material {
public:
//只允许单参数构造,在创建磨砂材质的时候,请直接传入它的颜色。
lambertian(const color& a) : albedo(a) {}

//覆写scatter函数。
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();
// 直接对传进来的引用赋值,传出反射光线方向。
scattered = ray(rec.p, scatter_direction);
// 直接对传进来的引用赋值,把本材质的albedo传出去。
attenuation = albedo;
return true;
}

public:
color albedo;
};

和main函数中唯一的不同就是我们可以指定材质的颜色了。

但作为底层代码,我们应该想到更多,比如在随机选点的时候,我们有可能会选到离碰撞点很近的点,然后进而导致生成的反射方向极度接近于0向量。

从代码鲁棒性的角度来看,这会导致我们在后续的某些计算中——比如球类中光线和球求焦点的代码中,除以一个和0很接近的数,这可能会导致我们最终的值变成infinities或者NaNs等等奇怪的结果(即便现在没有不代表未来不会有)。

所以我们要剔除零反射向量。先在vec3.h中写一个判断一个向量是否是0向量的函数:

1
2
3
4
5
6
7
8
9
10
class vec3 {
...
// 判断本向量是否是0向量。
bool near_zero() const {
// 如果三个分量都极接近于0,则返回true,否则返回false。
const auto s = 1e-8;
return (fabs(e[0]) < s) && (fabs(e[1]) < s) && (fabs(e[2]) < s);
}
...
};

修改磨砂材质类,消灭零反射向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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();

//如果抓到零向量,直接生成一个指向法线方向的反射向量,由于概率不高,所以从简处理
if (scatter_direction.near_zero())
scatter_direction = rec.normal;

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

public:
color albedo;
};

成果

在main函数所在文件中使用磨砂材质类,除了要包含material头文件之外,还需要在main中定义数个智能指针指向新创建的材质对象,再把原本两个参数的球构造函数改成三个参数的。当然最重要的部分还是我们的光线取色函数中和材质类的交互,.cpp文件中代码修改为:

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

#include "material.h"

color ray_color(const ray& r, const hittable& world, int depth) {
hit_record rec;

if (depth <= 0)
return color(0,0,0);

if (world.hit(r, 0.001, infinity, rec)) {

// 两个用于接收结果的容器。
ray scattered;
color attenuation;
// 调用物体材质的scatter函数,传入入射光线和碰撞信息,用容器接收结果。
if (rec.mat_ptr->scatter(r, rec, attenuation, scattered))
// 使用颜色衰减和新光线进行递归。原来这里是0.5。
// 现在我们使用vec3的乘法,可以对三个通道分别指定衰减比率。
return attenuation * ray_color(scattered, world, depth-1);

// 如果scatter函数返回了false(目前看不可能),直接返回黑色。
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);
}



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.7, 0.3, 0.3));
//替换成三参数构造。
world.add(make_shared<sphere>(point3(0, 0, -1), 0.5, material_center));
world.add(make_shared<sphere>(point3(0, -100.5, -1), 100, material_ground))

...

}

就可以得到有颜色的磨砂材质了:

拓展

  1. 材质属性可以放在hittable中吗?

    我认为是可以的,但是这样做目前看来并没有什么好处。在没有出现其他形状的物体之前,把它放在球类中,我们可以清晰的看到该类的所有成员,是目前的最优解。

  2. 材质类与hitable可解耦吗?

    我认为是不可以的,材质类本就不是一个很独立的模块,碰撞点信息里包含物体材质信息,而材质类依托碰撞点信息(如法线)才能计算反射方向。我们从学习代码最初就被叮嘱要进行代码解耦,但是解耦并不代表着类与类之间一定不能有半点交际。材质和物体本就是关系极为紧密的两个事物。只有有了物体,我们才会讨论物体的材质,而且没了材质,我们也就无法知晓物体的外观。把它们分成两个类,是为了能更好的实现代码复用,是为了以后有了多种物体和多种材质之后,能由我们自己随心所欲的进行“连连看”配对。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第9.1节到9.3节。

浮点数精度

看到标题,我们就知道上一章画面太暗的罪魁祸首是谁了吧,生成反射光线的起点是碰撞点,但是因为浮点数并不能精确的等于某个数,比如double d = 0;,实际的项目运行过程中,d不会精确等于0,而是会等于诸如-0.00000083之类的逼近0的小数。

浮点数精度问题会导致一部分光线的起点在球的内部,这样发射光线,光线会和球的内壁碰撞,然后在球内反复弹射,耗尽自己的一生。

虽然可以通过微移光线起点的方式来解决,但是这样会把问题复杂化。别忘了我们在写hit函数的时候,留有限制t的参数t_min和t_max。我们完全可以使用t_min的限制,让光线自动忽略那些和发射点很近的物体。

1
if (world.hit(r, 0.001, infinity, rec)) {...}

图片变得正常了!

image-20220309090857497

伽马矫正

人的眼睛并不是精准的机器,它对亮度的感知和实际能量的功率是不成线性函数关系的,而是幂函数关系,这个函数的指数通常为2.2,称为Gamma值。

也就是说,如果光线真的是每次碰撞到物体都衰减一半的能量,那对于百分之50功率的灰色,人眼实际感受到的亮度为$\sqrt[2.2]{0.5}=0.77297$,是一种偏向于白色的淡灰色。

而人眼中的中灰色,实际上是功率只有img

为了适应人眼去纠正光线的能量,让它符合人眼生物学中的颜色,这叫做伽马矫正。为了方便,我们并不需要那么精准,我们使用”Gamma 2”矫正,即直接对最后的颜色值开方,在write_color函数中,有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void write_color(std::ostream &out, color pixel_color, int samples_per_pixel) {
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();

//Gamma矫正(Gamma = 2.0)。
auto scale = 1.0 / samples_per_pixel;
r = sqrt(scale * r);
g = sqrt(scale * g);
b = sqrt(scale * b);

out << static_cast<int>(256 * clamp(r, 0.0, 0.999)) << ' '
<< static_cast<int>(256 * clamp(g, 0.0, 0.999)) << ' '
<< static_cast<int>(256 * clamp(b, 0.0, 0.999)) << '\n';
}

这样的图像看起来会更亮了,也更接近真实世界我们看到的漫反射材质

image-20220309091636573

更真实的漫反射

之前的随机的向量更倾向于接近法线,并不能做到完全随机,接下来介绍一种单位球表面随机选点模型——又称真实兰伯特模型(true Lambertian),它是最贴合现实世界物理规律的漫反射材质的反射方向的模型。

很简单,我们只需要把体积的随机改成面积的随机即可,我们在单位球的表面随机选点。

image-20220309132029289

所以在vec3.h文件中加入全局函数:

1
2
3
4
5
6
inline vec3 random_in_unit_sphere() {
...
}
vec3 random_unit_vector() {
return unit_vector(random_in_unit_sphere());
}

修改ray_color函数中的代码:

1
2
3
4
5
6
7
8
9
10
11
color ray_color(const ray& r, const hittable& world, int depth) {

...

if (world.hit(r, 0.001, infinity, rec)) {
// 这次改为在球面上取点。
point3 target = rec.p + rec.normal + random_unit_vector();
...
}
...
}

就可以得到这张图片:

image-20220309132224913

半球选点模型

这一种最容易想到的模型(我脑海里也是第一时间想到这个),很多早期的光线追踪论文使用的是这样一种模型。

在以碰撞点P为球心的单位半球内找点,取点半球和表面法线在面的同侧。继续添加一个vec3.h的全局函数。

1
2
3
4
5
6
7
8
9
vec3 random_in_hemisphere(const vec3& normal) {
vec3 in_unit_sphere = random_in_unit_sphere();

// 如果该向量和法线夹角为锐角,即在面的同侧,接受它,否则取反。
if (dot(in_unit_sphere, normal) > 0.0)
return in_unit_sphere;
else
return -in_unit_sphere;
}

同时更改ray_color中的调用

1
point3 target = rec.p + random_in_hemisphere(rec.normal);

image-20220309092409576

拓展

  1. 利用这一节的代码加工一下我们就可以制作各种各样的有趣的图片了:
    1. 太极image-20220309204931529
    2. 炽日image-20220309204955574
    3. 蛋灯image-20220309205203309
  2. 了解光栅化光照模型是如何实现漫反射的。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第8.3节到8.6节。

之前我们一直在画一些看起来完全不真实的东西,但从现在开始我们可以来实现更加真实的物体了。

漫反射原理

例如土墙、手机壳、木制桌面等等这些表面有很多微小凹凸的材质叫做漫反射材质,它们会吸收一部分光线,并且把剩余的光线朝随机方向反射出去,因为光线被反射到了不同的方向,自然不会出现某一个方向能接收到很亮的光线,也就不会有高光点存在。它们一般会吸收特定颜色的光,比如红色的砖块,它会吸收不是红色的光线,而把红色的光线按照随机方向反弹回去。

编程思路

现在暂时还没有光源,我们不妨假设“蓝天”就是光源,光的能量都是从蓝天上来的。

我们再假设任何物体对各种颜色光线的吸收率都是一样的,是1:1。光线碰撞到物体后,都会吸收掉每种光的一半的能量,然后反射一半。

又因为我们是逆光路取色,有以下n种情况:

  1. 从相机射出一根光线,这根光线没有碰到任何物体,即它射中了“蓝天”,那逆光路顺过来看看这意味着相机直接望到了蓝天,蓝天发出的光没有经过任何弹射直接进入了相机。
  2. 从相机射出一根光线,这根光线碰到了一颗球,然后经过随机弹射之后,再也没有射中任何物体,朝无穷远处射出,即,它经过一次弹射之后射中了蓝天。逆光路顺过来看看这意味着光线从蓝天射出打到了物体上并且弹到我们的眼睛里。因为这个物体的能量吸收和反射的比率是1:1,所以这根光线只有一半的能量了。
  3. 我们从相机射出一根光线,这根光线碰到了一颗球,然后经过随机弹射之后,它又碰到了一颗球,逆光路顺过来看光线从蓝天射出打到了一颗球上,反射到了另外一颗球上,再反射到我们的眼睛里,没错,这根光线只剩四分之一的能量了。
  4. ……

那么光线在空间中弹射了n-1次,它的能量只剩$\frac{1}{2^{n-1}}$。

从相机中射出一根光线之后,只要碰撞到物体,就从这个碰撞点朝随机方向发射一根光线,然后把这根光线取到的颜色乘以0.5并返回。

我们的取色函数叫ray_color(const ray& r, const hittable& world),我们这个函数的返回值应该写什么呢?应该是return 0.5 * ray_color(newRay,world),没错,这是一个递归,函数会疯狂的调用自己,直到某根随机反射光线射中了“蓝天”,再一层一层地返回。

随机光线

之前遗留的最后一个问题是如何随机发射一根光线呢?

这个问题并不像表面看上去那么简单,使用什么模型可以更加真实的模拟漫反射呢?最简单的模型是单位球体积内随机选点模型,如图:

image-20220307211216029

碰撞点是P,法线为N(单位化),在以(P+N)这个点为球心的单位球内随机寻找一点S,然后以S减去P为光线的反射方向向量就是最终我们需要的向量。这个S点是P点坐标+N向量+一个由球心指向球内随机点的向量三部分组成。

r向量是相机观察方向,因为我们的漫反射和视角方向无关,不用去管它。

在vec3文件中写入:

1
2
3
4
5
6
7
8
9
10
class vec3 {
public:
...
inline static vec3 random() {
return vec3(random_double(), random_double(), random_double());
}

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

这里函数都是static的,就表示它属于整个类而不属于某个特定的对象,我们可以使用上面的函数直接调用vec3类构造生成一个三个分量都在[0,1)或者[min,max)内随机的随机vec3。

random() 函数生成的vec3可不是在单位球内的,它的XYZ轴都是在[0,1)之间的,它是一个在单位立方体内的点或者向量,我们得做一个简单的处理,让它的随机值最终落于单位球内。

再在vec3.h文件中vec3的类外写一个全局函数,它只有四句代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
vec3 random_in_unit_sphere() {
while (true) {
// 先来个中心在原点,边长为2的立方体内的点。
auto p = vec3::random(-1,1);

// 如果发现这个vec3的长度(它离原点的距离)大于1,即表示它是落于立方体内且落于球外的。
// 直接让他暴力再随机一次。
if (p.length_squared() >= 1) continue;

// 返回一个位于单位球内的点。
return p;
}
}

这里用了一个很暴力的方法:拒绝算法(rejection method),直接让他疯狂的循环,只要点不落于单位球内,我们就让他一直随机到单位球内为止。

接着更新着色代码:

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

if (world.hit(r, 0, infinity, rec)) {

//由三部分组成的S点坐标。
point3 target = rec.p + rec.normal + random_in_unit_sphere();

//创造新的光线并开启下一轮递归。
return 0.5 * ray_color(ray(rec.p, target - rec.p), world);
}

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

至于为什么将它写在这里,有一个原则就是对于不自信的内容,先写到表层实现需求,再抽象到底层维护框架。所以这里不要在意它污染了main函数(对所有的物体都采用了漫反射)。

递归终止条件

到这里运行程序法线根本无法运行:

image-20220307213818888

之前设置的递归终止条件是光线射入蓝天,但是如果光线没有射到蓝天而是一直在夹缝里不停的弹跳,就会导致系统栈溢出。为此因为引入一个光线弹射次数的上限值:

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
color ray_color(const ray& r, const hittable& world, int depth) {
hit_record rec;

// 如果次数消耗殆尽,直接终止递归,我们的系统栈可耗不起了!
if (depth <= 0)
return color(0,0,0);

if (world.hit(r, 0, infinity, rec)) {
point3 target = rec.p + rec.normal + random_in_unit_sphere();
//每一轮新的递归,我们把光线可弹射次数减一。
return 0.5 * ray_color(ray(rec.p, target - rec.p), world, depth-1);
}

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

int main() {
...
const int samples_per_pixel = 100;

//给定光线最大弹射次数。
const int max_depth = 50;
...
for (...) {

...
//更改ray_color调用代码。
pixel_color += ray_color(r, world, max_depth);
}
}

至此我们应该可以得到一张带有漫反射材质的图了:
image-20220307214423049

非常的暗的图片,但确实可以看到漫反射的细节,如果我们只看上方小球的球顶,常识告诉我们,光线打到这里,很大概率能反弹到蓝天上,也就是说,它的颜色应该趋向于蓝天的颜色衰减了一半之后的某种蓝色。而图像并非如此。

另一种方法也可以佐证,用文本模式打开ppm文件,可以看到上边右图中的RGB值,它们都是很靠近黑色的值,我们的程序似乎就没生成过只弹射一次就碰到蓝天的光线,这个问题在下一章得到一个非常巧妙且完美的解决办法。

拓展

  1. 可以证明单位球体积随机选点模型的代码取到的点一定是随机的吗?

    首先如果没有单位球的限制,在单位立方体内部的点,它们必然是均匀的,因为随机的三个标量都足够均匀。

    现在加上单位球的限制,进行n次独立实验(n足够大),我们把这n次实验的结果按照随机几次才得出结果再分成m堆。

    首先是最大的那一堆,这一堆中的点都是只随机一次就落在了单位球内的,有$\frac{\frac{4}{3}\prod}{8}*n$个点在这个堆里(球的体积比上立方体体积)。这些点必然均匀。因为咱们的随机点肯定均匀分布于立方体,也必然均匀分布于立方体中的球内。

    接下来看看第二大的那个堆,这个堆里的点都是第一次随机到了球外,第二次随机到了球内的,这部分的点有$(\frac{\frac{4}{3}\prod}{8})(\frac{8-\frac{4}{3}\prod}{8})*n$个。如果只针对这一批点来说,肯定也是均匀分布于球内(因为我们采用的算法并没有改变)。

    以此类推我们就能得出整体必然均匀的结论。

  2. 如果光线是从球内打到球的内壁上,我们代码能正确的运转吗?

    答案见下章。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第8.1节到8.3节。

仔细观察前面生成的图片,在物体的交界处呈现的是锯齿状,哪怕分辨率非常大,比如:

1
const int image_width = 1600;

得到这样的图像

image-20220307193903032

放大边缘,可以看到依然如此,为了解决这个问题,先看抗锯齿的本质。

image-20220307193945194

本质

其实这个问题之前讨论过,相机只对着每个像素的左上角发射光线,那左上的带回的颜色可以代表整个像素吗?当然不行,所以最好的解决办法就是在这个像素内多发射几次光线,然后取颜色的平均值,即提高采样率

image-20220307194536541

比如上图中我们可以在一个像素内采样四次,得到的图像就会准确的多。

随机数

在这里我们就需要用到随机数,其实光线追踪的很多地方都会用到它,首先回忆一下C语言中的随机数,在rtweekend.h中加入内联函数:

1
2
3
4
5
6
7
8
9
10
11
12
#include <cstdlib>
...

inline double random_double() {
// rand()会返回一个0~RAND_MAX之间的随机数,所以下面这个式子返回的随机数值范围是[0,1)。
return rand() / (RAND_MAX + 1.0);
}

inline double random_double(double min, double max) {
// 范围在[min,max)的随机数。
return min + (max - min) * random_double();
}

在C++中,我们有更强大的随机数算法,那就是mt19937,它的随机性好,在计算机上容易实现,占用内存较少,所以这里选用mt19937随机数,可以把上面代码中random_double函数改掉:

1
2
3
4
5
6
7
8
9
#include <random>

inline double random_double() {
//设置随机范围0到1
static std::uniform_real_distribution<double> distribution(0.0, 1.0);
//创建随机数对象
static std::mt19937 generator;
return distribution(generator);
}

封装相机

这是一个封装我们的相机的好机会。在开始多次采样之前,我们先把相机处理完,让main函数中少一点乱七八糟的代码。

我们可以把在main函数中渲染循环外对相机的所有操作都移动到相机类的构造函数里,然后创建一个类内函数专门用来发射光线,这样设计下来,在main函数中所剩的代码最为清爽。

创建camera.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
#ifndef CAMERA_H
#define CAMERA_H

#include "rtweekend.h"

class camera {
public:
camera() {
//暂时全部写死,代码保持和之前在main函数中的一致。
auto aspect_ratio = 16.0 / 9.0;
auto viewport_height = 2.0;
auto viewport_width = aspect_ratio * viewport_height;
auto focal_length = 1.0;

origin = point3(0, 0, 0);
horizontal = vec3(viewport_width, 0.0, 0.0);
vertical = vec3(0.0, viewport_height, 0.0);
lower_left_corner = origin - horizontal/2 - vertical/2 - vec3(0, 0, focal_length);
}

//发射光线的函数,吃xy轴的偏移,吐出一根从原点射往指定方向的光线。
ray get_ray(double u, double v) const {
return ray(origin, lower_left_corner + u*horizontal + v*vertical - origin);
}

private:
//这些参数我们没有暴露的必要。之后我们制作高级相机的时候,再考虑要不要把它们的权限放开。
point3 origin;
point3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
};
#endif

修改颜色类

因为现在需要多次采样,原color类中的write_color函数也需要修改,为了用户更方便的使用main函数中,颜色只管叠加,其余交给write_color处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void write_color(std::ostream &out, color pixel_color, int samples_per_pixel) {
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();

// 除以采样次数
auto scale = 1.0 / samples_per_pixel;
r *= scale;
g *= scale;
b *= scale;

//确保最终的值是在[0,255]之间,换句话说,我们需要确保r,g,b都在[0,1]之间。
//这个clamp函数在之后给出。
out << static_cast<int>(256 * clamp(r, 0.0, 0.999)) << ' '
<< static_cast<int>(256 * clamp(g, 0.0, 0.999)) << ' '
<< static_cast<int>(256 * clamp(b, 0.0, 0.999)) << '\n';
}

这个函数首先需要把处理传进来的颜色除以采样数,还需要将每个通道的颜色映射到0到255的范围内。其中clamp函数是为了确保传入的值在特定区间内,比如这里如果传入的值大于1,就会导致颜色无法显示。所以还是在这个文件中写入函数:

1
2
3
4
5
inline double clamp(double x, double min, double max) {
if (x < min) return min;
if (x > max) return max;
return x;
}

成果验收

接下来只要替换相机,渲染循环中再加一层for即可:

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
#include "camera.h"

...

int main() {
// 我们必须要一些参数来告诉程序需要生成什么样的图片,这部分代码逻辑上和摄像机没有关系,
const auto aspect_ratio = 16.0 / 9.0;
const int image_width = 400;
const int image_height = static_cast<int>(image_width / aspect_ratio);
// 采样次数
const int samples_per_pixel = 100;

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;

// Render Loop
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) {

// 随机数出场了,u和v每次都会随机加上一个[0,1)的数,然后除以image的长宽之后,
// 就会落到一个像素内的随机位置。
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);
}

//最终的绘制颜色代码中,再做最终除法。
write_color(std::cout, pixel_color, samples_per_pixel);
}
}

std::cerr << "\nDone.\n";
}

漫长等待后我们得到了一个更加“圆润”的球

image.png

强大的力量都需要代价,代价就是比原来要多花100倍的时间。但是多次采样是我们渲染效果真实感的保障,并且多次采样在后面还有着更为广泛的应用。

拓展

  1. 该场景下的抗锯齿算法是否可以优化?

    我认为是可以优化的,比如我们是没有必要在非物体的边缘做100次的采样,这些地方发射一次又或是多次带回来的结果也没有什么不同,所以这里可以在一个像素内先少量采样,如果它们返回的t值不同,则说明这个像素处于物体交界处,需要多次采样来抗锯齿。也类似光栅化中的FXAA。

  2. 了解光栅化有哪些抗锯齿方法。

    1. 增加屏幕分辨率。
    2. 在采样之前进行模糊处理(滤波)处理,边界弱化以后,对应像素值起缓冲作用。
    3. MSAA,跟本文一样检测图形覆盖面积,也是随机多次采样的方法。
    4. FXAA,获得由锯齿的图,再后处理后去除锯齿,速度快。
    5. TAA,在时间和空间上都采用不同的采样点取颜色混合,静态场景下,每一次采样使用像素中心添加一个随机的抖动取得,该帧结果与上一帧进行混合,动态场景下需要使用 Motion Vector 贴图来记录物体在屏幕空间中的变化距离,并使用它得到该物体片元上一帧的像素值进行混合。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第7节。

实现

有了物体类,我们可以创建单个物体了,但是如果场景物体非常的多,我们则需要一个数组储存它们,不仅如此我们在进行光线的碰撞检测的时候还需要依次调用它们碰撞函数,并且在创景变化或者程序结束的时候去释放内存等等。所以这些操作放在main函数中是不合适的,所以最好的办法是定义一个类去实现这些操作。创建hittable_list.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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#ifndef HITTABLE_LIST_H
#define HITTABLE_LIST_H

#include "hittable.h"

//智能指针的头文件
#include <memory>
#include <vector>

//智能指针!用它来帮我们管理物体列表,就不用担心内存泄漏问题了!
using std::shared_ptr;
using std::make_shared;

class hittable_list : public hittable {
public:
hittable_list() {}
//当创建物体列表的时候传入了某一个物体,我们直接调用add函数把这个物体加入列表。
hittable_list(shared_ptr<hittable> object) { add(object); }

//clear函数调用列表的clear函数清空列表。
void clear() { objects.clear(); }
//add函数调用push_back把新物体的智能指针加入列表
void add(shared_ptr<hittable> object) { objects.push_back(object); }

//声明我们需要override父类的hit函数。
virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

public:
//物体列表,使用vector去存每个物体的智能指针。
std::vector<shared_ptr<hittable>> objects;
};

//重写之后的hit函数。
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;

//最开始,把能接受的最远位置设置成外部传进来的t_max。
auto closest_so_far = t_max;

//对每个物体做碰撞
for (const auto& object : objects) {
//光线射到了这一堆物体里的某一个。
if (object->hit(r, t_min, closest_so_far, temp_rec)) {
//光线有射中东西,把最终函数的返回值设定为true。
hit_anything = true;

//光线虽然已经射中了一个东西了,但不确定射中的这个物体是不是离相机最近的。
//范围缩小,然后继续遍历物体列表。
closest_so_far = temp_rec.t;

//每次有新的碰撞,就设定它的record为最终record。
//遍历完成后,就可以找到最近的碰撞点并返回它的rec。
rec = temp_rec;
}
}
//返回这根光线是否有碰到物体列表中的任何物体。
return hit_anything;
}

#endif

分析

这里面有两个C++非常中要的概念:智能指针和多态

1)智能指针。

C++11给我们提供的瑰宝,它的本质是维护一个指针和一个计数,当创建智能指针并让他指向一个对象的时候,引用计数为1,之后每有一个新的指向它的指针被创建,引用计数加1,一个指针被销毁或者不再指向它,引用计数会下降。引用计数为0则释放对象所占内存。

之前的类我们一直都在“造轮子”,这个物品列表类的作用是管理我们之前做好的“轮子”。通常C++中用于管理一些对象的类,一般都是通过指针去管理。对象之间的传输不仅会占据系统栈的内存,还需要付出因拷贝构造而消耗的时间。

况且通过指针管理对象还有另一层作用,那就是“多态”。

2)多态。

继承结构内的多态的一大实现条件就是基类指针指向子类对象。这是一个极其经典的场景,我们通过虚函数的重写,再通过基类指针定义某一个接口,在实际程序运行过程中无论来的是哪个子类对象,都可以精确的调到对应的虚函数。

在这个程序中,hit函数是如何完成多态的呢?我们的物品列表类只说明了这个列表里面的东西会是一个物体,即它存的是基类指针,hit函数中我们分别调用了列表中每一个指针指向对象的hit函数的,虽然此时并不知道它到底是个球,或者是其他的什么东西,我们只管调用它,多态会帮我们找到具体到底是那个函数并且调用到它。

把物品列表类设计成了物品类的子类还有更多的好处,举一个只有这样设计才能达成的疯狂的玩法!我们可以在这里进行无限层的嵌套!比如下面的某个物品列表:

-物品列表

-子物品列表1

-球1

-球2

-子物品列表2

-球3

-球4

现在给最外层的物品列表调用hit函数,我们问这个列表:“这根光线有没有和这一大堆东西碰撞呀,碰撞结果如何啊?”

最外层的列表说:“我给你看看把,我这里有俩玩意,但我不知道这俩玩意是啥,我帮你找他们问问(最外层列表只知道列表中有俩物体,它不知道这些物体到底是啥)”

紧接着程序开始按照名单给这俩小物品列表发消息:“嘿,你们是什么玩意啊?是球吗?我不管你们是不是球,上面发话了,要看看这根光线有没有和你们碰撞,碰撞情况如何啊?我把光线信息和上面要求的t的范围发给你了啊,再给你一个地址,你们俩把record填好啊!我不管了啊!”

好了,俩小物品列表收到消息也急了,它赶忙找它们下面的东西:“嘿!听的到么!我不管你们是谁,老板要求你们把这根光线…….(省略)”

最外层的物品列表只需要维护自己的closest_so_far,比较两个子物品列表给出的t的大小并选择最小的那个,而再下层的东西它完全不用去管。

这叫什么?递归!我们用“物品列表类是物品类的子类”这一设计,实现了一个精美的递归代码帮我们完成一个光线和一堆物品的碰撞。这也多亏了多态的支持。我无法想象如果我们没有多态,实现一个嵌套列表到底要多出几百倍的代码。

我画了一张类图在来帮助理解这部分代码:

光追类图2

数学类

现在还需要一些常量数字,比如无穷大。不然我们要怎么传最初始的t_max值呢?我们不能在一开始就限定它为10000,1000之类的。显得很不专业,况且,一些常量和一些换算之后还会有更多作用。我们先把我们能想到的写上,之后需要其他的再添加。

文件取名为rtweekend.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
#ifndef RTWEEKEND_H
#define RTWEEKEND_H

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


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

// Constants

// 直接使用C++帮我们定义好的double类型的无限值作为无穷大
const double infinity = std::numeric_limits<double>::infinity();
// Π。 我们先把这个东西放在这,虽然暂时还没有什么用。
const double pi = 3.1415926535897932385;

// 一个角度值转弧度制的函数,嗯。。或许暂时也没有什么用。
inline double degrees_to_radians(double degrees) {
return degrees * pi / 180.0;
}

//这俩头文件可是我们的常客,之后用他们就直接包这个rtweekend文件就行了。
#include "ray.h"
#include "vec3.h"

#endif

成果

在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
#include "rtweekend.h"

#include "color.h"
#include "hittable_list.h"
#include "sphere.h"

#include <iostream>

//新的光线取色函数中,我们加入了一个名为世界的物体对象,别看它是一个物体对象,我们一般会传入一个物体列表。
color ray_color(const ray& r, const hittable& world) {
hit_record rec;
//我们使用到了我们定义的无限大,并且剔除了负t
if (world.hit(r, 0, infinity, rec)) {
//没错,依然是元气弹
return 0.5 * (rec.normal + color(1,1,1));
}
//蓝天...
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);
}

int main() {

const auto aspect_ratio = 16.0 / 9.0;
const int image_width = 400;
const int image_height = static_cast<int>(image_width / aspect_ratio);

// 创造世界
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 viewport_height = 2.0;
auto viewport_width = aspect_ratio * viewport_height;
auto focal_length = 1.0;

auto origin = point3(0, 0, 0);
auto horizontal = vec3(viewport_width, 0, 0);
auto vertical = vec3(0, viewport_height, 0);
auto lower_left_corner = origin - horizontal/2 - vertical/2 - vec3(0, 0, focal_length);

//渲染循环(render loop)
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) {
auto u = double(i) / (image_width-1);
auto v = double(j) / (image_height-1);
ray r(origin, lower_left_corner + u*horizontal + v*vertical);
//和原来没什么不同,也说明了作者的封装非常完美
color pixel_color = ray_color(r, world);
write_color(std::cout, pixel_color);
}
}
std::cerr << "\nDone.\n";
}

image-20220307133434367

于是我们得到了在一片草原之上的元气弹,实际上只是球体所在位置及其表面法线的可视化。这通常是查看模型缺陷和特征的好方法。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第6.5节到第6.7节。