OpenGL 学习系列---坐标系统
避免图片丢失,建议阅读微信原文:
在前面绘制基本图形中,遇到了很明显的问题,圆形不像圆形,正多边形不像正多边形?就像下面图形一样:
好好的正五边形却东倒西歪的,这就是因为我们前面的绘制都是把它当成 二维 的绘制,而在 OpenGL 中却是绘制 三维的。在二维和三维之间还有个转换,而之前为了方便学习则忽略了这个转换,现在就要开始理解它了 —— 坐标系统
!!
坐标系统
在立体几何的坐标系里面定义一个点的位置,需要 x、y、z 三个坐标轴的值,而在 OpenGL 中绘制 3D 物体也是需要的。
在绘制基本形状时,只是定义了 x、y 轴的坐标,这样 z 轴的坐标就默认为 0 了。
OpenGL 将定义好的坐标轴的值转换为实际绘制的坐标,需要经过五个坐标系统的转换。
如下图所示:
这里面涉及到了五个坐标空间和三个转换矩阵:
空间:
- 局部空间(Local Space)
- 世界空间(World Space)
- 观察空间(View Space)
- 裁剪空间(Clip Space)
- 屏幕空间(Screen Space)
矩阵:
- 模型矩阵(Model Matrix)
- 视图矩阵(View Matrix)
- 投影矩阵(Projection Matrix)
根据流程图,每个坐标空间的转换都需要一个转换矩阵来完成。
最后裁剪空间到屏幕空间的转换,就是将经过这一系列转换后的坐标映射到屏幕的坐标上,这一过程就不需要转换矩阵了。
在进入不同的坐标空间之前,需要先了解 OpenGL 的坐标系:
OpenGL 是一个右手坐标系,正 X 轴在右手边,正 Y 轴朝上,正 Z 轴穿过屏幕朝向你。
与之相对的就是左手坐标系,其正 Z 轴穿过屏幕朝向里面了。
局部空间
局部空间坐标是 OpenGL 绘制坐标的起点,接下来所有的转换操作都是在局部空间坐标基础上进行的。
局部空间坐标就是我们自己定义的起始坐标点,是相对于原点 $ (0,0,0) $ 的。
此时所在的空间就是局部空间,也就是说我们在局部空间里面定义物体的起始坐标。
世界空间
我们定义每一个坐标点都是在局部空间,相对于 $ (0,0,0) $ 的。这样一来,当多个物体同时绘制时,就会扎堆了。
而世界空间就是当所有物体一起绘制、仍然相对于原点的、更大的一个坐标系。
局部空间和世界空间有点相像,可以在局部空间定义坐标系时就考虑到世界坐标系,避免多个物体绘制时出现扎堆现象。
当然还有更好的方法,就是使用模型矩阵(Model Matrix)。
使用模型矩阵,可以对物体进行位移、缩放、旋转。
这样的话就可以将物体从坐标原点移开,并且还能够进行一些相关操作,不用去考虑在局部空间来定义世界空间的坐标了。
观察空间
横看成岭侧成峰 远近高低各不同
当物体在世界空间中就位了,接下来就是要考虑从哪个方向和角度来观察物体了。
观察空间,又是 OpenGL 的摄像机,是将世界空间的坐标转化为摄像机的视角所观察到的空间坐标。
也就是说,在观察空间里,坐标原点不再是世界空间的坐标原点了,而是以摄像机的视角作为场景原点,这就不再是简单地进行平移、旋转了,而是切换到另一种坐标系里。
OpenGL 本身是没有摄像机的概念的,不过可以通过把场景中的所有物体往相反的方向移动来模拟出摄像机。这样就场景没动,而摄像机在移动。
要定义一个摄像机,或者说要定义一个摄像机视角为坐标原点的坐标系,需要:
- 摄像机在世界空间中的位置
- 摄像机观察的方向
- 指向摄像机右测的向量
- 指向摄像机上方的向量
如图,最终建立了一个以摄像机位置为原点的坐标系。
其中,蓝色箭头为摄像机坐标系中的 Z 轴,绿色箭头为摄像机坐标系中的 Y 轴,红色箭头为摄像机坐标系中的 X 轴。
而接下来要做的就是将物体在世界空间中的坐标转换到以摄像机视角为原点的观察空间坐标中。
这其中也需要用到一个转换矩阵:视图矩阵(View Matrix)。通过视图矩阵来切换坐标系。
裁剪空间
当物体坐标都位于观察空间后,接下来要做的就是裁剪。根据我们的需要来裁剪一定范围内的物体,而在这个范围之外的坐标就会被忽略掉。
裁剪空间实质上还是进行坐标的操作。
从观察空间到裁剪空间,需要用到:投影矩阵(Projection Matrix)。
投影矩阵会指定一个坐标范围,这个范围内的坐标将变换为归一化设备坐标
,不在这个范围内的坐标就会被裁剪掉。
观察空间中的坐标经过投影矩阵的变换之后称为投影坐标,又叫做裁剪坐标
。
说是裁剪坐标,其实是待裁剪,接下来的裁剪过程将由 OpenGL 来完成的。投影矩阵的变换,只是筛选出那些不需要被裁剪的坐标。
由投影矩阵创建的范围,是一个封闭的空间几何体,被称为视景体
。
投影矩阵有两种不同的形式,创建的视景体也有两种样式。
正交投影
正交投影会创建一个类似立方体的视景体。它由左、上、右、下 四个方向距离和近平面距离、远平面距离组成。四个方向距离定义了近平面和远平面的大小。而在近平面和远平面之外的坐标点就会被裁剪掉了。
在场景中处于视景体内的物体会被投影到近平面上,然后再将近平面上投影出的内容映射到屏幕上。
它所用到的矩阵是正交投影矩阵。
由于正交投影是平行投影的一种,其投影线是平行的,所以投影到近平面上的图形不会产生真实世界中的近大远小
的效果。因为正交投影没有把透视考虑进去,所以,远处的物体不会变小,这适用于一些特定的场合。
透视投影
透视投影是能够产生近大远小
效果的,就像我们人眼一样,看远处的物体就变得很小了。
它所用到的矩阵就是透视投影矩阵。
透视投影也会创建一个视景体,类似于锥形。它同样也有着近平面距离和远平面距离,而且也是将近平面的内容映射到屏幕视口中,但不同与正交投影近平面和远平面大小相同,所以它的左、上、右、下距离都是相对于近平面的。
可以看到,透视投影的投影线互不平行,都相交于视点。因此,同样尺寸的物体,才会近处的投影出来大,远处的投影出来小。
透视除法
当坐标经过投影矩阵的变换到裁剪空间之后,紧接着就会进行透视除法
的操作。
透视除法是在三维绘制中产生近大远小
效果非常关键重要的一步。
在此之前要先来了解一下 OpenGL 中的 w 分量
。
OpenGL 坐标系中除了 x、y、z 坐标外,还有 w 分量,默认情况下都是 1 。而经过透视投影变换之后,w 分量不再是 1 了,正交投影不改变 w 分量。
而 OpenGL 进行裁剪,实质上是 GPU 进行裁剪的过程,就是将 x、y、z 坐标的绝对值与 w 分量绝对值进行比较,只要有一个分量的绝对值大于 w 的绝对值,就认为不在视景体内,会被裁剪掉。
经过裁剪之后,再进行透视除法。就是将 x、y、z 坐标分别除以 w 分量,得到新的 x、y、z 坐标。由于 x、y、z 坐标的绝对值都小于 w 的绝对值,所以得到新的坐标值都是位于 $ [-1,1] $ 的区间内的。此时得到的坐标,也就是归一化设备坐标
。
归一化设备坐标是独立于屏幕的,而且它的坐标系用的是左手坐标系。
经过透视投影矩阵变换之后,每个坐标的 w 分量都不相同了,这样再经过透视除法操作,就会使得远处的物体看起来变小了。
屏幕空间
有了归一化设备坐标,最后一步就是将坐标投射到屏幕上,这一步是由 OpenGL 来完成的。
OpenGL 会使用 glViewPort
函数来将归一化设备坐标映射到屏幕坐标,每个坐标都关联了屏幕上的一个点,这个过程称为视口变换
。这一步操作不再需要变换矩阵了。
就这样,一个点的坐标就完成了从局部空间坐标 $(x,y,z,w)$
到屏幕坐标 $(x,y)$
的转变。
用一张图总结如下:
坐标的矩阵操作
点的坐标可以看作是一个向量,用 $V$
表示,而矩阵用 $M$
表示。
那么,从 局部空间 -> 世界空间 -> 观察空间 -> 裁剪空间 ,四个空间的转换,需要用到三个转换矩阵,点从某个坐标系变换到另一个坐标系的时候都要左乘某个变换矩阵,最后裁剪空间的坐标可以表示如下:
而在着色器脚本中,gl_Position
对应的也是 $V_{clip}$
裁剪坐标。
有了裁剪空间坐标后,接下来的事情就交个 OpenGL 去完成裁剪和透视除法就好了。
图形适应宽高比
在文章一开始提到的,绘制的圆形变成了椭圆,绘制的正多边形却东倒西歪的,现在也能给出原因了。
默认情况下,局部空间、世界空间、观察空间、裁剪空间的坐标系都是重合的,都是以$(0,0,0)$为坐标原点。一开始只是给出了理想状态下的平面坐标点,并且定义着色器脚本如下:
attribute vec4 a_Position;
void main(){
gl_Position = a_Position;
}
那么它经过一系列转换后,最后 OpenGL 用来裁剪的坐标还是我们定义的基于平面的坐标,只有$(x,y)$值,而 $z$ 坐标默认为 0,$w$ 坐标默认为 1 。经过透视除法后的归一化设备坐标依旧是$(x,y)$。
而归一化设备坐标假定的坐标空间是一个正方形,但手机屏幕的视口却是一个长方形,这样的话,就会有一个方向被拉伸。同样的份数,但长度越长,导致每一份的长度也增加了,所以也就被拉伸了。
要解决这种问题,可以在归一化设备坐标上进行操作,将较长的一边乘以相应的比例系数,转化到同样的长度比上。
// 1280 * 720 的宽高比
aspect = width / height ;
x = x * aspect
y = y
这样一来,将较长的一边的比例放大了,取较短的那一边作为 1 的标准。
当然也可以在坐标转换成归一化设备坐标之前,也就是在投影时就把拉伸的情况考虑进去。
使用正交投影,再将物体的宽高投影到近平面上时,就把屏幕的宽高比例系数考虑进去,这样在转换成归一化设备坐标之前就已经完成了图形的宽高比适应。
这样的话,就需要修改着色器脚本语言,把投影矩阵考虑在内。
attribute vec4 a_Position;
uniform mat4 u_Matrix;
void main(){
gl_Position = u_Matrix * a_Position;
}
具体实操下一篇博客再写了。
参考
- 《OpenGL ES 应用开发实践指南》
- 《OpenGL ES 3.x 游戏开发》
- http://blog.csdn.net/iispring/article/details/27970937
具体代码详情,可以参考我的 Github 项目: https://github.com/glumes/AndroidOpenGLTutorial