Android OpenGL ES:三角形

291 阅读19分钟

Android OpenGL ES系列目录

本系列文章主要是基于LearnOpenGL和对应的中文教程。与原教程主要的差异是,该系列讲解的是基于Android设备环境的OpenGL ES,并提供对应的Java示例。

原文:Hello-Triangle

中文原文:你好,三角形

本文有三个关键名词:

  • 顶点数组对象:Vertex Array Object,VAO
  • 顶点缓冲对象:Vertex Buffer Object,VBO
  • 索引缓冲对象:Element Buffer Object,EBO或Index Buffer Object,IBO

图形渲染管线

在OpenGL中,一切都是在3D空间的。而屏幕/窗口却是2D像素数组。因此OpenGL的大部分工作是将所有3D坐标转换为适合屏幕的2D像素。将3D坐标转换为2D像素的过程由OpenGL的图形渲染管线(Graphics Pipeline)管理的。

图形渲染管线分为两部分:

  • 3D坐标转换为2D坐标
  • 将2D坐标转换为实际的彩色像素

这里会简单地讨论一下图形渲染管线,以及如何利用它创建一些漂亮的像素。

图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的,并且很容易并行执行。正是由于它们具有并行执行的特性,如今显卡都成千上万的小处理核心,可以在图形渲染管线中快速处理图像数据。处理核心会在GPU上,为渲染管线执行一些小程序。这些小程序叫做着色器(Shader)

我们可以自定义部分着色器,更细致地控制图形渲染管线中的特定部分。而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的,后续会进行学习。

下面是一个图形渲染管线的每个阶段的抽象展示。注意,蓝色部分代表的是我们可以注入自定义的着色器的部分。

简单介绍这几个阶段如何绘制一个三角形:

  • 顶点数据(Vertex Data):以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形
    • 顶点属性(Vertex attribute):可以包含我们想要的任何数据,如3D坐标、颜色值
    • 顶点(Vertex):每个顶点对应一个3D坐标(但不仅仅只是包含坐标),是一些顶点属性的集合
    • 顶点数据(Vertex Data):由一些顶点组成。如这个三角形的三个顶点。
  • 顶点着色器(Vertex Shader):把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标(后面会解释),同时顶点着色器允许我们对顶点属性进行一些基本处理。
  • 图元装配(Primitive Assembly):把顶点着色器输出的所有顶点作为输入,并将所有的点装配成指定图元的形状;本文例子中是一个三角形。

为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,OpenGL需要你去指定这些数据所表示的渲染类型。我们是希望把这些数据渲染成一系列的点?一系列的三角形?还是仅仅是一个长长的线?做出的这些提示叫做图元(Primitive),任何一个绘制指令的调用都将把图元传递给OpenGL。这是其中的几个:GL_POINTSGL_TRIANGLESGL_LINE_STRIP

  • 几何着色器:把图元装配阶段的输出,即图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。如上图,它生成了另一个三角形。
  • 光栅化:把几何着色器的输出作为输入,会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
  • 片段着色器:计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

OpenGL中的一个片段是OpenGL渲染一个像素所需的所有数据。

  • 测试与混合:在所有对应颜色值确定以后,最终的对象被传递至此。这个阶段检测片段的对应的深度(和模板(Stencil))值(后面会讲),用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

图形渲染管线非常复杂,它包含很多可配置的部分。不过我们只需要配置:

  • 顶点着色器
  • 片段着色器
  • 几何着色器(可选的)

定义顶点

开始绘制图形之前,我们必须先给OpenGL输入一些顶点数据。OpenGL是一个3D图形库,所以我们在OpenGL中指定的所有坐标都是3D坐标(x、y和z)。

OpenGL仅当3D坐标在3个轴(x、y和z)上都为[-1.0,1.0]的范围内时才处理它。即所有在标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上。

标准化设备坐标(Normalized Device Coordinates, NDC)

标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标经过顶点着色器处理后都会被丢弃/裁剪,不会显示在你的屏幕上。即一旦你的顶点坐标已经在顶点着色器中处理过,它们就是标准化设备坐标。下面是本文定义的在标准化设备坐标中的三角形(忽略z轴):

与通常的屏幕坐标不同,y轴正方向为向上,(0, 0)坐标是这个图像的中心,而不是左上角。最终你希望所有(变换过的)坐标都要在这个坐标空间中,否则它们就不可见了。

渲染一个三角形,要指定三个顶点。我们会将它们以标准化设备坐标的形式(OpenGL的可见区域)定义为一个float数组:

float vertices[] = {
        -0.5f, -0.5f, 0.0f,
        0.5f, -0.5f, 0.0f,
        0.0f, 0.5f, 0.0f
};

由于OpenGL是在3D空间中工作的,而我们渲染的是一个2D三角形,我们将它顶点的z坐标设置为0.0。这样子的话三角形每一点的深度(Depth)都是一样的,从而使它看上去像是2D的。

通常深度可以理解为z坐标,它代表一个像素在空间中和你的距离。如果离你远就可能被别的像素遮挡,你就看不到它了,它会被丢弃,以节省资源。

定义了顶点数据后,我们希望将其作为输入发送到图形渲染管道的第一个阶段:顶点着色器。这需要我们在GPU上创建存储顶点数据的内存,还要通过配置,告诉OpenGL如何解释这些内存,并且指定其如何发送给显卡。最后顶点着色器会处理我们指定的顶点数据。

我们可以通过顶点缓冲对象(Vertex Buffer Object,VBO)来管理这些内存。它可以存储大量的顶点在GPU内存(即显存)中。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU向显卡发送数据相对较慢,因此我们尽可能一次发送尽可能多的数据。一旦数据在显存中,顶点着色器访问顶点数据速度非常快。

创建顶点缓冲对象:

int[] vbo = new int[1];
GLES20.glGenBuffers(1, vbo, 0);
  • void glGenBuffers(int n,int[] buffers,int offset)

    向OpenGL ES申请开辟新的VBO,并通过buffers数组获取VBO handle,handle的类型为整型。

    • n:申请的VBO个数
    • buffers:用于存储VBO handle的数组
    • offset:buffers数组的偏移量,即从buffers的第offset个位置开始存储handle
    • 需要满足 n + offset <= buffers.length

OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。同时绑定多个缓冲是允许的,前提是它们是不同的缓冲类型。

绑定顶点缓冲对象到GL_ARRAY_BUFFER上:

GLES20.glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);

从这时起,我们进行的任何与GL_ARRAY_BUFFER相关的缓冲区调用都将用于配置当前绑定的缓冲区,即VBO。比如,将前面定义的顶点数据拷贝到缓冲区:

FloatBuffer verticesBuffer = ByteBuffer
        .allocateDirect(vertices.length * BYTES_PER_FLOAT)
        .order(ByteOrder.nativeOrder())
        .asFloatBuffer();
verticesBuffer.put(vertices).position(0);
GLES20.glBufferData(GL_ARRAY_BUFFER, vertices.length, verticesBuffer, GL_STATIC_DRAW);
  • void glBufferData(int target, int size, Buffer data, int usage)

    将数据传递给当前绑定的VBO。

    • target:指定VBO类型

    • size:指定VBO的大小,单位为byte

    • data:指定需要传递的数据

    • usage:指定VBO的存储方式,即显卡管理给定的数据的方式

      • GL_STATIC_DRAW :数据不会或几乎不会改变。
      • GL_DYNAMIC_DRAW:数据会被改变很多。
      • GL_STREAM_DRAW :数据每次绘制时都会改变。

顶点着色器

现代OpenGL需要我们至少设置一个顶点和一个片段着色器。

着色器的设置主要有两个步骤:

  • 使用GLSL(OpenGL ES Shading Language)编写着色器代码
  • 编译着色器代码

一个简单的顶点着色器代码

#version 300 es
layout (location = 0) in vec3 aPos;
void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
  • #version 300 es

    着色器的版本声明

    • 300:着色器版本。
      • OpenGL ES 3.0以及和更高版本中,GLSL版本号和OpenGL ES的版本是匹配的。
      • OpenGL ES 2.0用的是GLSL ES 1.00。GLSL ES 1.00也可以在OpenGL ES 3.x中使用,这是兼容的。
      • OpenGL ES 1.x为固定渲染管线。不支持GLSL ES。
      • GLSL ES与GLSL:GLSL ES 3.00是从OpenGL的GLSL 3.3增删而来的。
    • es:表示使用的是GLSL ES。
  • layout (location = 0) in vec3 aPos;

    • layout (location = 0):设定了输入变量的位置值(Location)。后面会看到为什么这么设。
    • in关键字:用于声明输入顶点属性(Input Vertex Attribute)。
    • vec3:GLSL的向量数据类型。向量数据类型可包含1-4个分量。vec3即表示3个分量。

向量(Vector)

在图形编程中我们经常会使用向量这个数学概念,因为它简明地表达了任意空间中的位置和方向,并且它有非常有用的数学属性。在GLSL中一个向量有最多4个分量,每个分量值都代表空间中的一个坐标,它们可以通过vec.xvec.yvec.zvec.w来获取。注意vec.w分量不是用作表达空间中的位置的(我们处理的是3D不是4D),而是用在所谓透视除法(Perspective Division)上。我们会在后面的教程中更详细地讨论向量。

  • gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
    • gl_Position:OpenGL预先定义的gl_Position变量,是vec4类型。用于设置顶点着色器的输出。

    • vec4(aPos.x, aPos.y, aPos.z, 1.0):将vec3分量转出vec4分量。最后一个参数,即vec.w,设置为了1.0,后面会解释原因。

编译着色器

创建顶点着色器:

int vertexShader = GLES20.glCreateShader(GL_VERTEX_SHADER);
  • int glCreateShader(int type)

    创建一个着色器

    • type:着色器类型。可选参数:
      • GL_VERTEX_SHADER
      • GL_FRAGMENT_SHADER
      • GL_GEOMETRY_SHADER:OpenGL ES 3.2以后才支持。
    • 返回值:着色器ID 编译着色器代码:
GLES20.glShaderSource(vertexShader, vertexShaderCode);
GLES20.glCompileShader(vertexShader);
  • void glShaderSource(int shader,String string)
    • shader:着色器ID
    • string:着色器代码

检查编译结果:

int[] result = new int[1];
GLES20.glGetShaderiv(vertexShader, GL_COMPILE_STATUS, result, 0);
if (result[0] != GL_NO_ERROR) {
    String errorMsg = GLES20.glGetShaderInfoLog(vertexShader);
    Log.d(TAG, "VertexShader compile failed : " + errorMsg);
}
  • void glGetShaderiv(int shader, int pname, int[] params, int offset)

    获取指定着色器对象的某个信息。

    • pname:着色器对象的字段名称。可接受的字段名称有GL_SHADER_TYPE、GL_DELETE_STATUS、GL_COMPILE_STATUS、GL_INFO_LOG_LENGTH、GL_SHADER_SOURCE_LENGTH等。
    • params:用于获取着色器对象的字段值。由OpenGL填充。根据pname的不同,返回的取值可能也不同,参考GLES2.0中文API-glGetShaderiv
  • String glGetShaderInfoLog(int shader)

    获取着色器错误信息。

片段着色器

片段着色器的主要工作是计算像素的颜色。

在计算机图形中颜色被表示为有4个元素的数组:红色、绿色、蓝色和alpha(透明度)分量,通常缩写为RGBA。每个分量的强度可选范围为[0.0 ,1.0]。最终的颜色由三个颜色分量组成,如果每个颜色分量采用8bit来存储,那可选颜色有28 * 28 * 28 = 16,777,216 种。再加上透明度,能表现的最终色彩也足够丰富。

一个简单的片段着色器代码

#version 300 es
precision mediump float;
out vec4 FragColor;
void main(){
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

编译着色器

与顶点着色器基本一致,只是着色器类型采用GL_FRAGMENT_SHADER

int fragShader = GLES20.glCreateShader(GL_FRAGMENT_SHADER);

着色器程序

前面我们创建了两个着色器对象:顶点和片段着色器。要在OpenGL上使用它们,我们还得把它们链接成一个着色器程序对象(Shader Program Object),并激活它。

当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

创建着色器程序对象:

int shaderProgram = GLES20.glCreateProgram();

将前面创建的两个着色器对象附加到着色器程序中,然后将它们链接在一起:

GLES20.glAttachShader(shaderProgram, vertexShader);
GLES20.glAttachShader(shaderProgram, fragmentShader);
GLES20.glLinkProgram(shaderProgram);

与着色器的编译一样,也可以检测链接着色器程序是否失败:

int[] result = new int[1];
GLES20.glGetProgramiv(shaderProgram, GL_LINK_STATUS, result, 0);

if (result[0] != GL_NO_ERROR) {
    String errorMsg = GLES20.glGetProgramInfoLog(shaderProgram);
    Log.d(TAG, "ShaderProgram Link failed : " + errorMsg);
}

链接完着色器对象后,着色器对象已经不再需要了:

GLES20.glDeleteShader(vertexShader); 
GLES20.glDeleteShader(fragmentShader);

当我们需要使用着色器程序对象进行渲染的时候,就要激活对应的着色器程序对象。在glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用这个程序对象:

GLES20.glUseProgram(shaderProgram);

当不再需要着色器程序对象时,我们可以删除它:

GLES20.glDeleteProgram(shaderProgram);

链接顶点属性

前面我们已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。但是OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上。

顶点着色器允许我们以顶点属性的形式指定任何我们想要的输入。这带来了很大的灵活性,但也意味着我们必须手动指定输入数据的哪一部分对应顶点着色器中的哪个顶点属性。

所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。

我们前面设置的顶点缓冲数据格式如下:

  • 顶点的顶点属性:即前面设置的aPos,位置属性,由X、Y、Z组成,被存储为3个连续的4字节的浮点值。所以,一个位置属性占了3 * 4 = 12个字节。
  • 位置属性是在缓冲开始的位置,即OFFSET为0。

OpenGL该如何解析顶点数据(逐个应用到对应的顶点属性上,比如这里的位置属性。下一篇教学我们会加入颜色属性。):

GLES20.glVertexAttribPointer(0, 3, GL_FLOAT, false, 3 * BYTES_PER_FLOAT, 0);
GLES20.glEnableVertexAttribArray(0);
  • void glVertexAttribPointer(int indx, int size, int type, boolean normalized, int stride, int offset)

    告诉OpenGL该如何解析某一类型的顶点属性

    • indx:顶点属性的编号。
      • 例如一个Vertex可能有多个属性:位置、颜色、纹理等,分别编号为0、1、2。将indx设为对应的编号,就代表选中指定的属性。
      • 前面我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)。该操作将顶点属性的位置值设置为了0。而Location是等价于indx。
    • size:指定顶点属性的组件数目。aPos是一个vec3,由3个值组成:X、Y、Z。
    • type:指定顶点属性组件的数据类型。GLSL的vec*都是浮点数组成的。所以这里是GL_FLOAT。
    • normalized:是否希望数据被标准化(Normalize)到[-1, 1](对有符号类型)或者[0, 1](对无符号类型)。
    • stride:指定两个领近的、类型相同的顶点属性的字节偏移。比如,Vertex1与Vertex2里两个类型相同的位置属性,它们的字节偏移是 12 个字节。如果顶点中,只有一种类型的顶点属性,比如位置属性,stride可以为0。所以这里传12或0均可。(后面加入颜色属性时,会帮助我们进一步理解stride)
    • offset:指定当前设置的顶点属性的第一份数据在数组中的偏移量(Offset),单位字节。由于第一个位置属性就在数组的开头,所以这里是0。我们会在后面详细解释这个参数。

每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO(程序中可以有多个VBO)获取则是通过在调用glVertexAttribPointer时绑定到GL_ARRAY_BUFFER的VBO决定的。由于在调用glVertexAttribPointer之前绑定的是先前定义的VBO对象。所以,layout (location = 0) in vec3 aPos里的aPos将会与我们最开始设定的三个顶点链接起来。

  • void glEnableVertexAttribArray(int index)
    • index:同glVertexAttribPointer的indx

默认情况下,出于性能考虑,所有顶点着色器的属性(Attribute)变量都是关闭的,意味着数据在着色器端是不可见的。哪怕数据已经上传到GPU,也要由glEnableVertexAttribArray启用指定属性,才可在顶点着色器中访问顶点的属性数据。glVertexAttribPointer或VBO只是建立CPU和GPU之间的逻辑连接,从而实现了CPU数据上传至GPU。但是,数据在GPU端是否可见,即,着色器能否读取到数据,由是否启用了对应的属性决定,这就是glEnableVertexAttribArray的功能,允许顶点着色器读取GPU(服务器端)数据。

顶点数组对象

前面我们使用一个顶点缓冲对象(VBO)将顶点数据初始化至缓冲中,建立了一个顶点和一个片段着色器,并告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL中绘制一个物体,代码会像是这样:

// 0. 复制顶点数组到缓冲中供OpenGL使用
GLES20.glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
GLES20.glBufferData(GL_ARRAY_BUFFER, vertices.length, verticesBuffer, GL_STATIC_DRAW);
// 1. 设置顶点属性
GLES20.glVertexAttribPointer(0, 3, GL_FLOAT, false, 3 * BYTES_PER_FLOAT, 0);
GLES20.glEnableVertexAttribArray(0);

// 2.当我们渲染一个物体时要使用着色器程序
GLES20.glUseProgram(shaderProgram);
// 3. 绘制物体
draw();

每当我们绘制一个物体的时候都必须重复这一过程。这看起来可能不多,但是如果有超过5个顶点属性,上百个不同物体呢?

顶点数组对象(Vertex Array Object, VAO)类似于VBO,可以用于存储我们设置的顶点属性。绑定一个VAO后,后续设置的顶点属性,都会存储在该VAO中。 这样,当我们分别为两个物体设置了对应的VAO后,在来回切换绘制它们的时候,就不需要重新设置对应的顶点属性了,只需要重新绑定对应的VAO。

一个顶点数组对象能存储的内容:

  • glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
  • glVertexAttribPointer设置的顶点属性配置。
  • 与顶点属性关联的顶点缓冲对象(VBO)。

创建并绑定一个VAO,与VBO类似:

int[] vao = new int[1];
GLES30.glGenVertexArrays(1, vao, 0);
GLES30.glBindVertexArray(vao[0]);

OpenGL ES 3.0及以后才支持VAO。

绑定VAO,就可以设置对应的VBO、顶点属性了。绑定多个VAO,然后使用VAO绘制不同物体的伪代码如下:

//绑定VAO1
GLES30.glBindVertexArray(vao1[0]);
//绑定对应的VBO、顶点属性
...

//绑定VAO2
GLES30.glBindVertexArray(vao2[0]);
//绑定对应的VBO、顶点属性
...

//循环渲染
while(true){
    //绑定VAO1
    GLES30.glBindVertexArray(vao1[0]);
    //绘制物体1
    ...
    
    //绑定VAO2
    GLES30.glBindVertexArray(vao2[0]);
    //绘制物体2
    ...
}

绘制三角形

绘制物体前,用glUseProgram启用对应的着色器程序,然后用glBindVertexArray绑定对应的VAO,最后用glDrawArrays进行图元绘制。绘制代码写在GLSurfaceView.Renderer实现类的onDrawFrame方法中。

@Override
public void onDrawFrame(GL10 gl) {
    GLES20.glUseProgram(shaderProgram);
    GLES30.glBindVertexArray(vao[0]);
    GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, 3);
}
  • void glDrawArrays(int mode, int first, int count) 绘制图元
    • mode:图元类型。类型有GL_POINTS, GL_LINE_STRIP, GL_LINE_LOOP, GL_LINES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN 和 GL_TRIANGLES。
    • first:顶点属性数组的起始索引。
    • count:顶点个数

相关代码在TriangleRender中。

索引缓冲对象

假设我们不再绘制一个三角形而是绘制一个矩形。我们可以绘制两个三角形来组成一个矩形(OpenGL主要处理三角形)。这会生成下面的顶点的集合:

float[] vertices[] = {
    // 第一个三角形
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, 0.5f, 0.0f,  // 左上角
    // 第二个三角形
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

可以看到,有几个顶点叠加了。我们指定了右下角和左上角两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。当我们绘制数千个三角形时,这会造成巨大的浪费。更好的解决方案是只储存不同的顶点,并设定绘制这些顶点的顺序。这样子我们只要储存4个顶点就能绘制矩形了,之后只要指定绘制的顺序就行了。

索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)的工作方式就是这样。和顶点缓冲对象一样,EBO也是一个缓冲,它专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。所谓的索引绘制(Indexed Drawing)正是我们问题的解决方案。

首先,我们先要定义(不重复的)顶点,以及绘制出矩形所需的索引:

float[] vertices = {
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

int[] indices = { // 注意索引从0开始! 
    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

如上,我们只定义了4个顶点,而不是6个。下一步我们需要创建索引缓冲对象:

GLES20.glGenBuffers(1, ebo, 0);

与VBO类似,我们先绑定EBO然后用glBufferData把索引复制到缓冲里。注意这里绑定的缓冲类型是GL_ELEMENT_ARRAY_BUFFER

GLES20.glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo[0]);

IntBuffer indicesBuffer = ByteBuffer
                .allocateDirect(indices.length * BYTES_PER_FLOAT)
                .order(ByteOrder.nativeOrder())
                .asIntBuffer()
                .put(indices);
indicesBuffer.position(0);
GLES20.glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.length * 4, indicesBuffer,
                GL_STATIC_DRAW);

使用EBO绘制矩形:

GLES20.glDrawElements(GLES20.GL_TRIANGLES, 6, GLES20.GL_UNSIGNED_INT, 0);
  • void glDrawElements(int mode, int count, int type, int offset)
    • mode:图元类型。
    • count:索引数。
    • type:指定索引的类型。必须是GL_UNSIGNED_BYTE、GL_UNSIGNED_SHORT或GL_UNSIGNED_INT中的一个。
    • offset:指定GL_ELEMENT_ARRAY_BUFFER的缓冲区的字节偏移,告知GPU从哪里开始读取索引。

绑定一个VAO后,后续绑定的EBO也会被保存在VAO中。

当目标是GL_ELEMENT_ARRAY_BUFFER的时候,VAO会储存glBindBuffer的函数调用。这也意味着它也会储存解绑调用,所以确保你没有在解绑VAO之前解绑索引数组缓冲,否则它就没有这个EBO配置了。

VAO的绑定要在VBO、EBO之前。至于VBO、EBO的绑定顺序不讲究。

最终绘制的矩形如下:

原教程提到OpenGL有一个glPolygonMode接口,来设置线框模式。线框矩形可以显示出矩形其实是由两个三角形组成的。OpenGL ES是没有这个接口的,但是我们仍然可以通过绘制线条,来达到类似的效果:

GLES30.glDrawElements(GLES20.GL_LINE_LOOP, 6, GLES20.GL_UNSIGNED_INT, 0);

相关源码在RectangleRender中。

附加资源

调试:如何调试OpenGL的错误。

参考资料

【OpenGL ES】关于VBO(Vertex Buffer Object)的一些坑——解析一些关于glBuffer的函数

OpenGL ES 3.0 glEnableVertexAttribArray的作用