Android OpenGL ES 2.0 初次相见

作者: 李晨玮 | 来源:发表于2018-06-24 14:26 被阅读61次

    又是一年毕业季,转眼自己也毕业一年了,最近工作上涉及到了一些OpenGL的东西,在网上找过一些资料,发现零零散散的,都不是很完整,所以决定自己写个总结系列,内容会涉及到OpenGL的基础概念,简单绘制,2D纹理贴图,纹理相关的裁剪、翻转、旋转、缩放,添加效果滤镜等。

    基础概念

    在OpenGL的世界里,只有点、线、面(三角形),复杂的图形是由n个三角形拼接组合而成。

    在OpenGL里,我们需要知道Vertex(顶点)和Fragments(片元):
    Vertex(顶点):
    可以近似理解成我们数学中的基础单位点,两点可以确定一条线,三点可以确定一个面(三角形)。
    Fragments(片元):
    这里我们需要了解什么是光栅化,光栅化是将点,线,面(三角形)映射成屏幕像素点的过程,一般来说,一个Fragment对应屏幕的一个像素点,光栅化也就是映射Fragment的过程。

    光栅化过程

    我们再来看下OpenGL里很重要的一个东西Shader(着色器),它是用来告诉OpenGL应该怎么渲染的,着色器有两大类,一类是Vertex Shader(顶点着色器),一类是Fragment Shader(片元着色器):
    Vertex Shader(顶点着色器):
    每一个顶点都会执行一次顶点着色器,它是用来确认顶点的最终位置的,通过顶点间的组合,我们就可以确认点、线、面所在的位置。
    Fragment Shader(片元着色器):
    每一个片元都是执行一次片元着色器,片元是用来确认图形的最终显示颜色。

    OpenGL的执行过程

    坐标系

    由于OpenGL是用来描述3D维度的,我们可以从不同的角度来观察物体,它最终会呈现在我们的手机屏幕(2D维度)上,所以这里涉及到了一些坐标系的转换。


    OpenGL里的坐标系

    1、Local Space(局部坐标)是物体的坐标,原点位于物体中心。
    2、将Local Space转换为World Space(世界坐标),世界坐标是一个更大空间范围的坐标系统,避免多个物体的原点重合,让其有独立的“位置”感。
    3、将World Space转换为View Space(观察坐标),观察坐标是指以摄像机或观察者的角度观察的坐标。
    4、将坐标转为View Space之后,我们需要将其投影到Clip Space(裁剪坐标),裁剪坐标是处理[-1,1]内并判断哪些顶点将会出现在屏幕上。
    5、最后,我们需要将Clip Space转换为Screen Space(屏幕坐标),我们将这一过程成为视口变换(Viewport Transform)。视口变换将位于[-1,1]的坐标转换到由glViewport函数所定义的坐标范围内。最后转换的坐标将会送到光栅器,由光栅器将其转化为片元。

    通常说的 Model,View,Projection 这三种变换都是针对Vertex坐标做的变换,也就是:


    image.png

    当然我们也可以一步到位,只是这样做会失去很多中间处理的灵活性。

    关于更多的坐标系讲解可以查阅:OpenGL世界里的坐标系

    小试牛刀

    说了这么多,我们来实战一下,记得在学习编程语言的时候,总喜欢从Hello World开始,这里我们也对OpenGL说声Hello World吧,我们来画一个三角形。

    在Android中怎么使用OpenGL绘图呢,谷歌大法给我们提供了一个GLSurfaceView,我们可以简单的把它看做一块画布,类似Canvas一样,它会帮我们处理OpenGL在初始化过程中的一些工作,比如配置显示设备,后台渲染线程等,然后只有画布没有笔咋可以,这里我们还需要一个Renderer渲染器,类似Paint一样,可以让我们在GLSurfaceView上渲染绘图。

            GLSurfaceView glSurfaceView = new GLSurfaceView(this);
            glSurfaceView.setRenderer(new GLSurfaceView.Renderer() {
                /**
                 * 创建的时候调用
                 * @param gl
                 * @param config
                 */
                @Override
                public void onSurfaceCreated(GL10 gl, EGLConfig config) {
    
                }
    
                /**
                 * 视图发生形状变化的时候调用
                 * @param gl
                 * @param width
                 * @param height
                 */
                @Override
                public void onSurfaceChanged(GL10 gl, int width, int height) {
    
                }
    
                /**
                 * 绘制视图的时候调用
                 * @param gl
                 */
                @Override
                public void onDrawFrame(GL10 gl) {
    
                }
            });
    

    我们会在onSurfaceCreated中进行一些初始化操作(比如设置清理屏幕颜色,编译/加载着色器等),这个方法只会执行一次,在初始化渲染器的时候调用,当视图形状发生变化(比如旋转屏幕等)会调用onSurfaceChanged,在绘制视图的时候会调用onDrawFrame,基础框架方法就是这些了,我们只需要按照它的框架结构是编写我们所需要的代码即可。

    我们来看下绘制三角形的完整代码:

    package com.lcw.opengl.demo;
    
    import android.opengl.GLSurfaceView;
    import android.os.Bundle;
    import android.support.v7.app.AppCompatActivity;
    
    public class MainActivity extends AppCompatActivity {
    
       @Override
       protected void onCreate(Bundle savedInstanceState) {
           super.onCreate(savedInstanceState);
    
           GLSurfaceView glSurfaceView = new GLSurfaceView(this);
           glSurfaceView.setEGLContextClientVersion(2);
           glSurfaceView.setRenderer(new TriangleRenderer());
           glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
           setContentView(glSurfaceView);
    
       }
    }
    

    在上面的代码中,我们初始化了GLSurfaceView和渲染器Renderer,指定OpenGL的版本为2和渲染模式为RENDERMODE_CONTINUOUSLY(实时渲染),渲染模式有两种,一种是惰性渲染(需要手动调用glSurfaceView.requestRender();才会生效),一种是实时渲染。

       /**
         * The renderer only renders
         * when the surface is created, or when {@link #requestRender} is called.
         *
         * @see #getRenderMode()
         * @see #setRenderMode(int)
         * @see #requestRender()
         */
        public final static int RENDERMODE_WHEN_DIRTY = 0;
        /**
         * The renderer is called
         * continuously to re-render the scene.
         *
         * @see #getRenderMode()
         * @see #setRenderMode(int)
         */
        public final static int RENDERMODE_CONTINUOUSLY = 1;
    

    为了方便,我直接setContentView为GLSurfaceView,也就是全屏,在实际开发中,我们可以在XML布局文件中去编写<android.opengl.GLSurfaceView/>,然后根据findViewById拿到对象也是可以的,重点来看下我们自定义的渲染器:

    package com.lcw.opengl.demo;
    
    import android.opengl.GLES20;
    import android.opengl.GLSurfaceView;
    
    import java.nio.ByteBuffer;
    import java.nio.ByteOrder;
    import java.nio.FloatBuffer;
    
    import javax.microedition.khronos.egl.EGLConfig;
    import javax.microedition.khronos.opengles.GL10;
    
    /**
    * 绘制三角形渲染器
    * Create by: chenwei.li
    * Date: 2018/6/22
    * Time: 上午12:16
    * Email: lichenwei.me@foxmail.com
    */
    public class TriangleRenderer implements GLSurfaceView.Renderer {
    
       //三角形顶点
       private float[] mTriangleVertex = {
               0f, 1f, 0f,
               -1f, -1f, 0f,
               1, -1f, 0f
    
       };
    
       //三角形颜色
       private float[] mTriangleColor = {1f, 0f, 0f, 1f};
    
    
       //顶点着色器
       private int mVertexShader;
       private String mVertexShaderCode = "attribute vec4 vPosition;\n"
               + "void main() {\n"
               + " gl_Position = vPosition;\n"
               + "}";
       //片元着色器
       private int mFragmentShader;
       private String mFragmentShaderCode = "precision mediump float;\n"
               + "uniform vec4 vColor;\n"
               + "void main() {\n"
               + " gl_FragColor = vColor;\n"
               + "}";
    
    
       //转换字节序
       private FloatBuffer mTriangleVertexBuffer;
       private FloatBuffer mTriangleColorBuffer;
    
       //GLSL程序
       private int mProgram;
    
       //着色器变量的句柄(引用)
       private int mTriangleVertexHandle;
       private int mTriangleColorHandle;
    
    
       public TriangleRenderer() {
           //进行顶端数组的字节序转换
           ByteBuffer triangleVertexBuffer = ByteBuffer.allocateDirect(mTriangleVertex.length * 4);
           triangleVertexBuffer.order(ByteOrder.nativeOrder());
           mTriangleVertexBuffer = triangleVertexBuffer.asFloatBuffer();
           mTriangleVertexBuffer.put(mTriangleVertex);
           mTriangleVertexBuffer.position(0);
           //进行片元数组的字节序转换
           ByteBuffer triangleColorBuffer = ByteBuffer.allocateDirect(mTriangleColor.length * 4);
           triangleColorBuffer.order(ByteOrder.nativeOrder());
           mTriangleColorBuffer = triangleColorBuffer.asFloatBuffer();
           mTriangleColorBuffer.put(mTriangleColor);
           mTriangleColorBuffer.position(0);
       }
    
       /**
        * 获取对应的着色器(顶点,片元)
        *
        * @param type
        * @param shaderCode
        * @return
        */
       public int getShader(int type, String shaderCode) {
           int shader = GLES20.glCreateShader(type);
           GLES20.glShaderSource(shader, shaderCode);
           GLES20.glCompileShader(shader);
           return shader;
       }
    
       @Override
       public void onSurfaceCreated(GL10 gl, EGLConfig config) {
           //设置清屏颜色
           GLES20.glClearColor(0f, 0f, 0f, 0f);
           //创建GLSL程序
           mProgram = GLES20.glCreateProgram();
           //编译/获取着色器
           mVertexShader = getShader(GLES20.GL_VERTEX_SHADER, mVertexShaderCode);
           mFragmentShader = getShader(GLES20.GL_FRAGMENT_SHADER, mFragmentShaderCode);
           //加载着色器到GLSL程序
           GLES20.glAttachShader(mProgram, mVertexShader);
           GLES20.glAttachShader(mProgram, mFragmentShader);
           //连接GLSL程序
           GLES20.glLinkProgram(mProgram);
           //使用GLSL程序
           GLES20.glUseProgram(mProgram);
           //获取着色器变量句柄(引用)
           mTriangleVertexHandle = GLES20.glGetAttribLocation(mProgram, "vPosition");
           mTriangleColorHandle = GLES20.glGetUniformLocation(mProgram, "vColor");
           //启用着色器,并找到对应变量位置
           GLES20.glEnableVertexAttribArray(mTriangleVertexHandle);
           GLES20.glEnableVertexAttribArray(mTriangleColorHandle);
           //绑定顶点坐标数组
           GLES20.glVertexAttribPointer(mTriangleVertexHandle, 3, GLES20.GL_FLOAT, false, 0, mTriangleVertexBuffer);
           GLES20.glUniform4fv(mTriangleColorHandle, 1, mTriangleColor, 0);
       }
    
       @Override
       public void onSurfaceChanged(GL10 gl, int width, int height) {
           //设置视野
           GLES20.glViewport(0, 0, width, height);
       }
    
       @Override
       public void onDrawFrame(GL10 gl) {
           //清屏
           GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
           //绘制三角形
           GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);
       }
    
    }
    
    

    1、首先我们在构造方法中对顶点坐标数组和颜色数组进行的字节序的转换(OpenGL是用来描述三维空间的,所以每个坐标点由x,y,z构成,然后颜色值由rgba构成),由于OpenGL是由C/C++编写的,直接运行在内存上,而Java对象是存放在堆里的,所以这里存在着一个大小端的问题,OpenGL的数据为小端(LittleEdian)数据,Java的数据为大端(BigEdian)数据,我们需要对其进行转换,我们把float[]数组转换成java.nio包下的FloatBuffer,并采用本地端的序列排序。

    2、在onSurfaceCreated创建方法中,我们做了以下几件事情:

    • 设置清理屏幕颜色为黑色
    • 由于编译着色器代码十分昂贵,所以放在这里让其只编译一次
    • 加载着色器到GL程序,并获取着色器中的变量引用,gl_Position和gl_FragColor是glsl里的内建变量,分别用来标识顶点位置和片元颜色值,关于glsl(OpenGL Shader Language)是GPU的编程语言,类似于C语言,相关语法可以查阅:GLSL语言基础
    • 创建、连接、使用GL程序
    • 启用着色器,并找到其变量位置
    • 为着色器的变量赋值(绑定数据)

    3、再来我们onSurfaceChanged中去设置视野范围,也就是从左上角(0,0)开始,宽度为屏幕的宽,高度为屏幕的高。

    4、然后在onDrawFrame进行三角形的绘制。

    我们来看下此时的运行效果:


    运行效果图

    明眼的朋友应该很快就可以发现这个三角形的显示比例有错,我们三角形的坐标单位长度都取1,很明显,这边宽高比不对。这是为什么呢?

    在OpenGL世界里,坐标原点在屏幕中心,x轴向右,y轴向上,z轴向内,我们可以用右手坐标系来表示:


    右手坐标系

    被渲染的一切物体都会被映射到屏幕坐标轴的x,y,且范围在[-1,1],有个专业名词叫归一化设备坐标。了解这个概念后,我们回头看下我们的三角形顶点数据,单位长度都为1,那么直接映射在屏幕就刚好占满了屏幕的宽高(范围在[-1,1]),所以直接以物体的Local Space(局部坐标)去映射,显示出来的比例是不对的,这里我们可以采用虚拟空间坐标投影来解决这个问题。

    投影变换

    在上面的坐标系中,我们可以知道,投影(Projection)就是将View Space转换成Clip Space,在这个过程中,我们可以进行一些宽高比的处理,投影大致分为两种,正交投影、透视投影:

    投影示例
    透视投影(左):
    观察点成视锥形,随观察点的距离变化而变化,观察点越远,视图越小,反之越大。
    正交投影(右):
    观察点成长方体形,正交投影投影物体的带下不会随观察点的远近而发生变化。

    这里额外提一下关于矩阵的相关知识,这里我们需要了解:
    矩阵的乘法:

    矩阵的乘法
    我们经常会说这是一个m行n列的矩阵,矩阵的乘法也就是m行*n列,由m行的矩阵的每一行的每一个元素去乘以n列的矩阵的每一列的元素,然后累加,最终得到的是n列的矩阵。

    单位矩阵:

    单位矩阵
    之所以被称为单位矩阵,是因为这个矩阵乘以任何向量总是得到与原来相同的向量。

    矩阵的平移:

    矩阵的平移
    和单位矩阵差不多,只是多了x,y,z平移单位,假设我们设定一个坐标为(2,2,0)的位置,(w分量默认为1,这里我们不需要用到)我们让其x,y轴都平移单位3,最终得到的位置就是(5,5,0)
    矩阵的平移

    我们以正交投影为例,其实正交投影就是矩阵的变换,把维度为3D的坐标系映射到维度为2D的坐标系上,坐标系的转换是通过左乘矩阵来实现的。

    这里我们修改顶点着色器代码,让它左乘一个矩阵:

        private String mVertexShaderCode = "attribute vec4 vPosition;\n"
                + "uniform mat4 vMatrix;\n"
                + "void main() {\n"
                + " gl_Position = vMatrix * vPosition;\n"
                + "}";
    

    对应的添加4*4的变换矩阵和着色器中的矩阵变量句柄:

          //变换矩阵
          private float[] mTriangleMatrix = new float[16];
    
          mTriangleMatrixHandle = GLES20.glGetUniformLocation(mProgram, "vMatrix");
    

    通过调用orthoM方法来解决归一化设备坐标问题:

    orthom(float[] m,int mOffset,float left,float rigth,float bottom,float top,float near,float far)
    float[] m:目标数组,这个数组长度至少有16个元素,这样它才能存储正交投影矩阵。
    int mOffset:结果矩阵起始的偏移值。
    float left:X轴的最小范围。
    float right:X轴的最大范围。
    float bottom:Y轴的最小范围。
    float top:Y轴的最大范围。
    float near:Z轴的最小范围。
    float far:Z轴的最大范围。

    当我们调用这个方法的时候,它会产生下面的正交投影矩阵:


    正交矩阵

    这个正交投影矩阵会把所有在上下左右远近之间的事物映射到归一化设备坐标中[-1,1],在这个范围内所有事物在屏幕上都是可见的。

        @Override
        public void onSurfaceChanged(GL10 gl, int width, int height) {
            //设置视野
            GLES20.glViewport(0, 0, width, height);
            //根据屏幕方向设置投影矩阵
            float aspectRatio = width > height ? (float) width / (float) height : (float) height / (float) width;
            if (width > height) {
                Matrix.orthoM(mTriangleMatrix, 0, -aspectRatio, aspectRatio, -1f, 1f, -1f, 1f);
            } else {
                Matrix.orthoM(mTriangleMatrix, 0, -1f, 1f, -aspectRatio, aspectRatio, -1f, 1f);
            }
        }
    

    在绘制方法中传入变换矩阵:

             //正交投影
            GLES20.glUniformMatrix4fv(mTriangleMatrixHandle, 1, false, mTriangleMatrix, 0);
    

    现在来看下运行效果:


    正交投影运行效果图

    这样我们的图形显示比例就是正常的了,我们再来绘制一个正方形,上文有提到在OpenGL的世界里,只有点、线、面(三角形),复杂的图形是由n个三角形拼接组合而成,所以正方形可以由2个三角形拼接而成,我们定义4个顶点(2个三角形有6个顶点,但是拼接起来后,其中有2个顶点是重合的):

        //正方形顶点
        private float[] mSquareVertex = {
                -1f, 1f, 0f,
                -1f, -1f, 0f,
                1f, -1f, 0f,
                1f, 1f, 0f
    
        };
    

    然后定义一下绘制顶点的顺序,并将其转换下字节序:

        //绘制顶点顺序
        private short[] mSquareVertexIndex = {0, 1, 2, 0, 2, 3};
        private FloatBuffer mSquareColorBuffer;
    
           //进行顶点绘制数组的字节序转换
            ByteBuffer SquareVertexIndexBuffer = ByteBuffer.allocateDirect(mSquareVertexIndex.length * 2);
            SquareVertexIndexBuffer.order(ByteOrder.nativeOrder());
            mSquareVertexIndexBuffer = SquareVertexIndexBuffer.asShortBuffer();
            mSquareVertexIndexBuffer.put(mSquareVertexIndex);
            mSquareVertexIndexBuffer.position(0);
    

    然后进行绘制,这样就可以了:

            //绘制正方形
            GLES20.glDrawElements(GLES20.GL_TRIANGLES, mSquareVertexIndex.length, GLES20.GL_UNSIGNED_SHORT, mSquareVertexIndexBuffer);
    

    来看下运行效果:


    绘制正方形

    这里来看下我们的绘制API,我们绘制三角形的时候采用的是glDrawArrays,而绘制正方形的时候采用的却是glDrawElements,来看下区别:

    glDrawArrays 和 glDrawElements 的作用都是从一个数据数组中提取数据渲染基本图元。

    我们只需要了解常用的就行:

     public static native void glDrawArrays(
            int mode,
            int first,
            int count
        );
    

    参数1:为绘图类型:
    GL_TRIANGLES:每三个顶之间绘制三角形,之间不连接,以V0V1V2,V3V4V5...的形式绘制
    GL_TRIANGLE_FAN:以V0V1V2,V0V2V3,V0V3V,…的形式绘制三角形
    GL_TRIANGLE_STRIP:顺序在每三个顶点之间均绘制三角形。这个方法可以保证从相同的方向上所有三角形均被绘制。以V0V1V2,V1V2V3,V2V3V4……的形式绘制三角形。

    参数2:从数组缓存中的哪一位开始绘制,一般都定义为0。

    参数3:顶点的数量。

      public static native void glDrawElements(
            int mode,
            int count,
            int type,
            java.nio.Buffer indices
        );
    

    参数1:是绘制点的类型。

    参数2:是绘制点的个数。

    参数3:是绘制点的数据类型

    参数4:是点的存储绘制顺序。

    好了,关于OpenGL的Hello World就到这里了,把剩余内容留给下一章。

    源码下载:

    这里附上源码地址(欢迎Star,欢迎Fork):源码下载

    相关文章

      网友评论

        本文标题:Android OpenGL ES 2.0 初次相见

        本文链接:https://www.haomeiwen.com/subject/jewryftx.html