最近在Github上复现了一个渲染器render的项目:
Github链接:tiny render
我希望在博客上可以记录自己的学习过程,博客主要分为两大类:《原理篇》和《语法篇》。
原理篇则主要讲的是——实现渲染器过程中所需要的图形学渲染算法知识。
语法篇则主要讲的是——实现渲染器过程中使用到的C++的语法知识。
一、必要的几何知识
下面这个头部的渲染大家要使用已知的Gouraud shading来完成渲染。
上图中,我移除了所有的纹理。Groraud Shading非常简单。
在.obj文件中的"vn x y z"行中,这行的内容提供了模型中每个顶点的法线矢量。
我们计算了每个顶点的光照强度(而不是像以前那样对每个三角形进行平面着色)
然后简单地在每个三角形内插值强度,就像我们在z或uv坐标中做的那样。
顺便说一下,如果我们没有.obj文件中的vn那一行的信息,那么我们应该怎么做?我们可以重新计算法线向量,作为与顶点相关(入射到顶点)的所有面的法向量的平均值。
二、3D空间中基准的变化
在欧几里得空间中,坐标可以由一个点(原点)和一个基准给出。
那我想问一下大家,
- 点P在框架(O, i, j, k) 中具有坐标 (x, y, z) 是什么意思?
意思是说向量OP可以表示成下面呢样:
想象现在我们有另外一个坐标系(O', i',j',k')。
- 我们如何将一个坐标系中的坐标变换到另一个坐标系中?
首先,我们注意到因为(i,j,k)和(i',j',k')是3D空间的基底,存在这样一个(非退化)矩阵M。
让我们画一个例子:
基于上图,让我们重新表达一下向量OP:
现在我们用基底变换矩阵M替换在右边的(i',j',k'):
它给出了一个公式,这个公式可以把一个坐标系变换到另一个坐标系:
三、创建自己的gluLookAt
OpenGL以及我们的微型渲染器只能使用位于z轴上的相机绘制场景。
如果我们想要移动摄像机,没问题,我们可以移动所有场景,而使摄像机不动。
让我们来解决这样一个问题:我们想要使用位于e点(眼睛)的摄像机来绘制一个场景。
摄像机应该指向c点(中心),这样给定的向量u(上向量)在最终渲染中是垂直的。
下面是栗子说明:
这意味着我们要在框架(c, x',y',z')中进行渲染。
但是我们的模型是在坐标系(O, x,y,z)中给出的,没问题,我们所需要做的就是计算坐标的变换。
下面是计算4x4矩阵模型视图ModelView所需的c++代码:
void lookat (Vec3f eye, Vec3f center, Vec3f up)
{
// Vec3f z = (eye - center).normalize;
Vec3f z = (eye - center).normalize();
Vec3f x = cross(up, z).normalize();
// Vec3f y = cross(x, z).normalize();
Vec3f y = cross(z, x).normalize();
// 这个cross中两个元素的前后有木有关系?比如cross(x, z)和cross(z, x)二者
Matrix Minv = Matrix::identity();
Matrix Tr = Matrix::identity();
for (int i = 0; i < 3; i++)
{
Minv[0][i] = x[i];
Minv[1][i] = y[i];
Minv[2][i] = z[i];
Tr[i][3] = -center[i];
}
ModelView = Minv * Tr;
}
注意,z'是由向量ce给出的(不要忘记对它进行规格化,稍后会有所帮助)。那么我们如何计算x'?
计算u和z'之间的叉积即可。然后我们计算y',使其与已经计算的x'和z'正交(友情提示,在我们的问题中ce和u是不一定正交的)。
最后一步是将原点转换为中心c,我们的转换矩阵已准备就绪。
现在就足以在模型框架中通过坐标(x,y,z,1)获取任何点,再将其乘以矩阵ModelView,就可以得到这些点在相机坐标系下对应的坐标。
(顺便说一下,ModelView这个名字来自OpenGL术语)
四、视口背景Viewport
大家刚开始的时候一定比较奇怪下面这行代码:
screen_coords[j] = Vec2i((v.x + 1.) * width / 2., (v.y + 1.) * height / 2.);
这行代码是什么意思?意思就是有一个点Vec2f v,它属于正方形[-1,1] * [-1,1]。我想在(width,height)维的图像中绘制它。
值(v.x + 1)在0和2之间变化,(v.x + 1)/ 2就在0和1之间变化,并且(v.x + 1)* width / 2就可以扫描所有图像。
因此,我们有效地将双单位正方形映射到图像上。
但是现在我们要去除这些难看的结构,我想把所有的计算重写成矩阵的形式。
参考以下的C++代码:
Matrix viewport (int x, int y, int w, int h)
{
Matrix m = Matrix::identity(4);
m[0][3] = x + w / 2.f;
m[1][3] = y + h / 2.f;
m[2][3] = depth / 2.f;
// 2.f是什么意思?
m[0][0] = w / 2.f;
m[1][1] = h / 2.f;
m[2][2] = depth / 2.f;
return m;
}
上述code产生了下面的矩阵:
这意味着将双单位立方体[-1,1] * [-1,1] * [-1,1]映射到屏幕立方体[x,x + w] * [y,y + h] * [0,d]。
正确,立方体而不是矩形,这是因为使用z缓冲区进行深度计算,上面中d是z缓冲区的分辨率。我喜欢将其等于255,因为转储z缓冲区的黑白图像以进行调试很简单。
在OpenGL术语中,这个矩阵称为Viewport矩阵。
五、坐标变换链
好,总结一下。
首先,我们的模型(例如人物)是在它们自身的局部框架(对象坐标)中创建的,我们将它看为局部空间。
它们被插入到以世界坐标表示的场景中,也就是世界空间中。
上述的变换的实现就是通过矩阵模型。
矩阵模型(matrix Model)可实现从一个坐标系到另一个坐标系的转换。
然后,我们要在相机框架(眼睛坐标)中表达它,该转换称为“视图”(View)。
然后,我们对场景进行变形以使用“透视投影”矩阵(第4课)创建透视变形,该矩阵将场景转换为所谓的裁剪坐标系。
最后,我们绘制场景scene,将裁剪坐标系转换为屏幕坐标系的矩阵称为Viewport矩阵。
同样,如果我们从.obj文件中读取了点v,然后将其绘制在屏幕上,则会经历以下一系列转换:
Viewport * Projection * View * Model * v.
别急,还没完,还有以下的代码实现:
Vec3f v = model -> vert(face[j]);
screen_coords[j] = Vec3f(ViewPort * Projection * ModelView * Matrix(v));
// 从代码中可以看出来上述转换是在screen_coords中实现的
当我只绘制了单个对象时,矩阵模型matrix Model就等于单位矩阵(identity),然后将其与矩阵视图(the matrix View)合并。
六、法向量的转换
有一个众所周知的事实:
- 如果我们有一个模型,并且第三方提供了其法向矢量,并且使用仿射映射对该模型进行了转换,则将通过映射转换法向矢量,该映射等于原始映射矩阵的逆矩阵的转置。
拿铅笔画一个2D三角形(0,0), (0,1), (1,0)和垂直于斜边的向量n。通常情况下,n = (1,1)。然后我们把所有的y坐标拉伸2倍,保持x坐标不变。这样,三角形就变成(0,0)(0,2)(1,0)。
如果我们以同样的方式变换向量n,它就会变成(1,2),不再正交于三角形变换后的边。
因此,要消除所有的难点,大家需要了解一件简单的事情:我们不需要简单地转换法线向量(因为它们可能不再变为法线),我们需要为转换后的模型计算(新)法线向量。
回到3D,我们有一个向量n = (A,B,C),大家应该都知道,经过原点且法线为n的平面具有方程Ax + By + Cz = 0。让我们把它写成矩阵形式(我从一开始就在齐次坐标下写)——
上图中,(A,B,C)-是一个向量,所以我们增加一个0使其变成4D,(x,y,z)则是增加1变成4D,因为它是一个点。
让我们在二者之间插入一个单位矩阵(M乘以M的倒数等于单位矩阵):
上图中,
右括号中的表达式表示——对象的转换点。
左括号的表达式表示——被变换对象的法向量。
在标准约定中,我们通常把坐标写成列(请不要把所有关于逆变向量和协变向量的东西都提出来),所以我们可以把前面的表达式改写成如下形式:
左括号告诉我们,可以通过应用仿射映射的逆转置矩阵从旧法线计算出变换后对象的法线。
请注意如果我们的变换矩阵M是由一致的缩放,旋转和平移组成的,那么M就等于它的逆的转置,因为逆和转置在这种情况下是互相抵消的。(但是由于我们的矩阵包含了透视变形,通常这种方法是没用的。)
在目前的代码中,我们没有使用法向量的变换,但在下一篇博客中,它将非常非常方便。
如何将坐标从一个坐标系变换到另一个坐标系?
- 一旦所有顶点被变换到裁剪空间,最终的操作——透视除法(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。
学习之初,我整理了每节课中代码的变化,现在看来用处不大,但是删了可惜,大家有需要的可以看看哈~~
这篇博客用到的代码文件的变化是这样的:
- tgaimage.h
(初始导入)->(新光栅化器+z-buffer)->(初始导入)->(diffuse texture work)->(高洛德着色)
- tgaimage.cpp
(初始导入)->(新光栅化器+z-buffer)->(初始导入)->(代码清理,C++学步检查)
- .obj文件夹
(线框渲染)->(新光栅化器+z-buffer)->(matrix class,立方体模型)->(textures)
- geometry.h
(线框渲染)->(新光栅化器+z-buffer)->(templates)->(投影和viewport矩阵)->(代码清理,C++学步检查)
- geometry.cpp
(templates)->(投影和viewport matrices矩阵)->(代码清理,C++学步检查)
- main.cpp
(朴素线段追踪)->(线段追踪、减少划分的次数)->(线段追踪:all integer Bresenham)->(线框渲染)->(better test triangles)->(三角形绘制routine)->(背面剔除 + 高洛德着色)->(y-buffer!)->(新光栅化器+z-buffer)->(templates)->(投影和viewport matrices矩阵)->(lookat 矩阵 bug修正)
- model.cpp
(线框渲染)->(新光栅化器+z-buffer)->(线框渲染)->(diffuse texture homework)->(高洛德着色)
- model.h
(线框渲染)->(diffuse texture homework)->(高洛德着色)
解释一下上述文件括号中的文字——
model.h开始变化,从diffuse texture work变到了高洛德着色。
model.cpp与.h同步,也从diffuse texture work变到了高洛德着色。
main.cpp则是加入了lookat矩阵,并进行了bugfix。
geometry.cpp,此番则是代码清理和C++检查。
geometry.h与.cpp同步。
.obj文件的主要作用还是textures。
tgaimage.h则是加入了高洛德着色
tgaimage.cpp则是加入了代码清理环节。
其中model时用来test测试的。