0%

十七:动态模糊

动态模糊

“带时间的光线”划开了新旧篇章的界限,新的维度下,新的可能性在孕育。

这一章中就来看看,“时间”会给这个世界带来什么变化。本章我们会创建继“球”之后,第二个实体物体类:“移动的球”。呃,抱歉,它还是球。

匀速运动的球

创建新文件moving_sphere.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
#ifndef MOVING_SPHERE_H
#define MOVING_SPHERE_H

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


class moving_sphere : public hittable {
public:
moving_sphere() {}
//带参构造,比sphere类要多吃四个参数,分别是两个时间值,以及这两个时间下对应的球心位置。
moving_sphere(
point3 cen0, point3 cen1, double _time0, double _time1, double r, shared_ptr<material> m)
: center0(cen0), center1(cen1), time0(_time0), time1(_time1), radius(r), mat_ptr(m)
{};

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

//多出来的函数,这个函数用来返回当前时间下球心位置。
point3 center(double time) const;

public:
//两个时间下的球心位置。
point3 center0, center1;
//两个时间值
double time0, time1;
double radius;
shared_ptr<material> mat_ptr;
};

// 通过插值得到球心位置
point3 moving_sphere::center(double time) const {
return center0 + ((time - time0) / (time1 - time0))*(center1 - center0);
}

#endif

center函数的参数time不一定非要处于time0和time1之间,它也可以小于time0或者大于time1。从另一个角度来说,time0,time1和其对应的球心位置center0,center1并不是球运动的边界,而是用于确定球运动方向和速度的参数。

总结来说,这颗球沿着空间中的某条直线做匀速运动,它在time0时刻处于直线上的center0位置,time1时间下处于center1位置

接下来补完moving_sphere类,它还差一个hit函数:

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
bool moving_sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
// 传入光线r的发射时间r.time()到center函数里,移动的球会根据这个值调整球心坐标。
vec3 oc = r.origin() - center(r.time());
auto a = r.direction().length_squared();
auto half_b = dot(oc, r.direction());
auto c = oc.length_squared() - radius*radius;

auto discriminant = half_b*half_b - a*c;
if (discriminant < 0) return false;
auto sqrtd = sqrt(discriminant);

auto root = (-half_b - sqrtd) / a;
if (root < t_min || t_max < root) {
root = (-half_b + sqrtd) / a;
if (root < t_min || t_max < root)
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
//同理,法线的计算中的center也得更换成插值计算版本。
auto outward_normal = (rec.p - center(r.time())) / radius;
rec.set_face_normal(r, outward_normal);
rec.mat_ptr = mat_ptr;

return true;
}

材质类对应修改

每次递归都会产生新的光线,得确保时间信息可以传递到新光线上,修改材质类:

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
class lambertian : public material {
...
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
...
//把time信息传递给出射光线。
scattered = ray(rec.p, scatter_direction, r_in.time());
...
}
...
};

class metal : public material {
...
virtual bool scatter(
...
) const override {
...
//把time信息传递给出射光线。
scattered = ray(rec.p, reflected + fuzz*random_in_unit_sphere(), r_in.time());
...
}
...
};

class dielectric : public material {
...
virtual bool scatter(
...
//把time信息传递给出射光线。
scattered = ray(rec.p, direction, r_in.time());
...
}
...
};

修改初篇最终场景

我们尝试把初篇最终场景中的所有漫反射小球都替换成移动的球,并且让他们移动的方向都为y轴正方向,为了做出差异性,让所有的小球移动的速度都不同,我们要利用随机数给定不同的参数。

假设相机镜头在 time0=0 时刻打开镜头,time1=1 时刻关闭镜头,且time0时刻这些移动的球正好都在y = 0.2 平面上(这也是我们以前最终场景中的设计),也就是说center0.y() 是 0.2 ,那么center1就可以根据center0加上一个随机长度的y轴正方向向量得到,具体如下:

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

...
hittable_list random_scene() {
...

for (int a = -11; a < 11; a++) {
for (int b = -11; b < 11; b++) {
...

if (choose_mat < 0.8) {
auto albedo = color::random() * color::random();
sphere_material = make_shared<lambertian>(albedo);

// center2表示1.0时刻球心位置。
auto center2 = center + vec3(0, random_double(0,.5), 0);

// 创建moving_sphere时,比sphere多传入四个参数。
world.add(make_shared<moving_sphere>(
center, center2, 0.0, 1.0, 0.2, sphere_material));

} else if (choose_mat < 0.95) {
...

}

再修改相机和图片参数,为了不等待太久,适当降低了分辨率

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {

// change
auto aspect_ratio = 16.0 / 9.0;
// ↓↓↓
int image_width = 400;
// ↓↓↓
int samples_per_pixel = 100;

...

//相机传入的两个时间值和刚刚移动的球中的值保持一致,方便同步修改。
camera cam(lookfrom, lookat, vup, 20, aspect_ratio, aperture, dist_to_focus, 0.0, 1.0);

会得到:

img

虽然这些移动的球的运动轨迹是“直破云霄”往天上去的,但是看起来就好像是球在平台上弹跳一样。这是因为无论是向下运动还是向上运动,动态模糊看上去都是一个样子的。

以下内容为个人探索(需要先完成初篇第十六章)。

配置ffmpeg*

现在来休息一下,暂停对技术的探索,一起来做一些工程方向的趣事。让我们来生成一系列的图片,并且把它串成视频。你不想看到自己的小球真的动起来吗?如果答案为肯定,那就来继续探索时空光线追踪的极限吧。

图片按序列帧转视频这件差事如果要靠代码完成可得费一番功夫,幸好伟大的前辈们早就写好了统一解决方案——ffmpeg!一个伟大的开源程序!它可以用来记录、转换数字音频、视频,并能将其转化为流,总之,它在音视频方面几乎无所不精无所不能。

但本篇并不是ffmpeg的教程贴,而且为了整体项目的精简,把ffmpeg接入光追项目暂时来看是没有必要。我们仅仅只利用ffmpeg作为外部工具生成视频,或者更准确来说,仅仅只用到一个命令。

一:先需要下载ffmpeg,直接点击该链接进行下载:https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-full.7z。

如果上方链接失效,可以通过以下方法:

  1. 先上官网:https://ffmpeg.org/download.html
  2. 因为我是windows系统,就按下图所示进入链接(其他系统下的ffmpeg安装远比windows下安装简单,具体请自行搜索):

img

进入之后,第一行就是最新的git分支的压缩包,俩个分别是只包含基础功能的以及全部功能的压缩包,随便选一个,因为我还使用ffmpeg做其他项目,所以安装的是full版本,但是应对本项目,essential应该就足够了。

img

你会下载到和刚刚我给你的链接里一样的安装包。

二:把下载的压缩包解压到某个目录,我将其解压到了D:\ffmpeg文件夹下。

三:配置环境变量。我的电脑右键属性-高级系统设置-环境变量-再编辑path-新建一个path为ffmpeg目录下的bin文件夹。(本部分我简单描述,环境变量配置为程序员必修课,如果不会的话去看教程,这里贴一个百度经验教程https://jingyan.baidu.com/article/a17d5285c9b0c48099c8f26a.html),配置完成你的path应该会大概如下多出一行:

img

四:配置完毕之后就可以通过命令行测试是否配置成功,在任意目录下输入ffmpeg -version,如果出现版本信息,即表示配置成功:

img

多线程输出序列帧*

切换代码为多线程模式,修改多线程的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
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
#if MULTITHREAD
//我们需要用到c语言中的部分内存操作函数,比如sprintf,在C++标准中它们被认为是危险和禁忌的。
//使用这个宏即可解禁这些函数,你亦可将其写入预处理器命令中。
#define _CRT_SECURE_NO_WARNINGS
...
#include "moving_sphere.h"

// 调整为16/9,和本章中动态模糊示例代码保持一致。
const auto aspect_ratio = 16.0 / 9.0;
// ↓↓↓,因为要输出多张图片,我们让其尽可能的快。
const int image_width = 200;

const int image_height = static_cast<int>(image_width / aspect_ratio);
const int samples_per_pixel = 100;
const int max_depth = 50;

...

hittable_list random_scene() {
...

if (choose_mat < 0.8) {
...

// 让center2尽可能的高,我打算让球高高的飞起!
auto center2 = center + vec3(0, random_double(0, 50), 0);

// 时间依然不变,卡在1个单位时间内运行完毕。
world.add(make_shared<moving_sphere>(
center, center2, 0.0, 1.0, 0.2, sphere_material));
} else if (choose_mat < 0.95) {
...
...
}

int main() {
// 视频一共有多少帧。
int video_frame_num = 10;
// 把这些序列帧存放在指定目录下,方便查看和编辑。
std::string video_folder_path = "video";

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;

// C的新建文件夹的多种方式之一,这种通过system命令新建文件夹的方式不需要多余的头文件包含,很方便。
std::string command;
// mkdir命令就是命令行中新建文件夹的命令,后跟新文件夹目录
command = "mkdir " + video_folder_path;
// 会在当前目录上创建video文件夹。
// system只接受c风格字符串,所以要用c_str转换一下。
system(command.c_str());
// 具体文件名。
char filename[50];

// 循环创建多帧。
for (int i = 0; i < video_frame_num; i++) {

std::cerr << "LineIndex:" << i << std::endl;

// sprintf和c语言中人尽皆知的printf几乎没有什么不同。
// 唯一的区别是它要把字符串输出给一个char数组而不是标准输出流。
// 图片名需要规律排列,ffmpeg会从编号为0的图片开始串帧成视频。
sprintf(filename, "./%s/videoframe%04d.ppm", video_folder_path.c_str(), i);

buf = new imageoutput(image_width, image_height, filename);
std::vector<std::thread> threads;

//本帧图片的时间区间,随后讲解。
cam = new camera(lookfrom, lookat, vup, 20, aspect_ratio, aperture, dist_to_focus, pow(i / (float)video_frame_num, 5), pow((i + 1) / (float)video_frame_num, 5));

for (int k = 0; k < numThread; k++) {
threads.push_back(std::thread(ray_tracing_by_thread, k));
}
for (auto& thread : threads) {
thread.detach();
}

while (thread_remain > 0) {
std::cerr << "\rScanlines remaining : " << line_remain - 1 << ' ' << std::flush;
}

//注意初始化这些进程监视相关变量。
line_remain = image_height;
thread_remain = numThread;

std::cerr << "\nDone.\n";
buf->buffer_2_file();

delete cam;
delete buf;
}

return 0;
}

#endif

移动的球类是一些匀速运动的小球,但这个匀速运动也是有条件的,那就是时间流速是匀速不变的。

我们将均匀增加的自变量i(每次循环加一),套进img函数,并将其映射到(0,1)区间内(和移动的球在创建之初指定的时间区间保持同步),即可实现球速逐渐加快效果,因为img在此区间为凹函数。可以简单理解为,套用该函数之后,时间流速不再均匀,而是会逐渐加快。

运行代码,如果你是在命令行中运行exe文件,就可以在exe所在目录下找到video文件夹。如果是vs内运行生成,这个文件夹会在代码所在目录下。

img

ffmpeg命令生成视频*

打开命令行,走到video目录,然后输入以下命令:

ffmpeg -r 10 -i videoframe%04d.ppm -q:v 1 output.avi

如图所示:

img

我们分开看看这个命令讲了什么:

ffmpeg 表示运行的exe的名字,系统会在环境变量里面的那些目录去找叫这个名字的exe,显然,它要找的就是ffmpeg/bin文件夹下的那个exe。

-r 10 表示我们要以一秒10帧的速度安排这些图片,最后生成的图片每秒钟会有十张图片播放。

-i videoframe%04d.ppm 表示图片输入,ffmpeg会找指定格式化输入的%d从0开始,这样它就可以找到目录下全部的10张图片。

-q:v 1 表示我们希望最后生成的图像质量高一些,但可惜的是,即使是这样它还是会失去一些清晰度。

output.avi 输出文件目录,没有前缀的话它就会存放在和图片同样的目录里。

在一阵提示输出之后,你会得到视频:

img

它的时长是1秒。我们做到了三个时间统一:移动的球类指定时间,相机区间和最终生成图片时间区间。

时间统一有很多好处,它可以让时间轴一目了然。

当然它们三个也可以不完全统一,而是某种映射关系,这取决于你的设计。

拓展

可以尝试通过修改代码,达到以下效果:

nxr3w-nkpff.gif

i1khw-t25yk.gif

参考文献

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

参考自《Ray Tracing: The Next Week》第2.2节到第2.5节。