Android OpenGl Es 学习(六):进入三维

2,973 阅读17分钟

概述

这是一个新的系列,学习OpengGl Es,其实是《OpenGl Es 应用开发实践指南 Android卷》的学习笔记,感兴趣的可以直接看这本书,当然这个会记录自己的理解,以下只作为笔记,以防以后忘记

之后会对本书的前九章依次分析记录

Android OpenGl Es 学习(一):创建一个OpenGl es程序

Android OpenGl Es 学习(二):定义顶点和着色器

Android OpenGl Es 学习(三):编译着色器

Android OpenGl Es 学习(四):增填颜色

Android OpenGl Es 学习(五):调整宽高比

Android OpenGl Es 学习(六):进入三维

Android OpenGl Es 学习(七):使用纹理

Android OpenGl Es 学习(八):构建简单物体

Android OpenGl Es 学习(九):增添触摸反馈

最终是要实现一个曲棍球的简单游戏,类似这样的

三维

在我们看一个三维的画的时候,其实是画家在一个二维的平面,绘制除了三维的物体,我们看一个例子

这是一个火车的轨道,当我们站在火车轨道向远处看的时候,他看起来越来越远,直到消失到地平线的一个单点上

那些铁路枕木离我们越远,他们看起来越小,如果我们测量每个枕木的尺寸,被测量出的尺寸我们会发现他是按照我们的眼睛与其之间的距离成比例递减,如图:

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

上节我们讨论了,从虚拟坐标和投影矩阵相乘得到着色器的坐标,这次我们看下从着色器到屏幕坐标的变化

上节我们讨论了归一化设备坐标,并且知道如果要一个顶点显示在屏幕上,x,y,z分量都需要在[-1,1]的范围内

裁剪空间

当顶点着色器把一个值写入gl_Position的时候,OpenGl期望这个位置是在剪裁空间(Clip space)中的,裁剪空间背后的逻辑很简单,对于给定的位置,他的xyz分量都需要都需要在-w和w之间,比如:如果一个w分量为1,那么x,y,z都需要在-1到1之间,任何这个范围外的事物,在屏幕上都是不可见的

透视除法

在一个顶点变为一个归一化设备坐标之前,Opengl实际上执行了一个额外的步骤,它被称为透视除法,在进行透视除法之后,这个位置就在归一化设备坐标中了,不管渲染的大小和形状,对于其中的每个可视坐标,其中的x,y,z分量取值都是[-1,1]之间

为了在屏幕上创建三维幻象,OpenGl会把每个x,y,z分量都除以w分量,当w分量用来表示距离时,就使得较远处的物体,移动到离渲染中心更近的地方,中心的作用就是一个消失点,OpenGl就是这种方式欺骗我们的视觉,使我们看到一个三维的场景

假如一个物体,他有俩个顶点,每个顶点在三维的同一个位置,x,y,z相同,但是w分量不同(1,1,1,1)和(1,1,1,2),OpenGl再把这些转化为归一化设备坐标之前,会做透视除法,前三个分量除以w,这俩个坐标会变成(1,1,1)和(0.5,0.5,0.5),有较大w分量的坐标被移动到离(0,0,0)更近的位置,(0,0,0)就是归一化设备坐标渲染区域的中心

同质化坐标

经过透视触发之后,几个不同的点会映射到同一点,比如(1,1,1,1)和(2,2,2,2)都会映射到(1,1,1)处

除以w的优势

增加w坐标作为第四个分量有一个优势,我们可以把投影的影响和与实际的z坐标解耦,以便我们可以在正交投影和透视投影质检切换,保留z分量作为深度缓冲区

视口变化

我们在看到结果之前,opengl需要把归一化设备坐标x,y分量映射到屏幕的某一个区域,这个区域是系统预留出来用于显示的,被称为视口(ViewPort)这些被映射的坐标被称为窗口坐标,除了要告诉opengl怎么映射之外,其他我们不太关心这些坐标,我们在代码设置 GLES20.glViewport(0, 0, width, height);告诉opengl映射的时候,会把(-1,-1,-1)到(1,1,1)范围映射到那个为显示而预留的窗口上,而这个范围之外的归一化设备坐标会被才裁剪掉

添加w分量,创建三维图

我们实际使用下w分量,我们在之前的顶点数据加入一个z和w分量,更新后的顶点数据如下

	//记得更新这个变量
  private int POSITION_COMPONENT_COUNT = 4;

  float[] tableVertices = {
            //顶点
            0f, 0f, 0f, 1.5f,
            //顶点颜色值
            1f, 1f, 1f,

            -0.5f, -0.8f, 0f, 1f,
            0.7f, 0.7f, 0.7f,

            0.5f, -0.8f, 0f, 1f,
            0.7f, 0.7f, 0.7f,

            0.5f, 0.8f, 0f, 2f,
            0.7f, 0.7f, 0.7f,

            -0.5f, 0.8f, 0f, 2f,
            0.7f, 0.7f, 0.7f,

            -0.5f, -0.8f, 0f, 1f,
            0.7f, 0.7f, 0.7f,

            //线
            -0.5f, 0f, 0f, 1.5f,
            1f, 0f, 0f,

            0.5f, 0f, 0f, 1.5f,
            0f, 1f, 0f,

            //点
            0f, -0.4f, 0f, 1.25f,
            1f, 0f, 0f,

            0f, 0.4f, 0f, 1.75f,
            0f, 0f, 1f
    };

我们把接近屏幕底部的w设为1,接近屏幕顶部的设为2,中间的设为1到2之间的小数,这样的效果应该是,桌子的底部看起来比顶部大,好像我们从近处向远处看一样,我们把所有的z设为0,因为我们不需要z就可以实现里立体效果

opengl会自动使用我们指定的w值进行透视除法,我们现在的正交投影,只会把w值复制过去,我们运行项目看下效果

我们只是加入w分量就就让他看起来像是三维了,但是我们不要写死w分量,我们要使用矩阵动态生成这些值,我们把上面代码恢复之前的状态,然后开启学习利用矩阵动态生成w分量

使用透视投影

上一张我们用正交投影矩阵修复了屏幕宽高比,他通过显示区域的宽度和高度使他变为归一化设备坐标,第二个图就是正交投影,第一张图是透视投影

视椎体

上面第一个图是被称为视椎体(Frustum),这个观看空间是由一个透视投影矩阵和透视除法创建的,简单来说视椎体是一个立方体,其远端比近端大,使其变成一个被截断的金字塔,俩端的差别越大,观察的范围越宽,我们看到的也就越多

一个视椎体有一个焦点,这个焦点可以这样得到,顺着从视椎体较大的端向较小的端扩展出来的那些直线,一直通过较小端直到他们汇聚到一起,当你用透视投影观察场景时,这个场景就会像把你的头放在了焦点处,焦点和视椎体最小端的距离被称为焦距,他影响视椎体的小端和大端的比例,以及其对应的视野

定义透视投影

为了创造三维,透视投影需要和透视除法一起发挥作用

如果一个物体向屏幕中心移动,当他们离我们越来越远时,他的大小也越来越小,因此投影矩阵的任务就是为w产生正确的值,这样在opengl做透视除法的时候,远处的物体就会比近处的物体小,能实现这种的方法之一就是利用z分量,把他作为物体与焦点的距离,并把这个距离映射到w,这个距离越大,w值就越大,所得的物体就越小

使用透视投影,物体离得越远,他在屏幕上呈现的越小,这个效果不能由投影矩阵自己实现,他需要借助w分量和透视除法一起使用,而透视投影的主要工作就是生成w分量的值,利用透视除法来达到三维的场景

对宽高比和视野进行调整

这是一个通用的透视投影矩阵,它允许我们调整视野以及屏幕的宽高比

投影矩阵变量

变量描述
a如果我们想象一个相机的拍摄场景,这个变量就代表那个相机的焦距,焦距是由1/tan(视野/2) 计算得到的,这个视野要小于180度,tan表示正切函数,比如一个90度的视野 1/tan(90/2) = 1/1 = 1
aspect屏幕的宽高比,他等于 = 宽度/高度
f到远处平面的距离必须是正值且大于到近平面的距离
n到近平面的距离,必须是正值,比如,此值设为1,那么近处平面就位于一个z值为-1处

随着视野的变小,焦距的变长,可以映射到归一化坐标中[ -1,1]范围中的x,y的值范围就越小,这会产生使视椎体变窄的效果

左边视椎体有90度视野,而右边的视椎体只有45度视野

可以看到45度的视椎体,他的焦点与近端之间的焦距比90度的长

下面图像,他们是在相同的视椎体内,分别从他们的焦点处看到的图像

较窄的视野通常很少会有扭曲的问题,反过来说,随着视野的变宽,最终图像的边缘看起来会扭曲的严重

在代码中创建透视投影

现在我们准备在代码添加透视投影,Android的Matrix为他准备的俩个方法,frustumM和perspectiveM俩个方法,不过frustumM方法有缺陷,它会影响某些类型的投影,而perspectiveM方法是在android 4.0被引入的,我们可以使用perspectiveM方法也可以根据上面的公式自己实现一个

下面我们自己实现一个透视投影矩阵,首先定义方法

    public static void perspetiveM(float[] m, float degree, float aspect, float n, float f) 

根据上面公式计算焦距

   		//计算焦距
        float angle = (float) (degree * Math.PI / 180.0);
        float a = (float) (1.0f / Math.tan(angle / 2.0));

输出矩阵

 m[0] = a / aspect;
        m[1] = 0f;
        m[2] = 0f;
        m[3] = 0f;

        m[4] = 0f;
        m[5] = a;
        m[6] = 0f;
        m[7] = 0f;

        m[8] = 0f;
        m[9] = 0f;
        m[10] = -((f + n) / (f - n));
        m[11] = -1f;

        m[12] = 0f;
        m[13] = 0f;
        m[14] = -((2f * f * n) / (f - n));
        m[15] = 0f;

完整方法

 public class MatrixHelper {
    /**
     * @param m      生成的新矩阵
     * @param degree 视野角度
     * @param aspect 宽高比
     * @param n      到近处平面的距离
     * @param f      到远处平面的距离
     */
    public static void perspetiveM(float[] m, float degree, float aspect, float n, float f) {

        //计算焦距
        float angle = (float) (degree * Math.PI / 180.0);
        float a = (float) (1.0f / Math.tan(angle / 2.0));

        m[0] = a / aspect;
        m[1] = 0f;
        m[2] = 0f;
        m[3] = 0f;

        m[4] = 0f;
        m[5] = a;
        m[6] = 0f;
        m[7] = 0f;

        m[8] = 0f;
        m[9] = 0f;
        m[10] = -((f + n) / (f - n));
        m[11] = -1f;

        m[12] = 0f;
        m[13] = 0f;
        m[14] = -((2f * f * n) / (f - n));
        m[15] = 0f;

    }
}

这个矩阵数据就存储到了参数m定义的浮点数组中,这个数组至少需要16个元素,OpenGl把矩阵按照以列为主的顺序储存,这就意味这我们一次写一列数据,而不是一次写一行, 前四个为第一列,下一组四个是第二列,一次类推

现在我们已经完成自己的perspetiveM方法,我们可以在代码中使用他,我们自己实现的其实和Android源码带的方法很相似

开始使用投影矩阵

我们更改onSurfaceChanged方法的代码,如下:

 @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        //设置屏幕的大小
        GLES20.glViewport(0, 0, width, height);
        //45度视野角创建一个透视投影,这个视椎体从z轴-1开始,-10结束
        MatrixHelper.perspetiveM(mProjectionMatrix, 90, (float) width / (float) height, 1f, 10f);
      
    }

我们用45度的角度,创建投影矩阵,这个视椎体从z值-1开始,在z值-10结束

如果我们现在运行的话,就会发现矩形不见了,是因为我们没有给桌子指定z轴的位置,默认情况下他处于z为0的位置,因为这个视椎体是从z值为-1处开始的,所以我们要把这个矩形移动到这个位置,否则我们是看不到这个桌子的

在使用投影矩阵之前,我们先用一个平移矩阵把桌子平移出来,我们称这个矩阵为模型矩阵(model matrix)

利用模型矩阵移动物体

首先我们定义模型矩阵

 	//模型矩阵
    private float[] mModelMatrix = new float[16];

我们要使用这个模型矩阵把矩形移到距离内,我们在onSurfaceChanged末尾加上

		//设置为单位矩阵
        Matrix.setIdentityM(mModelMatrix, 0);
        //向z轴平移-2f
        Matrix.translateM(mModelMatrix, 0, 0f, 0f, -2f);

这就把模型矩阵设置为单位矩阵,然后在z方向平移-2,当我们的矩形坐标和这个矩阵相乘,矩形坐标最终会沿着z轴的负方向移动2个单位

相乘一次还是俩次

现在我们有俩个矩阵需要处理,一个是投影矩阵,一个是模型矩阵,目前我们可以,给顶点着色器增加一个矩阵,先把顶点和模型矩阵相乘,然后再让顶点和投影矩阵相乘

如果不想这么麻烦,还有一个更好的方式,着色器保持不变,把模型矩阵和投影矩阵相乘,得到一个最终的矩阵,然后把这个最终矩阵传递给顶点着色器,通过这种方式就可以在着色器仅保留一个矩阵了

选择合适的相乘顺序

我们知道,A和B矩阵相乘,A*B ≠B*A,所以我们要选择适当的顺序把模型矩阵和投影矩阵相乘

所以最终得到,投影矩阵乘以模型矩阵,就是把投影矩阵放在左边,模型矩阵放在右边

我们在onSurfaceChanged的末尾加入如下代码

		float[] temp = new float[16];
        //矩阵相乘
        Matrix.multiplyMM(temp, 0, mProjectionMatrix, 0, mModelMatrix, 0);
        //把矩阵重复赋值到投影矩阵
        System.arraycopy(temp, 0, mProjectionMatrix, 0, temp.length);

首先创建一个临时变量,把相乘的结果储存在临时变量中,然后把临时变量的值复制到mProjectionMatrix中,那么现在这个矩阵中就包含,模型矩阵和投影矩阵的组合效应

现在运行的话会是这个效果

理解透视投影

我们看下 ① 表示公式 ② 表示把值带入公式 ②中的1.78其实是上章调整宽高比的,由于②中带有分数不好算,我们用③代替

我们看下③矩阵内发生了什么

  • 矩阵的前两行,简单的复制可x和y的值
  • 矩阵的第三行复制了z分量,同时把他翻转,第四列的-2会乘以一个w值,他默认是1,因此第三行最终会成为-z-2,(至于为什么是-2 而不是其他值,数学证明这里就不写了,我也不会)
  • 第四行把w值设置为-z,一旦执行opengl透视除法这会起到吧较远的物体缩小的而效果

我们把矩阵与视椎体近端一个点相乘,看下结果

我们再来看俩个点,每一个都比前一个远

随着点变得越来越远,z和w的值就越来越大

除以w

为了实现三维,首先我们定义一个矩阵,他会创建一个随距离增加而增加的w值,然后进行下一步,透视除法,opengl把每个分量都除以w之后,最终会得到下面结果

视椎体近端-1,远端1

这种类型的投影矩阵,远端其实处于无限远处,由于硬件精度限制,不管z多远,在归一化坐标中,他都是接近而不能完全区配远平面的 1

增加旋转

为什么上面加了投影矩阵,还不是三维呢?

因为我们的顶点数组,没有写z和w的值,默认都是1 ,所以投影矩阵和透视除法其实不会起作用,所以先看起来还是二维平面

我们可以利用旋转的方式让他变成三维,以便我们从某个角度观看他

旋转方向

首先我们要弄清楚一个事情,我们要围绕那个旋转轴,要旋转多少度,要搞清楚一个物体怎样围绕给定的轴旋转,我们将用右手坐标系原则

伸出你的右手握拳,让你的大拇指指向正轴的方向,弯曲的手指会告诉你,物体以正角度怎样围绕那个轴旋转

我们希望围绕x轴向后旋转,和旋转的正方向相反,所以我们要旋转的是负角度

旋转矩阵

沿x轴旋转

沿y轴旋转

沿z轴旋转

我们测试一下沿x轴旋转,(0,1,0)沿x轴旋转90度

这个点从(0,1,0)移动到了(0,0,1),我们用右手规则,我们可以看到这个旋转是正向的

在代码加入旋转

在onSurfaceChanged中加入下面代码

  		Matrix.translateM(mModelMatrix, 0, 0f, 0f, -2.5f);
        //绕着x轴旋转-60度
        Matrix.rotateM(mModelMatrix, 0, -60, 1.0f, 0f, 0f);

第一行是为了让桌子离我们更远一点,第二行是绕x轴旋转-60度

运行一下

完整代码

public class AirHockKeyRender3 implements GLSurfaceView.Renderer {

    //调整宽高比
    private final FloatBuffer verticeData;
    private final int BYTES_PER_FLOAT = 4;
    private int POSITION_COMPONENT_COUNT = 2;
    private final int COLOR_COMPONENT_COUNT = 3;
    private final int STRIDE = (POSITION_COMPONENT_COUNT + COLOR_COMPONENT_COUNT) * BYTES_PER_FLOAT;
    private final Context mContext;

    //逆时针绘制三角形
    float[] tableVertices = {
            //顶点
            0f, 0f,
            //顶点颜色值
            1f, 1f, 1f,

            -0.5f, -0.8f,
            0.7f, 0.7f, 0.7f,

            0.5f, -0.8f,
            0.7f, 0.7f, 0.7f,

            0.5f, 0.8f,
            0.7f, 0.7f, 0.7f,

            -0.5f, 0.8f,
            0.7f, 0.7f, 0.7f,

            -0.5f, -0.8f,
            0.7f, 0.7f, 0.7f,

            //线
            -0.5f, 0f,
            1f, 0f, 0f,

            0.5f, 0f,
            0f, 1f, 0f,

            //点
            0f, -0.4f,
            1f, 0f, 0f,

            0f, 0.4f,
            0f, 0f, 1f
    };
    private int a_position;
    private int a_color;

    //模型矩阵
    private float[] mModelMatrix = new float[16];

    //投影矩阵
    private float[] mProjectionMatrix = new float[16];
    private int u_matrix;


    public AirHockKeyRender3(Context context) {

        this.mContext = context;
        //把float加载到本地内存
        verticeData = ByteBuffer.allocateDirect(tableVertices.length * BYTES_PER_FLOAT)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(tableVertices);
        verticeData.position(0);
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        //当surface被创建时,GlsurfaceView会调用这个方法,这个发生在应用程序
        // 第一次运行的时候或者从其他Activity回来的时候也会调用

        //清空屏幕
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
        //读取着色器源码
        String fragment_shader_source = ReadResouceText.readResoucetText(mContext, R.raw.fragment_shader1);
        String vertex_shader_source = ReadResouceText.readResoucetText(mContext, R.raw.vertex_shader2);

        //编译着色器源码
        int mVertexshader = ShaderHelper.compileShader(GLES20.GL_VERTEX_SHADER, vertex_shader_source);
        int mFragmentshader = ShaderHelper.compileShader(GLES20.GL_FRAGMENT_SHADER, fragment_shader_source);
        //链接程序
        int program = ShaderHelper.linkProgram(mVertexshader, mFragmentshader);

        //验证opengl对象
        ShaderHelper.volidateProgram(program);
        //使用程序
        GLES20.glUseProgram(program);

        //获取shader属性
        a_position = GLES20.glGetAttribLocation(program, "a_Position");
        a_color = GLES20.glGetAttribLocation(program, "a_Color");
        u_matrix = GLES20.glGetUniformLocation(program, "u_Matrix");


        //绑定a_position和verticeData顶点位置
        /**
         * 第一个参数,这个就是shader属性
         * 第二个参数,每个顶点有多少分量,我们这个只有来个分量
         * 第三个参数,数据类型
         * 第四个参数,只有整形才有意义,忽略
         * 第5个参数,一个数组有多个属性才有意义,我们只有一个属性,传0
         * 第六个参数,opengl从哪里读取数据
         */
        verticeData.position(0);
        GLES20.glVertexAttribPointer(a_position, POSITION_COMPONENT_COUNT, GLES20.GL_FLOAT,
                false, STRIDE, verticeData);
        //开启顶点
        GLES20.glEnableVertexAttribArray(a_position);

        verticeData.position(POSITION_COMPONENT_COUNT);
        GLES20.glVertexAttribPointer(a_color, COLOR_COMPONENT_COUNT, GLES20.GL_FLOAT,
                false, STRIDE, verticeData);
        //开启顶点
        GLES20.glEnableVertexAttribArray(a_color);
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        //在Surface创建以后,每次surface尺寸大小发生变化,这个方法会被调用到,比如横竖屏切换
        //设置屏幕的大小
        GLES20.glViewport(0, 0, width, height);
        //45度视野角创建一个透视投影,这个视椎体从z轴-1开始,-10结束
        MatrixHelper.perspetiveM(mProjectionMatrix, 45, (float) width / (float) height, 1f, 10f);


        //设置为单位矩阵
        Matrix.setIdentityM(mModelMatrix, 0);
        //向z轴平移-2f
        Matrix.translateM(mModelMatrix, 0, 0f, 0f, -2f);

        Matrix.translateM(mModelMatrix, 0, 0f, 0f, -2.5f);
        //绕着x轴旋转-60度
        Matrix.rotateM(mModelMatrix, 0, -60, 1.0f, 0f, 0f);


        float[] temp = new float[16];
        //矩阵相乘
        Matrix.multiplyMM(temp, 0, mProjectionMatrix, 0, mModelMatrix, 0);
        //把矩阵重复赋值到投影矩阵
        System.arraycopy(temp, 0, mProjectionMatrix, 0, temp.length);
    }

    @Override
    public void onDrawFrame(GL10 gl) {
        //当绘制每一帧数据的时候,会调用这个放方法,这个方法一定要绘制一些东西,即使只是清空屏幕
        //因为这个方法返回后,渲染区的数据会被交换并显示在屏幕上,如果什么都没有话,会看到闪烁效果

        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

        GLES20.glUniformMatrix4fv(u_matrix, 1, false, mProjectionMatrix, 0);


        //绘制长方形
        //指定着色器u_color的颜色为白色
        /**
         * 第一个参数:绘制绘制三角形
         * 第二个参数:从顶点数组0索引开始读
         * 第三个参数:读入6个顶点
         *
         * 最终绘制俩个三角形,组成矩形
         */
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, 0, 6);

        //绘制分割线

        GLES20.glDrawArrays(GLES20.GL_LINES, 6, 2);

        //绘制点
        GLES20.glDrawArrays(GLES20.GL_POINTS, 8, 1);

        GLES20.glDrawArrays(GLES20.GL_POINTS, 9, 1);
    }
}