4

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

 1 year ago
source link: https://direct5dom.github.io/2022/09/29/TinyRenderer%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B003/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

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

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

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

2D图形几何学

2D仿射变换

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

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

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

缩放 - 正对角线系数

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

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

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

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

矩阵真的超级方便!

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

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轴上。

不难发现三角形ABC和ODC是相似的,也就有:
|AB||AC|=|OD||OC|→xc−z=x′c
换句话说就是:
x′=x1−z/c
三角形CPB和三角形CP′D有相同的推理结果,即:
y′=y1−z/c
这与上面的公式相似,我们得到了系数:
r=−1c

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

移动摄像机

3D空间中基底的变化

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

点P在帧(O,i,j,k)中具有坐标(x,y,z)是什么意思?意味着矢量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矩阵:

// 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′的计算 —— 只需要通过计算u和z′之间的叉积。

  • y′的计算 —— 只需通过计算刚刚得到的x′和z′之间的叉积(ce→和u不一定是正交的,这个需要注意)。

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

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

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

// 视口矩阵
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,要在屏幕上绘制它,它将经历以下转换链:

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;
}

这里我们使用了扫线的三角形填充算法:

// 三角形绘制算法(扫线)
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:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK