你开始觉得我们的图片的球体边缘的锯齿非常难看了对吧?嗯?你认为这是我们分辨率不够大的原因?那你把分辨率扩大四倍看看?
const int image_width = 1600;
试试?
你会得到下面这张图,我们把它放大:
image.pngimage.png
看到没,讨厌的锯齿还是存在,就算大分辨率带来的清晰可以一定程度上抵消锯齿带来的不适感,但是,去完成去挑战也是我们的使命,不是么?
我们要如何抗锯齿呢?我们先来想想为什么会有锯齿?

抗锯齿的本质

锯齿是如何产生的呢?想想看我们是怎么决定每个像素颜色的?没错,我们往像素的左上角射出一根光线,通过这根光线带回的信息来对像素着色?你想想这样合理吗?假设有一个球在空间中,我们以相机为起点向某一个像素中任意一点射出光线,有99.999%的情况下射出的光线最终会碰到这个球,只有很小概率——很不凑巧就唯独通过左上角的若干个点射出的光线碰不到这颗球,最终返回了“蓝天”,你觉得,左上角这一个点能决定整个像素的颜色吗?显然不能。
如果光线射到球的边界上,我们上述提到的问题就会被放大——这一个像素里,有蓝天和球,但是它最终只能选择着色成蓝天还是球,没有任何商量的空间,中间也没有任何可以过渡的状态,这就是锯齿产生的原因——采样的频率跟不上情况的变化。因为我们的采样频率是1(对于每一个像素我们只采样一次,即左上角),而真实中这个像素是什么颜色,我们可以多采样几次再综合进行着色。
image.png
了解抗锯齿的本质是增加采样频率之后,我们第一步要做什么呢?我们朝一个像素中的多个点射出光线,这些点最好分布足够均匀,这样采样出来的结果才更为自然准确。
我们可以用随机数,比如我们可以像上图一样,在一个像素内随机选了四个点,然后我们射出四根光线,对四根光线带回的结果求平均即可。

随机数

随机数可以说是光线追踪的核心了,我们需要用随机数来模拟在空间中胡乱弹射的光线,而且,抗锯齿中,在同一个像素中选点也是需要随机数的。
我先带你回忆一下C语言中的随机数,在rtweekend.h中加入如下内联函数:

  1. #include <cstdlib>
  2. ...
  3. inline double random_double() {
  4. // rand()会返回一个0~RAND_MAX之间的随机数,所以下面这个式子返回的随机数值范围是[0,1)。
  5. return rand() / (RAND_MAX + 1.0);
  6. }
  7. inline double random_double(double min, double max) {
  8. // 范围在[min,max)的随机数。
  9. return min + (max - min) * random_double();
  10. }

在C++中,我们有更强大的随机数算法,那就是mt19937,它的随机性好,在计算机上容易实现,占用内存较少,具体它是依照什么算法产生随机数的这里就不多赘述,和我们的项目无关,如果你选用mt19937随机数,可以把上面代码中random_double函数改掉:

  1. #include <random>
  2. inline double random_double() {
  3. static std::uniform_real_distribution<double> distribution(0.0, 1.0);
  4. static std::mt19937 generator;
  5. return distribution(generator);
  6. }

mt19937随机数生成器放在文件里,别忘了把它包裹进来,如上就是生成[0,1)之间随机数全部代码了。

相机封装

这是一个封装我们的相机的好机会。在开始我们的多次采样之前,我们先把相机处理完,让我们的main函数中少一点乱七八糟的代码。
你还记得我们在制作光线的时候提到,我们的相机是一个在(0,0,0)点并永远望向z轴负方向的相机吗?我们今天的主题是抗锯齿,虽然我很想在这里把相机移动和视场缩放等等功能定义出来,但是现在应该还不是时候,我们就简单的先封装一下,其他的之后再说。
我们可以把在main函数中渲染循环外对相机的所有操作都移动到相机类的构造函数里,然后创建一个类内函数专门用来发射光线,这样设计下来,在main函数中所剩的代码最为清爽。
创建camera.h文件,敲入如下代码:

  1. #ifndef CAMERA_H
  2. #define CAMERA_H
  3. #include "rtweekend.h"
  4. class camera {
  5. public:
  6. camera() {
  7. //暂时全部写死,代码保持和之前在main函数中的一致。
  8. auto aspect_ratio = 16.0 / 9.0;
  9. auto viewport_height = 2.0;
  10. auto viewport_width = aspect_ratio * viewport_height;
  11. auto focal_length = 1.0;
  12. origin = point3(0, 0, 0);
  13. horizontal = vec3(viewport_width, 0.0, 0.0);
  14. vertical = vec3(0.0, viewport_height, 0.0);
  15. lower_left_corner = origin - horizontal/2 - vertical/2 - vec3(0, 0, focal_length);
  16. }
  17. //发射光线的函数,吃xy轴的偏移,吐出一根从原点射往指定方向的光线。
  18. ray get_ray(double u, double v) const {
  19. return ray(origin, lower_left_corner + u*horizontal + v*vertical - origin);
  20. }
  21. private:
  22. //这些参数我们没有暴露的必要。之后我们制作高级相机的时候,再考虑要不要把它们的权限放开。
  23. point3 origin;
  24. point3 lower_left_corner;
  25. vec3 horizontal;
  26. vec3 vertical;
  27. };
  28. #endif

修改颜色类

之前我们在color类中写过一个函数,它把给定的颜色输出到给定输出流中。调用这个函数的地方是渲染循环,循环不断地确定每个像素的颜色,然后交由color类中这个write_color函数进行最终的输出。因为现在我们需要多次采样,现在我们从main函数中传给write_color函数的颜色可能不再是像素最终的颜色,而是多次采样后颜色值的叠加,我们需要在write_color中处理这个叠加后的颜色值——即除以采样次数,这样才能得到最终颜色值进行输出,当然,这一步你完全可以在main函数中做好,再传一个可以直接输出的颜色值进来,但是我喜好的设计是,咱们在外面不用管,只管累加颜色结果,最终的除法由color.h来做,看代码:

  1. void write_color(std::ostream &out, color pixel_color, int samples_per_pixel) {
  2. auto r = pixel_color.x();
  3. auto g = pixel_color.y();
  4. auto b = pixel_color.z();
  5. // 除以采样次数
  6. auto scale = 1.0 / samples_per_pixel;
  7. r *= scale;
  8. g *= scale;
  9. b *= scale;
  10. //我们要确保最终的值是在[0,255]之间,换句话说,我们需要确保r,g,b都在[0,1]之间。
  11. //这个clamp函数我们之后给出。
  12. out << static_cast<int>(256 * clamp(r, 0.0, 0.999)) << ' '
  13. << static_cast<int>(256 * clamp(g, 0.0, 0.999)) << ' '
  14. << static_cast<int>(256 * clamp(b, 0.0, 0.999)) << '\n';
  15. }
  1. 这个write_color函数只做下面两件事情:
  1. 对输入进来的颜色的每个通道除以采样次数。
  2. 输出它们,但在之前得确保颜色的各个分量在[0,1],以使得最终结果是[0,255]之间的整数。对此我们需要一个clamp函数,这个函数唯一要做的事情就是监督传进来的值是否是在给定min和max之间,如果不是,则把值调整到边界上,看如下代码,注意,这个函数你得写到rtweekend.h文件里,不要让项目乱了套。
    1. inline double clamp(double x, double min, double max) {
    2. if (x < min) return min;
    3. if (x > max) return max;
    4. return x;
    5. }

    修改main函数

    现在咱们只剩main函数需要修改了,把你的camera包进来,然后在渲染循环中多开一层for,进行多次采样即可,采样的结果无脑的加到最终颜色上,放心,咱们之前写好的write_color函数会帮我们把一切掰回正轨。 ```cpp

    include “camera.h”

int main() {

  1. // 我们必须要一些参数来告诉程序需要生成什么样的图片,遗憾的是这部分代码逻辑上和摄像机没有关系,
  2. // 咱们还得把它放在main里。
  3. const auto aspect_ratio = 16.0 / 9.0;
  4. const int image_width = 400;
  5. const int image_height = static_cast<int>(image_width / aspect_ratio);
  6. // 采样次数
  7. const int samples_per_pixel = 100;
  8. hittable_list world;
  9. world.add(make_shared<sphere>(point3(0,0,-1), 0.5));
  10. world.add(make_shared<sphere>(point3(0,-100.5,-1), 100));
  11. // 只需要一个构造函数,我们就可以把相机安排妥当。
  12. camera cam;
  13. // Render Loop
  14. std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
  15. for (int j = image_height-1; j >= 0; --j) {
  16. std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
  17. for (int i = 0; i < image_width; ++i) {
  18. color pixel_color(0, 0, 0);
  19. //多了一层循环哦。
  20. for (int s = 0; s < samples_per_pixel; ++s) {
  21. // 随机数出场了,u和v每次都会随机加上一个[0,1)的数,然后除以image的长宽之后,
  22. // 就会落到一个像素内的随机位置。
  23. auto u = (i + random_double()) / (image_width-1);
  24. auto v = (j + random_double()) / (image_height-1);
  25. // 调用摄像机中封好的函数创造射线。
  26. ray r = cam.get_ray(u, v);
  27. // 无脑颜色累加即可。
  28. pixel_color += ray_color(r, world);
  29. }
  30. //最终的绘制颜色代码中,再做最终除法。
  31. write_color(std::cout, pixel_color, samples_per_pixel);
  32. }
  33. }
  34. std::cerr << "\nDone.\n";

}

  1. 经过一段较长的等待之后,打开并放大图片,你会得到:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/25639626/1641055023853-5ad8e5c5-7671-4f8e-935c-e3cb0cb46544.png#clientId=u64bb19eb-b5b3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=175&id=u59ce6007&margin=%5Bobject%20Object%5D&name=image.png&originHeight=268&originWidth=456&originalType=binary&ratio=1&rotation=0&showTitle=false&size=21910&status=done&style=none&taskId=u8a35e3b4-76bd-468e-afa3-a75d57cb314&title=&width=298)![image.png](https://cdn.nlark.com/yuque/0/2022/png/25639626/1641054959394-b2494392-8f05-402d-8d58-fa3482970136.png#clientId=u64bb19eb-b5b3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=174&id=uc7049de7&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1183&originWidth=816&originalType=binary&ratio=1&rotation=0&showTitle=false&size=11506&status=done&style=none&taskId=udccd57fc-92ef-4944-a906-e2caf75a479&title=&width=120)<br />我们的小图片看起来比过去的要圆润清晰的多!<br />嘿,强大的力量都需要代价,这可比我们过去花了多一百倍的时间呢。你或许会问,这个时间能省下来吗?有优化的方法吗?我只能告诉你对于单线程程序,没有。多次采样是我们渲染效果真实感的保障,如果你想得到越好的效果,你就得使用更多次的采样。<br />多次采样不仅仅是为了抗锯齿,从下一章开始,我们将正式开始真实感图像的渲染,你会发现,如果离开了随机多次采样,我们根本无法得到那些真实感图片。<br />光线追踪的精髓就在于模拟光线的随机弹射,我们之所以能看见事物,是无数根光线综合作用的结果,真实的物体表面不可能是绝对光滑的,这会导致漫反射。而帮助我们看到物体的几乎全部的光线,都是通过漫反射得来的。<br />下章开始,我将带你制作光线随机弹射的模型,并实现漫反射材质。
  2. <a name="YSeR7"></a>
  3. #### 课后实践
  4. 1. 设置采样次数为1100,观察两张图片的物体边缘,对比观察抗锯齿的效果。
  5. 1. 修改渲染循环中的部分代码如下:
  6. ```cpp
  7. auto u = (i + random_double(-3,3)) / (image_width-1);
  8. auto v = (j + random_double(-3,3)) / (image_height-1);

你觉得这会产生什么样的效果?实际运行一下,效果和你想象的一致吗?

  1. 如果只是针对上述抗锯齿程序,是可以进行优化的。尝试只修改main函数所在文件中的代码,优化上述程序,使得生成图片速度加快。

(提示1:对于球的表面上,或者“蓝天”部分等原来就没有锯齿的地方,我们根本没有必要进行几百次的采样,我们可以进行少量采样,或者干脆一次采样。)
(提示2,可以尝试先进行少量的随机,如随机10-20次,然后通过这些光线返回的信息判断这个像素内是否出现“断层”,“断层”指的是:一半光线什么都没有射到,一半光线射中了一颗球。或者一半的光线射中了球A,另一半射中了球B。再通过像素内是否有“断层”来决定这个像素是否需要抗锯齿。)

参考文献

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