OpenGL坐标变换

3,289 阅读20分钟

基础概述

众所周知,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坐标
    在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),移动到另一个位置A^* =(x^*,y^*,z^*),移动距离D = A^* - A = (x^* - x , y^*- y , z^*- z) = (d_x , d_y , d_z),所以A^*可以用顶点A来表示:

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

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

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),通过缩放矩阵来表示如下所示:

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学习脚印