“带时间的光线”划开了新旧篇章的界限,新的维度下,新的可能性在孕育。
这一章中就来看看,“时间”会给这个世界带来什么变化。本章我们会创建继“球”之后,第二个实体物体类:“移动的球”。呃,抱歉,它还是球。

匀速运动的球

创建新文件moving_sphere.h,并敲入如下代码,其实其中大部分代码是从球类中复制过来的:

  1. #ifndef MOVING_SPHERE_H
  2. #define MOVING_SPHERE_H
  3. #include "rtweekend.h"
  4. #include "hittable.h"
  5. class moving_sphere : public hittable {
  6. public:
  7. moving_sphere() {}
  8. //带参构造,比sphere类要多吃四个参数,分别是两个时间值,以及这两个时间下对应的球心位置。
  9. moving_sphere(
  10. point3 cen0, point3 cen1, double _time0, double _time1, double r, shared_ptr<material> m)
  11. : center0(cen0), center1(cen1), time0(_time0), time1(_time1), radius(r), mat_ptr(m)
  12. {};
  13. virtual bool hit(
  14. const ray& r, double t_min, double t_max, hit_record& rec) const override;
  15. //多出来的函数,这个函数用来返回当前时间下球心位置。
  16. point3 center(double time) const;
  17. public:
  18. //两个时间下的球心位置。
  19. point3 center0, center1;
  20. //两个时间值
  21. double time0, time1;
  22. double radius;
  23. shared_ptr<material> mat_ptr;
  24. };
  25. // 通过插值得到球心位置
  26. point3 moving_sphere::center(double time) const {
  27. return center0 + ((time - time0) / (time1 - time0))*(center1 - center0);
  28. }
  29. #endif

center函数的参数time不一定非要处于time0和time1之间,它也可以小于time0或者大于time1。从另一个角度来说,time0,time1和其对应的球心位置center0,center1并不是球运动的边界,而是用于确定球运动方向和速度的参数。
总结来说,这颗球沿着空间中的某条直线做匀速运动,它在time0时刻处于直线上的center0位置,time1时间下处于center1位置
接下来补完moving_sphere类,它还差一个hit函数:

  1. bool moving_sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
  2. // 传入光线r的发射时间r.time()到center函数里,移动的球会根据这个值调整球心坐标。
  3. vec3 oc = r.origin() - center(r.time());
  4. auto a = r.direction().length_squared();
  5. auto half_b = dot(oc, r.direction());
  6. auto c = oc.length_squared() - radius*radius;
  7. auto discriminant = half_b*half_b - a*c;
  8. if (discriminant < 0) return false;
  9. auto sqrtd = sqrt(discriminant);
  10. auto root = (-half_b - sqrtd) / a;
  11. if (root < t_min || t_max < root) {
  12. root = (-half_b + sqrtd) / a;
  13. if (root < t_min || t_max < root)
  14. return false;
  15. }
  16. rec.t = root;
  17. rec.p = r.at(rec.t);
  18. //同理,法线的计算中的center也得更换成插值计算版本。
  19. auto outward_normal = (rec.p - center(r.time())) / radius;
  20. rec.set_face_normal(r, outward_normal);
  21. rec.mat_ptr = mat_ptr;
  22. return true;
  23. }

材质类对应修改

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

  1. class lambertian : public material {
  2. ...
  3. virtual bool scatter(
  4. const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
  5. ) const override {
  6. ...
  7. //把time信息传递给出射光线。
  8. scattered = ray(rec.p, scatter_direction, r_in.time());
  9. ...
  10. }
  11. ...
  12. };
  13. class metal : public material {
  14. ...
  15. virtual bool scatter(
  16. ...
  17. ) const override {
  18. ...
  19. //把time信息传递给出射光线。
  20. scattered = ray(rec.p, reflected + fuzz*random_in_unit_sphere(), r_in.time());
  21. ...
  22. }
  23. ...
  24. };
  25. class dielectric : public material {
  26. ...
  27. virtual bool scatter(
  28. ...
  29. //把time信息传递给出射光线。
  30. scattered = ray(rec.p, direction, r_in.time());
  31. ...
  32. }
  33. ...
  34. };

修改初篇最终场景

我们尝试把初篇最终场景中的所有漫反射小球都替换成移动的球,并且让他们移动的方向都为y轴正方向,为了做出差异性,让所有的小球移动的速度都不同,我们要利用随机数给定不同的参数。
假设相机镜头在 time0=0 时刻打开镜头,time1=1 时刻关闭镜头,且time0时刻这些移动的球正好都在y = 0.2 平面上(这也是我们以前最终场景中的设计),也就是说center0.y() 是 0.2 ,那么center1就可以根据center0加上一个随机长度的y轴正方向向量得到,具体如下:

  1. ...
  2. #include "moving_sphere.h"
  3. ...
  4. hittable_list random_scene() {
  5. ...
  6. for (int a = -11; a < 11; a++) {
  7. for (int b = -11; b < 11; b++) {
  8. ...
  9. if (choose_mat < 0.8) {
  10. auto albedo = color::random() * color::random();
  11. sphere_material = make_shared<lambertian>(albedo);
  12. // center2表示1.0时刻球心位置。
  13. auto center2 = center + vec3(0, random_double(0,.5), 0);
  14. // 创建moving_sphere时,比sphere多传入四个参数。
  15. world.add(make_shared<moving_sphere>(
  16. center, center2, 0.0, 1.0, 0.2, sphere_material));
  17. } else if (choose_mat < 0.95) {
  18. ...
  19. }
  1. 再修改相机和图片参数,为了不等待太久,适当降低了分辨率:
  1. int main() {
  2. // change
  3. auto aspect_ratio = 16.0 / 9.0;
  4. // ↓↓↓
  5. int image_width = 400;
  6. // ↓↓↓
  7. int samples_per_pixel = 100;
  8. ...
  9. //相机传入的两个时间值和刚刚移动的球中的值保持一致,方便同步修改。
  10. camera cam(lookfrom, lookat, vup, 20, aspect_ratio, aperture, dist_to_focus, 0.0, 1.0);
  1. 会得到:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/25639626/1645624221803-0845eb43-30f2-4979-af3f-9a2fbcc47c79.png#clientId=u17065cb5-76b4-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=295&id=ueb80cf4d&margin=%5Bobject%20Object%5D&name=image.png&originHeight=487&originWidth=832&originalType=binary&ratio=1&rotation=0&showTitle=false&size=185534&status=done&style=none&taskId=u55e458e4-0632-4330-ba46-a13f331d538&title=&width=504.2423950979987)<br />虽然这些移动的球的运动轨迹是“直破云霄”往天上去的,但是看起来就好像是球在平台上弹跳一样。这是因为无论是向下运动还是向上运动,动态模糊看上去都是一个样子的。<br />动态模糊的实现非常简单对吧。<br />跃动的小球就好像暴风雨来临之前躁动不安的动物们——这是因为在下一章中我们的代码底层将迎来风暴。我们即将开始往深处挖掘,探索本篇中最困难的部分——包围盒。<br />这是件枯燥且抽象的事业,但是包围盒子因为其实用价值和广泛应用,也是图形工程师方向的考试的常见考点之一。如果你有这方面的兴趣,那么就有认真掌握的必要。

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

配置ffmpeg*

现在来休息一下,暂停对技术的探索,一起来做一些工程方向的趣事。让我们来生成一系列的图片,并且把它串成视频。你不想看到自己的小球真的动起来吗?如果答案为肯定,那就来继续探索时空光线追踪的极限吧。
图片按序列帧转视频这件差事如果要靠代码完成可得费一番功夫,幸好伟大的前辈们早就写好了统一解决方案——ffmpeg!一个伟大的开源程序!它可以用来记录、转换数字音频、视频,并能将其转化为流,总之,它在音视频方面几乎无所不精无所不能。
但本篇并不是ffmpeg的教程贴,而且为了整体项目的精简,把ffmpeg接入光追项目暂时来看是没有必要。我们仅仅只利用ffmpeg作为外部工具生成视频,或者更准确来说,仅仅只用到一个命令。
一:先需要下载ffmpeg,直接点击该链接进行下载:https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-full.7z
(授人以鱼不如授人以渔,以防上方链接失效,我再教你如何得到本下载链接:

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

image.png

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

image.png

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

二:把下载的压缩包解压到某个目录,笔者将其解压到了D:\ffmpeg文件夹下。
三:配置环境变量。我的电脑右键属性-高级系统设置-环境变量-再编辑path-新建一个path为ffmpeg目录下的bin文件夹。(本部分我简单描述,环境变量配置为程序员必修课,如果不会的话去看教程,这里贴一个百度经验教程https://jingyan.baidu.com/article/a17d5285c9b0c48099c8f26a.html),配置完成你的path应该会大概如下多出一行:
image.png
四:配置完毕之后就可以通过命令行测试是否配置成功,在任意目录下输入ffmpeg -version,如果出现版本信息,即表示配置成功:
image.png

多线程输出序列帧*

切换代码为多线程模式,修改多线程的main所在文件如下所示:

#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
移动的球类是一些匀速运动的小球,但这个匀速运动也是有条件的,那就是时间流速是匀速不变的。<br />我们将均匀增加的自变量i(每次循环加一),套进![](https://cdn.nlark.com/yuque/__latex/97e5bb87482db93b9ec3dd2fbc98644f.svg#card=math&code=y%20%3D%20x%20%5E5&id=C78b0)函数,并将其映射到(0,1)区间内(和移动的球在创建之初指定的时间区间保持同步),即可实现球速逐渐加快效果,因为![](https://cdn.nlark.com/yuque/__latex/97e5bb87482db93b9ec3dd2fbc98644f.svg#card=math&code=y%20%3D%20x%20%5E5&id=jONb0)在此区间为凹函数。可以简单理解为,套用该函数之后,时间流速不再均匀,而是会逐渐加快。<br />运行代码,如果你是在命令行中运行exe文件,就可以在exe所在目录下找到video文件夹。如果是vs内运行生成,这个文件夹会在代码所在目录下。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/25639626/1645681526261-f9be0d88-488b-4080-b899-dd6b3989ea12.png#clientId=u17065cb5-76b4-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=192&id=u3431b712&margin=%5Bobject%20Object%5D&name=image.png&originHeight=316&originWidth=1115&originalType=binary&ratio=1&rotation=0&showTitle=false&size=32247&status=done&style=none&taskId=u29d4d3cb-ee86-46c4-9b4f-14d2da6c236&title=&width=675.757536699842)

ffmpeg命令生成视频*

打开命令行,走到video目录,然后输入以下命令:
ffmpeg -r 10 -i videoframe%04d.ppm -q:v 1 output.avi
如图所示:
image.png
我们分开看看这个命令讲了什么:
ffmpeg 表示运行的exe的名字,系统会在环境变量里面的那些目录去找叫这个名字的exe,显然,它要找的就是ffmpeg/bin文件夹下的那个exe。
-r 10 表示我们要以一秒10帧的速度安排这些图片,最后生成的图片每秒钟会有十张图片播放。
-i videoframe%04d.ppm 表示图片输入,ffmpeg会找指定格式化输入的%d从0开始,这样它就可以找到目录下全部的10张图片。
-q:v 1 表示我们希望最后生成的图像质量高一些,但可惜的是,即使是这样它还是会失去一些清晰度。
output.avi 输出文件目录,没有前缀的话它就会存放在和图片同样的目录里。
在一阵提示输出之后,你会得到视频:
olcs1-xlitr.gif
它的时长是1秒。我们做到了三个时间统一:移动的球类指定时间,相机区间和最终生成图片时间区间。
时间统一有很多好处,它可以让时间轴一目了然。
当然它们三个也可以不完全统一,而是某种映射关系,这取决于你的设计。

课后实践*

  1. 尝试制作更清晰时长更久的视频,享受结果。
  2. 尝试制作镜头动画,如下是一个最简单的镜头动画:

nxr3w-nkpff.gif

  1. 尝试实现球的弹跳效果,如下是物理弹跳效果的某种模拟近似:

i1khw-t25yk.gif

参考文献

https://raytracing.github.io/books/RayTracingTheNextWeek.html
参考自《Ray Tracing: The Next Week》第2.2节到第2.5节。