OpenGL坐标变换

基础概述

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

OpenGL坐标变换过程

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

咱们先简单看下整个流程:c++

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

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

左右手坐标系

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

模型变换

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

平移变换

平移就是将一个顶点A = (x,y,z),移动到另外一个位置A^* =(x^*,y^*,z^*),移动距离D = A^* - A = (x^* - x , y^*- y , z^*- z) = (d_x , d_y , d_z),因此A^*能够用顶点A来表示:ide

A^* =(x^*,y^*,z^*)= (x+d_x,y+d_y,z+d_z)

经过平移矩阵来表示以下所示:wordpress

A^* = M_{translation} * A = \begin{bmatrix} 1 & 0 & 0 & d_x \\ 0 & 1 & 0 & d_y \\ 0 & 0 & 1 & d_z \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} * \begin{bmatrix} x \\ y \\ z \\ 1 \\ \end{bmatrix} = \begin{bmatrix} x+d_x \\ y+d_y \\ z+d_z \\ 1 \end{bmatrix}

其中M_{translation}就是平移变换矩阵,d_x表示X轴上的位移,d_y表示Y轴上的位移,d_z表示Z轴上的位移。 虽然看上去很繁琐,可是在OpenGL中,咱们能够经过GLM库来实现平移变换。函数

glm::mat4 model; // 定义单位矩阵
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轴上分别放大s_xs_ys_z倍,那么能够获得放大后的顶点A^* =(s_x * x ,s_y * y , s_z * z),经过缩放矩阵来表示以下所示:spa

A^* = M_{scale} * A = 
\begin{bmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} * \begin{bmatrix} x \\ y \\ z \\ 1 \\ \end{bmatrix} = \begin{bmatrix} x * s_x \\ y * s_y \\ z * s_z \\ 1 \end{bmatrix}

其中M_{scale}就是缩放变换矩阵。 默认状况下,缩放的中心点是坐标原点,若是咱们要以指定顶点P(x_p , y_p , z_p)为中心对物体进行缩放。那么能够按照以下步骤操做:

  1. 把顶点P移动到坐标原点
  2. 以坐标原点为中心,旋转指定角度
  3. 把顶点P移动回原来的位置 整个过程能够简化成一个矩阵:
M_{scale} = Translation(P) * Scale(\theta) * Translation(-P)

在OpenGL中,咱们能够经过GLM库来实现缩放变换:

glm::mat4 model; // 定义单位矩阵
model = glm::scale(model, glm::vec3(2.0f, 0.5f, 1.0f);
复制代码

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

旋转变换

在3D空间中,旋转须要定义一个旋转轴和一个角度。物体会沿着给定的旋转轴旋转指定角度。 咱们首先看下,沿着Z轴旋转的旋转矩阵是怎样的? 假设有一个顶点P,原始坐标为 (x_o , y_o , z_o),离原点的距离是r,沿着Z轴顺时针旋转\theta度,新的坐标为(x , y , z),由于旋转先后,z坐标不变,因此暂时忽略,那么能够获得:

x_0 = r * \cos(\alpha)
y_0 = r * \sin(\alpha)
x = r * \cos(\alpha + \theta) = r * \cos(\alpha) * \cos(\theta) - r * \sin(\alpha) * \sin(\theta) = x_o * \cos(\theta) - y_o * \sin(\theta)
y = r * \sin(\alpha + \theta) = r * \sin(\alpha) * \cos(\theta) + r * \cos(\alpha) * \sin(\theta) = x_o * \sin(\theta) + y_o * \cos(\theta)

根据上述公式,能够获得围绕Z轴的旋转矩阵:

\begin{bmatrix} 
\cos(\theta) & -\sin(\theta) & 0 & 0
\\ 
\sin(\theta) & \cos(\theta) & 0 & 0
\\ 
0 & 0 & 1 & 0
\\ 
0 & 0 & 0 & 1
\\ 
\end{bmatrix}

同理,能够获得围绕X轴的旋转矩阵:

\begin{bmatrix} 
1 & 0 & 0 & 0
\\
0 & \cos(\theta) & -\sin(\theta)  & 0
\\ 
0 & \sin(\theta) & \cos(\theta) & 0
\\ 
0 & 0 & 0 & 1
\\ 
\end{bmatrix}

同理,能够获得围绕Y轴的旋转矩阵:

\begin{bmatrix} 
\cos(\theta) & 0 & \sin(\theta) & 0
\\
0 & 1 & 0  & 0
\\ 
-\sin(\theta) & 0 & \cos(\theta) & 0
\\ 
0 & 0 & 0 & 1
\\ 
\end{bmatrix}

在OpenGL中,咱们能够经过GLM库来实现旋转变换:

glm::mat4 model; // 定义单位矩阵
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操做矩阵的顺序和实际效果是相反的。以下所示,虽然书写顺序是:平移、旋转和缩放,可是实际最终的模型矩阵是:先缩放、再旋转、最后平移。

glm::mat4 model; // 定义单位矩阵
model = glm::translate(model, glm::vec3(1.0f, 1.0f, 1.0f));
model = glm::rotate(model, glm::radians(-45.0f), glm::vec3(0.4f, 0.6f, 0.8f));
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坐标系下的坐标变换公式以下所示:

[V]_A = [B]_A * [V]_B
[V]_B = [A]_B * [V]_A

简单解释一下:

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

顶点V在B坐标系的坐标 = A坐标系的基向量和原点在B坐标系下的坐标表示构成的变换矩阵 * 顶点V在A坐标系的坐标
复制代码

其中,[B]_A[A]_B互为逆矩阵。因此坐标系之间的切换,关键就是求出坐标系之间互相表示的变换矩阵。那么[A]_B矩阵应该怎么计算那?假设坐标系A的三个基向量和原点在B坐标空间的单位坐标向量分别是\vec{X^A_B}\vec{Y^A_B}\vec{Z^A_B}\vec{O^A_B},那么[A]_B矩阵以下所示:

[A]_B = \begin{bmatrix} 
\vec{X^A_B[0]} & \vec{Y^A_B[0]} & \vec{Z^A_B[0]} & \vec{O^A_B[0]} 
\\ 
\vec{X^A_B[1]} & \vec{Y^A_B[1]} & \vec{Z^A_B[1]} & \vec{O^A_B[1]}
\\ 
\vec{X^A_B[2]} & \vec{Y^A_B[2]} & \vec{Z^A_B[2]} & \vec{O^A_B[2]} 
\\ 
0 & 0 & 0 & 1 
\\ 
\end{bmatrix}

[B]_A矩阵的计算方式也相似,此处再也不赘述。

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

众所周知,世界坐标系的原点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. 摄像机位置向量(\vec{eye})
  2. 摄像机指向的目标位置向量(\vec{target})
  3. 指向摄像头上方的向量(\vec{up}

接下来是根据上面3个参数,推导出摄像头坐标系单位基向量的步骤:

  1. 首先计算摄像头的方向向量\vec{forwrad}(方向向量是摄像头坐标系的Z轴正方向,和实际的观察方向是相反的)。
\vec{forwrad}=(\vec{eye} - \vec{target})

而后计算出单位方向向量

\vec{forwrad_{norm}} = \frac {\vec{forwrad}}{|\vec{forwrad}|}
  1. 根据上向量\vec{up}和单位方向向量\vec{forwrad_{norm}}肯定摄像头的右向量\vec{side}
\vec{side} = cross(\vec{forwrad_{norm}},\vec{up})

而后计算出单位右向量

\vec{side_{norm}} = \frac {\vec{side}}{|\vec{side}|}
  1. 根据单位右向量\vec{side_{norm}}和单位方向向量\vec{forwrad_{norm}}肯定单位上向量\vec{up_{norm}}
\vec{up_{norm}} = cross(\vec{side_{norm}},\vec{forwrad_{norm}})

这样,就肯定了摄像头坐标系的三个单位基向量:\vec{side_{norm}}\vec{up_{norm}}\vec{forwrad_{norm}}以及摄像头的位置向量\vec{eye}。这四个参数一块儿肯定了摄像头坐标系:摄像头位置是坐标原点,单位右向量指向正X轴,单位上向量指向正Y轴,单位方向向量指向正Z轴。

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

[V]_E = [W]_E * [V]_W = [E]^{-1}_W * [V]_W

因此关键是计算变换矩阵[E]^{-1}_W,而根据摄像头坐标系的基向量和原点在世界空间中的坐标表示,咱们能够获得[E]_W

[E]_W = \begin{bmatrix} 
\vec{side_{norm}}[0] & \vec{up_{norm}}[0] & \vec{forward_{norm}}[0] & \vec{eye}[0] 
\\ 
\vec{side_{norm}}[1] & \vec{up_{norm}}[1] & \vec{forward_{norm}}[1] & \vec{eye}[1] 
\\ 
\vec{side_{norm}}[2] & \vec{up_{norm}}[2] & \vec{forward_{norm}}[2] & \vec{eye}[2] 
\\
0 & 0 & 0 & 1 
\\ 
\end{bmatrix}

那么最终的变换矩阵[E]^{-1}_W以下所示:

[E]^{-1}_W = \begin{bmatrix} 
\vec{side_{norm}}[0] & \vec{side_{norm}}[1] & \vec{side_{norm}}[2] & -dot(\vec{side_{norm}},\vec{eye})
\\ 
\vec{up_{norm}}[0] & \vec{up_{norm}}[1] & \vec{up_{norm}}[2] & -dot(\vec{up_{norm}},\vec{eye}) 
\\ 
\vec{forward_{norm}}[0] & \vec{forward_{norm}}[1] & \vec{forward_{norm}}[2] & dot(\vec{forward_{norm}},\vec{eye}) 
\\ 
0 & 0 & 0 & 1 
\\ 
\end{bmatrix}

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

[V]_E = 
\begin{bmatrix} 
\vec{side_{norm}}[0] & \vec{side_{norm}}[1] & \vec{side_{norm}}[2] & -dot(\vec{side_{norm}},\vec{eye})
\\ 
\vec{up_{norm}}[0] & \vec{up_{norm}}[1] & \vec{up_{norm}}[2] & -dot(\vec{up_{norm}},\vec{eye}) 
\\ 
\vec{forward_{norm}}[0] & \vec{forward_{norm}}[1] & \vec{forward_{norm}}[2] & dot(\vec{forward_{norm}},\vec{eye}) 
\\ 
0 & 0 & 0 & 1 
\\ 
\end{bmatrix}
* 
\begin{bmatrix} 
x_w
\\ 
y_w
\\ 
z_w
\\ 
1 
\\ 
\end{bmatrix}

上面的[E]^{-1}_W矩阵就是View变换矩阵。

下面看一个案例:假设摄像头的坐标是(0, 0, 3),摄像头的观察方向是世界坐标系的原点(0,0,0),上向量是(0,1,0),顶点V在世界坐标系的坐标为(1,1,0),那么能够计算出摄像头坐标系的基向量和原点以下所示:

  1. \vec{side_{norm}} = \begin{bmatrix} 
1 \\ 0 \\ 0 \\ \end{bmatrix}
  2. \vec{up_{norm}} = \begin{bmatrix} 
0 \\ 1 \\ 0 \\ \end{bmatrix}
  3. \vec{forward_{norm}} = \begin{bmatrix} 
0 \\ 0 \\ 1 \\ \end{bmatrix}
  4. \vec{eye} = \begin{bmatrix} 
0 \\ 0 \\ 3 \\ \end{bmatrix} 因此对应的View变换矩阵就是:
View = \begin{bmatrix} 
1 & 0 & 0 & 0
\\ 
0 & 1 & 0 & 0
\\ 
0 & 0 & 1 & -3
\\ 
0 & 0 & 1 & 1
\\ 
\end{bmatrix}

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

[V]_E = \begin{bmatrix} 
1 & 0 & 0 & 0
\\ 
0 & 1 & 0 & 0
\\ 
0 & 0 & 1 & -3
\\ 
0 & 0 & 0 & 1
\\ 
\end{bmatrix}
* 
\begin{bmatrix} 
1 \\ 1 \\ 0 \\ 1 \\ 
\end{bmatrix}
.=
\begin{bmatrix} 
1 \\ 1 \\ -3 \\ 1 \\ 
\end{bmatrix}

虽然上述流程很复杂,但在OpenGL中,咱们能够经过GLM库定义View矩阵。针对上述案例,经过lookAt函数就能够获得View矩阵。

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));
复制代码

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

\begin{bmatrix} 
1 & 0 & 0 & 0
\\ 
0 & 1 & 0 & 0
\\ 
0 & 0 & 1 & -3
\\ 
0 & 0 & 0 & 1
\\ 
\end{bmatrix}

很显然,经过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在摄像头坐标系的坐标 = (x_e , y_e , z_e , w_e),变换到裁剪坐标系的坐标 = (x_c , y_c , z_c , w_c),透视除法到NDC坐标系的坐标 = (x_n , y_n , z_n , w_n)。咱们的目标是计算出投影矩阵M_{projection},使得:

\begin{bmatrix} 
x_c \\ y_c \\ z_c \\ w_c \\ 
\end{bmatrix}
= M_{projection} * 
\begin{bmatrix} 
x_e \\ y_e \\ z_e \\ w_e \\ 
\end{bmatrix}

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

\begin{bmatrix} 
x_n \\ y_n \\ z_n \\ 
\end{bmatrix}
.= 
\begin{bmatrix} 
x_c/w_c \\ y_c/w_c \\ z_c/w_c \\ 
\end{bmatrix}

首先,咱们看下投影矩阵M_{projection}对X轴和Y轴的变换。顶点P投影到近平面后,获得顶点P_{near} = (x^p , y^p , −near)。具体示意图以下所示:

X轴映射
Y轴映射
利用三角形的类似性,经过左图可知:

\frac {-near} {z_e} = \frac {x^p} {x_e}

因此,能够获得X轴上的投影值:

x^p = \frac {near * x_e} {-z_e} 
\tag{1}

同理,经过右图,能够获得Y轴上的投影值:

y^p = \frac {near * y_e} {-z_e} 
\tag{2}

由(1)(2)公式能够发现,他们都除以了{-z_e}份量,而且与之成反比。这能够做为透视除法的一个线索,所以咱们的矩阵M_{projection}以下所示:

\begin{bmatrix} 
* & * & * & * \\ 
* & * & * & * \\ 
* & * & * & * \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}

也就是说w_c = -z_e

接下来,咱们根据x^py^p与NDC坐标的映射关系,推导出M_{projection}的前两行。 x^p知足[left,right]映射到[-1,1],以下所示:

Mapping from $x_p$ to $x_n$
由于是线性映射关系,因此能够设置线性方程,求出系数 K和常量 P

x_n = K * x_p + P

经过代入[left,right]到[-1,1]的映射关系,能够获得线性方程:

x_n = \frac {2}{right - left} * x_p - \frac {right + left}{right - left}
\tag{3}

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

x_n = 
\frac {2 * x_e * near}{right - left} * \frac {1}{-z_e} - \frac {right + left}{right - left} 
= \frac {\frac{2 * x_e * near}{right - left} + \frac {right + left}{right - left} * z_e}{-z_e}
\tag{4}

又由于w_c = -z_e,因此能够进一步简化公式:

x_c = \frac {2 * near}{right - left} * x_e + \frac {right + left}{right - left} * z_e
\tag{5}

根据公式(5),能够进一步获得矩阵M_{projection}

\begin{bmatrix} 
\frac {2 * near}{right - left} & 0 & \frac {right + left}{right - left} & 0 \\ 
* & * & * & * \\ 
* & * & * & * \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}

OK,继续看下y^{p}的映射关系:知足[bottom,top]映射到[-1,1],以下所示:

Mapping from $y_p$ to $y_n$
同理,根据 y^{p}线性映射关系,能够获得以下公式:

y_n = 
\frac {2 * y_e * near}{top - bottom} * \frac {1}{-z_e} - \frac {top + bottom}{top - bottom} 
= \frac {\frac{2 * y_e * near}{top - bottom} + \frac {top + bottom}{top - bottom} * z_e}{-z_e}
\tag{6}

又由于w_c = -z_e,因此能够进一步简化公式:

y_c = \frac {2 * near}{top - bottom} * y_e + \frac {top + bottom}{top - bottom} * z_e
\tag{7}

根据公式(7),能够进一步获得矩阵M_{projection}

\begin{bmatrix} 
\frac {2 * near}{right - left} & 0 & \frac {right + left}{right - left} & 0 \\ 
0 & \frac {2 * near}{top - bottom} & \frac {top + bottom}{top - bottom} & 0 \\ 
* & * & * & * \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}

接下来须要计算z_n的系数,这和x_ny_n的计算方式不一样,由于摄像头坐标系的坐标z_e投影到近平面后老是-near。同时咱们知道z_n与x和y份量无关,所以,可进一步获得矩阵M_{projection}

\begin{bmatrix} 
x_c \\ y_c \\ z_c \\ w_c 
\end{bmatrix}
.=
\begin{bmatrix} 
\frac {2 * near}{right - left} & 0 & \frac {right + left}{right - left} & 0 \\ 
0 & \frac {2 * near}{top - bottom} & \frac {top + bottom}{top - bottom} & 0 \\ 
0 & 0 & A & B \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}
* 
\begin{bmatrix} 
x_e \\ y_e \\ z_e \\ w_e 
\end{bmatrix}

由于w_c = -z_e,因此能够获得:

z_n = \frac {A * z_e + B * w_e}{-z_e}

又由于摄像头坐标系中w_e = 1,因此进一步获得:

z_n = \frac {A * z_e + B }{-z_e}

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

z_n = \frac {-\frac{far + near}{far - near} * z_e - \frac {2 * far * near}{far - near}}{-z_e}
\tag{8}

又由于w_c = -z_e,能够进一步简化获得z_cz_e的关系:

z_c = -\frac{far + near}{far - near} * z_e - \frac {2 * far * near}{far - near}
\tag{9}

由公式(9)就能够知道A和B了,所以,最终的矩阵M_{projection}

\begin{bmatrix} 
\frac {2 * near}{right - left} & 0 & \frac {right + left}{right - left} & 0 \\ 
0 & \frac {2 * near}{top - bottom} & \frac {top + bottom}{top - bottom} & 0 \\ 
0 & 0 & -\frac{(far + near)}{far - near} & -\frac {2 * far * near}{far - near} \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}

通常状况下,投影的视见体都是对称的,即知足left=−right,bottom=−top,那么能够获得:

\begin
{cases} right + left = 0 
\\ 
right - left = 2 * right = width 
\end{cases}
\begin
{cases} top + bottom = 0 
\\ 
top - bottom = 2 * top = height 
\end{cases}

则矩阵M_{projection}能够简化为:

\begin{bmatrix} 
\frac {near}{right} & 0 & 0 & 0 \\ 
0 & \frac {near}{top} & 0 & 0 \\ 
0 & 0 & -\frac{(far + near)}{far - near} & -\frac {2 * far * near}{far - near} \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}

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

glm::mat4 proj = glm::perspective(glm::radians(45.0f), width/height, 0.1f, 100.0f);
复制代码

观察视角的示意图以下所示:

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

Height = 2 * near * \tan(\frac{\theta}{2}) \tag{10}
Width = height * Aspect  \tag{11}

由于视见体是对称的,因此把公式(10)(11)代入已有的M_{projection}矩阵,能够获得由视角Fov表示的M_{projection}矩阵,以下所示:

M_{projection} = 
\begin{bmatrix} 
\frac {\cot(\frac{\theta}{2})}{Aspect} & 0 & 0 & 0 \\ 
0 & \cot(\frac{\theta}{2}) & 0 & 0 \\ 
0 & 0 & -\frac{(far + near)}{far - near} & -\frac {2 * far * near}{far - near} \\ 
0 & 0 & -1 & 0 \\
\end{bmatrix}

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

正交投影

相比于透视投影矩阵,正交投影矩阵要简单一些,以下所示:

正交投影
由于正交投影不考虑远小近大的状况,因此正交投影矩阵 M_{orthographic}的第4行始终为 [0 , 0 , 0 , 1]

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

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

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

x_c = x_n = \frac{2}{right - left} * x_e - \frac{right + left}{right - left}
\tag{$x_n$和$x_e$的映射关系}
y_c = y_n = \frac{2}{top - bottom} * y_e - \frac{top + bottom}{top - bottom}
\tag{$y_n$和$y_e$的映射关系}
z_c = z_n = \frac{-2}{far - near} * z_e - \frac{far + near}{far - near}
\tag{$z_n$和$z_e$的映射关系}

根据上述3个线性方程,能够获得正交投影矩阵M_{orthographic}

M_{orthographic} =
\begin{bmatrix} 
\frac{2}{right - left} & 0 & 0 & - \frac{right + left}{right - left} \\ 
0 & \frac{2}{top - bottom} & 0 & - \frac{top + bottom}{top - bottom} \\ 
0 & 0 & \frac{-2}{far - near} & - \frac{far + near}{far - near} \\ 
0 & 0 & 0 & 1 \\
\end{bmatrix}

若是视见体是对称的,即知足left=−right,bottom=−top,那么能够获得:

\begin
{cases} right + left = 0 
\\ 
right - left = 2 * right = width 
\end{cases}
\begin
{cases} top + bottom = 0 
\\ 
top - bottom = 2 * top = height 
\end{cases}

则正交投影矩阵M_{orthographic}能够进一步简化为:

M_{orthographic} =
\begin{bmatrix} 
\frac{1}{right} & 0 & 0 & 0 \\ 
0 & \frac{1}{top} & 0 & 0 \\ 
0 & 0 & \frac{-2}{far - near} & - \frac{far + near}{far - near} \\ 
0 & 0 & 0 & 1 \\
\end{bmatrix}

视口变换

通过投影变换和透视除法后,咱们裁减掉了不可见物体,获得了NDC坐标。最后一步是把NDC坐标映射到屏幕坐标(x_sy_s , z_s)。以下所示:

NDC坐标变换到屏幕坐标
在映射到屏幕坐标时,咱们须要指定窗口的位置、宽高和深度。以下所示:

//指定窗口的位置和宽高
glViewport(GLint x , GLint y , GLsizei width , GLsizei height); 
//指定窗口的深度
glDepthRangef(GLclampf near , GLclampf far);
复制代码

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

映射关系 映射值
x_nx_s的映射关系 [-1 , 1] \iff [x , x + width]
y_ny_s的映射关系 [-1 , 1] \iff [y , y + height]
z_nz_s的映射关系 [-1 , 1] \iff [near , far]

所以,能够设置线性方程,求出系数K和常量P

Y = K * X + P

把上述映射关系代入线性方程,能够获得各个份量的参数值。

坐标份量 线性方程的系数K 线性方程的常量P
X份量线性方程 \frac {width} {2} x + \frac {width} {2}
Y份量线性方程 \frac {height} {2} y + \frac {height} {2}
Z份量线性方程 \frac {far - near} {2} \frac {far + near} {2}

经过上述各个坐标份量值,能够获得视口变换矩阵:

ViewPort = 
\begin{bmatrix} 
\frac {width} {2} & 0 & 0 & x + \frac {width} {2}
\\ 
0 & \frac {height} {2} & 0 & y + \frac {height} {2}
\\ 
0 & 0 & \frac {far - near} {2} &  \frac {far + near} {2} 
\\ 
0 & 0 & 0 & 1
\\ 
\end{bmatrix}

所以,经过ViewPort矩阵左乘NDC坐标,就获得了屏幕坐标。

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

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

参考文档

  1. Cmd Markdown 公式指导手册
  2. 齐次坐标系入门级思考
  3. 仿射变换与齐次坐标
  4. 坐标和变换的数学基础
  5. OpenGL学习脚印
相关文章
相关标签/搜索