Skip to content

TinyRenderer 学习笔记03:透视变换与移动摄像机

先来简单回顾一下我们之前所学。

我们成功绘制了点,然后连点成线,扫线成面。

我们还成功的找到一种方式(Z-buffer),避免了前后关系的错误绘制。

以上这些都不难,而从现在开始,我们则需要大量的数学知识来解决我们的需求。

透视变换

2D图形几何学

2D仿射变换

还是先从简单易理解的二维平面开始。

如果我们有一个点(x,y),那么它的变换可以写成:

最简单(非退化)的情况是恒等式:

缩放 - 正对角线系数

不难看出,矩阵的对角线系数给出了沿坐标轴的缩放比例,例如:

则我们会得到这样的一个结果:

图中:

  • 红绿是xy轴;
  • 白色是原始图形;
  • 黄色是转变后的结果。

将上图图形的顶点坐标(x,y)代入是这样的:

矩阵真的超级方便!

当然,这里的2×5矩阵不过是顶点的坐标转换,我们只需要取得对象上的每一个顶点,将其乘以变换矩阵,即可得到变换后的图像(黄色)。

伪代码:

c++
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));
}

这段代码对对象的每个顶点执行两个线性变换。

值得注意的是我们经常以百万为单位来进行计算,而且连续几十次的转换并不少见,进而导致千万次的操作,计算量十分巨大。

因此一般我们会将所有矩阵在循环体之外做乘法,然后将结果调入循环体内使用。O(n)乃至O(na)O(1),这不难理解。

剪切 - 反对角线系数

矩阵的反对角线系数也有着他的用途:

结果如下:

它执行的操作被我们称为“剪切”,上面就是一个沿x轴剪切的简单案例。

旋转 - 缩放和剪切的组合

和人的直觉相悖,旋转并不是平面上的基本线性变换。

平面上的基本线性变换只有两种:缩放剪切

对于旋转,实际上任何旋转(围绕原点)都可以表示为三个剪切的符合动作。

例如,这里的白色图形被转换为红色图形,然后是绿色图形,最终是蓝色:

当然,为了简单,也可以直接写一个旋转矩阵:

变换矩阵的提取

就像上面说的,为了避免多个矩阵的相乘在循环中被计算n次乃至na次,我们可以将其从循环体中提出单独计算。

但是要注意,矩阵的乘法是不可交换的:

从几何的角度也很好理解:剪切一个物体然后再旋转它,与旋转它之后再剪切它不同!

2D仿射变换

平面上的任何线性变换都可以总结为两大基本变换(缩放、剪切)的组合,这意味着我们可以做任何我们想要的线性变换,唯一的问题在于原点无法移动,即:线性变换做不到平移

所谓仿射变换,值得就是一个线性变换后接一个平移,它的表达式如下:

这个表达式很酷,我们现在可以做到缩放、剪切、旋转和平移。但是在实际使用中,我们可能要经历多次变换,如:

这还只是两次变换,我们经常需要数十次这样的变换,表达式会变得逐渐丑陋。

齐次坐标

指一个n维向量用一个n+1维向量表示。

处理矩阵问题时,升维是一个不错的选择(降维打击)。

我们会得到一个这样的矩阵:

将其乘以一个z=1的单位向量,即可得到另一个向量:

除了最后一个分量为1,其他两个分量正是我们想要的。

其实这并不难理解,因为平移在2D空间不是线性的,所以我们将2D嵌入到3D空间的z=1平面上,然后执行3D空间的线性变换,并将结果投影到我们的2D平面上。

至于将3D投影回2D:

实际上我们做的是:在原点和要投影的点之间绘制一条直线,然后找到它与平面z=1的交点。

例如,这里我们的2D平面(z=1)采用洋红色,点(x,y,z)投影到(x/z,y/z)上:

试想一个垂直的竖线穿过点(x,y,1),它将投影到(x,y)

现在我们给定几个例子:

  • (x,y,1/2)将投影到(2x,2y)

  • (x,y,1/4)将投影到(4x,4y)

这很简单,比较特殊是考虑z=0的情况,即:(x,y,0)。那是一个极限情况,它将投影到沿着方向(x,y)无穷远的点上。这是什么?这是一个向量。

齐次坐标可以区分向量和点。在齐次坐标中,所有z=0的事物都是向量,其余都是点。

于是乎我们得到了一个平面平移管线:

  1. 将2D嵌入到3D的z=1平面;
  2. 在3D空间中做我们想做的事情;
  3. 将每个点从3D投影到2D。

复合变换

我们现在已经具备了所有我们所需的数学知识,设想一个情况:我们围绕(x0,y0)旋转一个2D对象,该怎么做呢?

事实上,我们需要做的事情很明确:

  1. 平移对象,让(x0,y0)与原点重合;
  2. 旋转对象;
  3. 平移对象到原来的位置。

这三个动作可以用三个矩阵相乘的方式表达:

这样,我们就得到了一个复合变换矩阵M

等号右边:

  • 第一个矩阵:完成了平移
  • 第二个矩阵:完成了旋转
  • 第三个矩阵:完成了逆平移

近大远小

我们在齐次坐标中接触到了一个神奇的3×3矩阵,它的底行前两列都是0。

如果我们尝试修改这两个0,会发生什么?

例如,有原始对象:

经历这样的一个变换:

结果:

有意思的事情发生了,设想一下,我们从右向左看这个2D对象,原来是这样的:

现在是这样的:

可以看到以y轴为界,靠近相机的部分被拉长,远离相机的部分被缩短。

而所谓透视,简单来说就是近大远小。

因此,如果我们能找到一个合适的系数,就能获得正确的透视效果!

2D的3×3矩阵总结

这个魔法矩阵以很简单的方式完成了复杂的工作:

3D下的情况

像2D一样,3D的仿射变换也可以升维到4D来进行一个线性变换,然后投影回3D。即:

逆投影公式:

按照中心投影的标准定义,给定一个点P(x,y,z),我们想要将其投影到平面z=0上,摄像机位于点(0,0,c),即z轴上。

不难发现三角形ABCODC是相似的,也就有:

|AB||AC|=|OD||OC|xcz=xc

换句话说就是:

x=x1z/c

三角形CPB和三角形CPD有相同的推理结果,即:

y=y1z/c

这与上面的公式相似,我们得到了系数:

r=1c

透视总结

我们现在拥有的公式正好可以构成一个3D透视变换的管线:

移动摄像机

3D空间中基底的变化

在欧几里得空间中,坐标可以由点(原点)和基底给出。

P在帧(O,i,j,k)中具有坐标(x,y,z)是什么意思?意味着矢量$\overrightarrow{OP} $可以表示为:

现在,我们找到另一个帧(O,i,j,k)。我们如何将一帧中给出的坐标转换为另一帧?

首先,我们注意到,由于(i,j,k)(i,j,k)是3D空间的基底,因此存在一个(非退化)矩阵M,使得:

画图解释就是:

让我们重新表达OP

代入上面的公式化简:

这样我们就得到了两个帧之间相互转换的公式:

创建我们自己的gluLookAt

这里需要科普的一点,无论是OpenGL,还是我们自己写的Renderer,都只能使用位于z轴上的摄像机绘制场景。

或者说,OpenGL/我们的Renderer这个层级的东西,不存在“摄像机”这个概念。如果我们想移动摄像机,实际上要做的是反其道而行——移动整个场景

例如上面这场图,我们想要在帧(c,x,y,z)进行渲染,但是我们的模型是在(O,x,y,z)中给出。

我们要做的是计算坐标的变换,使用一个4×4矩阵:

c++
// 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;
}

需要注意的是:

  • z的计算 —— 由向量ce给出(不要忘记归一化,这对之后有好处)。

  • x的计算 —— 只需要通过计算uz之间的叉积。

  • y的计算 —— 只需通过计算刚刚得到的xz之间的叉积(ceu不一定是正交的,这个需要注意)。

  • 最后只需要将原点平移e点,我们的变换矩阵就准备好了。

ModelView实际上是OpenGL的术语。

视口矩阵

这里插个题外话,我们的主函数里经常能看到这样的代码:

c++
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)02之间变化,也就是说值(v.x+1)/2将在01之间变化。

这一行代码真的很丑陋,我们可以用矩阵的方式实现,并且能很好的和透视变换结合到一起。

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;

    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)计算。

这里的d是zbuffer的分辨率,一般将其等于255,因为zbuffer的黑白图十分方便调试。

在OpenGL中,这个矩阵被称为视口矩阵

代码实现

虽然大量的数学公式看起来很头疼,但其代码实现却相对简单很多。

坐标变换链

我们现在已经学了很多的变换,从透视到摄像机的移动。

  • 坐标转换——从对象的物体坐标(Object Coordinates),转换到世界坐标(World Coordinates)。通过ModelView矩阵来实现。
  • View转换——在摄像机(Eye)中表达对象。
  • 场景变形——使用投影(Projection)矩阵完成对象的移动和透视,将场景转换为剪辑坐标(Clip Coordinates)。
  • 视口矩阵——将剪辑坐标转换为屏幕坐标(Screen Coordinates)。

更具体的,我们从OBJ文件读取了一个点v,要在屏幕上绘制它,它将经历以下转换链:

c++
Viewport * Projection * View * Model * v.

真实的代码就像这样:

c++
Vec3f v = model->vert(face[j]);
screen_coords[j] =  Vec3f(ViewPort*Projection*ModelView*Matrix(v));

具体实现

首先我们要创建两个工具函数,来完成向量和矩阵的相互转换:

c++
// 矩阵 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));
			}
		}
	}
}

主函数调用:

c++
// 主函数
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我暂时不会修复。