【Graphic】空间坐标变换


最近面试发现对于图形学知识,虽然能知道具体脉络以及相关名词,但对于其细节实现已经有所淡忘。因此开设此系列来回顾自己在图形学学习中遇到的一些相关问题。

1. 坐标空间

我们知道,在渲染管线中每次顶点着色器运行后,会将模型的所有可见顶点都转化为标准化设备坐标(NDC)。也就是说,每个顶点的x,y,z坐标都应该在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。通常我们所传入渲染管线的坐标为模型相对于局部原点的坐标。将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统。

我们的顶点坐标起始于局部空间(Local Space),之后会变为**世界空间(World Space), 观察空间(View Space), 裁剪空间(Clip Space),屏幕空间(Screen Space) 。

1.1 局部空间(Local Space)

局部空间是指物体所在的坐标空间,即对象最开始所在的地方,模型有自己的前后左右,这是它的自身属性,旋转或者是移动和缩放模型并不能改变它的前后左右。

1.2 世界空间(World Space)

世界空间指顶点相对于世界的坐标。如果你希望将物体分散在世界上摆放(特别是非常真实的那样),这就是你希望物体变换到的空间。模型一开始放置在世界坐标系中时,其本地坐标系与世界坐标系重合,经过一系列缩放,旋转,平移变换后被放置在世界坐标系中的适当位置构成渲染场景,该变换是由模型矩阵(Model Matrix)实现的。

1.3 观察空间(View Space)

观察空间是将世界空间坐标转化为用户视野前方的坐标而产生的结果。因此观察空间就是从摄像机的视角所观察到的空间。观察空间是将世界空间坐标转化为用户视野前方的坐标而产生的结果。因此观察空间就是从摄像机的视角所观察到的空间。而这通常是由一系列的位移和旋转的组合来完成,平移/旋转场景从而使得特定的对象被变换到摄像机的前方。这些组合在一起的变换通常存储在一个**观察矩阵(View Matrix)**里,它被用来将世界坐标变换到观察空间。

1.4 裁剪空间(Clip Space)

我们希望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。而这工作是由一个投影矩阵(Projection Matrix)完成。经过投影矩阵变换后的坐标如果没有映射在-1.0到1.0的范围之间,就会被剪裁掉。(如果只是图元(Primitive)的一部分超出了裁剪体积(Clipping Volume),则会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。)
此时顶点所在的空间也被称为齐次剪裁空间(Homogeneous Clip Space)

一旦所有的顶点被变换到齐次剪裁空间后,就会进行最终的操作—**透视除法(Perspective Division)**。在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。

1.5 屏幕空间(Screen Space)

最后将3D标准化设备坐标会被映射到屏幕空间,并进行下一步工作。

2. 矩阵变换

2.1 模型矩阵

在本地坐标系中建好的模型都会经过缩放,旋转,平移等操作后变换到世界坐标系中的不同位置构成渲染场景。

2.2 观察矩阵

摄像机变换的目的是得到所有可视物体与摄像机的相对位置,我们把物体和摄像机一起做移动,如果能够把摄像机的坐标轴(假设为u,v,w 分别对应原世界空间中的x,y,z)移动到标准的x,y,z轴,那么此时物体的坐标便是相机的相对坐标。
e表示相机在世界空间的位置
g表示观察方向(直接作为相机坐标系的z轴)
t表示视点正上方向
根据这三个相机设置的参数,我们可以建立以相机位置为原点的坐标系:

其中u,v,w分别对应准坐标系下的x,y,z,示意图:

成功建立坐标系后,我们就可以很容易地得出从相机坐标系到世界坐标系的变换。但我们需要的是观察坐标系到世界坐标系的逆变换,即世界坐标系到观察坐标系的变换 ,即:

这个矩阵在OpenGL里也被称为LookAt矩阵

2.3 投影矩阵

由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection),因为使用投影矩阵能将3D坐标投影(Project)到很容易映射到2D的标准化设备坐标系中。

2.3.1 正交投影(Orthographic Projection)

正交投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。它的平截头体看起来像一个容器,我们只需将物体(可视部分,即图中的长方体)全部转换到一个$[-1,1]^3$的空间之中即可。
我们需要先定义可视空间的值如上图所示:
l = left plane,r = right plane,b = bottom plane,t = top plane,n = near plane, f = far plane,
根据空间变换,我们要先把可视空间中心点平移至原点位置,再将可视空间缩小为$[-1,1]^3$的空间。Translate矩阵为:$[-(r+l)/2, -(t+b)/2, -(n+f)/2]$,长,宽,高的scale分别为$2/(r-l),2/(t-b), 2/(n-f) $。最后的正交投影变换矩阵为:

2.3.2 透视投影(Perspective Projection)

透视就和实际生活中眼睛所看到的景象一样,离自己越远的东西看起来会越小,如下图所示:

想要达到这种效果,需要使用透视投影矩阵来完成,这个矩阵定义了可视空间的大平截头体,任何在这个平截头体以外的东西最后都不会出现在裁剪空间体积内,并且将会受到裁剪。

观察空间中位于视椎体内的3D点会被映射到NDC空间中;该映射将x坐标从[l, r]映射到[-1,1],y坐标从[b, t]映射到[-1,1],z坐标从[n, f]映射到[-1,1]。
需要注意的是观察空间是使用的是右手坐标系,而NDC空间使用的是左手坐标系,因为摄像机在观察空间原点沿着Z轴的负方向看过去,而在NDC空间是沿着Z轴的正方向看过去。

观察空间的3D点会被投影到近平面(投影平面)。下面两张图展示了观察空间的点$(x_e, y_e, z_e)$如何被投射为近平面上的点$(x_p, y_p,z_p)$。(注:下标c, e, p, n分别代表的是裁剪空间、观察空间、投影空间和NDC空间)

通过相似三角形,我们可以知道:

这里$x_p, y_p$都依赖于$z_e$,并与$z_e$成反比,因此我们可以构造出一个矩阵来乘以观察空间下的坐标,由于剪裁空间坐标仍然是齐次坐标,它最终通过除以剪裁坐标的w分量转化为归一化坐标NDC:

因此,我们可以将剪裁坐标的 w 分量设置为 $-z_e$。并且,PROJECTION 矩阵的第 4 行变为 (0, 0, -1, 0):

接下来推导请看手写部分:



最终的透视投影矩阵就可以转换为:

这里根据前面$z_n和z_e$的关系,可以发现$z_n和z_e$之间是非线性的关系,如下图。这意味着近平面的精度非常高,但远平面的精度很低。所以,远近平面应该尽可能缩短,以减少深度缓冲精度的问题。

2.4 视口变换

视口变换就很简单,只要把$[-1,1]^2$平面转换到窗口大小平面[0, width] * [0, height]。
可以直接套用公式:

至此,经过上面4个变换,我们就可以将想要的物体模型转换到屏幕上了,总结下来就是:
$MVP = M_{pro} * M_{lookat} * M_{model}$
$M = M_{view} * MVP$

3. OpenGL代码

OpenGL中有自带的API来调用上述矩阵,我们只需设定参数,并将矩阵传入vertex shader。着色器会将顶点的裁切空间输出的位置向量(gl_Position)输出。

3.1 矩阵设置

  • 模型矩阵

    glm::mat4 model;
    model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));	//旋转
    model = glm::translate(model, glm::vec3(0.0f, 0.0f, 0.0f)); //移动
    model = glm::scale(model, glm::vec3(0.5f, 0.5f, 0.5f));	//缩放
  • 观察矩阵
    观察矩阵我们可以自定义一个相机,设定好位置,观察目标位置和世界空间中的上向量的向量就可以定义:

    glm::mat4 view;
    view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f), 
               glm::vec3(0.0f, 0.0f, 0.0f), 
               glm::vec3(0.0f, 1.0f, 0.0f));

由于采用右手坐标系,如果我们希望摄像机向后移动,我们就沿着z轴的正方向移动。

  • 投影矩阵
    定义投影矩阵,如果是使用透视投影,我们需要定义4个参数,分别是FOV,宽高比,近平面和远平面的位置,调用API如下:
    glm::mat4 projection;
    projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);

3.2 传入着色器

我们还应该将矩阵传入着色器,需要在vertex shader中声明一个uniform变换矩阵:

#version 330 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 注意乘法要从右向左读
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    ...
}

在主程序中将着色器传入:

Shader.use();

Shader.setMat4("projection", projection);
Shader.setMat4("view", view);
Shader.setMat4("model", model);
//shader已经被我封装好了,具体可以参考LearnOpenGL中的封装
//具体调用:
//int modelLoc = glGetUniformLocation(ourShader.ID, "model"));
//glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));

参考

Games101
LearnOpenGL
OpenGL Projection Matrix


文章作者: YukinoKyoU
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 YukinoKyoU !
评论
  目录