你开始觉得我们的图片的球体边缘的锯齿非常难看了对吧?嗯?你认为这是我们分辨率不够大的原因?那你把分辨率扩大四倍看看?const int image_width = 1600;
试试?
你会得到下面这张图,我们把它放大:
看到没,讨厌的锯齿还是存在,就算大分辨率带来的清晰可以一定程度上抵消锯齿带来的不适感,但是,去完成去挑战也是我们的使命,不是么?
我们要如何抗锯齿呢?我们先来想想为什么会有锯齿?
抗锯齿的本质
锯齿是如何产生的呢?想想看我们是怎么决定每个像素颜色的?没错,我们往像素的左上角射出一根光线,通过这根光线带回的信息来对像素着色?你想想这样合理吗?假设有一个球在空间中,我们以相机为起点向某一个像素中任意一点射出光线,有99.999%的情况下射出的光线最终会碰到这个球,只有很小概率——很不凑巧就唯独通过左上角的若干个点射出的光线碰不到这颗球,最终返回了“蓝天”,你觉得,左上角这一个点能决定整个像素的颜色吗?显然不能。
如果光线射到球的边界上,我们上述提到的问题就会被放大——这一个像素里,有蓝天和球,但是它最终只能选择着色成蓝天还是球,没有任何商量的空间,中间也没有任何可以过渡的状态,这就是锯齿产生的原因——采样的频率跟不上情况的变化。因为我们的采样频率是1(对于每一个像素我们只采样一次,即左上角),而真实中这个像素是什么颜色,我们可以多采样几次再综合进行着色。
了解抗锯齿的本质是增加采样频率之后,我们第一步要做什么呢?我们朝一个像素中的多个点射出光线,这些点最好分布足够均匀,这样采样出来的结果才更为自然准确。
我们可以用随机数,比如我们可以像上图一样,在一个像素内随机选了四个点,然后我们射出四根光线,对四根光线带回的结果求平均即可。
随机数
随机数可以说是光线追踪的核心了,我们需要用随机数来模拟在空间中胡乱弹射的光线,而且,抗锯齿中,在同一个像素中选点也是需要随机数的。
我先带你回忆一下C语言中的随机数,在rtweekend.h中加入如下内联函数:
#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函数改掉:
#include <random>
inline double random_double() {
static std::uniform_real_distribution<double> distribution(0.0, 1.0);
static std::mt19937 generator;
return distribution(generator);
}
mt19937随机数生成器放在
相机封装
这是一个封装我们的相机的好机会。在开始我们的多次采样之前,我们先把相机处理完,让我们的main函数中少一点乱七八糟的代码。
你还记得我们在制作光线的时候提到,我们的相机是一个在(0,0,0)点并永远望向z轴负方向的相机吗?我们今天的主题是抗锯齿,虽然我很想在这里把相机移动和视场缩放等等功能定义出来,但是现在应该还不是时候,我们就简单的先封装一下,其他的之后再说。
我们可以把在main函数中渲染循环外对相机的所有操作都移动到相机类的构造函数里,然后创建一个类内函数专门用来发射光线,这样设计下来,在main函数中所剩的代码最为清爽。
创建camera.h文件,敲入如下代码:
#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类中写过一个函数,它把给定的颜色输出到给定输出流中。调用这个函数的地方是渲染循环,循环不断地确定每个像素的颜色,然后交由color类中这个write_color函数进行最终的输出。因为现在我们需要多次采样,现在我们从main函数中传给write_color函数的颜色可能不再是像素最终的颜色,而是多次采样后颜色值的叠加,我们需要在write_color中处理这个叠加后的颜色值——即除以采样次数,这样才能得到最终颜色值进行输出,当然,这一步你完全可以在main函数中做好,再传一个可以直接输出的颜色值进来,但是我喜好的设计是,咱们在外面不用管,只管累加颜色结果,最终的除法由color.h来做,看代码:
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';
}
这个write_color函数只做下面两件事情:
- 对输入进来的颜色的每个通道除以采样次数。
- 输出它们,但在之前得确保颜色的各个分量在[0,1],以使得最终结果是[0,255]之间的整数。对此我们需要一个clamp函数,这个函数唯一要做的事情就是监督传进来的值是否是在给定min和max之间,如果不是,则把值调整到边界上,看如下代码,注意,这个函数你得写到rtweekend.h文件里,不要让项目乱了套。
inline double clamp(double x, double min, double max) {
if (x < min) return min;
if (x > max) return max;
return x;
}
修改main函数
现在咱们只剩main函数需要修改了,把你的camera包进来,然后在渲染循环中多开一层for,进行多次采样即可,采样的结果无脑的加到最终颜色上,放心,咱们之前写好的write_color函数会帮我们把一切掰回正轨。 ```cppinclude “camera.h”
…
int main() {
// 我们必须要一些参数来告诉程序需要生成什么样的图片,遗憾的是这部分代码逻辑上和摄像机没有关系,
// 咱们还得把它放在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";
}
经过一段较长的等待之后,打开并放大图片,你会得到:<br /><br />我们的小图片看起来比过去的要圆润清晰的多!<br />嘿,强大的力量都需要代价,这可比我们过去花了多一百倍的时间呢。你或许会问,这个时间能省下来吗?有优化的方法吗?我只能告诉你对于单线程程序,没有。多次采样是我们渲染效果真实感的保障,如果你想得到越好的效果,你就得使用更多次的采样。<br />多次采样不仅仅是为了抗锯齿,从下一章开始,我们将正式开始真实感图像的渲染,你会发现,如果离开了随机多次采样,我们根本无法得到那些真实感图片。<br />光线追踪的精髓就在于模拟光线的随机弹射,我们之所以能看见事物,是无数根光线综合作用的结果,真实的物体表面不可能是绝对光滑的,这会导致漫反射。而帮助我们看到物体的几乎全部的光线,都是通过漫反射得来的。<br />下章开始,我将带你制作光线随机弹射的模型,并实现漫反射材质。
<a name="YSeR7"></a>
#### 课后实践
1. 设置采样次数为1和100,观察两张图片的物体边缘,对比观察抗锯齿的效果。
1. 修改渲染循环中的部分代码如下:
```cpp
auto u = (i + random_double(-3,3)) / (image_width-1);
auto v = (j + random_double(-3,3)) / (image_height-1);
你觉得这会产生什么样的效果?实际运行一下,效果和你想象的一致吗?
- 如果只是针对上述抗锯齿程序,是可以进行优化的。尝试只修改main函数所在文件中的代码,优化上述程序,使得生成图片速度加快。
(提示1:对于球的表面上,或者“蓝天”部分等原来就没有锯齿的地方,我们根本没有必要进行几百次的采样,我们可以进行少量采样,或者干脆一次采样。)
(提示2,可以尝试先进行少量的随机,如随机10-20次,然后通过这些光线返回的信息判断这个像素内是否出现“断层”,“断层”指的是:一半光线什么都没有射到,一半光线射中了一颗球。或者一半的光线射中了球A,另一半射中了球B。再通过像素内是否有“断层”来决定这个像素是否需要抗锯齿。)
参考文献
https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第7节。