最近在Github上复现了一个渲染器render的项目:
Github链接:tiny render
我希望在博客上可以记录自己的学习过程,博客主要分为两大类:《原理篇》和《语法篇》。
原理篇则主要讲的是——实现渲染器过程中所需要的算法知识。
语法篇则主要讲的是——实现渲染器过程中使用到的C++的语法知识。
一、The goal
在之前的3篇博客中,大家通过简单地忽略z坐标来渲染正投影模型。
也就是说,我们之前的目标是渲染一个正投影模型。
今天的目标是如何画透视图:
我们现在完成透视图的绘制,最终可以完成全3D的渲染。
二、2D几何
2.1 线性转换
平面上的线性变换可以由相应的矩阵表示。
如果我们取一个点(x,y),那么它的变换可以写成下面这样:
最简单的(不是退化的)变换是单位矩阵,它一点都不会动。
矩阵的对角系数沿坐标轴缩放。
让我来说明一下,如果大家进行以下转换:
然后,白色物体(一个角被切掉的白色正方形)将变成黄色。
红色和绿色线段分别给出了与x和y对齐的单位长度向量:
我们为什么要用矩阵来解决问题呢?因为它很方便。
首先,在矩阵形式中我们可以像这样表示整个物体的变换:
在此表达式中,变换矩阵与上一个矩阵相同,但是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轴的移动):
但是这些都是复杂的细节,为了简单起见,可以直接编写一个旋转矩阵(大家都还记得预乘技巧吗?):
大家可以将矩阵以任何顺序相乘,但请记住,矩阵的乘法是不可交换的:
这下讲得通了: 先剪切一个物体,然后旋转它,与先旋转它,然后剪切它是不一样的!
上面两张图可以进行比较,大家可以看看。
三、2D仿射变换
因此,平面上的任何线性变换都是比例变换和剪切变换的组合。
这意味着我们可以做任何我们想做的线性变换,而原点,则永远不会移动!
是的,平移不是线性的。大家可以试着在完成线性部分后加上平移:
上面这个表达式也真的很酷,它告诉我们可以旋转、缩放、剪切和平移。
但是我们需要注意:我们的目标是组合多个转换。
下面的表达式则是两个转换的组合(记住,我们需要组合许多这样的转换):
大家需要明白一点,即使仅仅是单一的合成物,看起来也会很难看,那么如果后面添加更多的东西,看起来则会变得更糟。
四、齐次坐标
好,下来带大家看看什么叫黑科技。
想象一下,我们要将一列纵行和一行横行添加到我们的转换矩阵中(因此使其变为3x3),并将一个始终等于1的坐标附加到将要转换的向量上:
如果我们将转换矩阵乘以加了1后的矢量,我们将得到一个新的向量(最后一个元素是1),如上图右边所示。而元素1上面的其他两个分量表达的形状恰好是我们想要的!
大家需要明白一点,在2D空间中,平行平移不是线性的。
因此,我们将2D嵌入到3D空间中(只需将1添加到第3个元素即可)。
这意味着我们的2D空间是建立在3D空间中的平面z = 1的基础上的。
然后,我们执行线性3D变换并将结果投影到我们的2D物理平面上。
平行平移并不是线性的,但是传递路径是简单的。
那么大家简单地想想,如何将3D投影回2D平面呢?只需用前两个元素向量除以第三个向量分量即可:
五、不要除以零!
OK,让我们一起先回顾以下传递路径:
- 我们将2D嵌入3D,方法是将它置于平面z=1内;
- 我们在3D中做任何我们想做的事;
- 对于要从3D投影到2D的每个点,我们在原点和要投影的点之间绘制一条直线,然后找到其与平面z = 1的交点。
在此图像中,我们的2D平面为品红色,点(x, y, z)投影到了点(x / z, y / z, 1)上(回顾一下上面说的3个步骤):
假设有一条垂直的直线穿过点(x,y,1)。那么点(x,y,1)会投影在哪里?
没错,在(x, y)上。
现在我们沿着直线向下,例如,3D坐标点(x, y, 1/2)会投影到2D坐标点(2x, 2y, 0)上:
不要停,我们继续,点(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矩阵的底排
让我们将以下转换应用于我们的标准方形对象:
大家回想一下,原始对象是白色的,单位轴向量是红色和绿色的。
下面是转换后的对象:
在这里,另一种魔法(白色!)发生了。
大家还记得我们之前ybuffer区的练习吗?
接下来大家只需要做同样的事就可以了:将2D对象投影到x=0的垂直线上。
让我们对规则再进行一些加强:大家必须使用中心投影,我们的相机是位于点(5,0)的,并且是指向原点的。
为了找到投影,我们需要跟踪相机和要投影的点之间的直线(黄色线),并找到与屏幕线(白色垂直线)的交点。
现在我用转换后的对象替换原来的对象,但是我没有触及到我们之前画的黄线。
如果我们用标准的正交投影将红色的物体投影到屏幕上,那么我们会找到完全相同的点。
让我们仔细看看转换是如何进行的:所有的垂直部分都还是转换成了垂直部分,但是那些靠近摄像机的部分被拉长了,那些远离摄像机的部分被缩小了。
如果我们正确选择系数(在我们的变换矩阵中是-1/5系数),我们将获得透视(中央)投影的图像!
八、是时候开启全3D的渲染了!
基于2D仿射变换,大家可以使用齐次坐标得到3D仿射变换:点(x, y, z)增加一维,数值为1,变成(x, y, z, 1),接下来我们将它转换为4D,然后重新投影为3D。
举个例子,如果我们进行以下转换:
逆投影为我们提供了以下3D坐标:
大家先记住这个结果,但先把它放在一边。
让我们回到中心投影的标准定义,没有任何花哨的4D变换。
给定一个点P =(x, y, z),我们要将其投影到平面z = 0的面上,相机位于点(0, 0, c)的z轴上:
三角形ABC和ODC是相似的,这意味着我们可以写下如下的等式:
|AB|/|AC|=|OD|/|OC| => x/(c-z) = x'/c
换句话说,
通过对三角形CPB和CP'D进行相同的推理,很容易找到以下表达式:
这和我们几分钟前放到一边的结果很相似,但是我们通过单矩阵乘法得到了结果。
我们得到了求系数的规律:
九、总结:今日重要的公式
如果我们想要用一个相机计算中心投影
相机位于距离原点为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测试的。