实现OpenGL渲染器原理篇(四)——透视投影

最近在Github上复现了一个渲染器render的项目:
Github链接:tiny render

我希望在博客上可以记录自己的学习过程,博客主要分为两大类:《原理篇》和《语法篇》。
原理篇则主要讲的是——实现渲染器过程中所需要的算法知识。
语法篇则主要讲的是——实现渲染器过程中使用到的C++的语法知识。


一、The goal

在之前的3篇博客中,大家通过简单地忽略z坐标来渲染正投影模型。
也就是说,我们之前的目标是渲染一个正投影模型
今天的目标是如何画透视图

The goal

我们现在完成透视图的绘制,最终可以完成全3D的渲染


二、2D几何

2.1 线性转换

平面上的线性变换可以由相应的矩阵表示。
如果我们取一个点(x,y),那么它的变换可以写成下面这样:

变换1

最简单的(不是退化的)变换是单位矩阵,它一点都不会动

变换2

矩阵的对角系数沿坐标轴缩放。

让我来说明一下,如果大家进行以下转换

变换3

然后,白色物体(一个角被切掉的白色正方形)将变成黄色
红色和绿色线段分别给出了与x和y对齐的单位长度向量:

白色object

我们为什么要用矩阵来解决问题呢?因为它很方便。

首先,在矩阵形式中我们可以像这样表示整个物体的变换

变换矩阵

在此表达式中,变换矩阵与上一个矩阵相同,但是2x5矩阵就是我们的方形物体的顶点

我们简单地将所有顶点放在数组中,然后将其乘以转换矩阵,即可获得转换后的对象

真正的原因是这样: 大部分人经常希望通过许多连续的转换来转换对象。

想象一下,在你的源代码中写了这样的转换函数——

vec2 foo(vec2 p) 
    return vec2(ax+by, cx+dy);
vec2 bar(vec2 p)
    return vec2(ex+fy, gx+hy);
[..]

for (each p in object)
{
    p = foo(bar(p));
}

这段代码对对象的每个顶点执行两次线性转换,我们通常以百万计的次数来计算这些顶点。

连续进行数十次的转换并不少见,从而导致数以千万计的操作是非常昂贵的。

在矩阵形式中,我们可以对所有的变换矩阵进行预乘,并对我们的对象进行一次变换。

对于只有乘法的表达式,我们可以在所需的地方加上括号,可以吗?

大家应该知道,事实上,矩阵的对角系数是沿着坐标轴缩放的。

让我们考虑下面的转换:

对角系数

以下就是它对物体的作用

白色物体变换

通过观察上图,大家可以发现,它是沿着x轴的简单剪切

另一个对角元素则是沿y轴剪切空间。因此,在一个平面上有两个基本的线性变换缩放和剪切

当然,看到这里,大家或许会反应: 那旋转呢?

事实证明,任何旋转(绕原点的旋转)都可以表示为三个剪切的组合动作——

这里白色对象被转换为红色对象,然后转换为绿色对象,最后转换为蓝色对象(白色换红色的时候是x轴的移动,然后红色换绿色的时候是y轴的移动,绿色换为蓝色则又是x轴的移动):

旋转

但是这些都是复杂的细节,为了简单起见,可以直接编写一个旋转矩阵(大家都还记得预乘技巧吗?):

旋转矩阵

大家可以将矩阵以任何顺序相乘,但请记住,矩阵的乘法是不可交换的

image.png

这下讲得通了: 先剪切一个物体,然后旋转它,与先旋转它,然后剪切它是不一样的!

先剪切一个物体,后旋转它,与先旋转它,后剪切它是不一样的!

上面两张图可以进行比较,大家可以看看。


三、2D仿射变换

因此,平面上的任何线性变换都是比例变换和剪切变换组合
这意味着我们可以做任何我们想做的线性变换,而原点,则永远不会移动

是的,平移不是线性的。大家可以试着在完成线性部分后加上平移

加号左边则是线性转换部分,加号右边则是平移

上面这个表达式也真的很酷,它告诉我们可以旋转缩放剪切平移

但是我们需要注意:我们的目标是组合多个转换

下面的表达式则是两个转换的组合(记住,我们需要组合许多这样的转换):

括号中的左边是线性转换,右边是平移部分。下来对得到新的括号中的坐标,再进行第2轮线性变换,然后第2轮的平移变换

大家需要明白一点,即使仅仅是单一的合成物,看起来也会很难看,那么如果后面添加更多的东西,看起来则会变得更糟


四、齐次坐标

好,下来带大家看看什么叫黑科技

想象一下,我们要将一列纵行一行横行添加到我们的转换矩阵中(因此使其变为3x3),并将一个始终等于1的坐标附加到将要转换的向量上:

上图中,左边3×3则是转换矩阵,3×1则是要转换的向量,等式右边则是结果

如果我们将转换矩阵乘以加了1后的矢量,我们将得到一个新的向量(最后一个元素是1),如上图右边所示。而元素1上面的其他两个分量表达的形状恰好是我们想要的!

大家需要明白一点,在2D空间中,平行平移不是线性的

因此,我们将2D嵌入到3D空间中(只需将1添加到第3个元素即可)。

这意味着我们的2D空间是建立在3D空间中的平面z = 1的基础上的。

然后,我们执行线性3D变换并将结果投影到我们的2D物理平面上。

平行平移不是线性的,但是传递路径是简单的。

那么大家简单地想想,如何将3D投影回2D平面呢?只需用前两个元素向量除以第三个向量分量即可:

将3D投影回2D平面

五、不要除以零!

OK,让我们一起先回顾以下传递路径

  • 我们将2D嵌入3D,方法是将它置于平面z=1内
  • 我们在3D中做任何我们想做的事;
  • 对于要从3D投影到2D的每个点,我们在原点和要投影的点之间绘制一条直线,然后找到其与平面z = 1的交点

在此图像中,我们的2D平面为品红色,点(x, y, z)投影到了点(x / z, y / z, 1)上(回顾一下上面说的3个步骤):

从3D投影到2D

假设有一条垂直的直线穿过点(x,y,1)。那么点(x,y,1)会投影在哪里?

没错,在(x, y)上

3D空间点(x, y, 1)投影到2D平面上的点(x, y, 0)上

现在我们沿着直线向下,例如,3D坐标点(x, y, 1/2)会投影到2D坐标点(2x, 2y, 0)上:

3D空间点投影到2D空间上对应的点

不要停,我们继续点(x, y, 1/4)变成了点(4x, 4y, 0)

同上的栗子

如果我们继续这个过程趋近于z=0,那么其投影将沿着(x,y)方向上,离原点越来越远

换句话说,点(x,y,0)会投影到(x,y)方向上一个无限远的点上。

没错!那这个是个什么呢?很简单,一个向量而已!

大家需要记住一点,齐次坐标是可以区分向量和点的。

我想问问大家,如果一个程序员写了vec2(x,y),那么它是一个向量还是一个点
答案可以这样讲:

  • 在齐次坐标中,z = 0的所有事物都是向量,其余均为

注意:

  • vector + vector = vector.
  • vector - vector = vector.
  • point + vector = point.

六、复合转换

正如我之前所说的,大家在这个过程中应该能够积累数十个转换

让我们想象一下,如果我们需要围绕点(x0,y0)旋转一个对象(2D),我们需要怎么做呢?

可以查查公式呀,可以手算,都可以的!

因为大家知道如何围绕原点旋转,如何平移。这些就是所有的我们需要的了!

大家可以将点(x0, y0)平移到原点旋转反平移,完成:

平移旋转反平移

在3D中,动作序列会更长一些,但思路是一样的:我们需要知道一些基本的转换,在它们的帮助下,我们可以表示任何组合的动作


七、Touch一下神奇的3×3矩阵的底排

让我们将以下转换应用于我们的标准方形对象

新的转换

大家回想一下,原始对象是白色的,单位轴向量是红色和绿色的。

原始对象是白色的,单位轴向量是红色和绿色的

下面是转换后的对象

image.png

在这里,另一种魔法(白色!)发生了

大家还记得我们之前ybuffer区的练习吗?

接下来大家只需要做同样的事就可以了:将2D对象投影到x=0的垂直线上。

让我们对规则再进行一些加强:大家必须使用中心投影,我们的相机是位于点(5,0)的,并且是指向原点的

为了找到投影,我们需要跟踪相机和要投影的点之间的直线(黄色线),并找到与屏幕线(白色垂直线)的交点

跟踪相机和要投影的点之间的直线(黄色线),并找到与这条黄色线和屏幕线(白色垂直线)之间的交点

现在我用转换后的对象替换原来的对象,但是我没有触及到我们之前画的黄线

用转换后的对象替换原来的对象,但是没有触及到之前画的黄线

如果我们用标准的正交投影红色的物体投影到屏幕上,那么我们会找到完全相同的点

让我们仔细看看转换是如何进行的:所有的垂直部分都还是转换成了垂直部分,但是那些靠近摄像机的部分被拉长了,那些远离摄像机的部分被缩小了。

如果我们正确选择系数(在我们的变换矩阵中是-1/5系数),我们将获得透视(中央)投影的图像


八、是时候开启全3D的渲染了!

基于2D仿射变换,大家可以使用齐次坐标得到3D仿射变换点(x, y, z)增加一维,数值为1,变成(x, y, z, 1),接下来我们将它转换为4D,然后重新投影为3D

举个例子,如果我们进行以下转换:

将3D转换为4D

逆投影为我们提供了以下3D坐标

4D转换为3D

大家先记住这个结果,但先把它放在一边。

让我们回到中心投影的标准定义,没有任何花哨的4D变换

给定一个点P =(x, y, z),我们要将其投影到平面z = 0的面上,相机位于点(0, 0, c)的z轴上:

中心投影

三角形ABC和ODC相似的,这意味着我们可以写下如下的等式:
|AB|/|AC|=|OD|/|OC| => x/(c-z) = x'/c

换句话说,

新的等式1

通过对三角形CPB和CP'D进行相同的推理,很容易找到以下表达式:

新的等式2

这和我们几分钟前放到一边的结果很相似,但是我们通过单矩阵乘法得到了结果。
我们得到了求系数的规律:


九、总结:今日重要的公式

如果我们想要用一个相机计算中心投影

相机位于距离原点为c的Z轴上

然后我们通过增加1把这个点嵌入到4D中(公式1)

然后我们将它与下面的矩阵(公式2左边的矩阵)相乘,并将其反投影到3D中(公式3)。

重要公式截图

我们以某种方式使对象变形,只需忽略其z坐标,我们就可以得到透视图

如果我们要使用z缓冲区,那么自然不要忘记z


学习之初,我整理了每节课中代码的变化,现在看来用处不大,但是删了可惜,大家有需要的可以看看哈~~

这篇博客用到的代码文件的变化是这样的:

  • tgaimage.h

(初始导入)->(新光栅化器+z-buffer)->(初始导入)->(diffuse texture work)

  • tgaimage.cpp

(初始导入)->(新光栅化器+z-buffer)->(初始导入)

  • african_head.obj

(线框渲染)->(新光栅化器+z-buffer)->(matrix class,立方体模型)->(textures)

  • geometry.h

(线框渲染)->(新光栅化器+z-buffer)->(templates)->(投影和viewport矩阵)

  • geometry.cpp

(templates)->(投影和viewport matrices矩阵)

  • main.cpp

(朴素线段追踪)->(线段追踪、减少划分的次数)->(线段追踪:all integer Bresenham)->(线框渲染)->(better test triangles)->(三角形绘制routine)->(背面剔除 + 高洛德着色)->(y-buffer!)->(新光栅化器+z-buffer)->(templates)->(投影和viewport matrices矩阵)

  • model.cpp

(线框渲染)->(新光栅化器+z-buffer)->(线框渲染)->(diffuse texture homework)

  • model.h

(线框渲染)->(diffuse texture homework)

解释一下上述文件括号中的文字——
model.h开始变化,从线框渲染变成了diffuse texture work
model.cpp则是变回了线框渲染,然后又变成了diffuse texture work
main.cpp加入了templates,后面又应用到了投影和viewport matrices矩阵,其中viewport矩阵有多个,所以是matrices。
新增了一个文件geometry.cpp,也是加入了templates,后续也是加入了投影和viewport matrices矩阵
geometry.h也是从光栅格器 + z-buffer变成了templates,最后成为了投影和viewport matrices矩阵
.obj文件的作用此番变成了矩阵类,立方体模型,后面呢,又成为了textures
tgaimage.h/.cpp则均是由光栅格化器 + z-buffer回到了初始导入,但是tgaimage.h第二次加入了diffuse texture homework

其中model时用来test测试的。

你可能感兴趣的:(实现OpenGL渲染器原理篇(四)——透视投影)