您的位置:首页 > 移动开发 > Android开发

OpenGL.ES在Android上的简单实践:5-曲棍球(透视投影/模型矩阵)

2018-01-25 18:23 239 查看

OpenGL.ES在Android上的简单实践:5-曲棍球(透视投影/模型矩阵)

1、初识三维投影

想象一下,我们平常玩的乒乓球(曲棍球),面向你的对手观察这个桌子。从你的角度看,这个桌子看上去是什么样子的?你那端的桌子显得较大,因为你是从一个角度向对面观察这个桌子的,而不是直接从空中俯视。毕竟没有人玩乒乓球(曲棍球)的时候站在桌子的上面并直接向下看。
那么问来又来了,在二维平面(显示器)上,OpenGL是怎样显示一个完整的三维场景?下面我们来介绍一个术语 -> 线性投影(linear Projection)它的工作原理是,在一个想象中的消失点处把并行线段聚合在一起,从而创建出立体化的错觉。  
下面我来一个典型的例子说明什么是线性投影。假设我们站在一对笔直的铁轨上时,向铁轨的远处看,他们看起来越来越近了,直到它们看起来消失在地平线上的一个单个点上。那些铁路枕木离我们越远,它们看上去也越小。如果我们测量每个铁路枕木的外观尺寸,被测量出来的尺寸将按照我们的眼睛与其之间的距离成比例递减。(如下图)




2、从着色器到屏幕的坐标变换

我们现在熟悉了归一化设备坐标,并且知道要让一个顶点显示在屏幕上,它的x、y和z分量都要在范围[-1, 1]内取值。让我们看看下面的流程图,复习一下怎样把一个在顶点着色器上的原始 gl_Position 坐标变换为最终的屏幕坐标。


从虚拟坐标空间 经过投影操作 到达gl_Position这个流程,我想大家应该是清楚的(不清楚的请看这里)。我们继续,“透视除法”???在一个顶点位置成为一个归一化设备坐标之前,OpenGL实际上执行了一个额外的步骤,就是这个被称为透视除法(perspective division)。透视除法之后,那个位置就在归一化设备坐标中了,不管渲染区域的大小和形状,对于其中的每个可视坐标,其x、y和z分量的取值都位于[-1, 1]的范围内。为了在屏幕上创建三维效果,OpenGL会把每个gl_Position的x、y和z分量都除以它的w分量。当w分量用来表示距离的时候,就是得较远的物体被移动到距离渲染区域中心更近的地方,这个中心的作用就像一个消失点。OpenGL正是用这种方法欺骗我们的,使我们看见一个三维场景。举个例子,下面用一张图说明,假设一个物体,它有两个顶点,每个顶点都在三维空间中的同一个位置,它们有同样的xyz分量,但是w分量去不同。假设这两个坐标是(1,1,1,1)和(1,1,1,2)。在OpenGL把这些作为归一化设备坐标使用之前,它会做透视除法,其前三个分量都除以w;这两个坐标被整除后变为(1/1,1/1,1/1)和(1/2,1/2,1/2),这就可以看出,有较大值的坐标移动到距离(0,0,0)更近的位置了,(0,0,0)就是归一化设备坐标里渲染区域的中心。


你可能会问,为什么不直接除以z呢?毕竟,如果我们把z解释为距离,并且两个坐标,分别是(1,1,1)和(1,1,2),我们就可以通过除以z得到归一化坐标了。尽管这行得通,但增加w作为第四个分量有另外一个优点好处。我们可以把投影的影响与实际的z坐标解耦,以便我们可以在正交投影和其他投影之间切换,其实这是为以后保留z分量作为深度缓冲区(往后介绍)的标志。
在我们看到屏幕的结果之前,剩下最好的一步就是视口变换,OpenGL需要把归一化设备坐标x和y映射到屏幕上的区域内,这个区域是操作系统预留出来显示的,被称为视口(viewport);这些被映射的坐标被窗口坐标。除了要告诉OpenGL怎样映射之外,我们不需要太关心这些坐标。在代码中我们已经介绍过glViewport告诉OpenGL映射的区域是满屏了。
现在,我们修改代码,添加w分量以创建三维图。
private static final int POSITION_COMPONENT_COUNT = 4;

float[] tableVerticesWithTriangles = {
// X, Y, Z, W, R, G, B
// 三角扇
0,      0,   0f,  1.5f,  1f, 1f, 1f,
-0.5f,    -0.8f,  0f,  1.0f,  0.7f,0.7f,0.7f,
0.5f,     -0.8f,  0f,  1.0f,  0.7f,0.7f,0.7f,
0.5f,     0.8f,  0f,  2.0f,  0.7f,0.7f,0.7f,
-0.5f,    0.8f,  0f,  2.0f,  0.7f,0.7f,0.7f,
-0.5f,    -0.8f,  0f,  1.0f,  0.7f,0.7f,0.7f,
// 中间的分界线
-0.5f,   0f,   0f,  1.5f,  1f,0f,0f,
0.5f,    0f,   0f,  1.5f,  1f,0f,0f,
// 两个木槌的质点位置
0f,     -0.4f,  0f,  1.25f,  0f,0f,1f,
0f,      0.4f,  0f,  1.75f,  1f,0f,0f,
};
我们給顶点数据加入了一个z和一个w分量。更新了所有顶点,其中接近屏幕底部的顶点的w设为1,而那些接近屏幕顶部的顶点的w设为2;我们也把那条直线和木槌的w更新为从1到2之间的小数。我们把所有顶点的z分量都设为0,因为我们不需要z上有任何值就能获得立体效果。OpenGL会自动使用我们指定的w值做透视除法,我们现在的正交投影只是把这些w值复制过去;继续运行这个项目,看看它看起来是什么样的。


有点三维感觉了对吧,我们只是放进了一个自己制定的w就做到了这些。然而,如果我们想让这些物体更动态,比如要改变桌子的角度怎么办呢?作为一个优雅的程序员要写出优雅的代码,我们不能使用硬编码指定w的值,我们要学会用矩阵生成这些值。把我们刚刚增加的代码还原。下面我们来学习如何使用透视投影矩阵自动生成这些w的值。

3、使用透视投影

在进入透视投影背后的矩阵数学之前,让我们在视觉层次上认识一下。在前些文章上,我们使用正交投影矩阵来弥补屏幕的宽高比,它是通过调整显示区域的宽度和高度使之变换为归一化设备坐标。一旦我们使用了矩阵投影矩阵,场景中的并行线就会在屏幕上的一个消失点汇聚在一起,当物体离得越远,物体会变得更小。不使用立方体,我们使用一个类似矩形台体的空间。如下图所示(灵魂画师上线)


这个形状我们叫做视椎体(frustum)这个观看空间是由一个透视投影矩阵和投影除法创建的。简单来说,视椎体只是一个立方体,其远端比近端大,从而使其变成一个被截断的金字塔。两端的大小差别越大,观察的范围越宽,我们能看到的也越多。一个视椎体有一个焦点(focal point)这个焦点可以这样得到,顺着从视椎体较大端向较小端扩展处理的那些直线,一直向前通过较小端知道它们汇聚到一起。当你用透视投影观察一个场景的时候,那个场景看上去就像你站在焦点处观察一样。焦点和视椎体小端的距离被称为焦距(focal length),它影响视椎体小端和大端的比例,以及其对应的视野。焦点还有一个注意事项,从焦点看上去视椎体两端在屏幕上占据了同样大小的空间。视椎体的远端虽然较大,但是它也比较远,它仍然占据同样大小的空间。这与我们看到的日食是一样的原理;月亮虽然比太阳小很多,但因为它也近了很多,使它看上去很大,足够掩盖太阳的光辉。
接下来我们定义透视投影。我们来看看通用的投影矩阵的数学模型,它允许我们调整视野以及屏幕的宽高比。


下面介绍其中变量代表什么以及逻辑关系:        a : 焦距。如果我们想象一个相机拍摄的场景,这个变量就代表那个相机的焦距。焦距是由1/tan(a/2)计算得到的。这个视野必须小于180读。比如一个90度的视野,它的焦距会被设置为1/tan(90°/2),也就是1/1=1。        aspect : 屏幕的宽高比,它等于宽度/高度        f : 到远处平面的距离,必须是正值且大于到近处平面的距离。        n : 到近处屏幕的距离,必须是正值。比如,如果此值设为1,那么近处就位于一个z值为-1处。随着视野a变小,焦距变长,可以映射到归一化坐标中的x和y值的范围就越小,这会产生使视椎体变窄的效果。
好了,基本理论介绍完了,我们怎样在代码中创建透视投影矩阵呢?回顾一下我们之前的正交矩阵,我们是使用Android的Matrix类接口orthoM,到透视投影矩阵,Android也为我们准备了个方法perspectiveM 。但是这个方法只是从Android的4.0版本开始才被引入,在早期的Android是没有这个方法的。使用的时候请做好判断,那么4.0之前的怎么办呢?我们也不怕啊,按照上面的数学关系来自己写一个就好了。我们在项目utils中添加一个MatrixHelper类,添加以下代码:
${applicationID}/utils/MatrixHelper.java

public class MatrixHelper {
public static void perspectiveM(float[] m, float yFovInDegrees, float aspect, float n, float f){
final float angleInRadians = (float) (yFovInDegrees * Math.PI / 180.0);
final float a = (float) (1.0/ Math.tan(angleInRadians / 2.0));
//Android数组是列阵
m[0] = a / aspect;  m[4] = 0f;  m[8] = 0f;              m[12] = 0f;
m[1] = 0f;          m[5] = a;   m[9] = 0f;              m[13] = 0f;
m[2] = 0f;          m[6] = 0f;  m[10] = -((f+n)/(f-n)); m[14] = -((2f*f*n)/(f-n));
m[3] = 0f;          m[7] = 0f;  m[11] = -1f;            m[15] = 0f;
}
}
现在我们开始使用透视投影矩阵,首先我们把onSurfaceChanged的代码注销,只保留glViewport。加入代码:
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
GLES20.glViewport(0,0,width,height);

MatrixHelper.perspectiveM(projectionMatrix, 45, (float)width/(float)height, 1f, 100f);
}
这里我们用45度的视野创建一个透视投影,这个视椎体从z值为-1的位置开始,在z值为-100的位置结束。运行项目看看有没吓倒你的小心肝┌(。Д。)┐因为桌子不见了!!!这是因为我们的顶点数据tableVerticesWithTriangles现在还是 X, Y, R, G, B 。没有指定z的值,默认就全都是0了。但是我们这个视椎体的近平面是从-1开始(为什么是负值,请看这里的左手右手坐标系),那是不是又要硬编码这个z值,全改成-1呢?为了检测我们的透视投影是否有效,可以分别硬编码z分量-1,-2,-3~到顶点数组,分别看看效果,并思考此时z值又具体代表的是什么呢?

写到这里,我们先小结以上的知识点:首先我们知道了OpenGL会自动把顶点着色器的vec4顶点的xyz分量都除以w分量(透视除法),利用这样的数学关系创造三维效果,随后我们使用透视投影矩阵,分离了硬编码w,把w分量和焦距,视野等物理量绑定上关系。  (透视投影矩阵传进着色器 乘以默认w分量为1的顶点数据来达到准确的w分量)


4、利用模型矩阵移动物体

思考问题有答案了吗? z值具体代表的就是桌子模型位置距离中心点的偏移值。亦可以描述为移动的单位值。我们可以使用一个平移矩阵把桌子移出来。我们可以把这个矩阵称为模型矩阵(model matrix)我们在投影矩阵的定义下追加定义:
private final float[] projectionMatrix = new float[16];

private final float[] modelMatrix = new float[16];
并在onSurfaceChanged追加以下代码:
Matrix.setIdentityM(modelMatrix,0);
Matrix.translateM(modelMatrix, 0, 0f,0f,-2f);
这就把模型矩阵设为单位矩阵,再沿着z轴平移-2。当我们把桌子的坐标与这个坐标相乘的时候,那些坐标最终会沿着z轴负方向移动2个单位。   那么模型矩阵怎么作用到模型顶点上呢?这需要我们再复习复习大学的线性代数了,由于篇幅问题,我们到这里的一些基本的矩阵数学概念

现在,我们有一个透视矩阵 一个模型矩阵,我们需要把这两个矩阵应用于每个顶点,因此出现两个选项了:
        1、给顶点着色器新增一个额外的uniform mat4   model_u_Matrix,把模型矩阵传进model_u_Matrix,然后把每个顶点与这个模型矩阵做运算,接下来再与投影矩阵运算。这样OpenGL就可以做透视除法了,并把顶点变为归一化设备坐标。        2、我们可以把模型矩阵和投影矩阵相乘,得到一个矩阵,然后把这个矩阵传递給顶点着色器。通过这中方式,顶点着色器只保留一个矩阵。
让我们再更新代码,添加一个总的矩阵:
private final float[] uMatrix = new float[16];
更新onSurfaceChanged的代码如下:
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
GLES20.glViewport(0,0,width,height);

MatrixHelper.perspectiveM(projectionMatrix, 45, (float)width/(float)height, 1f, 100f);

Matrix.setIdentityM(modelMatrix,0);
Matrix.translateM(modelMatrix, 0, 0f,0f,-2f);

Matrix.multiplyMM(uMatrix,0,  projectionMatrix,0,   modelMatrix,0);
}
更新onDrawFrame的代码:
@Override
public void onDrawFrame(GL10 gl10) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

GLES20.glUniformMatrix4fv(uMatrixLocation,1, false,  uMatrix,0);
... ...
}
这里提醒注意矩阵乘法的顺序性,不明所以的,请查看到这里的矩阵累积变换

运行项目看看是不是下面这样:



咋又还原成平面二维图了?   各位看官稍安勿躁,我们看看一下示意图:


我们现在的tableVerticesWithTriangles顶点数据是不是只有 xy分量的值,我们通过改变模型矩阵前后平移,调整z值,使得桌子离开 x轴和y轴构成的平面(手机屏幕)所以当我们更改Matrix.translateM(modelMatrix, 0, 0f,0f,-2f);的z值可以观察不同大小的桌子平面图,那么我们可不可以通过模型矩阵,沿着x轴旋转一定角度,达到三维效果呢?   事不宜迟,我们增加旋转代码,更新onSurfaceChanged的代码:
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
GLES20.glViewport(0,0,width,height);
MatrixHelper.perspectiveM(projectionMatrix, 45, (float)width/(float)height, 1f, 100f);

Matrix.setIdentityM(modelMatrix,0);
Matrix.translateM(modelMatrix, 0, 0f,0f,-3f);  // 平移
Matrix.rotateM(modelMatrix,0,  -60f, 1f,0f,0f);  // 旋转

Matrix.multiplyMM(uMatrix,0,  projectionMatrix,0,   modelMatrix,0);
}
看看现在效果如何?(因为懒 (* ̄︶ ̄)V,关于Matrix.旋转平移缩放等操作,请参考Android.API介绍,不难明白的)

小结:这次文章的信息量很大,当我们学习透视投影矩阵以及他们怎样与OpenGL的透视除法一起工作的时候,引入了更多有关矩阵数学的内容,接着我们学习了如何使用第二个矩阵移动和旋转桌子。  面对着复杂的线性代数,我们也不要紧张,只要基本理解了什么是视椎体以及矩阵如何帮我们移动模型,你就会发现今后用OpenGL/Untiy3d/Vulkan之类的引擎就更容易了。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: