0%

十三:自由相机

球为什么会被拉长

在以前的图片中可以发现越靠近边缘位置越会被拉伸的厉害,现在我来尝试解释一下这个问题:

image-20220313131416140

上图是y = 0的情况下,场景的 XoZ面截面。O为相机位置,也是发射光线的原点,横线为Z = -1 位置上的虚拟视口,在空间中有三个大小一样的球,三个球的球心都在y = 0平面上,中间的那颗球处在虚拟视口的正中心,即它的位置是(0,0,-1)。现在开始往虚拟视口上发射光线,来看看每颗球x轴上的宽度是多大。

可以清楚的看到,A点虽然在虚拟视口上来看,和球还是有点距离,但是往A点发射光线来确定这一点的颜色的时候,发现它正好处于球的边界上,即在最终渲染的2D图片上,AB才是这颗球与x轴平行的直径,它显然比2D图片上处于中间的球的直径BD要长的多。

那如何对抗图片边缘变形问题呢?有两个解决方法:让虚拟视口变得非常小,或者让虚拟视口离相机位置足够远。

image-20220313131438632如图所示,把虚拟视口缩小,这样图中的三个球在最终显示的图片上来看就不会有非常明显的长度不一的问题了。因为视口变小,视口中的像素数量不变,所以每个像素在三维空间中的大小也变小了,所以图片的最终质量并不会有任何改变。

可调节视野

虚拟视口的长宽比不应该被限制,同时虚拟视口的高度也不应该恒定为2,假设有一个物体在我们的面前但是高度很高,比如它在(0,10,-1)的位置,它不会被相机捕捉到,我们得赋予它自由度,让它想看多高看多高。

对于方形的照片,在真实的摄影技术中,有一个数据叫视场(field of view),简写为fov,一般使用的是竖直fov,它表示相机处到视野最高处和视野最低处连线的夹角,如果是水平fov的话就是相机处和视野中最左最右处连线的夹角。如果知道长宽比的话,竖直和水平的fov就可以进行简单换算。

image-20220313132406568

如图所示,θ就是fov,我们就使用指定fov的方式来让视口高度变得可自定义。

还有一点纯属个人设计风格:传入角度值,再由类内转为弧度制进行后续计算。修改camera.h中的camera类代码如下所示:

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
class camera {
public:
camera(double vfov /*竖直fov(vertical field of view)*/
,double aspect_ratio /*长宽比,把它暴露出去使之可自定义*/ ) {

//角度转弧度制,我们好久之前写的工具函数。
auto theta = degrees_to_radians(vfov);

//虚拟视口还是放在z = -1的地方。
auto focal_length = 1.0;

//通过换算得到上图中的h。
auto h = focal_length * tan(theta/2);

//这样虚拟视口就一步一步由fov换算得来了。
auto viewport_height = 2.0 * h;

auto viewport_width = aspect_ratio * viewport_height;
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);
}

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;
};

现在来测试一下,在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
int main() {
...

// 指定视场大小
double vfov = 10.0;

// 更改物体信息。
hittable_list world;

auto material_up = make_shared<lambertian>(color(1, 0, 0));
auto material_bottom = make_shared<lambertian>(color(0, 0, 1));

// 我们想创造两个球心在边界上的球。
auto R = tan(degrees_to_radians(vfov) / 2);
world.add(make_shared<sphere>(point3(0, R, -1), R, material_up));
world.add(make_shared<sphere>(point3(0, -R, -1), R, material_bottom));

// 定义一个vfov是10度的相机,并且传入我们之前定义在main中长宽比。
camera cam(vfov, aspect_ratio);

// 渲染循环
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for (int j = image_height-1; j >= 0; --j) {
...

会得到两个图片边缘的球,它们没有再被明显的拉长了,因为fov = 10° 的情况下,虚拟视口的大小比原来小得多(之前我们的高度h恒定为2的情况,fov按照反向换算应该为90°)。

image-20220313132539787

完全自由的相机

在视野大小和长宽比可以调整之后,相机身上的五个枷锁中的后两个已经解开,现在还有三个固定死的桎梏。接下来只要让相机的位置和朝向可以自由选定,就可以让相机彻底解放。

另外,虚拟视口大小可以通过fov自由控制之后,我们就没有必要再改变虚拟视口离相机的远近了,这不会给场景带来更多新的变化。可以缩小虚拟视口,实际上和把虚拟视口拉远得到的效果是完全一样的,所以固定死这个距离,假设这个距离恒定为1。

我们需要自由指定相机的位置,也就是说我们要暴露出一个point3(vec3的别名)变量来表示位置,这很容易想到。

那要如何指定相机看向的方向呢?有两个策略,给一个方向向量或者给定一个点表示看向的目标点,这样就可以通过和相机位置的连线得到同样方向向量,这里选择后者。

image-20220313132829324

相机放在lookfrom点上,看的目标点是lookat。

fov也已经有了,现在能开始构建相机了吗?不能,还有一个东西我们没法确定。想想看,你站在某个地方,望着桌子上的苹果,苹果一定会处在你视角的正中心,但是,我无法确定你有没有以你的鼻子为中心左右旋转你的头——在lookfrom所在的面和lookat-lookfrom向量垂直的向量有无数多条,我们不知道,相机的“头顶”是哪个方向。

这个问题,业界比较青睐的解决方案是确定一个vup(view up)方向,一般来说,只要不歪头,这个vup的值都是(0,1,0),即y轴正方向。但是搞不好就有一些特殊的要求,比如说,从墙角伸出一杆狙击枪,我们需要歪头去看瞄准镜,这时候,瞄准镜内的世界就是歪斜的,vup就会指向斜上方。

image-20220313133058687

看上左图,lookfrom、lookat和vup向量唯一确定了一个相机。

左图中的uv两个坐标轴,也就是我们的老相机里面的horizontal和vertical向量。

老相机是朝-z方向看的,而新的相机是朝-w(上右图中的向量)方向看的。右图中w,v和vup三个向量在同一个面上。最终相机代码如下:

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
class camera {
public:
camera(
// 相机位置。
point3 lookfrom,
// 相机看向的目标点。
point3 lookat,
// 相机正上方方向向量(通常为(0,1,0))。
vec3 vup,
// 视场大小。
double vfov,
// 长宽比。
double aspect_ratio
) {
auto theta = degrees_to_radians(vfov);
auto h = tan(theta/2);
auto viewport_height = 2.0 * h;
auto viewport_width = aspect_ratio * viewport_height;

// w向量是从lookat指向lookfrom的向量。
auto w = unit_vector(lookfrom - lookat);

// u向量与vup和w都垂直,我们可以直接叉乘得到它。
// 叉乘注意两个变量的前后顺序,注意叉乘结果向量的方向满足右手定则。
auto u = unit_vector(cross(vup, w));
// v向量与w及u向量都垂直,叉乘得到。
auto v = cross(w, u);

// 赋值原点。
origin = lookfrom;
// horizontal方向不再是(1,0,0),而是u。
horizontal = viewport_width * u;
// vertical方向不再是(0,1,0),而是v。
vertical = viewport_height * v;

// 虚拟视口左下角的坐标位置,原本减去vec3(0, 0, focal_length)的位置改成了w。
lower_left_corner = origin - horizontal/2 - vertical/2 - w;
}

ray get_ray(double s, double t) const {
return ray(origin, lower_left_corner + s*horizontal + t*vertical - origin);
}

private:
point3 origin;
point3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
};

虚拟视口离相机的距离由w决定,它是个单位向量,永远是1。我们不需要动他,改变fov能达到相同的效果。

在main函数中改变场景物体和相机参数,来测试一下这个自由的相机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hittable_list world;

auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.5);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2), 0.0);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.0), 0.5, material_center));
//空心玻璃球不要紧贴其他物体,否则会产生黑点。
world.add(make_shared<sphere>(point3(-1.001, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(-1.001, 0.0, -1.0), -0.45, material_left));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));

camera cam(point3(-2,2,1), point3(0,0,-1), vec3(0,1,0), 90, aspect_ratio);

相机原点放在(-2,2,1)上,这离场景中的这些球很远,所以我们会得到:

image.png

fov为90的情况下,虚拟视口的高度为2,球处在我们图片的正中心,但很小.

如果想得到近景,只需要把fov改小,比如改成20,就可以得到清晰的细节,就好像使用了望远镜一样。

1
camera cam(point3(-2,2,1), point3(0,0,-1), vec3(0,1,0), 20, aspect_ratio);

image.png

参考文献

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

参考自《RayTracingInOneWeekend》第11节。