TinyRenderer 学习笔记03:透视变换与移动摄像机
先来简单回顾一下我们之前所学。
我们成功绘制了点,然后连点成线,扫线成面。
我们还成功的找到一种方式(Z-buffer),避免了前后关系的错误绘制。
以上这些都不难,而从现在开始,我们则需要大量的数学知识来解决我们的需求。
透视变换
2D图形几何学
2D仿射变换
还是先从简单易理解的二维平面开始。
如果我们有一个点
最简单(非退化)的情况是恒等式:
缩放 - 正对角线系数
不难看出,矩阵的对角线系数给出了沿坐标轴的缩放比例,例如:
则我们会得到这样的一个结果:
图中:
- 红绿是
和 轴; - 白色是原始图形;
- 黄色是转变后的结果。
将上图图形的顶点坐标
矩阵真的超级方便!
当然,这里的
伪代码:
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轴剪切的简单案例。
旋转 - 缩放和剪切的组合
和人的直觉相悖,旋转并不是平面上的基本线性变换。
平面上的基本线性变换只有两种:缩放和剪切。
对于旋转,实际上任何旋转(围绕原点)都可以表示为三个剪切的符合动作。
例如,这里的白色图形被转换为红色图形,然后是绿色图形,最终是蓝色:
当然,为了简单,也可以直接写一个旋转矩阵:
变换矩阵的提取
就像上面说的,为了避免多个矩阵的相乘在循环中被计算
但是要注意,矩阵的乘法是不可交换的:
从几何的角度也很好理解:剪切一个物体然后再旋转它,与旋转它之后再剪切它不同!
2D仿射变换
平面上的任何线性变换都可以总结为两大基本变换(缩放、剪切)的组合,这意味着我们可以做任何我们想要的线性变换,唯一的问题在于原点无法移动,即:线性变换做不到平移。
所谓仿射变换,值得就是一个线性变换后接一个平移,它的表达式如下:
这个表达式很酷,我们现在可以做到缩放、剪切、旋转和平移。但是在实际使用中,我们可能要经历多次变换,如:
这还只是两次变换,我们经常需要数十次这样的变换,表达式会变得逐渐丑陋。
齐次坐标
指一个
维向量用一个 维向量表示。
处理矩阵问题时,升维是一个不错的选择(降维打击)。
我们会得到一个这样的矩阵:
将其乘以一个
除了最后一个分量为1,其他两个分量正是我们想要的。
其实这并不难理解,因为平移在2D空间不是线性的,所以我们将2D嵌入到3D空间的
至于将3D投影回2D:
实际上我们做的是:在原点和要投影的点之间绘制一条直线,然后找到它与平面
例如,这里我们的2D平面(
试想一个垂直的竖线穿过点
现在我们给定几个例子:
点
将投影到 : 点
将投影到 :
这很简单,比较特殊是考虑
齐次坐标可以区分向量和点。在齐次坐标中,所有
于是乎我们得到了一个平面平移管线:
- 将2D嵌入到3D的
平面; - 在3D空间中做我们想做的事情;
- 将每个点从3D投影到2D。
复合变换
我们现在已经具备了所有我们所需的数学知识,设想一个情况:我们围绕
事实上,我们需要做的事情很明确:
- 平移对象,让
与原点重合; - 旋转对象;
- 平移对象到原来的位置。
这三个动作可以用三个矩阵相乘的方式表达:
这样,我们就得到了一个复合变换矩阵
等号右边:
- 第一个矩阵:完成了平移
- 第二个矩阵:完成了旋转
- 第三个矩阵:完成了逆平移
近大远小
我们在齐次坐标中接触到了一个神奇的
如果我们尝试修改这两个0,会发生什么?
例如,有原始对象:
经历这样的一个变换:
结果:
有意思的事情发生了,设想一下,我们从右向左看这个2D对象,原来是这样的:
现在是这样的:
可以看到以
而所谓透视,简单来说就是近大远小。
因此,如果我们能找到一个合适的系数,就能获得正确的透视效果!
2D的 矩阵总结
这个魔法矩阵以很简单的方式完成了复杂的工作:
3D下的情况
像2D一样,3D的仿射变换也可以升维到4D来进行一个线性变换,然后投影回3D。即:
逆投影公式:
按照中心投影的标准定义,给定一个点
不难发现三角形
换句话说就是:
三角形
这与上面的公式相似,我们得到了系数:
透视总结
我们现在拥有的公式正好可以构成一个3D透视变换的管线:
移动摄像机
3D空间中基底的变化
在欧几里得空间中,坐标可以由点(原点)和基底给出。
点
现在,我们找到另一个帧
首先,我们注意到,由于
画图解释就是:
让我们重新表达
代入上面的公式化简:
这样我们就得到了两个帧之间相互转换的公式:
创建我们自己的gluLookAt
这里需要科普的一点,无论是OpenGL,还是我们自己写的Renderer,都只能使用位于z轴上的摄像机绘制场景。
或者说,OpenGL/我们的Renderer这个层级的东西,不存在“摄像机”这个概念。如果我们想移动摄像机,实际上要做的是反其道而行——移动整个场景。
例如上面这场图,我们想要在帧
我们要做的是计算坐标的变换,使用一个
// lookat函数
Matrix lookat(Vec3f eye, Vec3f center, Vec3f up) {
Vec3f z = (eye-center).normalize();
Vec3f x = (up^z).normalize();
Vec3f y = (z^x).normalize();
Matrix Minv = Matrix::identity(4);
Matrix Tr = Matrix::identity(4);
Matrix ModelView = Matrix::identity(4);
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] = -eye[i];
}
ModelView = Minv*Tr;
return ModelView;
}
需要注意的是:
的计算 —— 由向量 给出(不要忘记归一化,这对之后有好处)。 的计算 —— 只需要通过计算 和 之间的叉积。 的计算 —— 只需通过计算刚刚得到的 和 之间的叉积( 和 不一定是正交的,这个需要注意)。 最后只需要将原点平移
点,我们的变换矩阵就准备好了。
ModelView实际上是OpenGL的术语。
视口矩阵
这里插个题外话,我们的主函数里经常能看到这样的代码:
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
之间变化。
这一行代码真的很丑陋,我们可以用矩阵的方式实现,并且能很好的和透视变换结合到一起。
// 视口矩阵
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;
m[0][0] = w/2.f;
m[1][1] = h/2.f;
m[2][2] = depth/2.f;
return m;
}
此代码会创建如下矩阵:
意思是立方体[-1,1]*[-1,1]*[-1,1]
映射到屏幕立方[x,x+w]*[y,y+h]*[0,d]
。
注意,是立方体,而不是矩形。因为这里用到了zbuffer进行深度(
depth
)计算。这里的
是zbuffer的分辨率,一般将其等于255,因为zbuffer的黑白图十分方便调试。
在OpenGL中,这个矩阵被称为视口矩阵。
代码实现
虽然大量的数学公式看起来很头疼,但其代码实现却相对简单很多。
坐标变换链
我们现在已经学了很多的变换,从透视到摄像机的移动。
- 坐标转换——从对象的物体坐标(Object Coordinates),转换到世界坐标(World Coordinates)。通过ModelView矩阵来实现。
- View转换——在摄像机(Eye)中表达对象。
- 场景变形——使用投影(Projection)矩阵完成对象的移动和透视,将场景转换为剪辑坐标(Clip Coordinates)。
- 视口矩阵——将剪辑坐标转换为屏幕坐标(Screen Coordinates)。
更具体的,我们从OBJ文件读取了一个点v,要在屏幕上绘制它,它将经历以下转换链:
Viewport * Projection * View * Model * v.
真实的代码就像这样:
Vec3f v = model->vert(face[j]);
screen_coords[j] = Vec3f(ViewPort*Projection*ModelView*Matrix(v));
具体实现
首先我们要创建两个工具函数,来完成向量和矩阵的相互转换:
// 矩阵 to 向量
Vec3f m2v(Matrix m)
{
return Vec3f(m[0][0] / m[3][0], m[1][0] / m[3][0], m[2][0] / m[3][0]);
}
// 向量 to 矩阵
Matrix v2m(Vec3f v)
{
Matrix m(4, 1);
m[0][0] = v.x;
m[1][0] = v.y;
m[2][0] = v.z;
m[3][0] = 1.f;
return m;
}
这里我们使用了扫线的三角形填充算法:
c++// 三角形绘制算法(扫线) void triangle(Vec3i t0, Vec3i t1, Vec3i t2, TGAImage &image, float intensity, int *zbuffer) { // 将顶点t0、t1、t2从低到高排序 if (t0.y == t1.y && t0.y == t2.y) // 不关注退化三角形 return; if (t0.y > t1.y) { std::swap(t0, t1); } if (t0.y > t2.y) { std::swap(t0, t2); } if (t1.y > t2.y) { std::swap(t1, t2); } // 总高度 int total_height = t2.y - t0.y; // 遍历整个三角形 for (int i = 0; i < total_height; i++) { // 确定现在是上下哪部分 bool second_half = i > t1.y - t0.y || t1.y == t0.y; // 不同的部分用不同的公式计算局部高度 int segment_height = second_half ? t2.y - t1.y : t1.y - t0.y; float alpha = (float)i / total_height; //小心除以0 float beta = (float)(i - (second_half ? t1.y - t0.y : 0)) / segment_height; Vec3i A = t0 + Vec3f(t2 - t0) * alpha; Vec3i B = second_half ? t1 + Vec3f(t2 - t1) * beta : t0 + Vec3f(t1 - t0) * beta; if (A.x > B.x) { std::swap(A, B); } for (int j = A.x; j <= B.x; j++) { float phi = B.x == A.x ? 1. : (float)(j - A.x) / (float)(B.x - A.x); Vec3i P = Vec3f(A) + Vec3f(B - A) * phi; int idx = P.x + P.y * width; if (zbuffer[idx] < P.z) { zbuffer[idx] = P.z; image.set(P.x, P.y, TGAColor(intensity * 255, intensity * 255, intensity * 255, 255)); } } } }
主函数调用:
// 主函数
int main(int argc, char **argv)
{
if (2 == argc)
{
model = new Model(argv[1]);
}
else
{
model = new Model("obj/bun_zipper.obj");
}
zbuffer = new int[width * height];
for (int i = 0; i < width * height; i++)
{
zbuffer[i] = std::numeric_limits<int>::min();
}
// 渲染对象
{
// ModelView
Matrix ModelView = lookat(eye, center, Vec3f(0,1,0));
// 投影
Matrix Projection = Matrix::identity(4);
// 计算视口矩阵
Matrix ViewPort = viewport(width / 8, height / 8, width * 3 / 4, height * 3 / 4);
// 摄像机投影
// Projection[3][2] = -1.f / camera.z;
Projection[3][2] = -1.f/(eye-center).norm();
TGAImage image(width, height, TGAImage::RGB);
for (int i = 0; i < model->nfaces(); i++)
{
std::vector<int> face = model->face(i);
Vec3i screen_coords[3];
Vec3f world_coords[3];
for (int j = 0; j < 3; j++)
{
Vec3f v = model->vert(face[j]);
// 视口矩阵 * 摄像机投影 * ModelView * 点矩阵
screen_coords[j] = m2v(ViewPort * Projection * ModelView * v2m(v));
world_coords[j] = v;
}
Vec3f n = (world_coords[2] - world_coords[0]) ^ (world_coords[1] - world_coords[0]);
n.normalize();
float intensity = n * light_dir;
if (intensity > 0)
{
triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, intensity, zbuffer);
}
}
image.flip_vertically();
image.write_tga_file("output.tga");
}
// 转储zbuffer
{
TGAImage zbimage(width, height, TGAImage::GRAYSCALE);
for (int i = 0; i < width; i++)
{
for (int j = 0; j < height; j++)
{
zbimage.set(i, j, TGAColor(zbuffer[i + j * width], 1));
}
}
zbimage.flip_vertically();
zbimage.write_tga_file("zbuffer.tga");
}
delete model;
delete[] zbuffer;
return 0;
}
结果:
zbuffer:
这个zbuffer图像存在一定问题。因为我错误的将光照方向用于背面剔除。
因为后面的课程涉及到代码的重构,因此这个bug我暂时不会修复。