目录
- 1. 概述
- 2. 详论
- 1) 模型变换
- (1) 平移变换
- (2) 缩放变换
- (3) 旋转变换
- (4) 组合变换
- 2) 视图变换
- (1) 原理
- (2) 推导
- 3) 投影变换
- (1) 透视投影
- (2) 正射投影
- 1) 模型变换
- 3. 综合运用
- 4. 参考
1. 概述
通过之前的教程,对WebGL中可编程渲染管线的流程有了一定的认识。但是只有前面的知识还不足以绘制真正的三维场景,可以发现之前我们绘制的点、三角形的坐标都是[-1,1]之间,Z值的坐标都是采用的默认0值,而一般的三维场景都是很复杂的三维坐标。为了在二维视图中绘制复杂的三维场景,需要进行相应的的图形变换;这一篇教程,就是详细讲解WebGL的图形变换的过程,这个过程同样也适合OpenGL/OpenGL ES,甚至其他3D图形接口。
可以用照相机拍摄照片来模拟这个图形变换的过程,如果要对某个物体拍摄照片,大致过程如下:
- 准备物体,把物体放置在某个合适的位置;这个过程就是模型变换(model transform)。
- 准备照相机,把照相机移动到准备拍摄的位置;这个过程就是视图变换(view transform)。
- 设置相机的焦距,或者调整缩放比例;这个过程就是投影变换(projection transform)。
- 对结果图形进行拉伸或者挤压,确定最终照片的大小;这个过程就是视口变换(viewport transform)。
而在WebGL/OpenGL中,具体的图形变换流程如下所示[3]:
其中模型变换、视图变换、投影变换是我们自己在着色器里定义和实现的,而视口变换一般是WebGL/OpenGL自动完成的。这就好像我们拍照的时候,需要自己去调整位置,相机镜头焦距,而成像的过程就交给相机。所以模型变换、视图变换、投影变换这三者特别重要,另外附一张WebGL/OpenGL矩阵变换的流程图[4]:
从上两图中可以发现,场景中的物体总是从一个坐标系空间转换到另外一个坐标系空间。
- 局部坐标系(Local Space)指的是物体最初开始的坐标系;而世界坐标系(World Space)指的是物体与WebGL/OpenGL相机建立联系时的坐标系。这里的局部坐标系与世界坐标系跟通常意义的不同,只有与WebGL/OpenGL相机建立了联系,才是这里规定的世界坐标系。为了建立联系,经过的就是模型变换。
- 进入世界坐标系空间之后,物体与WebGL/OpenGL相机虽然建立了联系,但是并没有进一步确定观察物体的状态。这个时候就需要调整相机位置姿态,也就是视图变换,转换成视图坐标系(View Space),也可以简称为人眼坐标系(Eye Space),或者照相机坐标系(Camera Space)。
- 在人眼坐标系空间中,虽然物体就在眼前了,但是还需要进一步去确定可视空间。就像人眼是把水平视角大约200度左右的场景投影到视网膜中,人才能看清物体的那样,WebGL/OpenGL需要经过投影变换,才能正确的显示场景。这个过程通常还顺带进行了场景的裁剪,将可视空间范围外的东西去掉,所以投影变换后的坐标系就是裁剪坐标系(Clip Space)。
- 最后一步就是通过视口变换,从裁剪坐标系转换成屏幕坐标系,得到渲染结果。这一步由WebGL/OpenGL自动完成。
在参考文献[2]中描述的WebGL/OpenGL整个图形变换过程的坐标系和单位:
其流程与前文论述的基本一致,可以看到投影变换之后的过程不是那么简单,还需要将得到的齐次裁剪坐标做透视除法(除以w),做剪切和视口/深度范围变换,光栅化等。
其中,用户/着色器变换(也就是教程要具体详述的模型变换、视图变换和投影变换)包含坐标系和单位如下所示:
2. 详论
在一个三维软件中浏览一个三维物体时候,总是会提供给用户平移、缩放和旋转的交互操作,而这正是模型变换的内容。在图形学的范畴当中,平移变换、旋转变换属于刚体变换,缩放和旋转属于线性变换,刚体变换和线性变换又属于仿射变换,而仿射变换也可以看成投影变换的一种[5]。
也就是说这些图形变换,本质上可以看成是同一种变换;在数学上,可以使用矩阵来描述这种变换。并且,为了兼容各种变换的特殊性,会在3维的基础上再加一维,使用4维的向量和矩阵。4维向量表述一个点(x,y,z,w)等价于三维向量(x/w,y/w,z/w),这就是前面提到的齐次坐标。
具体来说,对于空间某个点v0(x0,y0,z0,1),经过空间图像变换后得到新的点v1(x1,y1,z1,1),那么存在这样一个4行4列的矩阵M:
\[ M= \left[ \begin{matrix} a & b & c & d \ e & f & g & h\ i & j & k & l\ m & n & o & p\ \end{matrix} \right] \]
满足:
\[ M*V0=V1 \]
展开这个式子,有式(1):
\[ \left[ \begin{matrix} a & b & c & d \ e & f & g & h\ i & j & k & l\ m & n & o & p\ \end{matrix} \right] * \left[ \begin{matrix} x0\\y0\\z0\\1\ \end{matrix} \right] = \left[ \begin{matrix} x1\\y1\\z1\\1\ \end{matrix} \right] \tag{1} \]
根据矩阵乘法,有方程组式(2):
\[\begin{cases} a*x0 +b*y0 +c*z0 + d =x1\e*x0 +f*y0 +g*z0 +h =y1\i*x0 +j*y0 +k*z0 + l =z1\m*x0 +n*y0 +o*z0 + p =1 \end{cases} \tag{2} \]
通过以上式子,就可以求得各种不同图形变换矩阵。
1) 模型变换
模型变换包括平移变换、缩放变换和旋转变换。从内容上来讲,这几种变换正好应对的三维交互操作的平移、变换和缩放。通过鼠标操作调整模型变换矩阵就可以实现一种简单三维交互操作。
(1) 平移变换
对于一个点(x,y,z,1),平移之后,得到的点就是(x+Tx,y+Ty,z+Tz,1),其中Tx、Ty、Tz分别表示点在X轴、Y轴、Z轴方向上移动的距离。那么将其代入方程组式(2)的两边,有:
\[\begin{cases} a*x +b*y +c*z + d =x+Tx\e*x +f*y +g*z +h =y+Ty\i*x +j*y +k*z + l =z+Tz\m*x +n*y +o*z + p =1 \end{cases} \]
那么根据多项式相等的原理,可以求得每个多项式系数,继而可得平移矩阵T:
\[ T= \left[ \begin{matrix} 1 & 0 & 0 & Tx \ 0 & 1 & 0 & Ty\ 0 & 0 & 1 & Tz\ 0 & 0 & 0 & 1\ \end{matrix} \right] \]
(2) 缩放变换
对于一个点(x,y,z,1),以原点为中心缩放,在X方向缩放Sx倍,在Y方向缩放Sy倍,在Z方向缩放Sz倍,那么新的坐标值为(x*Sx,y*Sy,z*Sz,1)。将其代入方程组式(2)的两边,有:
\[\begin{cases} a*x +b*y +c*z + d =x*Sx\e*x +f*y +g*z +h =y*Sy\i*x +j*y +k*z + l =z*Sz\m*x +n*y +o*z + p =1 \end{cases} \]
同样根据多项式相等的原理,求得缩放矩阵S:
\[ S= \left[ \begin{matrix} Sx & 0 & 0 & 0 \ 0 & Sy & 0 & 0\ 0 & 0 & Sz & 0\ 0 & 0 & 0 & 1\ \end{matrix} \right] \]
(3) 旋转变换
旋转变换就稍微复杂一点,对旋转变换而言,必须知道旋转轴、旋转方向和旋转角度。可以绕X轴,Y轴和Z轴旋转,所以一般都会有三个旋转矩阵。以绕Z轴旋转为例,在Z轴正半轴沿着Z轴负方向进行观察,如果看到的物体是逆时针旋转的,那么就是正旋转,旋转方向就是正的,旋转值就是正数;反之如果旋转值为负数,说明旋转方向就是负的,沿着顺时针旋转。用更加通用的说法来说,正旋转就是右手法则旋转:右手握拳,大拇指伸直并使其指向旋转轴的正方向,那么右手其余几个手指就指明了旋转的方向。
对于一个点p(x,y,z,1),绕Z轴旋转,因为旋转后的Z值不变,所以可以忽略Z值的变换,只考虑XY空间的变化。此时设r为原点到点p的距离,α是X轴旋转到该点的角度。如图所示:
那么p点的坐标表示为式(3):
\[\begin{cases} x=r*cosα\y=r*sinα\\end{cases} \tag{3} \]
同样的绕Z轴旋转后,得到新的点p’,X轴旋转到该点的角度为(α+β),其坐标值为:
\[\begin{cases} x'=r*cos?(α+β)\y'=r*sin?(α+β)\\end{cases} \]
根据三角函数两脚和公式,可得式(4):
\[\begin{cases} x'=r*(cos?α*cosβ-sinα*sinβ)\y'=r*(sin?α*cosβ+cosα*sinβ)\\end{cases} \tag{4} \]
将式(3)代入到式(4),可得式(5):
\[\begin{cases} x'=x*cosβ-y*sinβ\y'=x*sinβ+y*cosβ\z'=z \end{cases} \tag{5} \]
将式(5)代入到方程组式(2)的两边,有:
\[\begin{cases} a*x +b*y +c*z + d =x*cosβ - y*sinβ\e*x +f*y +g*z +h = x*sinβ + y*cosβ\i*x +j*y +k*z + l = z\m*x +n*y +o*z + p =1\\end{cases} \]
同样根据多项式相等的原理,求得绕Z轴旋转β角度时的旋转矩阵Rz:
\[ Rz= \left[ \begin{matrix} cosβ & -sinβ & 0 & 0 \ sinβ & cosβ & 0 & 0\ 0 & 0 & 1& 0\ 0 & 0 & 0 & 1\ \end{matrix} \right] \]
用同样的方式可以推导,绕X轴旋转β角度时的旋转矩阵Rx:
\[ Rx= \left[ \begin{matrix} 1 & 0 & 0 & 0 \ 0 & cosβ & -sinβ & 0\ 0 & sinβ & cosβ & 0\ 0 & 0 & 0 & 1\ \end{matrix} \right] \]
绕Y轴旋转β角度时的旋转矩阵Ry:
\[ Ry= \left[ \begin{matrix} cosβ & 0 & sinβ & 0 \ 0 & 1 & 0 & 0\ -sinβ & 0 & cosβ & 0\ 0 & 0 & 0 & 1\ \end{matrix} \right] \]
(4) 组合变换
使用矩阵来描述图形变换的好处之一就是能够将以上所有的变换组合起来,例如如下式(6):
\[ v1=S*(R*(T*v0)) \tag{6} \]
表达的图形变换是对于点v0,首先经过平移变换,再经过旋转变换,最后再进行缩放,得到新的点v1。
根据矩阵乘法的结合律,式(6)可以写成:
\[ v1=(S*R*T)*v0 \]
那么模型矩阵M就可以表示为:
\[ M=S*R*T \]
注意上述模型矩阵的SRT顺序并不是固定的,需要根据实际的情况采取合适的矩阵,否则会达不到想要的效果。一个重要的原则就是记住缩放变换总是基于原点的,旋转变换总是基于旋转轴的,在进行缩放变换和旋转变换之前往往需要先平移变换至原点位置(不是绝对)。
2) 视图变换
(1) 原理
视图变换其实就是模型变换的逆变换。试想一下,拿一个物体给相机拍摄,其实也就是拿相机去拍摄一个物体,视图变换和模型变换的结果并没有显著的区别,有些情况下两者甚至可以合并成一个模型-视图变换(model-view transform)。两者之所以需要分开进行完全是由实际的交互操作决定的:旋转、缩放到合适的位置其实是很难设置的,很多交互操作需要在视空间/摄像机空间中设置才比较合适,这个时候就需要视图变换了。
视图变换其实就是构建一个视空间/摄像机空间,需要三个条件量:
- 视点eye:也就是观察者/摄像机的位置;
- 观察目标点at:被观察者目标所在的点,确定了视线方向;
- 上方向up:最终绘制在屏幕上的影像中的向上的方向,通俗来讲,就是用来控制是正着拍、横着拍还是斜着拍。
通过上述三个条件量,就可以构建一个视图矩阵。这个矩阵一般可以通过图形矩阵库的LookAt()函数进行设置,例如在WebGL的cuon-matrix.js中,其设置函数为:
(2) 推导
由前文得知,视图变换构建了一个视空间/摄像机空间坐标系,为了对应于世界坐标系的XYZ,可以将其命名为UVN坐标系,它由之前提到的三个条件量构建而成:
- 选取视线的方向为N轴:N = eye–at;并归一化N。
- 选取up和N的叉积为U轴: U= up×N,并归一化U。
- 选取N和U叉积得到V轴:V = N×U,并归一化V。
如图所示[7]:
由于视图变换是模型变换的逆变换,以上视图变换的效果,等价于进行一个旋转变换,再进行一个平移变换。故有视图矩阵V:
\[ V=M^{-1}=(TR)^{-1}=R^{-1}T^{-1} \]
根据之前平移矩阵的定义,那么有:
\[ T^{-1}= \left[ \begin{matrix} 1 & 0 & 0 & -Tx \ 0 & 1 & 0 & -Ty\ 0 & 0 & 1 & -Tz\ 0 & 0 & 0 & 1\ \end{matrix} \right] \]
这里的(Tx,Ty,Tz)就是视点eye(eyeX, eyeY, eyeZ)。经过平移变换之后,相机的原点就和世界原点重合,剩下的操作就是通过旋转矩阵R,将世界坐标系XYZ的点转换到成UVN坐标系上的点。令:
\[ X=(1,0,0),Y=(0,1,0),Z=(0,0,1)\U=(Ux,Uy,Uz),V=(Vx,Vy,Vz),N=(Nx,Ny,Nz) \]
则有:
\[ \left[ \begin{matrix} U & V & N \ \end{matrix} \right] = \left[ \begin{matrix} X & Y & Z \ \end{matrix} \right] * R = \left[ \begin{matrix} X & Y & Z \ \end{matrix} \right] * \left[ \begin{matrix} Ux & Vx & Nx \ Uy & Vy & Ny \ Uz & Vz & Nz \ \end{matrix} \right] \]
又由旋转矩阵R为正交矩阵,所以有:
\[ R^{-1} = \left[ \begin{matrix} Ux & Uy & Uz \ Vx & Vy & Vz \ Nx & Ny & Nz \ \end{matrix} \right] \]
最后即可得视图矩阵:
\[ V=R^{-1} T^{-1}= \left[ \begin{matrix} Ux & Uy & Uz & 0 \ Vx & Vy & Vz & 0 \ Nx & Ny & Nz & 0 \ 0 & 0 & 0 & 1 \ \end{matrix} \right] * \left[ \begin{matrix} 1 & 0 & 0 & -Tx \ 0 & 1 & 0 & -Ty\ 0 & 0 & 1 & -Tz\ 0 & 0 & 0 & 1\ \end{matrix} \right] = \left[ \begin{matrix} Ux & Uy & Uz & -U·T \ Vx & Vy & Vz & -V·T \ Nx & Ny & Nz & -N·T \ 0 & 0 & 0 & 1 \ \end{matrix} \right] \]
3) 投影变换
投影变换定义的是一个可视空间,决定了哪些物体显示,哪些物体不显示,以及物体如何显示。常用的可视空间有两种:
- 四棱椎/金字塔可视空间,由透视投影产生;
- 长方体可视空间,由正射投影产生。
(1) 透视投影
a) 原理
投影投影模拟的就是人眼成像或者摄像机成像的过程,试想一下,摄像机拍摄的总是取景器方位内的物体,并且呈现近大远小的效果。在WebGL/OpenGL中,透视投影就决定了一个视点、视线、近裁剪面、远裁剪面组成的四棱椎可视空间。如图所示:
在实际使用中,图形矩阵库(我这里用的WebGL的cuon-matrix.js)一般都会提供类似setPerspective()的函数,具体定义如下:
b) 推导
如图所示,已知视空间坐标系XYZ,坐标系原点(视点)为O,视椎体近截面与视点距离为n,远平面与视点的距离为f。已知视椎体空间中有一点为P(x0,y0,z0),那么要求的就是射线OP与近截面的投影点P1(x1,y1,z1)。如图所示:
近截面与平面XOY平行,那么z1 = -near,那么问题可以简化为:已知空间上点P的坐标,存在点P与坐标O连线上一点P1,P1的Z值已知,求P1坐标。如图所示:
显然这是一个三角形相似的问题,P1点在视空间坐标系的XY坐标为:
\[ \begin{cases} x1'=-n/z0*x0\y1'=-n/z0*y0\\end{cases} \]
根据前文论述,投影变换得到的4维度齐次坐标(x1,y1,z1,w1),会除以w1使得x1和y1的值归一化到-1到1之间。那么可设l和r分别为近截面左、右边框的x坐标,那么就是l映射到-1,r映射到1。这是一个线性变换问题:存在两组点(l,-1)(r,1)满足方程y=kx+b。
\[ \begin{cases} kl+b=-1\kr+b=1\\end{cases} \]
解方程组:
\[ \begin{cases} k=\frac{2}{r-l}\b=-\frac{r+l}{r-l}\\end{cases} \]
那么P1归一化后的x坐标xn为:
\[ xn=\frac{2}{r-l}*x1'-\frac{r+l}{r-l}=-\frac{1}{z0}*(\frac{2n}{r-l}*x0+\frac{r+l}{r-l}*z0) \]
同理可得,P1归一化之后y 坐标yn为:
\[ yn=-\frac{1}{z0}*(\frac{2n}{t-b}*y0+\frac{t+b}{t-b}*z0) \]
可以发现,归一化的坐标xn、yn都存在一个乘数因子(-1/z0),那么可以令投影变换后的w1=-z0,这样就可以满足归一化之后的wn=1,并且满足上面xn、yn的表达式。即有裁剪坐标系的点P1(x1,y1,z1,w1):
\[ \begin{cases} x1= \frac{2n}{r-l}*x0+\frac{r+l}{r-l}*z0 \y1= \frac{2n}{t-b}*y0+\frac{t+b}{t-b}*z0 \w1= -z0 \\end{cases} \]
代入到式(2)中,得:
\[ \left[ \begin{matrix} \frac{2n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \ 0 & \frac{2n}{t-b} & \frac{t+b}{t-b} & 0 \ I & J & K & L \ 0 & 0 & -1 & 0 \ \end{matrix} \right] * \left[ \begin{matrix} x0 \\ y0 \\ z0 \\ 1 \ \end{matrix} \right] = \left[ \begin{matrix} x1 \\ y1 \\ z1 \\ w1 \ \end{matrix} \right] \]
继续求上式的投影矩阵的第三行。投影转换后得到的z1是一个深度值,它是一个与x0,y0无关的值,所以I=0,J=0。并且在归一化之后,z1会成为一个-1到1之间的值:当z0=-n时(近截面),z1=-1;当 z0=-f时(远截面),z1=1。代入上式,有:
\[ \begin{cases} (K*(-n)+L)/n=-1 \(K*(-f)+L)/f=1 \\end{cases} \]
得到:
\[ \begin{cases} K=(f+n)/(n-f) \L=2fn/(n-f) \\end{cases} \]
综合,可得透视投影矩阵P:
\[ P= \left[ \begin{matrix} \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}{n-f} & \frac{2fn}{n-f} \ 0 & 0 & -1 & 0 \ \end{matrix} \right] \]
注意,通过类似setPerspective()的函数定义的矩阵是对称的视锥体,视点在近截面的投影点为近截面的中心,因而有:
\[ \begin{cases} r=-l \t=-b \t-b=height \width= height*aspect \tan?(\frac{fovy}{2})=\frac{height/2}{n} \end{cases} \]
代入透视投影矩阵P,得到对称透视投影矩阵P:
\[ P= \left[ \begin{matrix} \frac{1}{aspect*tan?(\frac{fovy}{2})} & 0 & 0 & 0 \ 0 & \frac{1}{tan?(\frac{fovy}{2})} & 0 & 0 \ 0 & 0 & \frac{f+n}{n-f} & \frac{2fn}{n-f} \ 0 & 0 & -1 & 0 \ \end{matrix} \right] \]
(2) 正射投影
a) 原理
正射投影一个很常见的应用就是地图。无论是纸质地图还是谷歌地图,甚至于室内设计的户型图、工程设计的工程图,无一例外全部都是正射投影。正射投影能够很方便的比较场景中物体的大小,并且每个地方的所代表的大小都是一样的(分辨率一致)。当然,在这种投影下是没有深度感的,就像你在卫星地图上是看不出一座山有多高的。
正射投影同样也是近裁剪面和远裁剪面组成的可视空间,只不过这个可视空间是个长方体,如图所示:
同样的,可以使用类似setOrtho()函数来设置正射投影:
b) 推导
在正射投影的盒状可视空间中,XYZ三个方向上都是等比例的。设盒状可视空间中某一物体点P(x0,y0,z0),那么P点在近截面的投影点为P1(x0,y0,z0’),仅仅只是Z值不同。
同透视变换的推导一样,将P1的X、Y坐标(x0,y0)映射到-1到1的范围(xn,yn)。即有两组点(l,-1)和(r,1)满足式子(线性关系y=kx+b):
\[ Xn=Kx*x0+Bx \]
有两组点(b,?1)和(t,1)满足式子(线性关系y=kx+b):
\[ Yn=Ky*y0+By \]
分别代入解方程组,可得:
\[ \begin{cases} xn=2/(r-l)*x0-(r+l)/(r-l) \yn=2/(t-b)*y0-(t+b)/(t-b) \\end{cases} \]
同样的,在Z方向上,将z0映射成-1到1直接的值:当点在近截面时,映射成-1;当点在远截面时,映射成1。故也有两组点(-n,-1)和(-f,1)满足线性关系y=kx+b,同理可求得:
\[ zn=(-2)/(f-n)*z0-(f+n)/(f-n) \]
对于正射变换而言,w变量是不必要的,可直接令w=1。那么裁剪坐标P1(x1,y1,z1,w1)就是经过透视除法的标准化设备坐标(xn,yn,zn,1)。故有:
\[ \begin{cases} x1=2/(r-l)*x0-(r+l)/(r-l) \y1=2/(t-b)*y0-(t+b)/(t-b) \z1=(-2)/(f-n)*z0-(f+n)/(f-n) \w1=1 \\end{cases} \]
代入到式(2)的两边,可得正射投影矩阵:
\[ O = \left[ \begin{matrix} \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{matrix} \right] \]
3. 综合运用
综上所述,模型矩阵M,视图矩阵V,投影矩阵P,同时作用于物体的顶点,使得最终的物体能后被看见或者进行UI操作。根据之前教程内容,逐顶点的操作可以将其放入到顶点着色器。一般而言,先进行模型变换,再进行视图变换,最后进行投影变换:
\[ v1=P*V*M*v0 \]
根据矩阵乘法的结合律:
\[ v1=(P*V*M)*v0 \]
这个P*V*M矩阵合并得到的模型视图投影矩阵(model view projection matrix),简称为MVP矩阵。在实际使用过程中,只需要将这个MVP矩阵传入到顶点着色器,就能根据设置的矩阵得到想要的渲染效果:
gl_Position = u_MvpMatrix * a_Position;
这一篇教程是纯理论知识,相对来说不太容易理解。如果是初次接触,至少应该先做大致的了解,后续会大量用到这里的知识。
4. 参考
[1]《WebGL编程指南》
[2]《OpenGL编程指南》第八版
[3] OpenGL学习脚印: 投影矩阵和视口变换矩阵(math-projection and viewport matrix)
[4] OpenGL矩阵变换的数学推导
[5] 基本图像变换:线性变换,仿射变换,投影变换
[6] 旋转变换(一)旋转矩阵
[7] 视图矩阵的推导