[关闭]
@ltlovezh 2020-04-06T11:33:06.000000Z 字数 15922 阅读 5443

OpenGL坐标变换

OpenGL


基础概述

众所周知,OpenGL是一个3D图形库,在终端设备上广泛使用。但是我们的显示设备都是2D平面,那么OpenGL怎么把3D图形映射到2D屏幕那?这就是OpenGL坐标变换所要完成的工作。
一般情况下,我们总是通过一个2D屏幕,观察3D世界。因此,我们实际看到的是3D世界在2D屏幕上的一个投影。通过OpenGL坐标变换,我们可以在一个给定的观察视角下,把3D物体投影到2D屏幕上,再经过后面的光栅化和片元着色,整个3D物体就映射成了2D屏幕上的像素。
OpenGL的坐标变换流程如下所示:
OpenGL坐标变换过程

第一行和第二行的模型变换、视变换和投影变换是顶点着色器负责完成的,它决定了一个图元在3D空间中的位置。
第三行的透视除法和视口变换是图元装配阶段完成的,它决定了一个图元在屏幕上的位置。

我们先简单看下整个流程:
1. 首先,输入顶点一般是以本地坐标表示的3D模型。本地坐标是为了研究孤立的3D模型,坐标原点一般都是模型的中心。每个3D模型都有自己的本地坐标系(Local Coordinate),互相之间没有关联。
2. 当我们需要同时渲染多个3D物体时,需要把不同的3D模型,变换到一个统一的坐标系,这就是世界坐标系(World Coordinate)。把物体从本地坐标系变换到世界坐标系,是通过一个Model矩阵完成的。模型矩阵可以实现多种变换:平移(translation)、缩放(scale)、旋转(rotation)、镜像(reflection)、错切(shear)等。例如:通过平移操作,我们可以在世界坐标系的不同位置绘制同一个3D模型;
3. 世界坐标系中的多个物体共同构成了一个3D场景。从不同的角度观察这个3D场景,我们可以看到不同的屏幕投影。OpenGL提出了摄像头坐标系的概念,即从摄像头位置来观察整个3D场景。把物体从世界坐标系变换到摄像头坐标系,是通过一个View矩阵完成的。视图矩阵定义了摄像头的位置、方向向量和上向量等构成摄像头坐标系的基础信息。View矩阵左乘世界坐标系中顶点A的坐标,就把顶点A变换到了摄像头坐标系。同一个3D物体,在世界坐标系中,拥有一个世界坐标;在摄像头坐标系中,拥有一个摄像头坐标,View变换就是负责把物体的坐标从世界坐标系变换到摄像头坐标系。
4. 因为我们是从一个2D屏幕观察3D场景,而屏幕本身不是无限大的。所以当从摄像头的角度观察3D场景时,可能无法看到整个场景,这时候就需要把看不到的场景裁减掉。投影变换就是负责裁剪工作,投影矩阵指定了一个视见体(View Frustum),在视见体内部的物体会出现在投影平面上,而在视见体之外的物体会被裁减掉。投影包括很多类型,OpenGL中主要考虑透视投影(Perspective Projection)和正交投影(Orthographic Projection),两者的区别在后面会详细介绍。除此之外,通过Projection矩阵,可以把物体从摄像头坐标系变换到裁剪坐标系。在裁剪坐标下,X、Y、Z各个坐标轴上会指定一个可见范围,超过可见范围的顶点(vertex)都会被裁剪掉。
5. 每个裁剪坐标系指定的可见范围可能是不同的,为了得到一个统一的坐标系,需要对裁剪坐标进行透视除法(Perspective Division),得到NDC坐标(Normalized Device Coordinates - 标准化设备坐标系)。透视除法就是将裁剪坐标除以齐次分量W,得到NDC坐标:

在NDC坐标系中,X、Y、Z各个坐标轴的区间是[-1,1]。因此,可以把NDC坐标系看做作一个边长为2的立方体,所有的可见物体都在这个立方体内部。
6. NDC坐标系的范围是[-1,1],但是我们的屏幕尺寸是千变万化的,那么OpenGL是如何把NDC坐标映射到屏幕坐标的那?视口变换(Viewport Transform)就是负责这块工作的。在OpenGL中,我们只需要通过glViewport指定绘制区域的坐标和宽高,系统会帮我们自动完成视口变换。经过视口变换,我们就得到了2D屏幕上的屏幕坐标。需要注意的是:屏幕坐标与屏幕的像素位置是不一样的,屏幕坐标是屏幕上任意一个顶点的精确位置,可以是任意小数。但是像素位置只能是整数(具体的某个像素)。这里的视口变换是从NDC坐标变换到屏幕坐标,还没有生成最终的像素位置。从屏幕坐标映射到对应的像素位置,是后面光栅化完成的。

在OpenGL中,本地坐标系、世界坐标系和摄像头坐标系都属于右手坐标系,而最终的裁剪坐标系和标准化设备坐标系属于左手坐标系。
左右手坐标系的示意图如下所示,其中大拇指、食指、其余手指分别指向x,y,z轴的正方向。
左右手坐标系

下面我们分别来看下模型变换、视图变换、投影变换和视口变换的推导和使用。

模型变换

模型变换通过对3D模型执行平移、缩放、旋转、镜像、错切等操作,来调整模型在世界坐标系中的位置。模型变换是通过模型矩阵来完成的,我们看下每种模型矩阵的推导过程。

平移变换

平移就是将一个顶点A = (x,y,z),移动到另一个位置 =(,,),移动距离D = - A = ( - x , - y , - z) = ( , , ),所以可以用顶点A来表示:


通过平移矩阵来表示如下所示:

其中就是平移变换矩阵,表示X轴上的位移,表示Y轴上的位移,表示Z轴上的位移。
虽然看上去很繁琐,但是在OpenGL中,我们可以通过GLM库来实现平移变换。

  1. glm::mat4 model; // 定义单位矩阵
  2. model = glm::translate(model, glm::vec3(1.0f, 1.0f, 1.0f));

上述代码定义了平移模型矩阵,表示在X、Y、Z轴上同时位移1。

缩放变换

可以在X、Y和Z轴上对物体进行缩放,3个坐标轴相互独立。对于以原点为中心的缩放,假设顶点A(x,y,z)在X、Y和Z轴上分别放大倍,那么可以得到放大后的顶点 =( * x , * y , * z),通过缩放矩阵来表示如下所示:


其中就是缩放变换矩阵。
默认情况下,缩放的中心点是坐标原点,如果我们要以指定顶点P( , , )为中心对物体进行缩放。那么可以按照如下步骤操作:
1. 把顶点P移动到坐标原点
2. 以坐标原点为中心,缩放指定倍数
3. 把顶点P移动回原来的位置
整个过程可以简化成一个矩阵:

在OpenGL中,我们可以通过GLM库来实现缩放变换:

  1. glm::mat4 model; // 定义单位矩阵
  2. model = glm::scale(model, glm::vec3(2.0f, 0.5f, 1.0f);

上述代码定义了缩放模型矩阵,表示在X轴上放大2倍,Y轴上缩小0.5倍、Z轴上保持不变。

旋转变换

在3D空间中,旋转需要定义一个旋转轴和一个角度。物体会沿着给定的旋转轴旋转指定角度。
我们首先看下,沿着Z轴旋转的旋转矩阵是怎样的?
假设有一个顶点P,原始坐标为 ( , , ),离原点的距离是,沿着Z轴顺时针旋转度,新的坐标为( , , ),因为旋转前后,z坐标不变,所以暂时忽略,那么可以得到:





根据上述公式,可以得到围绕Z轴的旋转矩阵:

同理,可以得到围绕X轴的旋转矩阵:

同理,可以得到围绕Y轴的旋转矩阵:

在OpenGL中,我们可以通过GLM库来实现旋转变换:

  1. glm::mat4 model; // 定义单位矩阵
  2. model = glm::rotate(model, glm::radians(-45.0f), glm::vec3(0.4f, 0.6f, 0.8f));

上述代码表示:围绕向量(0.4f, 0.6f, 0.8f),顺时针旋转45度。

在进行旋转操作时,经常有一个困惑:顺时针是正方向,还是逆时针是正方向?
其实,存在一个左手规则和右手规则,可以用于判断物体绕轴旋转时的正方向。
左手规则和右手规则
在OpenGl中,我们使用右手规则,大拇指指向旋转轴的正方向,其余手指的弯曲方向即为旋转正方向。所以上面的-45度是顺时针旋转。

模型变换的顺序问题

因为矩阵不满足交换律,所以平移、旋转和缩放的顺序十分重要,
一般是先缩放、再旋转、最后平移。当然最终还是要考虑实际情况。
还有一点需要注意,GLM操作矩阵的顺序和实际效果是相反的。如下所示,虽然书写顺序是:平移、旋转和缩放,但是实际最终的模型矩阵是:先缩放、再旋转、最后平移。

  1. glm::mat4 model; // 定义单位矩阵
  2. model = glm::translate(model, glm::vec3(1.0f, 1.0f, 1.0f));
  3. model = glm::rotate(model, glm::radians(-45.0f), glm::vec3(0.4f, 0.6f, 0.8f));
  4. model = glm::scale(model, glm::vec3(2.0f, 0.5f, 1.0f);

视图变换

经过模型变换,都有的坐标都处于世界坐标系中,本节就是以摄像头的角度观察整个世界空间。首先需要定义一个摄像头坐标系。
一般情况下,定义一个坐标系需要以下参数:
1. 指定坐标系的维度:2D、3D、4D等。
2. 定义坐标空间的轴向量,例如:X轴、Y轴、Z轴,这些向量称为基向量,基向量一般都是正交的。坐标系中的所有顶点都是通过基向量表示的。
3. 坐标系的原点O,原点是坐标系中所有其他点的参考点。
简单来说,坐标系=(基向量,原点O)

同一个顶点,在不同的坐标系中拥有不同的坐标,那怎么才能把世界坐标系中的顶点坐标,变换到摄像头坐标系那?
要实现不同坐标系之间的坐标转换,需要计算一个变换矩阵。这个矩阵就是坐标系A中的原点和基向量在另一个坐标系B下的坐标表示。假设存在A坐标系B坐标系以及顶点V,那么顶点V在A和B坐标系下的坐标变换公式如下所示:


简单解释一下:

  1. 顶点VA坐标系的坐标 = B坐标系的基向量和原点在A坐标系下的坐标表示构成的变换矩阵 * 顶点VB坐标系的坐标;
  2. 顶点VB坐标系的坐标 = A坐标系的基向量和原点在B坐标系下的坐标表示构成的变换矩阵 * 顶点VA坐标系的坐标

其中,互为逆矩阵。所以坐标系之间的切换,关键就是求出坐标系之间互相表示的变换矩阵。那么矩阵应该怎么计算那?假设坐标系A的三个基向量和原点在B坐标空间的单位坐标向量分别是,那么矩阵如下所示:


矩阵的计算方式也类似,此处不再赘述。

下面我们看下OpenGL的视图变换矩阵是怎么计算出来的?
现在存在两个坐标系:世界坐标系W和摄像头坐标系E,还有一个顶点V,并且知道顶点V在世界坐标系的坐标 = (,,),那么顶点V在摄像头坐标系下的坐标是多少那?根据上面的公式可知,我们首先需要计算出矩阵。

众所周知,世界坐标系的原点O = (0,0,0),三个基向量分别是,X轴:(1,0,0)、Y轴:(0,1,0)、Z轴:(0,0,1)。
理论上,定义一个摄像头坐标系,需要4个参数:
1. 摄像头在世界坐标系中的位置(摄像头坐标系的原点)
2. 摄像头的观察方向(摄像头坐标系的Z基向量)
3. 一个指向摄像头右侧的向量(摄像头坐标系的X基向量)
4. 一个指向摄像头上方的向量(摄像头坐标系的Y基向量)。

通过上述4个参数,我们实际上创建了一个三个单位轴相互垂直的,以摄像机位置为原点的坐标系。
摄像头坐标系

在使用过程中,我们只需要指定3个参数:
1. 摄像机位置向量()
2. 摄像机指向的目标位置向量()
3. 指向摄像头上方的向量(

接下来是根据上面3个参数,推导出摄像头坐标系单位基向量的步骤:
1. 首先计算摄像头的方向向量(方向向量是摄像头坐标系的Z轴正方向,和实际的观察方向是相反的)。


然后计算出单位方向向量

2. 根据上向量和单位方向向量确定摄像头的右向量

然后计算出单位右向量

3. 根据单位右向量和单位方向向量确定单位上向量

这样,就确定了摄像头坐标系的三个单位基向量:以及摄像头的位置向量。这四个参数一起确定了摄像头坐标系:摄像头位置是坐标原点,单位右向量指向正X轴,单位上向量指向正Y轴,单位方向向量指向正Z轴。

现在我们已经定义了一个摄像头坐标系,下一步就是把世界坐标系中的顶点V = (,,),变换到这个摄像头坐标系。根据上文可知,顶点V在摄像头坐标系E的坐标计算过程如下所示:


所以关键是计算变换矩阵,而根据摄像头坐标系的基向量和原点在世界空间中的坐标表示,我们可以得到

那么最终的变换矩阵如下所示:

其中,dot函数表示向量的点积,是一个标量。最终,顶点V在摄像头坐标系下的坐标如下所示:

上面的矩阵就是View变换矩阵。

下面看一个案例:假设摄像头的坐标是(0, 0, 3),摄像头的观察方向是世界坐标系的原点(0,0,0),上向量是(0,1,0),顶点V在世界坐标系的坐标为(1,1,0),那么可以计算出摄像头坐标系的基向量和原点如下所示:
1. =
2. =
3. =
4. =
所以对应的View变换矩阵就是:

最后,顶点V在摄像头坐标系的坐标就是:

虽然上述流程很复杂,但在OpenGL中,我们可以通过GLM库定义View矩阵。针对上述案例,通过lookAt函数就可以得到View矩阵。

  1. glm::mat4 view;
  2. 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));

经过验证,通过lookAt函数得到View矩阵为:

很显然,通过lookAt函数得到View矩阵和上面我们推导的View矩阵是一致的。

投影变换

前面经过模型变换和视图变换后,3D模型已经处于摄像头坐标系中。本节的投影变换将物体从摄像头坐标系变换到裁剪坐标系,为下一步的视口变换做好准备。
投影变换通过指定视见体来决定场景中哪些物体可以呈现在屏幕上。在视见体中的物体会出现在投影平面上,而在视见体之外的物体不会出现在投影平面上。在OpenGL中,我们主要考虑透视投影和正交投影,两者的区别如下所示:
透视投影和正交投影
上图中,红色和黄色球在视见体内,因而呈现在投影平面上;绿色球在视见体外,所以没有投影到近平面上。除此之外,透视投影会根据物体的Z坐标,决定物体在投影平面的大小,原则是:远小近大,符合生活常识。而正交投影不考虑物体Z坐标,所有物体在投影平面上保持原来的大小。

不管透视投影,还是正交投影,都可以通过指定(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble near, GLdouble far)6个参数来指定视见体。(left,bottom)指定了近裁剪面左下角的坐标,(right,top)指定了近裁剪面右上角的坐标,-near表示近裁剪面,−far表示远裁剪面。下面需要利用这6个参数,推导投影矩阵。

在摄像头坐标系下,摄像头指向-z轴,所以近裁剪面z=−near,远裁剪面z=−far。并且OpenGL是在近平面上成像的。

通过上述6个参数指定的透视投影变换如下所示:
透视投影变换

通过上述6个参数指定的正交投影变换如下所示:
正交投影变换

投影变换和透视除法后,摄像头坐标系中的顶点被映射到一个标准立方体中,即NDC坐标系。其中X轴上:[left,right]映射到[−1,1],Y轴上:[bottom,top]映射到[-1,1]中,Z轴上:[near,far]映射到[−1,1],下面的矩阵推导会利用这里的映射关系。下面我们分别看下两种投影矩阵的推导过程。

透视投影

透视投影和透视除法的坐标映射如下所示:
投影映射关系

上图中,摄像头坐标系是右手坐标系,NDC是左手坐标系,NDC坐标系的Z轴指向摄像头坐标系的-Z轴方向。

假设顶点V在摄像头坐标系的坐标 = ( , , , ),变换到裁剪坐标系的坐标 = ( , , , ),透视除法到NDC坐标系的坐标 = ( , , , )。我们的目标是计算出投影矩阵,使得:


同时,可得到透视除法的变换:

首先,我们看下投影矩阵对X轴和Y轴的变换。顶点P投影到近平面后,得到顶点 = ( , , −near)。具体示意图如下所示:
X轴映射
Y轴映射
利用三角形的相似性,通过左图可知:

所以,可以得到X轴上的投影值:

同理,通过右图,可以得到Y轴上的投影值:

由(1)(2)公式可以发现,他们都除以了分量,并且与之成反比。这可以作为透视除法的一个线索,因此我们的矩阵如下所示:

也就是说

接下来,我们根据与NDC坐标的映射关系,推导出的前两行。
满足[left,right]映射到[-1,1],如下所示:
Mapping from $x_p$ to $x_n$
因为是线性映射关系,所以可以设置线性方程,求出系数K和常量P


通过代入[left,right]到[-1,1]的映射关系,可以得到线性方程:

将上面的公式(1)代入公式(3),可得:

又因为,所以可以进一步简化公式:

根据公式(5),可以进一步得到矩阵

OK,继续看下的映射关系:满足[bottom,top]映射到[-1,1],如下所示:Mapping from $y_p$ to $y_n$
同理,根据线性映射关系,可以得到如下公式:


又因为,所以可以进一步简化公式:

根据公式(7),可以进一步得到矩阵

接下来需要计算的系数,这和的计算方式不同,因为摄像头坐标系的坐标投影到近平面后总是-near。同时我们知道与x和y分量无关,因此,可进一步得到矩阵

因为,所以可以得到:

又因为摄像头坐标系中 = 1,所以进一步得到:

同样的,代入的映射关系:[-near,-far]映射到[-1,1],可得到:

又因为,可以进一步简化得到的关系:

由公式(9)就可以知道A和B了,因此,最终的矩阵

一般情况下,投影的视见体都是对称的,即满足left=−right,bottom=−top,那么可以得到:


则矩阵可以简化为:


除了可以通过(left,right,bottom,top,near,far)指定透视投影矩阵外,还可以通过函数glm::perspective指定视角(Fov)、宽高比(Aspect)、近平面(Near)、远平面(Far)来生成透视投影矩阵,如下所示,指定了45度视角,近平面和远平面分别是0.1f和100.0f:

  1. glm::mat4 proj = glm::perspective(glm::radians(45.0f), width/height, 0.1f, 100.0f);

观察视角的示意图如下所示:
观察视角
通过视角指定的透视投影变换如下所示:
通过视角指定的透视投影变换
通过视角指定的透视投影矩阵的视见体是对称的:
透视投影矩阵的对称视见体
由上图可知,近平面的宽和高如下所示:



因为视见体是对称的,所以把公式(10)(11)代入已有的矩阵,可以得到由视角Fov表示的矩阵,如下所示:

通过矩阵左乘摄像头坐标系中的顶点,就把这些顶点变换到了裁剪坐标系。然后再经过透视除法,就变换到了NDC坐标系。

正交投影

相比于透视投影矩阵,正交投影矩阵要简单一些,如下所示:
正交投影
因为正交投影不考虑远小近大的情况,所以正交投影矩阵的第4行始终为

对于正交投影变换,投影到近平面的坐标( , ) = ( , ),因此可以直接利用的线性映射关系,求出线性方程系数。X、Y、Z轴的映射关系如下所示:

映射关系 映射值 示意图
的映射关系 [left , right] [-1 , 1] $x_e$与$x_n$的映射关系
的映射关系 [bottom , top] [-1 , 1] $y_e$与$y_n$的映射关系
的映射关系 [near , far] [-1 , 1] $z_e$与$z_n$的映射关系

根据上述的映射关系,同时摄像头坐标系的 = 1,可以得到三个线性方程,如下所示:




根据上述3个线性方程,可以得到正交投影矩阵

如果视见体是对称的,即满足left=−right,bottom=−top,那么可以得到:


则正交投影矩阵可以进一步简化为:

视口变换

经过投影变换和透视除法后,我们裁减掉了不可见物体,得到了NDC坐标。最后一步是把NDC坐标映射到屏幕坐标( , )。如下所示:
NDC坐标变换到屏幕坐标
在映射到屏幕坐标时,我们需要指定窗口的位置、宽高和深度。如下所示:

  1. //指定窗口的位置和宽高
  2. glViewport(GLint x , GLint y , GLsizei width , GLsizei height);
  3. //指定窗口的深度
  4. glDepthRangef(GLclampf near , GLclampf far);

那么可以NDC坐标和屏幕坐标的线性映射关系:

映射关系 映射值
的映射关系 [-1 , 1] [x , x + width]
的映射关系 [-1 , 1] [y , y + height]
的映射关系 [-1 , 1] [near , far]

因此,可以设置线性方程,求出系数K和常量P


把上述映射关系代入线性方程,可以得到各个分量的参数值。

坐标分量 线性方程的系数K 线性方程的常量P
X分量线性方程 x +
Y分量线性方程 y +
Z分量线性方程

通过上述各个坐标分量值,可以得到视口变换矩阵:


因此,通过ViewPort矩阵左乘NDC坐标,就得到了屏幕坐标。

对于2D屏幕,nearfar一般为0。因此ViewPort矩阵的第三行都是0。所以经过视口变换后,屏幕坐标的Z值都是0。

至此,OpenGL的整个坐标变换过程都介绍完了,关键还是要多实践、实践、实践!!!

参考文档

  1. Cmd Markdown 公式指导手册
  2. 齐次坐标系入门级思考
  3. 仿射变换与齐次坐标
  4. 坐标和变换的数学基础
  5. OpenGL学习脚印
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注