要开始编码了,我们首先要做的就是能确保程序可以输出图片。我们在上一章中提到并解释了ppm文件,我们得确保程序可以正确生成一张ppm文件,也就是说,我们要先把光追器的框架搭建起来。

最开始的代码

在VS中创建C++项目,直接创建一个控制台项目,因为我们会用到标准输出流。如果你是其他环境的话,只要能确保能控制台输出helloworld程序即可。
输入以下代码。

  1. #include <iostream>
  2. int main() {
  3. // 图片的长宽,我们先用一张256×256的图片来试试水。
  4. const int image_width = 256;
  5. const int image_height = 256;
  6. // 下面这行语句是打印ppm文件的前三行。
  7. std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
  8. // 渲染循环 (render loop)
  9. // 嘿!这个for循环好奇怪啊!为什么外层是减内层又是加?别急,我们在后面解释。
  10. for (int j = image_height-1; j >= 0; --j) {
  11. for (int i = 0; i < image_width; ++i) {
  12. //指定像素颜色,我给了一个常量,这是一种很漂亮的颜色。
  13. int ir = 219;
  14. int ig = 112;
  15. int ib = 147;
  16. //每一行只写一个像素。
  17. std::cout << ir << ' ' << ig << ' ' << ib << '\n';
  18. }
  19. }
  20. }
  1. 我想你阅读上面的代码应该没有什么问题,我们先运行一下试试:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/25639626/1639808399947-17c81b62-1adc-461a-a496-292a3e7ef43d.png#clientId=u30af9a18-7bf0-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=383&id=udf4bef24&margin=%5Bobject%20Object%5D&name=image.png&originHeight=766&originWidth=1468&originalType=binary&ratio=1&rotation=0&showTitle=false&size=61488&status=done&style=none&taskId=ua6a804a2-097d-4106-ad2c-56ee3835d11&title=&width=734)<br />没错,他会输出3+256×256行东西。你可能会疑惑:“你搞这些有什么用,我学C++的第一课就会输出这些东西了。”。<br />没事,把它转换成图片是下一步,我们先把上面代码中的问题先解释完再说。

代码解析

为什么for循环要写的这么奇怪呢?这是因为视口坐标的不同。
ppm文件要求的坐标的左上角为(0,0)。即,按行优先把数值告诉ppm文件,默认左边最上边的像素是第一个元素。
而学过平面直角坐标系的同学应该了解我们习惯把左下角看成坐标原点。想想看第一象限,坐标原点是不是在左下角?这就是为什么外层for循环会反向遍历的原因。
第一次进入渲染循环的时候,i 被初始化为0,而 j 被初始化为255,即表示坐标为(0,255)。这正好是左上角第一个像素在传统笛卡尔坐标系中的位置表示。
当然,这只是本文的习惯,你如果说:“我就是要把左上角看成坐标原点,如果for循环不是两个都是循环变量递增我就难受!”那也没问题,不会有啥大影响,不过如果这造成了之后我们的代码在你的程序里运行,出现图形颠倒等问题,自行斟酌解决。

生成图片

如果你在ide里直接点击运行,你永远得不到一张图片,因为你的输出全部输出给了控制台。我们要把这些字符串输出到一个文件里,这就需要命令行和重定向符号”>”。
打开命令行(window下win+R再输入cmd),接下来我们要进入到你项目的目录下。不过多赘述命令行的知识,你可以像下图一样往命令行输入字符串,注意,你的项目目录和我的不一样,请注意一定要索引到项目文件夹下那个可执行文件。
image.png
因为我的项目采用debug调试生成,所以它在debug文件夹下,具体命令行如下图所示:
image.png
我的项目在D盘,我先输入 D: 进入D盘。
然后输入 cd + 项目路径 进入exe文件所在的目录下
然后输入 文件名.exe > 图像名.exe 完成重定向,它把本应该输出到控制台的数据写成了一个ppm文件。
完成上述步骤之后,你应该会发现文件目录下多了一个ppm文件。如下图所示:
image.png
因为默认路径是缺省的,即当前路径。我想把这个图像输出到桌面上也是可以的,只要使用 桌面路径/图像名.ppm 去代替上述命令中的 图像名.ppm 就可以了。
注意,Debug模式是可以让你更好的调试,而release模式速度更快,请酌情选择两种模式。关于这两种模式的区别,请看这篇文章:
https://www.zhihu.com/question/443340911
笔者更推荐release模式,出了问题再切换到debug模式下调试,这不失为一种利用两种模式优势的方法。
最终发布版本请使用release。
好了,我们已经成功的生成一个图片了!

欣赏图片

呃,他要怎么打开呢?因为ppm是一个不常见的图片格式,很多系统不会提供默认的图片查看器,下面给出window下的查看该图片的方法,其他的系统请自行搜索并下载ppm查看器。
https://www.xnview.com/en/xnviewmp/#downloads XnView
https://www.mydown.com/soft/113/473304113.shtml 极速看图
随便选择一个下载安装即可,然后记得更改一下ppm图片的默认打开方式。
然后再打开它,你就可以看到这张可爱的便签纸了!
image.png

进度提示

你是不是觉得,在生成图片的过程中没有半点反馈,感觉心里很没底?如果以后图片是25600×25600的,我们就要花万倍于我们刚刚使用的时间去生成图片,这个漫长的等待可不好受,我们需要反馈!
还好,我们可以通过std下的一些函数得到满足。尝试在渲染循环中相应位置加入如下代码。

  1. for (int j = image_height-1; j >= 0; --j) {
  2. // 提示还有多少行数据没有处理完。
  3. std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
  4. for (int i = 0; i < image_width; ++i) {
  5. //.....
  6. }
  7. }
  8. //提示已经完全搞定。
  9. std::cerr << "\nDone.\n";

std::cerr也是使用了一种输出流,叫错误输出流,他不会把这些字符输出到最终目标里,而是直接输出给命令行,其实这个输出流一般是提示运行中的错误而使用的,不过我们在这里借用它,没有关系。
我们使用“\r”可以把光标强行移回本行开头,这样这次输出的内容就会覆盖掉这一行原本的内容,就好像每次到来的新东西会“冲洗”掉之前输出的东西。
注意这一行的结尾是std::flush,它表示再输出完这一行之后,会强行把内存中缓冲区内的数据打出到错误输出流里(清空缓冲区)。
关于std::flush的其他知识,请看这篇文章:std中ends、endl和flush的不同
再在命令行里跑一遍刚刚的命令:文件名.exe > 图像名.exe。你会看到很酷炫的提示。
image.png

更绚丽的玩法

单色图片还是有些单调,我们尝试一下输出一张绚丽的彩色图片!
因为我们准备输出一张256×256的图片,而颜色的阶数也刚好是256,何不把这两者联系起来呢?尝试一下如下代码,去替换上述代码的渲染循环部分。

  1. // 渲染循环 (render loop)
  2. for (int j = image_height-1; j >= 0; --j) {
  3. std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
  4. for (int i = 0; i < image_width; ++i) {
  5. // r通道的值和该像素所在坐标的横坐标决定,即越靠右的像素“越红”
  6. auto r = double(i) / (image_width-1);
  7. // g通道的值和该像素所在坐标的纵坐标决定,即越靠上的像素“越绿”
  8. auto g = double(j) / (image_height-1);
  9. // 蓝色?who care?
  10. auto b = 0.25;
  11. // 你应该注意到了上面的rgb的值在0-1之间,下面把它们再映射到0-255之间。
  12. // 255.999是什么东西?见解析。
  13. int ir = static_cast<int>(255.999 * r);
  14. int ig = static_cast<int>(255.999 * g);
  15. int ib = static_cast<int>(255.999 * b);
  16. std::cout << ir << ' ' << ig << ' ' << ib << '\n';
  17. }
  18. }
  19. std::cerr << "\nDone.\n";
  1. 因为rgb的值取值范围是[0,1],所以它乘以255.999之后会完美的落到[0,255]之间。因为任何浮点数强转成int类型都会丢弃小数部分,即向下取整。你可以尝试一下看看两个极端情况01乘以255.999之后再向下取整是不是落到了[0,255]之间。如果你非得说为什么不是255.888,那我只能说,只要你高兴,都可以。<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/25639626/1639813686029-b9663327-f675-4eed-87cb-032db126af94.png#clientId=u30af9a18-7bf0-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=181&id=u5e9f5c5c&margin=%5Bobject%20Object%5D&name=image.png&originHeight=361&originWidth=500&originalType=binary&ratio=1&rotation=0&showTitle=false&size=2113&status=done&style=none&taskId=ufd98d7c6-96e6-45b8-a3b4-36453c186b5&title=&width=250)<br />挺漂亮不是么?这样,我们就已经把前期的准备工作全部完成了,接下来的章节里,将正式开始进行光追器的开发。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第2节。