写在前面

记录一下 OpenGL ES Android 开发的入门教程。逻辑性可能不那么强,想到哪写到哪。也可能自己的一些理解有误。

参考资料:

一、前言

目前android 4.3或以上支持opengles 3.0,但目前很多运行android 4.3系统的硬件能支持opengles 3.0的也是非常少的。不过,opengles 3.0是向后兼容的,当程序发现硬件不支持opengles 3.0时则会自动调用opengles 2.0的API。Andorid 中使用 OpenGLES 有两种方式,一种是基于Android框架API, 另一种是基于 Native Development Kit(NDK)使用 OpenGL。本文介绍Android框架接口。

二、绘制三角形实例

本文写一个最基本的三角形绘制,来说明一下 OpenGL ES 的基本流程,以及注意点。

2.1 创建一个 Android 工程,在 AndroidManifest.xml 文件中声明使用 opengles3.0

<!-- Tell the system this app requires OpenGL ES 3.0. -->
<uses-feature android:glEsVersion="0x00030000" android:required="true" />

如果程序中使用了纹理压缩的话,还需进行如下声明,以防止不支持这些压缩格式的设备尝试运行程序。

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

2.2 MainActivity 使用 GLSurfaceView

MainActivity.java 代码:

package com.sharpcj.openglesdemo;

import android.app.ActivityManager;
import android.content.Context;
import android.content.pm.ConfigurationInfo;
import android.opengl.GLSurfaceView;
import android.os.Build;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;

public class MainActivity extends AppCompatActivity {
    private static final String TAG = MainActivity.class.getSimpleName();

    private GLSurfaceView mGlSurfaceView;
    private boolean mRendererSet;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
//        setContentView(R.layout.activity_main);
        if (!checkGlEsSupport(this)) {
            Log.d(TAG, "Device is not support OpenGL ES 2");
            return;
        }
        mGlSurfaceView = new GLSurfaceView(this);
        mGlSurfaceView.setEGLContextClientVersion(2);
        mGlSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0);
        mGlSurfaceView.setRenderer(new MyRenderer(this));
        setContentView(mGlSurfaceView);
        mRendererSet = true;
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (mRendererSet) {
            mGlSurfaceView.onPause();
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (mRendererSet) {
            mGlSurfaceView.onResume();
        }
    }

    /**
     * 检查设备是否支持 OpenGLEs 2.0
     *
     * @param context 上下文环境
     * @return 返回设备是否支持 OpenGLEs 2.0
     */
    public boolean checkGlEsSupport(Context context) {
        final ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
        final ConfigurationInfo configurationInfo = activityManager.getDeviceConfigurationInfo();
        final boolean supportGlEs2 = configurationInfo.reqGlEsVersion >= 0x20000
                || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1
                && (Build.FINGERPRINT.startsWith("generic")
                || Build.FINGERPRINT.startsWith("unknown")
                || Build.MODEL.contains("google_sdk")
                || Build.MODEL.contains("Emulator")
                || Build.MODEL.contains("Andorid SDK built for x86")));
        return supportGlEs2;
    }
}

关键步骤:

  • 创建一个 GLSurfaceView 对象
  • 给GLSurfaceView 对象设置 Renderer 对象
  • 调用 setContentView() 方法,传入 GLSurfaceView 对象。

2.3 实现 SurfaceView.Renderer 接口中的方法

创建一个类,实现 GLSurfaceView.Renderer 接口,并实现其中的关键方法

package com.sharpcj.openglesdemo;

import android.content.Context;
import android.opengl.GLSurfaceView;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

import static android.opengl.GLES30.*;


public class MyRenderer implements GLSurfaceView.Renderer {
    private Context mContext;
    private MyTriangle mTriangle;

    public MyRenderer(Context mContext) {
        this.mContext = mContext;
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
        mTriangle = new MyTriangle(mContext);
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        glViewport(0, 0, width, height);
    }

    @Override
    public void onDrawFrame(GL10 gl) {
        glClear(GL_COLOR_BUFFER_BIT);
        mTriangle.draw();
    }
}

三个关键方法:

  • onSurfaceCreated() - 在View的OpenGL环境被创建的时候调用。
  • onSurfaceChanged() - 如果视图的几何形状发生变化(例如,当设备的屏幕方向改变时),则调用此方法。
  • onDrawFrame() - 每一次View的重绘都会调用

2.4 OpenGL ES 的关键绘制流程

创建 MyTriangle.java 类:

package com.sharpcj.openglesdemo;

import android.content.Context;

import com.sharpcj.openglesdemo.util.ShaderHelper;
import com.sharpcj.openglesdemo.util.TextResourceReader;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;

import static android.opengl.GLES30.*;

public class MyTriangle {
    private final FloatBuffer mVertexBuffer;

    static final int COORDS_PER_VERTEX = 3;  // number of coordinates per vertex in this array
    static final int COLOR_PER_VERTEX = 3;  // number of coordinates per vertex in this array

    static float triangleCoords[] = {   // in counterclockwise order:
            0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f,     // top
            -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,   // bottom left
            0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f     // bottom right
    };


    private Context mContext;
    private int mProgram;

    public MyTriangle(Context context) {
        mContext = context;
        // initialize vertex byte buffer for shape coordinates
        mVertexBuffer = ByteBuffer.allocateDirect(triangleCoords.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
        mVertexBuffer.put(triangleCoords);  // add the coordinates to the FloatBuffer
        mVertexBuffer.position(0);  // set the buffer to read the first coordinate

        String vertexShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_vertex_glsl);
        String fragmentShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_fragment_glsl);

        int vertexShader = ShaderHelper.compileVertexShader(vertexShaderCode);
        int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderCode);

        mProgram = ShaderHelper.linkProgram(vertexShader, fragmentShader);
    }

    public void draw() {
        if (!ShaderHelper.validateProgram(mProgram)) {
            glDeleteProgram(mProgram);
            return;
        }
        glUseProgram(mProgram);  // Add program to OpenGL ES environment

//        int aPos = glGetAttribLocation(mProgram, "aPos");  // get handle to vertex shader's vPosition member
        mVertexBuffer.position(0);
        glVertexAttribPointer(0, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data
        glEnableVertexAttribArray(0);  // Enable a handle to the triangle vertices

//        int aColor = glGetAttribLocation(mProgram, "aColor");
        mVertexBuffer.position(3);
        glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data
        glEnableVertexAttribArray(1);

        // Draw the triangle
        glDrawArrays(GL_TRIANGLES, 0, 3);

    }
}

在该类中,我们使用了,两个工具类:
TextResourceReader.java, 用于读取文件的类容,返回一个字符串,准确说,它与 OpenGL 本身没有关系。

package com.sharpcj.openglesdemo.util;

import android.content.Context;
import android.content.res.Resources;
import android.util.Log;

import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

public class TextResourceReader {

    private static String TAG = "TextResourceReader";

    public static String readTextFileFromResource(Context context, int resourceId) {
        StringBuilder body = new StringBuilder();
        InputStream inputStream = null;
        InputStreamReader inputStreamReader = null;
        BufferedReader bufferedReader = null;
        try {
            inputStream = context.getResources().openRawResource(resourceId);
            inputStreamReader = new InputStreamReader(inputStream);
            bufferedReader = new BufferedReader(inputStreamReader);
            String nextLine;
            while ((nextLine = bufferedReader.readLine()) != null) {
                body.append(nextLine);
                body.append("\n");
            }
        } catch (IOException e) {
            throw new RuntimeException("Could not open resource: " + resourceId, e);
        } catch (Resources.NotFoundException nfe) {
            throw new RuntimeException("Resource not found: " + resourceId, nfe);
        } finally {
            closeStream(inputStream);
            closeStream(inputStreamReader);
            closeStream(bufferedReader);
        }
        return body.toString();
    }

    private static void closeStream(Closeable c) {
        if (c != null) {
            try {
                c.close();
            } catch (IOException e) {
                Log.e(TAG, e.getMessage());
            }
        }
    }
}

ShaderHelper.java 着色器的工具类,这个跟 OpenGL 就有非常大的关系了。

package com.sharpcj.openglesdemo.util;

import android.util.Log;

import static android.opengl.GLES30.*;

public class ShaderHelper {
    private static final String TAG = "ShaderHelper";

    public static int compileVertexShader(String shaderCode) {
        return compileShader(GL_VERTEX_SHADER, shaderCode);
    }

    public static int compileFragmentShader(String shaderCode) {
        return compileShader(GL_FRAGMENT_SHADER, shaderCode);
    }

    private static int compileShader(int type, String shaderCode) {
        final int shaderObjectId = glCreateShader(type);
        if (shaderObjectId == 0) {
            Log.w(TAG, "could not create new shader.");
            return 0;
        }
        glShaderSource(shaderObjectId, shaderCode);
        glCompileShader(shaderObjectId);

        final int[] compileStatus = new int[1];
        glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0);
        /*Log.d(TAG, "Results of compiling source: " + "\n" + shaderCode + "\n: "
                + glGetShaderInfoLog(shaderObjectId));*/

        if (compileStatus[0] == 0) {
            glDeleteShader(shaderObjectId);
            Log.w(TAG, "Compilation of shader failed.");
            return 0;
        }
        return shaderObjectId;
    }

    public static int linkProgram(int vertexShaderId, int fragmentShaderId) {
        final int programObjectId = glCreateProgram();
        if (programObjectId == 0) {
            Log.w(TAG, "could not create new program");
            return 0;
        }
        glAttachShader(programObjectId, vertexShaderId);
        glAttachShader(programObjectId, fragmentShaderId);
        glLinkProgram(programObjectId);
        final int[] linkStatus = new int[1];
        glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0);
        /*Log.d(TAG, "Results of linking program: \n"
                + glGetProgramInfoLog(programObjectId));*/
        if (linkStatus[0] == 0) {
            glDeleteProgram(programObjectId);
            Log.w(TAG, "Linking of program failed");
            return 0;
        }
        return programObjectId;
    }

    public static boolean validateProgram(int programId) {
        glValidateProgram(programId);
        final int[] validateStatus = new int[1];
        glGetProgramiv(programId, GL_VALIDATE_STATUS, validateStatus, 0);
        /*Log.d(TAG, "Results of validating program: " + validateStatus[0]
                + "\n Log: " + glGetProgramInfoLog(programId));*/
        return validateStatus[0] != 0;
    }
}

着色器是 OpenGL 里面非常重要的概念,这里我先把代码贴上来,然后来讲流程。
在 res/raw 文件夹下,我们创建了两个着色器文件。
顶点着色器,simple_vertex_shader.glsl

#version 330

layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 vColor; // 向片段着色器输出一个颜色

void main()
{
    gl_Position = vec4(aPos.xyz, 1.0);
    vColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

片段着色器, simple_fragment_shader.glsl

#version 330
precision mediump float;

in vec3 vColor;

out vec4 FragColor;

void main()
{
    FragColor = vec4(vColor, 1.0);
}

全部的代码就只这样了,具体绘制过程下面来说。运行程序,我们看到效果如下:

三、OpenGL 绘制过程


一张图说明 OpenGL 渲染过程:
OpenGL ES 入门-LMLPHP

我们看 MyTriangle.java 这个类。
要绘制三角形,我们肯定要定义三角形的顶点坐标和颜色。(废话,不然GPU怎么知道用什么颜色绘制在哪里)。
首先我们定义了一个 float 型数组:

static float triangleCoords[] = {   // in counterclockwise order:
            0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f,     // top
            -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,   // bottom left
            0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f     // bottom right
    };

坐标系统

可能注意到了,因为我们这里绘制最简单的平面二维图像,Z 轴坐标都为 0 ,屏幕中的 X, Y 坐标点都是在(-1,1)的范围。我们没有对视口做任何变换,设置的默认视口,此时的坐标系统是以屏幕正中心为坐标原点。 屏幕最左为 X 轴 -1 , 屏幕最右为 X 轴 +1。同理,屏幕最下方为 Y 轴 -1, 屏幕最上方为 Y 轴 +1。OpenGL 坐标系统使用的是右手坐标系,Z 轴正方向为垂直屏幕向外。

3.1 复制数据到本地内存

mVertexBuffer = ByteBuffer.allocateDirect(triangleCoords.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
mVertexBuffer.put(triangleCoords);

这一行代码,作用是将数据从 java 堆复制到本地堆。我们知道,在 java 虚拟机内存模型中,数组存在 java 堆中,受 JVM 垃圾回收机制影响,可能会被回收掉。所以我们要将数据复制到本地堆。
首先调用 ByteBuffer.allocateDirect() 分配一块本地内存,一个 float 类型的数字占 4 个字节,所以分配的内存大小为 triangleCoords.length * 4 。
调用 order() 指定字节缓冲区中的排列顺序, 传入 ByteOrder.nativeOrder() 保证作为一个平台,使用相同的排序顺序。
调用 asFloatBuffer() 可以得到一个反映底层字节的 FloatBuffer 类的实例。
最后调用 put(triangleCoords) 把数据从 Android 虚拟机堆内存中复制到本地内存。

3.2 编译着色器并链接到程序

接下来,通过 TextResourceReader 工具类,读取顶点着色器和片段着色器文件的的内容。

String vertexShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_vertex_shader);
String fragmentShaderCode = TextResourceReader.readTextFileFromResource(mContext, R.raw.simple_fragment_shader);

然后通过 ShaderHelper 工具类编译着色器。然后链接到程序。

int vertexShader = ShaderHelper.compileVertexShader(vertexShaderCode);
int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderCode);

mProgram = ShaderHelper.linkProgram(vertexShader, fragmentShader);
ShaderHelper.validateProgram(mProgram);

着色器

着色器是一个运行在 GPU 上的小程序。着色器的文件其实定义了变量,并且包含 main 函数。关于着色器的详细教程,请查阅:(LearnOpenGL CN 中的着色器教程)[https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/]

我这里记录一下,着色器的编译过程:

3.2.1 创建着色器对象

int shaderObjectId = glCreateShader(type);`    

创建一个着色器,并返回着色器的句柄(类似java中的引用),如果返回了 0 ,说明创建失败。GLES 中定义了常量,GL_VERTEX_SHADERGL_FRAGMENT_SHADER 作为参数,分别创建顶点着色器和片段着色器。

3.2.2 编译着色器

编译着色器,

glShaderSource(shaderObjectId, shaderCode);
glCompileShader(shaderObjectId);

下面的代码,用于获取编译着色器的状态结果。

final int[] compileStatus = new int[1];
glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0);
Log.d(TAG, "Results of compiling source: " + "\n" + shaderCode + "\n: "
        + glGetShaderInfoLog(shaderObjectId));

if (compileStatus[0] == 0) {
    glDeleteShader(shaderObjectId);
    Log.w(TAG, "Compilation of shader failed.");
    return 0;
}

亲测上面的程序在我手上真机可以正常运行,在 genymotion 模拟器中运行报了如下错误:

JNI DETECTED ERROR IN APPLICATION: input is not valid Modified UTF-8: illegal start byte 0xfe

网上搜索了一下,这个异常是由于Java虚拟机内部的dalvik/vm/CheckJni.c中的checkUtfString函数抛出的,并且JVM的这个接口明确是不支持四个字节的UTF8字符。因此需要在调用函数之前,对接口传入的字符串进行过滤,过滤函数,可以上网搜到,这不是本文重点,所以我把这个 log 注释掉了

Log.d(TAG, "Results of compiling source: " + "\n" + shaderCode + "\n: "
        + glGetShaderInfoLog(shaderObjectId));

3.2.3 将着色器连接到程序

编译完着色器之后,需要将着色器连接到程序才能使用。

int programObjectId = glCreateProgram();

创建一个 program 对象,并返回句柄,如果返回了 0 ,说明创建失败。

glAttachShader(programObjectId, vertexShaderId);
glAttachShader(programObjectId, fragmentShaderId);
glLinkProgram(programObjectId);

将顶点着色器个片段着色器链接到 program 对象。下面的代码用于获取链接的状态结果:

final int[] linkStatus = new int[1];
glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0);
/*Log.d(TAG, "Results of linking program: \n"
        + glGetProgramInfoLog(programObjectId));*/
if (linkStatus[0] == 0) {
    glDeleteProgram(programObjectId);
    Log.w(TAG, "Linking of program failed");
    return 0;
}

3.2.4 判断 program 对象是否有效

在使用 program 对象之前,我们还做了有效性判断:

glValidateProgram(programId);
final int[] validateStatus = new int[1];
glGetProgramiv(programId, GL_VALIDATE_STATUS, validateStatus, 0);
/*Log.d(TAG, "Results of validating program: " + validateStatus[0]
                + "\n Log: " + glGetProgramInfoLog(programId));*/

如果 validateStatus[0] == 0 , 则无效。

3.3 关联属性与顶点数据的数组

首先调用glUseProgram(mProgram) 将 program 对象添加到 OpenGL ES 的绘制环境。

看如下代码:

mVertexData.position(0); // 移动指针到 0,表示从开头开始读取

// 告诉 OpenGL, 可以在缓冲区中找到 a_Position 对应的数据
int aPos = glGetAttribLocation(mProgram, "aPos");
glVertexAttribPointer(aPos, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data
glEnableVertexAttribArray(aPos);

int aColor = glGetUniformLocation(mProgram, "aColor");
glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, mVertexBuffer);  // Prepare the triangle coordinate data
glEnableVertexAttribArray(aColor);

在 OpenGL ES 2.0 中,我们通过如上代码,使用数据。调用 glGetAttribLocation() 方法,找到顶点和颜色对应的数据位置,第一个参数是 program 对象,第二个参数是着色器中的入参参数名。
然后调用 glVertexAttribPointer() 方法
参数如下(图片截取自《OpenGL ES应用开发实践指南Android卷》):

最后调用glEnableVertexAttribArray(aPos); 使 OpenGL 能使用这个数据。

但是你发现,我们上面给的代码中并没有调用 glGetAttribLocation() 方法寻找位置,这是因为,我使用的 OpenGLES 3.0 ,在 OpenGL ES 3.0 中,着色器代码中,新增了 layout(location = 0) 类似的语法支持。

#version 330

layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 vColor; // 向片段着色器输出一个颜色

void main()
{
    gl_Position = vec4(aPos.xyz, 1.0);
    vColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

这里已经指明了属性在顶点数组中对应的位置,所以在代码中,可以直接使用 0 和 1 来表示位置。

3.4 绘制图形

最后调用 glDrawArrays(GL_TRIANGLES, 0, 3) 绘制出一个三角形。
glDrawArrays() 方法第一个参数指定绘制的类型, OpenGLES 中定义了一些常量,通常有 GL_TRIANGLES , GL_POINTS, GL_LINES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN 等等类型,具体每种类型代表的意思可以查阅API 文档。

四、 OpenGL 中的 VAO 和 VBO。

VAO : 顶点数组对象
VBO :顶点缓冲对象

通过使用 VAO 和 VBO ,可以建立 VAO 与 VBO 的索引对应关系,一次写入数据之后,每次使用只需要调用 glBindVertexArray 方法即可,避免重复进行数据的复制, 大大提高绘制效率。

int[] VBO = new int[2];
int[] VAO = new int[2];

glGenVertexArrays(0, VAO, 0);
glGenBuffers(0, VBO, 0);
glBindVertexArray(VAO[0]);
glBindBuffer(GL_ARRAY_BUFFER, VBO[0]);
glBufferData(GL_ARRAY_BUFFER, triangleCoords.length * 4, mVertexBuffer, GL_STATIC_DRAW);

glVertexAttribPointer(0, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, 0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, COORDS_PER_VERTEX * 4);
glEnableVertexAttribArray(1);
glBindVertexArray(VAO[0]);

glGenVertexArrays(1, VAO, 0);
glGenBuffers(1, VBO, 0);
glBindVertexArray(VAO[1]);
glBindBuffer(GL_ARRAY_BUFFER, VBO[1]);
glBufferData(GL_ARRAY_BUFFER, triangleCoords.length * 4, mVertexBuffer2, GL_STATIC_DRAW);

glVertexAttribPointer(0, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, 0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, COORDS_PER_VERTEX, GL_FLOAT, false, (COORDS_PER_VERTEX + COLOR_PER_VERTEX) * 4, COORDS_PER_VERTEX * 4);
glEnableVertexAttribArray(1);
glBindVertexArray(VAO[1]);


glBindVertexArray(VAO[0]);
glDrawArrays(GL_TRIANGLES, 0, 3);

glBindVertexArray(VAO[1]);
glDrawArrays(GL_TRIANGLES, 0, 3);
06-19 19:24