在 OpenGL 学习笔记 (二)- 顶点与绘制指令 中,已经对绘制指令与顶点规范进行了简单介绍,接下来的学习笔记将按照渲染管线的顺序继续说明。本节学习笔记将会介绍顶点数据在渲染管线中经过的第一步,也就是顶点着色器相关的操作。
虽然作为可编程着色器,顶点着色器并没有固定的功能,但是有一些操作通常都会交由顶点着色器处理。对应于操作固定的固定管线,这些操作通常包含:应用模型视图矩阵、应用投影矩阵、应用法线矩阵、应用纹理矩阵等等。这些操作都针对顶点数据进行,而且用处都是进行坐标变换,因此本节将会结合坐标系来讲解这些变换。
坐标系
在顶点的处理过程中,有若干过渡用的坐标系,这些坐标系可以清楚的表达坐标变化的过程,并简化部分计算。下面是几个相对重要的坐标系:
局部空间(Local Space) 世界空间(World Space) 观察空间(View Space) 裁剪空间(Clip Space) 标准化设备坐标(Normalized Device Coordinate,NDC) 屏幕空间(Screen Space)
若连起来,我们就可以整理出顶点坐标变换的大致步骤了。
顶点变换的步骤(修改自 Reference)
我们先来了解变换中出现的各个坐标系,之后再来了解处理过程中的各个矩阵和步骤。
局部空间
局部空间是我们传入的顶点坐标所在的空间。一般情况下,我们可以把要渲染的场景拆分为若干小物件。这些小物件通常是一个模型,包含了大量的顶点。而建模时,我们一般不会考虑模型在场景中的绝对坐标,因此我们会人为指定一个坐标系。这个坐标系就是局部空间,它的取值和模型所在的位置是无关的。
世界空间
世界空间就是我们渲染场景的空间。每个模型在世界空间都有各自的位置,因此我们需要把局部空间的坐标移动至它的位置,以变成世界坐标。
观察空间
观察空间是我们真正看到的空间。我们最后渲染出的场景一定是在世界空间的某一处看到的,而这个观察到的空间就是观察空间。观察空间是特定位置、角度下的世界空间。可以想象出一个摄像机,从摄像机拍照的角度看到的空间就是观察空间。
裁剪空间
裁剪空间可以理解为一个中间空间。由于需要将三维空间以二维的形式呈现出来,因此我们需要对观察空间的物体进行投影,而裁剪空间可以理解为投影的中间步骤。裁剪空间中,我们已经可以确定最终会显示在屏幕上的顶点了,因此之后我们就可以裁剪掉所有视野外的顶点(发生在 “顶点后处理” 的 “裁剪”)。这就是这个空间被称作裁剪空间的原因。
标准化设备坐标
标准化设备坐标是真正绘制在屏幕内顶点的坐标,其 x、y、z 的取值范围都必须在[ − 1 , 1 ] [-1,1] [ − 1 , 1 ] 之内。以屏幕的正中心为( 0 , 0 , 0 ) \left( 0,0,0 \right) ( 0 , 0 , 0 ) ;屏幕方向看为 xOy 直角坐标系;z 为深度,由屏幕外向内递增。NDC 是左手坐标系,而之前的坐标系都是右手坐标系。
屏幕空间
屏幕空间即 glViewport 函数定义的视口,它的取值范围由视口大小决定。屏幕空间的坐标就是真实屏幕(严格来说是视口)上的坐标,经过栅格化后就对应于屏幕像素。
预备知识
在真正了解这些变换之前,我们有必要先了解一些数学相关的知识。这些内容主要是关于线性代数和投影几何的,是构成之后变换矩阵的基本知识。
齐次坐标系
齐次坐标就是较原先坐标增广 1 维度 的坐标,而齐次坐标所构成的坐标系就是齐次坐标系。齐次坐标的一个重要的目的就是用于进行仿射变换。在线性代数中,我们可以通过一个矩阵来代表一个变换,比如如下的矩阵可以拉伸一个坐标(或向量)。
( 1 0 0 0 2 0 0 0 3 ) ( 2 1 2 ) = ( 2 2 6 ) \begin{pmatrix}
1 & 0 & 0\\
0 & 2 & 0\\
0 & 0 & 3
\end{pmatrix}\left(
\begin{array}{ccc}
2 \\ 1 \\ 2 \\
\end{array}
\right)=\left(
\begin{array}{ccc}
2 \\ 2 \\ 6 \\
\end{array}
\right) ⎝ ⎛ 1 0 0 0 2 0 0 0 3 ⎠ ⎞ ⎝ ⎛ 2 1 2 ⎠ ⎞ = ⎝ ⎛ 2 2 6 ⎠ ⎞
同样,除了放缩,我们还能写出旋转、剪切和反射对应的矩阵。但是,我们并没有办法写出平移操作的矩阵。因为实质上,我们刚刚列举的操作都是在变换一个三维空间内的向量,而平移向量是一个毫无意义的操作。因此,为了平移一个坐标,我们需要在更高阶的空间进行操作。我们可以把一个三维空间的坐标增广写为( x , y , z , 1 ) T \left( x,y,z,1 \right)^{T} ( x , y , z , 1 ) T ,这样平移操作就可以表示为
( 1 0 0 x 0 1 0 y 0 0 1 z 0 0 0 1 ) ( a 1 a 2 a 3 1 ) = ( a 1 + x a 2 + y a 3 + z 1 ) \begin{pmatrix}
1 & 0 & 0 & x\\
0 & 1 & 0 & y\\
0 & 0 & 1 & z\\
0 & 0 & 0 & 1\\
\end{pmatrix}\left(
\begin{array}{ccc}
a_1 \\ a_2 \\ a_3 \\ 1
\end{array}
\right)=\left(
\begin{array}{ccc}
a_1+x \\ a_2+y \\ a_3+z \\ 1
\end{array}
\right) ⎝ ⎛ 1 0 0 0 0 1 0 0 0 0 1 0 x y z 1 ⎠ ⎞ ⎝ ⎛ a 1 a 2 a 3 1 ⎠ ⎞ = ⎝ ⎛ a 1 + x a 2 + y a 3 + z 1 ⎠ ⎞
这样我们就可以写出任何代表仿射变换的矩阵了。另外,对于原先用于变换的三阶方阵,我们可以等价的改写为
( a 11 a 12 a 13 0 a 21 a 22 a 23 0 a 31 a 32 a 33 0 0 0 0 1 ) \begin{pmatrix}
a_{11} & a_{12} & a_{13} & 0\\
a_{21} & a_{22} & a_{23} & 0\\
a_{31} & a_{32} & a_{33} & 0\\
0 & 0 & 0 & 1
\end{pmatrix} ⎝ ⎛ a 11 a 21 a 31 0 a 12 a 22 a 32 0 a 13 a 23 a 33 0 0 0 0 1 ⎠ ⎞
除此之外,我们还可以使用齐次坐标来区分点和向量。若一个齐次坐标的 w 分量为 0,即( x , y , z , 0 ) T \left( x,y,z,0 \right)^{T} ( x , y , z , 0 ) T ,那么这个齐次坐标就可以视为代表一个三维空间向量。可以简单的理解为,w 分量为 0 的齐次坐标不能被移动,因此它代表一个向量。当然也可以从基底的角度进行解释,这里就不做展开了。
矩阵合成的顺序
为了减少计算量,我们通常会合成多个变换矩阵。但是需要注意的是,因为矩阵乘法不适合交换律,因此矩阵的乘法是有顺序的。比如你希望对向量v \textbf{v} v 先进行变换M 1 M_1 M 1 之后再进行M 2 M_2 M 2 ,变换后的向量应当是M 2 ( M 1 v ) M_2 (M_1 \textbf{v}) M 2 ( M 1 v ) 。由于矩阵乘法适合结合律(列向量可以看作 n 行 1 列的矩阵),故M 2 ( M 1 v ) = ( M 2 ⋅ M 1 ) v M_2 (M_1 \textbf{v})=(M_2 \cdot M_1) \textbf{v} M 2 ( M 1 v ) = ( M 2 ⋅ M 1 ) v ,因此最终合成的矩阵应该是M 2 ⋅ M 1 M_2 \cdot M_1 M 2 ⋅ M 1 ,其运算顺序和操作顺序正好相反。
顶点变换
之前我们已经提及,在渲染的过程中,顶点坐标将在多个坐标系之间转换。一般来说,顶点着色器负责的是模型矩阵、视图矩阵和投影矩阵(还可能有法线矩阵)的操作,而透视除法和视口变换是 OpenGL 在 “顶点后处理” 中已经替我们实现的。
模型矩阵
模型矩阵复杂把局部空间的顶点变换为世界空间的顶点,可以理解为把一个 “模型” 摆到它在世界中的位置,因此模型矩阵并没有什么固定的要求。在 OpenGL 中,我们使用齐次坐标来描述一个顶点的位置,因此模型矩阵是一个 4 阶方阵。
一般来说,模型矩阵是一系列仿射变换的合成,而且通常会包括平移M t r a n s M_{trans} M t r an s 、旋转M r o t a t e M_{rotate} M ro t a t e 和缩放M s c a l e M_{scale} M sc a l e 。另外,需要注意的是平移操作通常会影响后续的旋转和缩放操作,因此推荐把平移操作留至最后进行:M m o d e l = M t r a n s ⋅ M r o t a t e ⋅ M s c a l e M_{model} = M_{trans} \cdot M_{rotate} \cdot M_{scale} M m o d e l = M t r an s ⋅ M ro t a t e ⋅ M sc a l e 。
视图矩阵
视图矩阵就是将世界空间变换为观察空间的矩阵,经过变换之后,物体的坐标将会变换为摄像机观察的坐标,这里我们把视点称作 “摄像机”。改变观察的方式在某种程度上其实等价于反向移动世界中的物体,比如用摄像机拍摄一个物体,摄像机推进 1m 和物体退后 1m 在摄像机看来是没有区别的。这里我们也用同样的思路来构造这个矩阵。
一个常见的视图矩阵是 LookAt 矩阵,可以创建一个在摄像机位置( x c , y c , z c ) ( x_c, y_c, z_c ) ( x c , y c , z c ) 看向目标位置( x t , y t , z t ) ( x_t, y_t, z_t ) ( x t , y t , z t ) 的视图矩阵。因此我们大致需要进行两个操作:首先把世界移动到摄像机位置;之后把世界旋转至摄像机角度。要注意的是,这里我们并不是移动摄像机,而是反向操作整个场景,因此需要先移动再进行旋转。由此我们可以给出 LookAt 矩阵大致的形式。
L o o k A t = M r o t ⋅ M t r a n s = ( r 11 r 12 r 13 0 r 21 r 22 r 23 0 r 31 r 32 r 33 0 0 0 0 1 ) ( 1 0 0 t x 0 1 0 t y 0 0 1 t z 0 0 0 1 ) LookAt=M_{rot} \cdot M_{trans} =\begin{pmatrix}
r_{11} & r_{12} & r_{13} & 0\\
r_{21} & r_{22} & r_{23} & 0\\
r_{31} & r_{32} & r_{33} & 0\\
0 & 0 & 0 & 1
\end{pmatrix}\begin{pmatrix}
1 & 0 & 0 & t_x\\
0 & 1 & 0 & t_y\\
0 & 0 & 1 & t_z\\
0 & 0 & 0 & 1
\end{pmatrix} L oo k A t = M ro t ⋅ M t r an s = ⎝ ⎛ r 11 r 21 r 31 0 r 12 r 22 r 32 0 r 13 r 23 r 33 0 0 0 0 1 ⎠ ⎞ ⎝ ⎛ 1 0 0 0 0 1 0 0 0 0 1 0 t x t y t z 1 ⎠ ⎞
移动矩阵的构造是很简单的,我们只需要反向移动到摄像机位置就可以了。
M t r a n s = ( 1 0 0 − x c 0 1 0 − y c 0 0 1 − z c 0 0 0 1 ) M_{trans}=\begin{pmatrix}
1 & 0 & 0 & -x_c\\
0 & 1 & 0 & -y_c\\
0 & 0 & 1 & -z_c\\
0 & 0 & 0 & 1
\end{pmatrix} M t r an s = ⎝ ⎛ 1 0 0 0 0 1 0 0 0 0 1 0 − x c − y c − z c 1 ⎠ ⎞
旋转矩阵的构造相对复杂,这里需要一些线性代数知识的储备。旋转操作实际上就是一个正交变换,因此我们只需要找到旋转后的一组正交基就可以了。但是由于世界需要反向旋转,因此这个正交基的寻找会困难不少,所以我们可以采用先找出正向旋转矩阵,然后再进行逆操作(取矩阵逆)得到目标矩阵,因此我们首先需要找到一组合适的正交基。因此我们以目标位置向摄像机位置取一组正交基。(这里不能以摄像机朝物体的方向取正交基,因为我们假定摄像机看向 z 轴的负方向,如果我们使用这组正交基那世界会整体旋转前后颠倒)
选取的正交基(图源 Reference)
接着我们来计算这组正交基。首先最好计算的就是前向向量f f f ,使用摄像机位置和目标位置做差并标准化即可。
f = v c − v t ∥ v c − v t ∥ f=\frac{v_c-v_t}{\left\Vert v_c-v_t \right\Vert} f = ∥ v c − v t ∥ v c − v t
之后的左向向量和上向向量稍微有点复杂。这里我们需要规定,LookAt 矩阵最终的视野一定是 “正” 的,也就是说不会左右 “倾斜”,因此我们需要给出代表上方向的向量u p up u p 。由此我们就可以使用叉积计算左方向的向量l e f t = u p × f left=up \times f l e f t = u p × f (注意叉积的顺序)。之后对这组向量进行施密特正交化,我们就能得到这组正交基了。(如果你不知道施密特正交化,那实际上这一步骤就是根据确定的左向向量和前向向量计算出上向向量)
f = l e f t ∥ l e f t ∥ = u p × f ∥ u p × f ∥ f=\frac{left}{\left\Vert left \right\Vert}=\frac{up \times f}{\left\Vert up \times f \right\Vert} f = ∥ l e f t ∥ l e f t = ∥ u p × f ∥ u p × f
u = f × l u=f \times l u = f × l
由此我们就能得出正交变换矩阵,之后对它求逆就能得到M r o t M_{rot} M ro t 。这里运用了正交矩阵的逆等于其转置的特性。
M r o t = ( l x u x f x 0 l y u y f y 0 l z u z f z 0 0 0 0 1 ) − 1 = ( l x u x f x 0 l y u y f y 0 l z u z f z 0 0 0 0 1 ) T = ( l x l y l z 0 u x u y u z 0 f x f y f z 0 0 0 0 1 ) M_{rot}=\begin{pmatrix}
l_x & u_x & f_x & 0\\
l_y & u_y & f_y & 0\\
l_z & u_z & f_z & 0\\
0 & 0 & 0 & 1
\end{pmatrix}^{-1}=\begin{pmatrix}
l_x & u_x & f_x & 0\\
l_y & u_y & f_y & 0\\
l_z & u_z & f_z & 0\\
0 & 0 & 0 & 1
\end{pmatrix}^{T}=\begin{pmatrix}
l_x & l_y & l_z & 0\\
u_x & u_y & u_z & 0\\
f_x & f_y & f_z & 0\\
0 & 0 & 0 & 1
\end{pmatrix} M ro t = ⎝ ⎛ l x l y l z 0 u x u y u z 0 f x f y f z 0 0 0 0 1 ⎠ ⎞ − 1 = ⎝ ⎛ l x l y l z 0 u x u y u z 0 f x f y f z 0 0 0 0 1 ⎠ ⎞ T = ⎝ ⎛ l x u x f x 0 l y u y f y 0 l z u z f z 0 0 0 0 1 ⎠ ⎞
因此最终的 LookAt 矩阵就可以表示为:
L o o k A t = M r o t ⋅ M t r a n s = ( l x l y l z 0 u x u y u z 0 f x f y f z 0 0 0 0 1 ) ( 1 0 0 − x c 0 1 0 − y c 0 0 1 − z c 0 0 0 1 ) = ( l x l y l z − l x x e − l y y e − l z z e u x u y u z − u x x e − u y y e − u z z e f x f y f z − f x x e − f y y e − f z z e 0 0 0 1 ) \begin{aligned}
LookAt=M_{rot} \cdot M_{trans}&=\begin{pmatrix}
l_x & l_y & l_z & 0\\
u_x & u_y & u_z & 0\\
f_x & f_y & f_z & 0\\
0 & 0 & 0 & 1
\end{pmatrix}\begin{pmatrix}
1 & 0 & 0 & -x_c\\
0 & 1 & 0 & -y_c\\
0 & 0 & 1 & -z_c\\
0 & 0 & 0 & 1
\end{pmatrix}\\&=\begin{pmatrix}
l_x & l_y & l_z & -l_x x_e-l_y y_e-l_z z_e\\
u_x & u_y & u_z & -u_x x_e-u_y y_e-u_z z_e\\
f_x & f_y & f_z & -f_x x_e-f_y y_e-f_z z_e\\
0 & 0 & 0 & 1
\end{pmatrix}
\end{aligned} L oo k A t = M ro t ⋅ M t r an s = ⎝ ⎛ l x u x f x 0 l y u y f y 0 l z u z f z 0 0 0 0 1 ⎠ ⎞ ⎝ ⎛ 1 0 0 0 0 1 0 0 0 0 1 0 − x c − y c − z c 1 ⎠ ⎞ = ⎝ ⎛ l x u x f x 0 l y u y f y 0 l z u z f z 0 − l x x e − l y y e − l z z e − u x x e − u y y e − u z z e − f x x e − f y y e − f z z e 1 ⎠ ⎞
不过需要注意的是,摄像机的方向不能与 up 向量平行。另外,LookAt 矩阵仅仅是视图矩阵的一种,如果要实现复杂的摄像机运动,还可以使用其他的矩阵。而且,LookAt 矩阵也不是实现摄像机的全部,实现摄像机的过程中可能还会遇到万向节锁的问题,还需要使用四元数的知识来解决。关于这些,我可能会单独用一篇文章来介绍。
投影矩阵
投影矩阵是将观察空间变换为裁剪空间的矩阵。投影的过程实际上就是将 3D 空间转化为 2D 空间的过程,只不过我们还希望保留顶点的深度信息,以供我们判断之后的绘制与否。在 OpenGL 中,整个投影过程实际上包括:应用投影矩阵、裁剪和透视除法。经过这一系列操作之后,我们将获得 NDC 空间下的若干顶点。
之前我们已经介绍过齐次坐标在仿射变换中的应用,现在简单介绍齐次坐标在投影几何中的应用。仿射变换中,齐次坐标的 w 分量通常是 1。但在投影过程中,我们可以通过调整 w 的值来确定一个视锥(frustum,又称平截头体)。视锥就是可视范围,所有可见的顶点都必须落于其中。根据投影方式的不同,视锥的形状一般也不相同。
透视投影和正视投影的视锥(图源 Reference)
图示为透视投影和正视投影的视锥,这是最常见的两种投影方式。接下来我们将介绍这两种投影方式的投影矩阵的推导。
透视投影
透视投影是十分贴近现实的一种投影方式,因此投影的结果也相对正视投影更符合现实。简而言之,透视投影就是能模拟近大远小的投影方式。透视投影的视锥是一个平截头棱锥体(也就是金字塔切掉上半部分),符合我们 “进处视野小、远处视野大” 的认知。
透视投影视锥与 NDC 空间(图源 Reference)
从图中我们可以看到,我们的目的实际上就是将左侧的视锥映射到右侧的 NDC 空间中。还需要注意一点,NDC 实际上是左手坐标系,而之前的空间都是右手坐标系,因此我们还需要在投影过程中翻转 z 轴。
整个视锥可以分为近面(near plane,也是投影面)和远面(far plane),给出近面的左右上下(l、r、t、b)和近面与远面的位置(n、f,因为都是正数因此计算时使用 - n 与 - f)。我们从 xOz 和 yOz 两个平面来分析投影过程。
xOz 平面的投影(图源 Reference)
视锥内的点( x e , y e , z e ) (x_e, y_e, z_e) ( x e , y e , z e ) 的投影结果( x p , y p , z p ) (x_p, y_p, z_p) ( x p , y p , z p ) 可以用相似三角形计算。
x p x e = − n z e ⇒ x p = − n ⋅ x e z e \frac{x_p}{x_e}=\frac{-n}{z_e}\:\Rightarrow\:x_p=-\frac{n \cdot x_e}{z_e} x e x p = z e − n ⇒ x p = − z e n ⋅ x e
同理,我们可以计算出y p y_p y p 。
yOz 平面的投影(图源 Reference)
y p y e = − n z e ⇒ y p = − n ⋅ y e z e \frac{y_p}{y_e}=\frac{-n}{z_e}\:\Rightarrow\:y_p=-\frac{n \cdot y_e}{z_e} y e y p = z e − n ⇒ y p = − z e n ⋅ y e
最终投影点的坐标是( − n ⋅ x e z e , − n ⋅ y e z e , − n ) (-\frac{n \cdot x_e}{z_e}, -\frac{n \cdot y_e}{z_e}, -n) ( − z e n ⋅ x e , − z e n ⋅ y e , − n ) 。可以看到,在投影的过程中x e x_e x e 和y e y_e y e 都需要除以− z e -z_e − z e 。但是z e z_e z e 本身是顶点坐标的一部分,因此我们并没有办法使用线性变换的方式来完成这个目标。因此,我们引入了透视除法这一操作。在齐次坐标转化为 NDC 坐标时,我们进行如下操作:
( x n d c y n d c z n d c ) = ( x c l i p / w c l i p y c l i p / w c l i p z c l i p / w c l i p ) \left(
\begin{array}{ccc}
x_{ndc} \\ y_{ndc} \\ z_{ndc}
\end{array}
\right)=\left(
\begin{array}{ccc}
x_{clip} / w_{clip} \\
y_{clip} / w_{clip} \\
z_{clip} / w_{clip}
\end{array}
\right) ⎝ ⎛ x n d c y n d c z n d c ⎠ ⎞ = ⎝ ⎛ x c l i p / w c l i p y c l i p / w c l i p z c l i p / w c l i p ⎠ ⎞
这样,我们就可以令w c l i p = − z e w_{clip}=-z_e w c l i p = − z e 来引入这一项。
之后我们继续变换投影后的坐标( x p , y p , z p ) (x_p, y_p, z_p) ( x p , y p , z p ) ,使x p x_p x p 和y p y_p y p 满足 NDC 坐标的约束。首先将x p x_p x p 从[ l , r ] [l, r] [ l , r ] 映射至[ − 1 , 1 ] [-1, 1] [ − 1 , 1 ] 。
x n d c = 2 × x p − l r − l − 1 x_{ndc} = 2 \times \frac{x_p-l}{r-l} - 1 x n d c = 2 × r − l x p − l − 1
之后我们代入x p = − n ⋅ x e z e x_p=-\frac{n \cdot x_e}{z_e} x p = − z e n ⋅ x e ,并进行约分。由于我们需要得到透视除法之前的裁剪坐标,因此我们需要提出w c l i p = − z e w_{clip}=-z_e w c l i p = − z e 一项。
x n d c = 2 × x p − l r − l − 1 x n d c = 2 × n ⋅ x e − z e − l r − l − 1 x n d c = 2 × n ⋅ x e + l ⋅ z e r − l + z e − z e x n d c = ( 2 n r − l ⋅ x e + r + l r − l ⋅ z e ) / − z e x n d c = x c l i p / w c l i p ⇒ x c l i p = 2 n r − l ⋅ x e + r + l r − l ⋅ z e \begin{aligned}
x_{ndc} &= 2 \times \frac{x_p-l}{r-l} - 1\\
x_{ndc} &= 2 \times \frac{\frac{n \cdot x_e}{-z_e}-l}{r-l} - 1\\
x_{ndc} &= \frac{2 \times \frac{n \cdot x_e + l \cdot z_e}{r-l}+z_e}{-z_e}\\
x_{ndc} &= \left( \frac{2n}{r-l} \cdot x_e + \frac{r+l}{r-l} \cdot z_e \right) / -z_e\\
x_{ndc} &= x_{clip} / w_{clip}\\
\Rightarrow x_{clip} &= \frac{2n}{r-l} \cdot x_e + \frac{r+l}{r-l} \cdot z_e
\end{aligned} x n d c x n d c x n d c x n d c x n d c ⇒ x c l i p = 2 × r − l x p − l − 1 = 2 × r − l − z e n ⋅ x e − l − 1 = − z e 2 × r − l n ⋅ x e + l ⋅ z e + z e = ( r − l 2 n ⋅ x e + r − l r + l ⋅ z e ) / − z e = x c l i p / w c l i p = r − l 2 n ⋅ x e + r − l r + l ⋅ z e
同理,我们可以计算出y c l i p y_{clip} y c l i p 。
y c l i p = 2 n t − b ⋅ y e + t + b t − b ⋅ z e y_{clip}=\frac{2n}{t-b} \cdot y_e + \frac{t+b}{t-b} \cdot z_e y c l i p = t − b 2 n ⋅ y e + t − b t + b ⋅ z e
接下来我们讨论z c l i p z_{clip} z c l i p 的计算。虽然按照投影结果来说,投影的目标点都会落在近面,因此z p = − n z_{p}=-n z p = − n 。但是在 NDC 坐标中,我们希望 z 分量能提供一个顶点的深度信息,因此我们还需重新考虑 z 分量的映射。
z n d c = 2 × z e + n n − f + 1 z n d c = z c l i p / w c l i p ⇒ z c l i p = − z e ( 2 × z e + n n − f + 1 ) \begin{aligned}
z_{ndc}&=2 \times \frac{z_e+n}{n-f} + 1\\
z_{ndc}&=z_{clip} / w_{clip}\\
\Rightarrow z_{clip} &= -z_e \left( 2 \times \frac{z_e+n}{n-f} + 1 \right)
\end{aligned} z n d c z n d c ⇒ z c l i p = 2 × n − f z e + n + 1 = z c l i p / w c l i p = − z e ( 2 × n − f z e + n + 1 )
但是当我们进行线性映射的时候,却发现计算出的z c l i p z_{clip} z c l i p 包含二次项。显然这是线性变换无法做到的,因此我们必须放弃线性映射,转而采用不同的方法来映射 z 分量。由线性代数,我们知道z c l i p z_{clip} z c l i p 一定可以写成:
z c l i p = A z e + B w e z_{clip}=A z_e+B w_e z c l i p = A z e + B w e
其中 A、B 是常数。接下来采用待定系数法解出 A、B 两个常数。首先计算z n d c z_{ndc} z n d c 。
z n d c = z c l i p / w = A z e + B w e − z e z_{ndc}=z_{clip}/w=\frac{A z_e+B w_e}{-z_e} z n d c = z c l i p / w = − z e A z e + B w e
可以看出,z n d c z_{ndc} z n d c 是一个关于z e z_e z e 的单调函数,因此代入边界点z e = − n z_e=-n z e = − n 和z e = − f z_e=-f z e = − f 可以联立:
{ − A n + B n = − 1 − A f + B f = 1 \left\{ \begin{aligned}
\frac{-An+B}{n} &= -1\\
\frac{-Af+B}{f} &= 1
\end{aligned}\right. ⎩ ⎨ ⎧ n − A n + B f − A f + B = − 1 = 1
解得
{ A = − f + n f − n B = − 2 f n f − n \left\{ \begin{aligned}
A &= -\frac{f+n}{f-n}\\
B &= -\frac{2fn}{f-n}
\end{aligned}\right.
⎩ ⎨ ⎧ A B = − f − n f + n = − f − n 2 f n
由此我们就可以得到透视投影的坐标变换:
( x c l i p y c l i p z c l i p w c l i p ) = ( 2 n r − l ⋅ x e + r + l r − l ⋅ z e 2 n t − b ⋅ y e + t + b t − b ⋅ z e − f + n f − n ⋅ z e − 2 f n f − n ⋅ w e − z e ) \large{\left(
\begin{array}{ccc}
x_{clip} \\ y_{clip} \\ z_{clip} \\ w_{clip}
\end{array}
\right)=\left(
\begin{array}{ccc}
\frac{2n}{r-l} \cdot x_e + \frac{r+l}{r-l} \cdot z_e \\
\frac{2n}{t-b} \cdot y_e + \frac{t+b}{t-b} \cdot z_e \\
-\frac{f+n}{f-n} \cdot z_e-\frac{2fn}{f-n} \cdot w_e \\
-z_e
\end{array}
\right)} ⎝ ⎛ x c l i p y c l i p z c l i p w c l i p ⎠ ⎞ = ⎝ ⎛ r − l 2 n ⋅ x e + r − l r + l ⋅ z e t − b 2 n ⋅ y e + t − b t + b ⋅ z e − f − n f + n ⋅ z e − f − n 2 f n ⋅ w e − z e ⎠ ⎞
不过我们之前的推导都是针对视锥内部的点的,而此时还没经过裁剪,因此也是存在视锥外部的点的。那么视锥外部的点是否也适用这个变换呢?答案是肯定的。观察原点到 NDC 坐标的变换,任意一维实际上都是单调的。而我们已经保证了视锥内部的点在[ − 1 , 1 ] [-1,1] [ − 1 , 1 ] 之间,因此视锥外部的点自然就会被映射到[ − 1 , 1 ] [-1,1] [ − 1 , 1 ] 之外。而在还未进行透视除法的裁剪坐标系,所有视锥内的坐标将会落在[ − w c l i p , w c l i p ] [-w_{clip},w_{clip}] [ − w c l i p , w c l i p ] 之内。事实上,裁剪时就是使用这个方法进行裁剪判断的。
由上面的变换,我们就可以写出最终的投影矩阵了。
M p e r s p e c t i v e = ( 2 n r − l 0 r + l r − l 0 0 2 n t − b t + b t − b 0 0 0 − f + n f − n − 2 f n f − n 0 0 − 1 0 ) \large{M_{perspective}=\begin{pmatrix}
\frac{2n}{r-l} & 0 & \frac{r+l}{r-l} & 0\\
0 & \frac{2n}{t-b} & \frac{t+b}{t-b} & 0\\
0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n} \\
0 & 0 & -1 & 0
\end{pmatrix}} M p ers p ec t i v e = ⎝ ⎛ r − l 2 n 0 0 0 0 t − b 2 n 0 0 r − l r + l t − b t + b − f − n f + n − 1 0 0 − f − n 2 f n 0 ⎠ ⎞
不过一般来说,视锥的近面的中心点都位于 z 轴上,因此我们可以用宽度 w 和高度 h 来替换 lr、tb。不过,定义近面宽高也不是很直观。我们一般情况下只是希望近面的比例和屏幕一致,因此通常我们只知道宽高比 aspect。在近面比例、距离确定的情况下,近面的大小就决定了视锥的大小,因此我们用 fov(field of view)来确定近面的大小。通常我们使用垂直 fov,也就是视点到近面上下缘的夹角。
fov 的位置(图源 Reference)
由此可以计算出简化版本的投影矩阵(参数为 fov、宽高比 aspect、近面位置 n、远面位置 f):
h = 2 n tan ( θ 2 ) w = a s p e c t ⋅ h M p e r s p e c t i v e = ( 2 n w 0 0 0 0 2 n h 0 0 0 0 − f + n f − n − 2 f n f − n 0 0 − 1 0 ) = ( cot ( θ 2 ) a s p e c t 0 0 0 0 cot ( θ 2 ) 0 0 0 0 − f + n f − n − 2 f n f − n 0 0 − 1 0 ) \large{\begin{aligned}
h&=2n \tan(\frac{\theta}{2}) \\
w&=aspect \cdot h\\
M_{perspective}&=\begin{pmatrix}
\frac{2n}{w} & 0 & 0 & 0\\
0 & \frac{2n}{h} & 0 & 0\\
0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n} \\
0 & 0 & -1 & 0
\end{pmatrix}\\
&=\begin{pmatrix}
\frac{\cot(\frac{\theta}{2})}{aspect} & 0 & 0 & 0\\
0 & \cot(\frac{\theta}{2}) & 0 & 0\\
0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n} \\
0 & 0 & -1 & 0
\end{pmatrix}
\end{aligned}} h w M p ers p ec t i v e = 2 n tan ( 2 θ ) = a s p ec t ⋅ h = ⎝ ⎛ w 2 n 0 0 0 0 h 2 n 0 0 0 0 − f − n f + n − 1 0 0 − f − n 2 f n 0 ⎠ ⎞ = ⎝ ⎛ a s p ec t c o t ( 2 θ ) 0 0 0 0 cot ( 2 θ ) 0 0 0 0 − f − n f + n − 1 0 0 − f − n 2 f n 0 ⎠ ⎞
Z-Fighting
不知道你是否还记得,我们之前计算的 NDC 坐标的 z 分量和 x、y 分量的映射是不同的:
z n d c = f + n f − n + 2 f n f − n ⋅ 1 z e z_{ndc}=\frac{f+n}{f-n}+\frac{2fn}{f-n} \cdot \frac{1}{z_e} z n d c = f − n f + n + f − n 2 f n ⋅ z e 1
显然这是一个反比例函数,并不是线性的。
Z-Fighting 问题(图源 Reference)
观察函数图像我们可以发现,越靠近近面(z e = − n z_e=-n z e = − n )时函数导数的绝对值越大,也就是说越靠近近面,深度的精度更大。直观上来说,其实并没有什么问题,因为相较远处的内容,我们更希望把近处的东西渲染的更加精确(近处通常有更多物体),但是同时在远处我们就会遇到精度问题(图中虚线处)。这个精度问题被称为 Z-Fighting,解决方法有很多,比如人为的在重合的顶点之间设置小偏移,这里不做详细介绍。
正视投影
在正视投影中,我们就不用考虑太多复杂的问题了。
正视投影的视锥(图源 Reference)
可以看到,正视投影的视锥和 NDC 空间同样都是长方体。因此我们只需要直接将各个坐标( x e , y e , z e ) (x_e, y_e, z_e) ( x e , y e , z e ) 映射到[ − 1 , 1 ] [-1,1] [ − 1 , 1 ] 就可以了。需要注意的是,我们仍需要给z e z_e z e 添加负号。
( x n d c y n d c z n d c ) = ( 2 × x e − l r − l − 1 2 × y e − l r − l − 1 2 × − z e − l r − l − 1 ) \large{\left(
\begin{array}{ccc}
x_{ndc} \\ y_{ndc} \\ z_{ndc}
\end{array}
\right)=\left(
\begin{array}{ccc}
2 \times \frac{x_e-l}{r-l} - 1 \\
2 \times \frac{y_e-l}{r-l} - 1 \\
2 \times \frac{-z_e-l}{r-l} - 1
\end{array}
\right)} ⎝ ⎛ x n d c y n d c z n d c ⎠ ⎞ = ⎝ ⎛ 2 × r − l x e − l − 1 2 × r − l y e − l − 1 2 × r − l − z e − l − 1 ⎠ ⎞
另外,正视投影中也不需要使用 w 分量,因此保持为 1 就好了,之后的透视除法也仅仅起到降维的作用。因此,正视投影下的裁剪坐标和 NDC 坐标只是多了 w 维度,其余维度的数值都是相同的。而且,由于w c l i p = 1 w_{clip}=1 w c l i p = 1 ,因此裁剪同样可以使用 w 维来进行判断。正视投影中也不存在 Z-Fighting 问题,因为 z 维度的投影是线性的。(但是整体上来说,精度仍然可能影响显示效果,只不过影响的程度与远近无关)
M o r t h o = ( 2 r − l 0 0 − r + l r − l 0 2 t − b 0 − t + b t − b 0 0 − 2 f − n − f + n f − n 0 0 0 1 ) \large{M_{ortho}=\begin{pmatrix}
\frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l}\\
0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b}\\
0 & 0 & -\frac{2}{f-n} & -\frac{f+n}{f-n} \\
0 & 0 & 0 & 1
\end{pmatrix}} M or t h o = ⎝ ⎛ r − l 2 0 0 0 0 t − b 2 0 0 0 0 − f − n 2 0 − r − l r + l − t − b t + b − f − n f + n 1 ⎠ ⎞
这个矩阵同样可以使用 w、h 简化。不过此时就没办法计算近面的大小了,因此我们需要手动给出 w、h。
M o r t h o = ( 2 w 0 0 0 0 2 h 0 0 0 0 − 2 f − n − f + n f − n 0 0 0 1 ) \large{M_{ortho}=\begin{pmatrix}
\frac{2}{w} & 0 & 0 & 0\\
0 & \frac{2}{h} & 0 & 0\\
0 & 0 & -\frac{2}{f-n} & -\frac{f+n}{f-n} \\
0 & 0 & 0 & 1
\end{pmatrix}} M or t h o = ⎝ ⎛ w 2 0 0 0 0 h 2 0 0 0 0 − f − n 2 0 0 0 − f − n f + n 1 ⎠ ⎞
透视除法
经过裁剪后,剩余的所有顶点的坐标值应该都不会大于其 w 分量(也就是都落在平截头体内),因此我们通过透视除法把裁剪空间坐标转化为标准化设备坐标。这是一个降维的过程,通过透视除法,齐次坐标将会转为三维直角坐标系坐标。
( x n d c y n d c z n d c ) = ( x c l i p / w c l i p y c l i p / w c l i p z c l i p / w c l i p ) \left(
\begin{array}{ccc}
x_{ndc} \\ y_{ndc} \\ z_{ndc}
\end{array}
\right)=\left(
\begin{array}{ccc}
x_{clip} / w_{clip} \\
y_{clip} / w_{clip} \\
z_{clip} / w_{clip}
\end{array}
\right) ⎝ ⎛ x n d c y n d c z n d c ⎠ ⎞ = ⎝ ⎛ x c l i p / w c l i p y c l i p / w c l i p z c l i p / w c l i p ⎠ ⎞
视口变换
视口变换的操作相对简单,只需要对坐标进行简单的处理。视口变换的数据来源于两个函数:
void glViewport ( GLint x, GLint y, GLsizei width, GLsizei height);
void glDepthRange ( GLclampd nearVal, GLclampd farVal);
首先计算屏幕空间的原点O = ( x + w 2 , y + h 2 ) O = \left( x + \frac{w}{2}, y + \frac{h}{2} \right) O = ( x + 2 w , y + 2 h ) ,之后就可以得到坐标变换:
( x w y w z w ) = ( w 2 x n d c + o x h 2 y n d c + o y f − n 2 z n d c + f + n 2 ) \left(
\begin{array}{ccc}
x_w \\ y_w \\ z_w
\end{array}
\right)=\left(
\begin{array}{ccc}
\frac{w}{2}x_{ndc} + o_x \\
\frac{h}{2}y_{ndc} + o_y \\
\frac{f-n}{2}z_{ndc} + \frac{f+n}{2}
\end{array}
\right) ⎝ ⎛ x w y w z w ⎠ ⎞ = ⎝ ⎛ 2 w x n d c + o x 2 h y n d c + o y 2 f − n z n d c + 2 f + n ⎠ ⎞
由此可以写出视口变换矩阵:
M v i e w p o r t = ( w 2 0 0 x + w 2 0 h 2 0 y + h 2 0 0 f − n 2 f + n 2 0 0 0 1 ) M_{viewport}=\begin{pmatrix}
\frac{w}{2} & 0 & 0 & x + \frac{w}{2} \\
0 & \frac{h}{2} & 0 & y + \frac{h}{2} \\
0 & 0 & \frac{f-n}{2} & \frac{f+n}{2} \\
0 & 0 & 0 & 1
\end{pmatrix} M v i e wp or t = ⎝ ⎛ 2 w 0 0 0 0 2 h 0 0 0 0 2 f − n 0 x + 2 w y + 2 h 2 f + n 1 ⎠ ⎞
很显然,如果需要使用矩阵运算,我们还需要把 NDC 坐标重新还原成齐次坐标的形式。
法线矩阵
之前我们都在谈论顶点坐标的变换。但是顶点并不仅仅包含坐标数据,它还包含了其他的数据。而在这些坐标系变换中,也不仅仅只有顶点坐标会受到影响。法线就是会受到这些变换影响的一个属性,而法线矩阵就是对法线应用类似变换的矩阵。法线广泛应用于光照的计算(下一篇文章就会介绍光照了),通过修改法线我们能给模型创造更和谐的视觉效果。对法线来说,我们看重的仅仅是它的方向,因此法线矩阵会比之前各种矩阵的创建要简单不少。
为了推导,我们设法向量为n ⃗ \vec{ n } n ,并且设切向量v ⃗ \vec{ v } v ,因此有
n ⃗ T ⋅ v ⃗ = 0 \vec{ n }^T \cdot \vec{ v }=0 n T ⋅ v = 0
成立(n ⃗ T \vec{ n }^T n T 表示行向量)。需要注意的是,这里的n ⃗ \vec{ n } n 和v ⃗ \vec{ v } v 都是齐次向量。显然,有
n ⃗ T M − 1 M v ⃗ = 0 \vec{ n }^T M^{-1}M \vec{ v }=0 n T M − 1 M v = 0
成立。由于切向量的变化和顶点坐标变化一致(不妨想象,如果模型足够光滑,我们就能找到一个分片上的另一点,若这个点无限接近顶点,则这个点和顶点构成的向量就无限接近切向量),因此M v ⃗ M \vec{ v } M v 就代表了一个顶点变换。由于光照的计算发生在观察空间,因此我们取
M = M v i e w ⋅ M m o d e l M=M_{view} \cdot M_{model} M = M v i e w ⋅ M m o d e l
那么变换后的法向量就是n ⃗ T M − 1 \vec{ n }^T M^{-1} n T M − 1 了。写成列向量的形式就是:
n ⃗ n e w = ( n ⃗ T M − 1 ) T = ( M − 1 ) T n ⃗ \vec{ n }_{new} = (\vec{ n }^T M^{-1})^T = (M^{-1})^T\vec{ n } n n e w = ( n T M − 1 ) T = ( M − 1 ) T n
由于我们只关注法向量的方向,而法向量通常只有三个维度,因此我们可以直接取( M − 1 ) T (M^{-1})^T ( M − 1 ) T 左上角的 3 阶方阵进行计算,这个矩阵就是法线矩阵。
Reference
OpenGL 编程指南(原书第 9 版)(红宝书) LearnOpenGL CN(https://learnopengl-cn.github.io/ ) 齐次坐标 – 维基百科(https://zh.wikipedia.org/wiki/%E9%BD%90%E6%AC%A1%E5%9D%90%E6%A0%87 ) OpenGL Transformation(http://www.songho.ca/opengl/gl_transform.html ) OpenGL Camera(http://www.songho.ca/opengl/gl_camera.html ) OpenGL Projection Matrix(http://www.songho.ca/opengl/gl_projectionmatrix.html ) OpenGL Normal Vector Transformation(http://www.songho.ca/opengl/gl_normaltransform.html ) Modern OpenGL(https://glumpy.github.io/modern-gl.html )
喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)
评论