概述
我们写完顶点着色器,我们程序中写的各种的坐标其实只是程序运行时最开始的坐标,它们处于一个归一化的坐标系统中,即 x
,y
,z
轴的范围为 -1.0f ~ 1.0f
。显然我们想要绘制的物体也不止一个(它相对于其他物体还有远近高低),我们观察物体的方向也不总是正上方,我们的设备也不会是一个归一化的正方体。这些因素都会影响到最终的绘制效果,这里的每一个因素都会有相应的坐标空间和矩阵变换。
五个空间和三个矩阵
- 局部空间(Local Space 或 物体空间(Object Space))
- 世界空间(World Space)
- 观察空间(View Space 或 视觉空间(Eye Space))
- 裁剪空间(Clip Space)
- 屏幕空间(Screen Space)
这些空间就是我们概述中说的坐标系统,空间之间通过相应的变换矩阵进行转换。三个矩阵分别是模型(Model)、视图(View)、投影(Projection)这三个矩阵。
- 局部空间是物体初始的空间,我们假设空间的原点在物体的中心点。
- 局部空间通过模型矩阵转换为世界空间后,各个物体之间就有了相对的远近高低。这是包含所有物体的空间,就称为世界空间。
模型矩阵负责对物体进行平移、旋转、缩放。
- 世界空间通过视图矩阵进入观察空间。在观察空间里我们是通过摄像机的视角来观察这个世界。
视图矩阵是改变 OpenGL 摄像机的位置。将空间坐标系的原点变换到摄像机的位置为新的原点。
- 观察空间通过投影矩阵进入裁剪空间。裁剪空间中能够通过透视来模拟出真实世界的物体远近,还会将位于裁剪空间之外的其他物体给裁减掉(即不显示)
投影矩阵有
正交投影
和透视投影
之分,一般我们更常用透视投影
,因为它能达到模拟真实世界远近的透视效果。投影矩阵会将无限的三维空间切出一块出来,只有位于这个空间中的物体是可被观察的,这块可观察区域我们称为视景体。我们再将视景体中的三位物体显示到一块指定的二维平面上,这个过程我们称为投影。
-
裁剪空间通过视口转换进入屏幕空间。裁剪空间的长宽大小和真实的显示屏幕长宽大小依然是对不上的,我们通过视口转换将空间中的坐标映射成屏幕坐标。视口转换通过
glViewPort
来完成。
投影
这一节具体来讲一讲投影。投影就是将视景体中的三维物体映射到近平面(Near Plane)所在的二维平面上的过程。在视景体外的所有物体,由于无法映射到近平面上,因此会被 OpenGL 裁减掉。
正射投影
正射投影通过指定一个立方体形状的视景体来定义裁剪空间。
OpenGL 提供函数
orthoM()
来创建一个正交矩阵。
/**
* Computes an orthographic projection matrix.
*
* @param m returns the result 正交投影矩阵
* @param mOffset 偏移量,默认为 0 ,不偏移
* @param left 左平面距离
* @param right 右平面距离
* @param bottom 下平面距离
* @param top 上平面距离
* @param near 近平面距离
* @param far 远平面距离
*/
public static void orthoM(float[] m, int mOffset,
float left, float right, float bottom, float top,
float near, float far)
我们假设近平面中心点坐标为 (0, 0)
。右为 x
轴正向,上为 y
轴正向。
上面函数中的上下左右为近平面四个边相对于中心点的位置。因此 left
、bottom
为负数,right
、top
为正数。同时,近平面大小最终会被映射到实际设备屏幕上,上下左右距离只是需要一个相对的比例。例如,如果需要图形居中,那么近平面的宽高比与 glViewport
的宽高比一致即可。
glViewport(0, 0, width, height);
float ratio = (float) width / height;
Matrix.frustumM(mProjectionMatrix, 0, -ratio, ratio, -1, 1, 4f, 15);
near
、far
则是视点相对于近平面、远平面的距离,物体太远(物体距离视点的距离大于远平面距离视点的距离)、太近(物体距离视点的距离小于近平面距离视点的距离)都会被认为在视景体之外而被裁剪。由于是表示的近、远平面的距离,因此 near
、far
都是正数,而且 far > near
。
这里的
far
和near
所设置的值单位什么,我目前没有查询到。但是可以肯定的是,这里的远近与观察矩阵中的摄像机坐标是相关的。
透视投影矩阵
OpenGL 提供了两个函数来创建透视投影矩阵:frustumM
和 perspectiveM
。
透视投影矩阵会修改每个顶点坐标的 w
值,从而使得离观察者越远的顶点坐标 w
分量就越大。被转换到裁剪空间的坐标都会在 -w
到 w
之间(任何大于这个范围的顶点都会被裁减掉)。OpenGL 要求所有可见坐标都落在 -1.0f ~ 1.0f
之内从而作为最后的顶点着色器输出,因此一旦坐标落在裁剪空间内,透视划分就会被应用到裁剪空间坐标:
每个顶点坐标的分量都会除以它的
w
分量,得到一个距离观察者较小的顶点坐标。
frustumM(视锥)
/**
* Defines a projection matrix in terms of six clip planes.
*
* @param m the float array that holds the output perspective matrix
* @param offset the offset into float array m where the perspective
* matrix data is written
* @param left
* @param right
* @param bottom
* @param top
* @param near
* @param far
*/
public static void frustumM(float[] m, int offset,
float left, float right, float bottom, float top,
float near, float far)
上面各个参数的意义与正交投影的 orthoM()
一样。
透视投影会产生近大远小的效果,因此当相机位置不变时,改变 near
值的大小也会改变最终物体在近平面上的成像效果:near
越小,则离视点越近,也就是物体越远,最终成像就越小。当然也可以 near
和 far
不动,通过视图矩阵改变摄像机的位置,相机位置越远,成像也会越小。
perspectiveM
OpenGL 也提供了
perspectiveM()
函数来创建视景体,其创建的视景体与 frustumM()
创建的一样,只是构造的条件不同。
/**
* Defines a projection matrix in terms of a field of view angle, an
* aspect ratio, and z clip planes.
*
* @param m the float array that holds the perspective matrix
* @param offset the offset into float array m where the perspective
* matrix data is written
* @param fovy field of view in y direction, in degrees
* @param aspect width to height aspect ratio of the viewport
* @param zNear
* @param zFar
*/
public static void perspectiveM(float[] m, int offset,
float fovy, float aspect, float zNear, float zFar)
可以看到该函数不再需要近平面的上下左右距离,而是通过视野(FOV - Field Of View) 来确定我们能看到的视野。对于一个真实的观察效果,视角 fovy
经常被设置为 45º。另一个参数就是近平面的宽高比。通过视角和近平面宽高比,就可以确定近平面大小,实现了跟 frustumM()
中使用近平面的上下左右距离一样的效果。
观察空间和观察矩阵
我们进入观察空间,实际上是进入一个以摄像机在世界空间中的位置为原点,通过摄像机位置来观察物体的场景。观察矩阵就是把所有的世界空间中的顶点坐标转换为观察空间中的顶点坐标。我们要定义一个摄像机,需要该摄像机在世界空间中的坐标,摄像机观察的方向(也就是摄像机的 x
,y
,z
轴),这样我们就可以创建一个以摄像机位置为原点,以三个相互垂直的单元向量为三轴的观察坐标系。
- 摄像机位置(图一)
设置摄像机的位置很简单,我们想象把摄像机放在世界空间中的某个坐标上就可以了,比如P(1, 2, 3)
。 -
z
轴(摄像机的方向向量)(图二)
我们定义摄像机的方向向量为摄像机注视方向的相反方向。我们假设希望摄像机注视到世界坐标的原点O(0, 0, 0)
,那么摄像机的方向向量就是OP
。方向向量就是z
轴的方向向量。 -
x
轴(相机的右侧,右向量)(图三)
2 中的方向向量相当于观察空间的z
轴,我们要计算出摄像机的右向量(即观察方向的右边,也是观察空间的x
轴),可以先定义一个上向量。这个上向量的方向是什么呢?在这个例子中,这个上向量一定是向量OP
中的y
轴方向,因为如果稍微偏一点,相机就会产生一个roll
轴的翻滚,最终算出来的观察坐标系就不是端正地观察世界坐标系原点的。因此我们这里的上向量就选(0, 2, 0)
就可以。如果我们希望右向量(x
轴)在摄像机的左边,也就是摄像机"倒着放",我们可以选择上向量为(0, -2, 0)
。我们将这个假定的上向量与方向向量进行叉乘就可以得到x
轴向量(即真正的右向量) -
y
轴(相机的上侧)
通过 3 计算出x
轴向量后,我们假定的上向量与x
轴向量(右向量)和z
轴向量(方向向量)都不垂直,但是我们可以通过右向量与方向向量再叉乘得出y
轴向量(即真正的上向量)。
这里使用叉乘目的就是构造出能够确定相机姿态的三个相互正交的轴向量,这一点有点像施密特正交。但是与施密特正交不同的是:
- 施密特正交是已知 n 个任意向量,能够计算出 n 个相互正交的向量。但是我们这里实际上是已知方向向量和上向量,需要计算出右向量,因此这里施密特正交不适用。
- 施密特正交算法的第一步是确定一个向量,以此向量来计算出其他所有的正交向量。我们这里进行到图 3 时就已知 3 个向量了,此时可以使用施密特正交。但是我们在图 3 时其实右向量和方向向量我们已经是固定了,且它们两个相互正交,如果我们使用施密特正交,那么第一步一定要计算右向量(或方向向量),第二步一定要计算方向向量(或右向量),第三步才计算最终的上向量,这样才能保证计算出来的坐标系与我们上面使用叉乘的结果相同。因为根据施密特正交的原理,如果第一步使用上向量,那么最终的计算结果会导致相机的姿态不是我们预期的(因为上向量实际上还未确定,施密特正交第一步就确定了上向量,因此最终的结果影响了方向向量和右向量)。
Android 给我们提供了 Matrix.setLookAtM()
函数:
public void static setLookAtM(float[] rm, int rmOffset, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ);
(eyeX, eyeY, eyeZ)
是摄像机的位置坐标,(centerX, centerY, centerZ)
是所要观察的区域的中心点坐标。相机坐标和中心点坐标一起决定了相机的观察方向。(upX, upY, upZ)
是上文中的自定义的上向量。
代码中的实践
例子都来自于Android OpenGL Projection。
1. 投影矩阵
上面的例子的 MyGLRenderer#onSurfaceChanged(GL10 gl10, int width, int height)
中,我们计算并构造了投影矩阵:
private final float[] projectionMatrix = new float[16];
@Override
public void onSurfaceChanged(GL10 unused, int width, int height) {
GLES20.glViewport(0, 0, width, height);
float ratio = (float) width / height;
// this projection matrix is applied to object coordinates
// in the onDrawFrame() method
Matrix.frustumM(projectionMatrix, 0, -ratio, ratio, -1, 1, 3, 7);
}
投影矩阵是一个 4✖️4 矩阵,因此 projectionMatrix
申明为一个大小为 16 的 float
形数组。接着调用 glViewport()
设置视口大小为 surface
的大小,计算出宽高比例,根据比例设置近平面的上下左右,使物体居中。设置 near
为 3,far
为 7。
2. 观察矩阵
@Override
public void onDrawFrame(GL10 unused) {
...
// Set the camera position (View matrix)
Matrix.setLookAtM(viewMatrix, 0, 0, 0, -3, 0f, 0f, 0f, 0f, 1.0f, 0.0f);
// Calculate the projection and view transformation
Matrix.multiplyMM(vPMatrix, 0, projectionMatrix, 0, viewMatrix, 0);
// Draw shape
triangle.draw(vPMatrix);
}
上面代码将相机放在 (0, 0, -3)
处向 (0, 0, 0)
点观察,并设置上向量为 (0.0, 1.0, 0.0)
。然后使用 Matrix.multiplyMM()
将投影矩阵和观察矩阵两个 4✖️4 的矩阵相乘,最后调用 triangle.draw()
方法,画出最终的三角型。