OpenGL ES 2.0 显示图形(上)

1、概述

Android框架提供了大量标准工具,用于创建有吸引力的功能性图形用户界面。如果想要更多地控制应用程序在屏幕上绘制的内容,或者想要绘制三维图形,则需要使用不同的工具。Android框架提供的OpenGL ES提供了一组工具,用于显示高端动画图形,并且还可以受益于许多Android设备上提供的图形处理单元(GPU)的加速。这边主要初步使用OpenGL ES来显示图形并与之交互。这边采用的OpenGL ES 2.0,使用这个版本的原因是OpenGL ES 1.x的版本和2.0基本是两套框架,许多东西不兼容而且过时,而现在比较新的OpenGL ES 3.x的版本是兼容和复用2.0的接口的,所以这边以2.0版本作为切入点来讨论。我之前有一篇ARCore的文章——ARCore 相关,里面就有提到OpenGL ES 3.x来实现相关AR功能。这篇文章主要讨论最最基础的一些OpenGL ES 2.0操作,来实现用OpenGL ES在Android系统上显示图形,并与之交互。

2、构建环境

要在Android应用程序中使用OpenGL ES绘制图形,必须为它们创建一个视图容器。其中一种比较直接的方法是实现一个 GLSurfaceView和一个GLSurfaceView.Renderer。

① GLSurfaceView:一个用OpenGL ES来绘制图片的视图容器。这个类是一个View可以使用OpenGL API调用绘制和操作对象的类,在功能上类似于SurfaceView。

② GLSurfaceView.Renderer:用于控制将什么内容显示在GLSurfaceView上。这是一个接口类,此接口定义了在一个GLSurfaceView中绘制图形所需的方法。必须将此接口的实现作为单独的类提供,并使用GLSurfaceView.setRenderer()将其附加到GLSurfaceView实例中 。GLSurfaceView.Renderer接口要求实现以下方法:

    onSurfaceCreated():创建GLSurfaceView的时候会调用一次该方法。使用此方法执行仅需要发生一次的操作,例如设置OpenGL环境参数或初始化OpenGL图形对象。

    onDrawFrame():GLSurfaceView在每次重绘时调用此方法。使用此方法作为绘制(和重新绘制)图形对象的主要执行点。

    onSurfaceChanged():GLSurfaceView在几何图形更改时调用此方法,包括更改GLSurfaceView设备屏幕的大小或方向。例如,当设备从纵向更改为横向时,系统会调用此方法。使用此方法响应GLSurfaceView容器更改时需要进行的操作。

GLSurfaceView只是将OpenGL ES图形合并到应用程序中的一种方法。对于全屏或近全屏图形视图,这是一个合理的选择。想要在布局的一小部分中加入OpenGL ES图形可以使用TextureView。也可以使用构建OpenGL ES视图SurfaceView,这样更灵活但这需要编写相当多的额外代码。

2.1 添加声明

为了使应用程序能够使用OpenGL ES 2.0 API,必须在manifest中添加以下声明:


<uses-feature android:glEsVersion="0x00020000" android:required="true" />

如果应用程序使用纹理压缩,则还必须声明应用程序支持哪种压缩格式,以便它仅安装在兼容设备上。


<supports-gl-texture android:name="GL_OES_compressed_ETC1_RGB8_texture" />

<supports-gl-texture android:name="GL_OES_compressed_paletted_texture" />

这边说到纹理压缩指可以通过减少内存需求和更有效地利用内存带宽来显着提高OpenGL应用程序的性能。Android框架提供对ETC1压缩格式的支持,作为标准功能。但是ETC1纹理压缩格式不支持具有透明度(alpha通道)的纹理。如果应用程序需要具有透明度的纹理,则应调查目标设备上可用的其他纹理压缩格式。支持使用OpenGL ES 3.0 API时,要保证可以使用ETC2 / EAC纹理压缩格式。这种纹理格式提供出色的压缩比和高视觉质量,格式还支持透明度(alpha通道)。而paletted是指通用的调色板纹理压缩。还有ATITC(ATC)、PVRTC、S3TC(DXT n / DXTC)、3DC等压缩策略。

对于Google Play上的应用,如果你添加了这些声明,会自动检测手机是否支持相关功能,如果不支持就不能下载该应用。对于国内的应用商店,不是很清楚是否会有改过滤。

2.2 创建用于OpenGL ES图形的Activity

使用OpenGL ES的Android应用程序就像任何其他具有用户界面的应用程序一样具有Activity。与其他应用程序的主要区别在于在Activity的布局中添加的内容。在使用OpenGL ES的应用程序,可以添加一个GLSurfaceView。

以下代码示例显示了使用一个GLSurfaceView作为其主视图的Activity,当然GLSurfaceView也可以像一般View一样用在XML中:


public class OpenGLES20Activity extends Activity {

    public static final String TAG = "OpenGLES20";

    private GLSurfaceView mGLView;

    @Override

    public void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);

        // 创建一个GLSurfaceView实例并将其设置为此Activity的ContentView。

        mGLView = new MyGLSurfaceView(this);

        setContentView(mGLView);

    }

}

2.3 构建GLSurfaceView

一个GLSurfaceView是一个专门的视图,可以在其中绘制OpenGL ES图形。它本身并没有太大作用。对象的实际绘制在GLSurfaceView.Renderer中。这边暂时可以直接使用GLSurfaceView,但下面会讲到的用于捕获触摸事件来进行交互时候就需要扩展这个类了。


class MyGLSurfaceView extends GLSurfaceView {

    private final MyGLRenderer mRenderer;

    public MyGLSurfaceView(Context context){

        super(context);

        // 创建一个OpenGL ES 2.0 的context

        setEGLContextClientVersion(2);

        mRenderer = new MyGLRenderer();

        // 设置渲染器(Renderer)以在GLSurfaceView上绘制

        setRenderer(mRenderer);

        // 仅在绘图数据发生更改时才渲染视图

        setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);

    }

}

上面这边先是设置了使用OpenGL ES 的版本,然后创建了一个GLSurfaceView.Renderer并将其设置为该GLSurfaceView的渲染者,最后设置了一下渲染模式,将其设置为RENDERMODE_WHEN_DIRTY,在该模式下当渲染内容变化时不会主动刷新效果,需要手动调用requestRender() 才行。

2.4 构建渲染器(GLSurfaceView.Renderer)

Renderer这个类前面已经提到,需要重写onSurfaceCreated() 、onDrawFrame() 、onSurfaceChanged() 这三个方法,下面实现一个最基本的渲染器,之后会再增加内容。


public class MyGLRenderer implements GLSurfaceView.Renderer {

    public void onSurfaceCreated(GL10 unused, EGLConfig config) {

       // 设置重绘背景框架颜色

        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);

    }

    public void onDrawFrame(GL10 unused) {

       // 重绘背景颜色

        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

    }

    public void onSurfaceChanged(GL10 unused, int width, int height) {

        // 设置渲染的位置和大小

        GLES20.glViewport(0, 0, width, height);

    }

}

上面的代码示例创建了一个简单的OpenGL ES应用程序,它使用OpenGL显示黑屏。

3、定义形状

OpenGL ES 中主要能定义点线和三角形,而其他多边形都是由三角形组合而成的,这边举一个三角形和正方形的例子。

3.1 三角形

OpenGL ES允许使用三维空间中的坐标定义绘制对象。因此,在绘制三角形之前,必须定义其坐标。在OpenGL中,执行此操作的典型方法是为坐标定义浮点数的顶点数组。为了获得最大效率,可以将这些坐标写入一个ByteBuffer中,然后传入OpenGL ES图形管道进行处理。


public class Triangle {

    private FloatBuffer vertexBuffer;

    // 此数组中每个顶点的维度

    static final int COORDS_PER_VERTEX = 3;

    static float triangleCoords[] = {  // 按逆时针顺序

            0.0f,  0.622008459f, 0.0f, // 上

            -0.5f, -0.311004243f, 0.0f, // 左下

            0.5f, -0.311004243f, 0.0f  // 右下

    };

    // 设置颜色的R(红),G(绿),B(蓝),A(透明度) 值

    float color[] = { 0.63671875f, 0.76953125f, 0.22265625f, 1.0f };

    public Triangle() {

         // 为形状坐标数组初始化顶点的字节缓冲区

        ByteBuffer bb = ByteBuffer.allocateDirect(

               // (# squareCoords 数组长度 * 每个float占4字节)

                triangleCoords.length * 4);

       // 缓冲区读取顺序使用设备硬件的本地字节读取顺序

        bb.order(ByteOrder.nativeOrder());

       // 从ByteBuffer创建一个浮点缓冲区

        vertexBuffer = bb.asFloatBuffer();

        // 将坐标点加到FloatBuffer中

        vertexBuffer.put(triangleCoords);

        // 设置缓冲区开始读取位置,这边设置为从头开始读取

        vertexBuffer.position(0);

    }

}

默认情况下,OpenGL ES会有一个坐标系,其中[0,0,0](X,Y,Z)指GLSurfaceView框架的中心,[1,1,0]是框架的右上角,并且[-1 ,-1,0]是框架的左下角。此形状的坐标以逆时针顺序定义。绘图顺序很重要,因为它定义了哪一面是想要绘制的形状的正面,以及背面,在绘制时候可以根据需求控制只绘制正面或者背面或者都绘制。

3.2 正方形

在OpenGL中定义三角形如上所示,但是如果想定义一个多边形,例如正方形。在OpenGL ES中绘制这样一个形状的典型途径是使用两个绘制在一起的三角形:

绘制正方形

同样,应该以逆时针顺序为表示此形状的两个三角形定义顶点,并将值放在一个ByteBuffer中。为了避免定义每个三角形共享的两个坐标点,使用绘图列表告诉OpenGL ES图形管道如何绘制这些顶点。


public class Square {

    private FloatBuffer vertexBuffer;

    private ShortBuffer drawListBuffer;

    // 此数组中每个顶点的坐标数

    static final int COORDS_PER_VERTEX = 3;

    static float squareCoords[] = {

            -0.5f,  0.5f, 0.0f,  // 左上

            -0.5f, -0.5f, 0.0f,  // 左下

            0.5f, -0.5f, 0.0f,  // 右下

            0.5f,  0.5f, 0.0f }; // 右上

    private short drawOrder[] = { 0, 1, 2, 0, 2, 3 }; // 顶点绘制顺序

    public Square() {

        // 为形状坐标数组初始化顶点的字节缓冲区

        ByteBuffer bb = ByteBuffer.allocateDirect(

                // (# squareCoords 数组长度 * 每个float占4字节)

                squareCoords.length * 4);

        bb.order(ByteOrder.nativeOrder());

        vertexBuffer = bb.asFloatBuffer();

        vertexBuffer.put(squareCoords);

        vertexBuffer.position(0);

        // 为绘制顺序数组 初始化字节缓冲区

        ByteBuffer dlb = ByteBuffer.allocateDirect(

                // (# drawOrder 数组长度 * 每个 short 占2字节)

                drawOrder.length * 2);

        dlb.order(ByteOrder.nativeOrder());

        drawListBuffer = dlb.asShortBuffer();

        drawListBuffer.put(drawOrder);

        drawListBuffer.position(0);

    }

}

4 绘制形状

在前面一节定义要使用OpenGL绘制的形状后,这一节介绍如何绘制。使用OpenGL ES 2.0绘制形状需要的代码比较多,因为API提供了对图形渲染管道的大量控制。这也是为什么说OpenGL ES对开发者不友好的的原因了。

4.1 初始化形状

在进行任何绘图之前,必须初始化并加载计划绘制的形状。除非在程序中使用的形状的结构(原始坐标)在执行过程中发生更改,否则应该在onSurfaceCreated()渲染器的方法中初始化它们以避免反复初始化。


public class MyGLRenderer implements GLSurfaceView.Renderer {

    ...

    private Triangle mTriangle;

    private Square  mSquare;

    public void onSurfaceCreated(GL10 unused, EGLConfig config) {

        ...

        // 初始化三角形

        mTriangle = new Triangle();

        // 初始化正方形

        mSquare = new Square();

    }

    ...

}

4.2 绘制形状

使用OpenGL ES 2.0绘制定义的形状需要大量代码,因为必须向图形渲染管道提供大量细节。具体而言,必须定义以下内容:

① 顶点着色器(Vertex Shader):用于渲染形状顶点的OpenGL ES图形代码。

② 片段着色器(Fragment Shader):OpenGL ES代码,用于渲染具有颜色或纹理的形状的面。

③ 程序(Program):一个OpenGL ES对象,包含要用于绘制一个或多个形状的着色器。

需要至少一个顶点着色器来绘制形状,并使用一个片段着色器为该形状着色。必须编译这些着色器,然后将其添加到OpenGL ES程序中,然后使用该程序绘制形状。以下是如何定义可用于在Triangle类中绘制形状的基本着色器的示例:


public class Triangle {

    private final String vertexShaderCode =

        "attribute vec4 vPosition;" +

        "void main() {" +

        "  gl_Position = vPosition;" +

        "}";

    private final String fragmentShaderCode =

        "precision mediump float;" +

        "uniform vec4 vColor;" +

        "void main() {" +

        "  gl_FragColor = vColor;" +

        "}";

    ...

}

着色器包含OpenGL着色语言(GLSL)代码,必须在OpenGL ES环境中使用它之前进行编译。要编译此代码,需在渲染器类中创建实用程序方法:


public static int loadShader(int type, String shaderCode){

        //创建顶点着色器类型(GLES20.GL_VERTEX_SHADER)

        //或片段着色器类型(GLES20.GL_FRAGMENT_SHADER)

        int shader = GLES20.glCreateShader(type);

        // 将源代码添加到着色器并进行编译

        GLES20.glShaderSource(shader, shaderCode);

        GLES20.glCompileShader(shader);

        return shader;

    }

为了绘制形状,必须编译着色器代码,将它们添加到OpenGL ES程序对象,然后链接该程序。在绘制对象的构造函数中执行此操作,也就是说只执行一次就好了。因为编译OpenGL ES着色器和链接程序在CPU周期和处理时间方面的消耗比较大,因此应该避免多次执行此操作。如果在运行时不需要修改着色器代码的内容,则应在构造器中构建代码,使其仅创建一次,然后缓存以供以后使用。


public class Triangle() {

    ...

    private final int mProgram;

    public Triangle() {

        ...

        int vertexShader = MyGLRenderer.loadShader(GLES20.GL_VERTEX_SHADER,

                                        vertexShaderCode);

        int fragmentShader = MyGLRenderer.loadShader(GLES20.GL_FRAGMENT_SHADER,

                                        fragmentShaderCode);

        // 创建一个空的OpenGL ES 程序

        mProgram = GLES20.glCreateProgram();

        // 将顶点着色器添加到程序中

        GLES20.glAttachShader(mProgram, vertexShader);

        // 将片段着色器添加到程序中

        GLES20.glAttachShader(mProgram, fragmentShader);

       // 编译链接OpenGL ES程序

        GLES20.glLinkProgram(mProgram);

    }

}

此时,已准备好添加绘制形状的实际调用。使用OpenGL ES绘制形状需要指定几个参数来告诉渲染管道想要绘制什么以及如何绘制它。由于绘图选项可能因形状而异,因此最好让形状类包含自己的绘图逻辑。创建draw()绘制形状的方法。此代码将位置和颜色值设置为形状的顶点着色器和片段着色器,然后执行绘图功能。


// Triangle.class

private int mPositionHandle;

private int mColorHandle;

private final int vertexCount = triangleCoords.length / COORDS_PER_VERTEX;

private final int vertexStride = COORDS_PER_VERTEX * 4; //一个顶点占用空间,其中每个顶点单维值占4字节

public void draw(float[] mvpMatrix) {

        // 将程序添加到OpenGL ES环境

        GLES20.glUseProgram(mProgram);

        // 获取顶点着色器vPosition属性(位置)的句柄

        mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition");

        // 启用三角形顶点的句柄

        GLES20.glEnableVertexAttribArray(mPositionHandle);

        // 准备三角坐标数据

        GLES20.glVertexAttribPointer(mPositionHandle, COORDS_PER_VERTEX,

                GLES20.GL_FLOAT, false,

                vertexStride, vertexBuffer);

        // 获取片段着色器vColor成员(颜色)的句柄

        mColorHandle = GLES20.glGetUniformLocation(mProgram, "vColor");

        //设置绘制三角形的颜色

        GLES20.glUniform4fv(mColorHandle, 1, color, 0);

        // 获取形状变换矩阵的具柄

        mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix");

        // Pass the projection and view transformation to the shader

        // 将模型视图投影矩阵传递给着色器

        GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mvpMatrix, 0);

        // 绘制三角形

        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount);

        // 禁用顶点数组

        GLES20.glDisableVertexAttribArray(mPositionHandle);

    }

一旦准备好所有这些代码,绘制此对象只需要在渲染器的onDrawFrame()方法中调用draw()方法。


// MyGLRenderer.class

public void onDrawFrame(GL10 unused) {

    ...

    mTriangle.draw();

}

之后运行程序会得到如下效果:

竖屏下渲染效果
横屏下渲染效果

上面已经初步将三角形显示在屏幕上了。但明显可以看到存在问题,首先如果按照前面三角形的坐标,按理说应该是一个等边三角形。其次,这个三角形竖屏和横屏拉伸方向也明显不同。关于这个问题的原因和解决办法,会在下一篇文章OpenGL ES 显示图形(下)中进行讨论。

推荐阅读更多精彩内容