OpenGL ES 3.0(一)综述

1、前言

之前有文章提到过用OpenGL ES 2.0在Android上来实现最最基础的图形显示——OpenGL ES 2.0 显示图形(上)OpenGL ES 2.0 显示图形(下)。其中设计到许多概念讨论的并不是非常详细。这边打算做一个系列来充分讨论一下关于OpenGL ES的方方面面。在OpenGL ES 2.0的那篇文章中提到3.0是对2.0向下兼容的。但是在2.0版本的有些内容对于3.0来说是过时的,并且3.0中跟新了不少内容。目前在Android中使用的OpenGL ES的最新版本也是3.0的版本,同时对于ARCore中的Sceneform组件 ARCore 使用Sceneform 创建ARApp 也是基于OpenGL ES3.0的版本来实现的。为了与时俱进所以这个系列以OpenGL ES 3.0 版本为基础来实现。这一篇文章会重新采用OpenGL ES 3.0 版本的内容再来实现一遍三角形的显示,其中一些重复的内容会进行略过,会讨论不同的地方,所以建议先看完前面两篇文章再来阅读该篇文章。并且会尽量详细的讨论其中的原理。同时为了顺应潮流,该系列的样例代码都会使用kotlin来实现。

2、概述

提到OpenGL ES 就不能不提到 OpenGL。 一般它被认为是一个API(Application Programming Interface, 应用程序编程接口),包含了一系列可以操作图形、图像的函数。然而,OpenGL本身并不是一个API,它仅仅是一个由Khronos组织制定并维护的规范(Specification)。OpenGL规范严格规定了每个函数该如何执行,以及它们的输出值。至于内部具体每个函数是如何实现的,将由OpenGL库的开发者自行决定。因为OpenGL规范并没有规定实现的细节,具体的OpenGL库允许使用不同的实现,只要其功能和结果与规范相匹配。实际的OpenGL库的开发者通常是显卡的生产商。显卡所支持的OpenGL版本都为这个系列的显卡专门开发的。当使用Apple系统的时候,OpenGL库是由Apple自身维护的。在Linux下,有显卡生产商提供的OpenGL库,也有一些爱好者改编的版本。这也意味着任何时候OpenGL库表现的行为与规范规定的不一致时,基本都是库的开发者留下的bug。这是OpenGL 3.3 版本的规范文档:OpenGL 3.3 规范文档 。OpenGL ES 源自于OpenGL ,但是在OpenGL ES版本针对资源受限的显示设备进行来优化,同时删除来许多冗余的API。在相同操作可以多种方式执行的情况下,采用最实用的方法,将多于的技术删除。指定几何形状就是一个很好的例子,在OpenGL 中程序可以使用立即模式、显示列表模式或者顶点数组。但是在OpenGL ES 中只存在顶点模式,而将其余两种去掉。而对于OpenGL ES 3.0版本在Android 4.3 以上的版本的设备都予以了支持。同时OpenGL ES 3.0也是从前面提到的OpenGL 3.3 规范下衍生而来的。

3、 渲染管线

在OpenGL ES中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL ES的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL ES的图形渲染管线管理。管线渲染指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程。图形渲染管线可以被划分为两个主要部分:第一部分把3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。2D坐标和像素是不同的,2D坐标精确表示一个点在2D空间中的位置,而2D像素是这个点在屏幕中的像素近似值,2D像素受到屏幕/窗口分辨率的限制。

图形渲染管线接受一组3D坐标,然后把它们转变为屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理数据。这些小程序叫做着色器(Shader)。有些着色器允许开发者自己配置,这就允许开发者用自己写的着色器来替换默认的。这样就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的,在之后的文章中会更加详细的讨论。

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

图形渲染管线流程图

如上图所示,图形渲染管线包含很多部分,每个部分都将在转换顶点数据到最终像素这一过程中处理各自特定的阶段。这边会概括性地讨论一下渲染管线的每个部分。

首先,以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据(Vertex Data);顶点数据是一系列顶点的集合。一个顶点(Vertex)是一个3D坐标的数据的集合。而顶点数据是用顶点属性(Vertex Attribute)表示的,它可以包含任何想用的数据,但是简单起见,这边假定每个顶点只由一个3D“位置”和一些颜色值组成。当讨论一个“位置”的时候,它代表在一个“空间”中所处地点的这个特殊属性;同时“空间”代表着任何一种坐标系,比如x、y、z三维坐标系,x、y二维坐标系,或者一条直线上的x和y的线性关系,只不过二维坐标系是一个扁扁的平面空间,而一条直线是一个很瘦的长长的空间。为了让OpenGL ES知道坐标和颜色值构成的到底是什么,OpenGL ES需要去指定这些数据所表示的渲染类型。是希望把这些数据渲染成一系列的点、线、或者三角形。做出的这些提示叫做图元(Primitive),任何一个绘制指令的调用都将把图元传递给OpenGL ES。在OpenGL ES中有这么几个 :GL_POINTS、GL_LINES、GL_LINE_LOOP、GL_LINE_STRIP、GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN。一般来说POINTS就是绘点,LINE是根据各种规则绘线,TRIANGLE是根据各种规则绘制三角形,其内部一般会进行填充。下面讨论下上图的各个步骤。

① 顶点着色器(Vertex Shader): 图形渲染管线的第一个部分是顶点着色器,它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标,同时顶点着色器允许对顶点属性进行一些基本处理。

② 图元装配(Primitive Assembly):图元装配阶段将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并所有的点装配成指定图元的形状;本节例子中是一个三角形。

③ 几何着色器(Geometry Shader):图元装配阶段的输出会传递给几何着色器。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。

④ 光栅化(Rasterization Stage):几何着色器的输出会被传入光栅化阶段,这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段。在片段着色器运行之前会执行裁切。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

⑤ 片段着色器(Fragment Shader):OpenGL ES中的一个片段是OpenGL ES渲染一个像素所需的所有数据。片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

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

可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,只需要配置顶点和片段着色器就行了。并且必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。

4、顶点输入

开始绘制图形之前,必须先给OpenGL ES输入一些顶点数据。OpenGL ES是一个3D图形库,所以在OpenGL ES中指定的所有坐标都是3D坐标(x、y和z)。OpenGL ES不是简单地把所有的3D坐标变换为屏幕上的2D像素, OpenGL ES仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。所有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上,也就是说在这个范围以外的坐标都不会显示。与通常的屏幕坐标不同,y轴正方向为向上,(0, 0)坐标是这个图像的中心,而不是左上角。标准化设备坐标接着会变换为屏幕空间坐标(Screen-space Coordinates),这是通过GLES30.glViewport()函数提供的数据,进行视口变换(Viewport Transform)完成的。所得的屏幕空间坐标又会被变换为片段输入到片段着色器中。这边希望渲染一个三角形,一共要指定三个顶点,每个顶点都有一个3D位置。会将它们以标准化设备坐标的形式(OpenGL ES的可见区域)定义为一个float数组。


// Triangle.kt

companion object {

    internal var coords =floatArrayOf(// 按逆时针顺序

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

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

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

    )

}

由于OpenGL ES是在3D空间中工作的,而渲染的是一个2D三角形,将它顶点的z坐标设置为0.0。这样子的话三角形每一点的深度都是一样的,从而使它看上去像是2D的。通常深度可以理解为z坐标,它代表一个像素在空间中和屏幕的距离,如果离屏幕远就可能被别的像素遮挡,就看不到它了,它会被丢弃,以节省资源。定义这样的顶点数据以后,会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。它会在GPU上创建内存用于储存顶点数据,还要配置OpenGL ES如何解释这些内存,并且指定其如何发送给显卡。顶点着色器接着会处理在内存中指定数量的顶点。通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。就像OpenGL ES中的其它对象一样,这个缓冲有一个独一无二的ID,所以可以使用GLES30.glGenBuffers(),第一个为需要创建VBO的数量,第二个为创建完VBO后对应的id数组,这边需要创建一个VBO,所以只给VBOids分配了一个能容纳一个id大小的空间。:


// Triangle.kt

private val VBO: IntBuffer

init {
        VBOids = IntBuffer.allocate(1)

        GLES30.glGenBuffers(1, VBOids)

}

创建完成缓存后需要绑定该缓存的缓冲对象类型。顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL ES允许同时绑定多个缓冲,只要它们是不同的缓冲类型。可以使用glBindBuffer()把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上,其中第一个参数是缓冲类型,第二个参数是前面得到的缓冲id:


// Triangle.kt

init {

        ...

        GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER,VBOids.get(0))

        ...

}

此时使用的任何在在GL_ARRAY_BUFFER目标上的缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后可以调用glBufferData(),它会把之前定义的顶点数据复制到缓冲的内存中,其中第一个参数是缓冲类型,第二个参数是待传递数据的字节数,第三个是代传递的数据,最后一个参数指定了希望显卡如何管理给定的数据,它有三种形式:

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

这边三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是GL_STATIC_DRAW。如果,一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。


// Triangle.kt
init {

        ...

        GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER,VBOids.get(0))

        GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER,

                squareCoords.size * 4,

                FloatBuffer.wrap(squareCoords)

                , GLES30.GL_STATIC_DRAW)

        ...

}

通过上述方式就已经把顶点数据储存在显卡的内存中,并用VBO这个顶点缓冲对象来进行管理。下面会创建一个顶点和片段着色器来真正处理这些数据。

5、顶点着色器

顶点着色器(Vertex Shader)是几个可编程着色器中的一个。需要做的第一件事是用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器,然后编译这个着色器,这样就可以在程序中使用它了。下面是一个非常基础的GLSL顶点着色器的源代码,并且这个采用的语言版本是300 es 该版本是OpenGL ES 3.0所对应的GLSL语言:


// Triangle.kt

private val vertexShaderCode =

            "#version 300 es \n" +

                    " layout (location = 0) in vec3 aPos;" +

                    "void main() {" +

                    " gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);" +

                    "}"

可以看到,GLSL看起来很像C语言。每个着色器都起始于一个版本声明。下一步,使用in关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute)。现在只关心位置(Position)数据,所以只需要一个顶点属性。GLSL有一个向量数据类型,它包含1到4个float分量,包含的数量可以从它的后缀数字看出来。由于每个顶点都有一个3D坐标,就创建一个vec3输入变量aPos。同样也通过layout (location = 0)设定了输入变量的位置值(Location),之后会需要这个位置值,这边和OpenGL ES 2.0 有所不同,在2.0版本中输入变量的声明是用attribute关键字的。前面说到来向量(Vector),在图形编程中经常会使用向量这个数学概念,因为它简明地表达了任意空间中的位置和方向,并且它有非常有用的数学属性。在GLSL中一个向量有最多4个分量,每个分量值都代表空间中的一个坐标,它们可以通过vec.x、vec.y、vec.z和vec.w来获取。注意vec.w分量不是用作表达空间中的位置的,处理的是3D不是4D,而是用在所谓透视除法(Perspective Division)上,这点后续的文章会有讨论。为了设置顶点着色器的输出,必须把位置数据赋值给预定义的gl_Position变量,它是vec4类型的。在main函数的最后,将gl_Position设置的值会成为该顶点着色器的输出。由于输入是一个3分量的向量,必须把它转换为4分量的。可以把vec3的数据作为vec4构造器的参数,同时把w分量设置为1.0f来完成这一操作。当前这个顶点着色器可能是能想到的最简单的顶点着色器了,因为对输入数据什么都没有处理就把它传到着色器的输出了。在真实的程序里输入数据通常都不是标准化设备坐标,所以首先必须先把它们转换至OpenGL的可视区域内。

6、编译着色器

上面已经写了一个顶点着色器源码,并储存在一个字符串中,但是为了能够让OpenGL使用它,必须在运行时动态编译它的源码。首先要做的是创建一个着色器对象,注意还是用ID来引用。储存这个顶点着色器为int类型,然后用glCreateShader创建这个着色器。把需要创建的着色器类型以参数形式提供给GLES30.glCreateShader(),参数是需要创建的着色器类型,有顶点着色器(GLES30.GL_VERTEX_SHADER)和片段着色器(GLES30.GL_FRAGMENT_SHADER)两种。然后将着色器源码附加到着色器对象上,并且对其进行编译。


// MyGLRenderer.kt
companion object {

        fun loadShader(type: Int, shaderCode: String): Int {

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

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

            val shader = GLES30.glCreateShader(type)

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

            GLES30.glShaderSource(shader, shaderCode)

            GLES30.glCompileShader(shader)

            return shader

        }

    }

编译完后可以通过调用glCompileShader()检验是否编译成功,如果返回的状态码等于0说明编译失败。如果失败可以将失败的内容通过调用GLES30.glGetShaderInfoLog()打印出来,。


init {

        ...
        var vertexShader = MyGLRenderer.loadShader(GLES30.GL_VERTEX_SHADER,

                vertexShaderCode)

        var success: IntBuffer = IntBuffer.allocate(1)

        GLES30.glGetShaderiv(vertexShader, GLES30.GL_COMPILE_STATUS, success)

        if (success.get(0) == 0) {

            Log.e(TAG, GLES30.glGetShaderInfoLog(vertexShader));

            GLES30.glDeleteShader(vertexShader);

            vertexShader = 0

        }

        ...

}

如果编译的时候没有检测到任何错误,顶点着色器就被编译成功了。

7、片段着色器

片段着色器(Fragment Shader)是第二个打算创建的用于渲染三角形的着色器。片段着色器所做的是计算像素最后的颜色输出。在计算机图形中颜色被表示为有4个元素的数组:红色、绿色、蓝色和alpha(透明度)分量,通常缩写为RGBA。当在OpenGL ES或GLSL中定义一个颜色的时候,把颜色每个分量的强度设置在0.0到1.0之间。比如说设置红为1.0f,绿为1.0f,会得到两个颜色的混合色,即黄色。


// Triangle.kt

private val fragmentShaderCode = (

            "#version 300 es \n " +

                    "#ifdef GL_ES\n"+

                    "precision highp float;\n"+

                    "#endif\n"+

                    "out vec4 FragColor; " +

                    "void main() {" +

                    "  FragColor = vec4(1.0f,0.5f,0.2f,1.0f);" +

                    "}")

这边在最前面除了需要声明版本号,同时需要定义一下float的精度。精度定义声明了底层实现存储这些变量必须要使用的最小范围和精度。实现可能会使用比要求更大的范围和精度,但绝对不会比要求少。

片段着色器只需要一个输出变量,这个变量是一个4分量向量,它表示的是最终的输出颜色,需要开发者将其计算出来。可以用out关键字声明输出变量,这里命名为FragColor。上面将一个alpha值为1.0的橘黄色的vec4赋值给颜色输出。编译片段着色器的过程与顶点着色器类似,只不过使用GL_FRAGMENT_SHADER常量作为着色器类型。


// Triangle.kt
init {

        ...

        var fragmentShader = MyGLRenderer.loadShader(GLES30.GL_FRAGMENT_SHADER,

                fragmentShaderCode)

        GLES30.glGetShaderiv(fragmentShader, GLES30.GL_COMPILE_STATUS, success)

        if (success.get(0) ==0) {

                Log.e(TAG, GLES30.glGetShaderInfoLog(fragmentShader))

                GLES30.glDeleteShader(fragmentShader)

                fragmentShader =0

        }

        ...

}

8、着色器程序

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在发送渲染调用的时候被使用。当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,会得到一个连接错误。


// Triangle.kt
init {

        ...

        mProgram = GLES30.glCreateProgram()

        GLES30.glAttachShader(mProgram, vertexShader)

        GLES30.glAttachShader(mProgram, fragmentShader)

        GLES30.glLinkProgram(mProgram)

        ...

}

就像着色器的编译一样,也可以检测链接着色器程序是否失败,并获取相应的日志。


// Triangle.kt

init {

        ...

        GLES30.glGetProgramiv(mProgram, GLES30.GL_COMPILE_STATUS, success)

        if (success.get(0) == 0) {

            Log.e(TAG, GLES30.glGetShaderInfoLog(mProgram))

            GLES30.glDeleteProgram(mProgram)

        }

        ...

}

得到的结果就是一个程序对象,可以调用glUseProgram(),用刚创建的程序对象作为它的参数,以激活这个程序对象,在glUseProgram()调用之后,每个着色器调用和渲染调用都会使用这个程序对象,也就是之前写的着色器。


// Triangle.kt
fun draw() {

        ...

        GLES30.glUseProgram(mProgram)

        ...

}

在把着色器对象链接到程序对象以后,记得删除着色器对象,此时不再需要它们:


// Triangle.kt

init {

        ...

        GLES30.glDeleteShader(vertexShader)

        GLES30.glDeleteShader(fragmentShader)

        ...

}

9、链接顶点属性

前面已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。但此时,OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上。顶点着色器允许指定任何以顶点属性为形式的输入。这使其具有很强的灵活性的同时,它还的确意味着必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,必须在渲染前指定OpenGL该如何解释顶点数据。

前面提到顶点缓冲对象VBO,其缓冲数据会被解析为下面这样子:

顶点缓冲数据

其中位置数据被储存为32位(4字节)浮点值,每个位置包含3个这样的值,在这3个值之间没有空隙(或其他值),这几个值在数组中紧密排列(Tightly Packed)。数据中第一个值在缓冲开始的位置。有了这些信息就可以使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)了:


// Triangle.kt

init {

        ...

        GLES30.glVertexAttribPointer(0,3,GLES30.GL_FLOAT,false,3*4, 0)

}

fun draw() {

        GLES30.glEnableVertexAttribArray(0);

        GLES30.glUseProgram(mProgram)

        ...

        GLES30.glDisableVertexAttribArray(0)

    }

glVertexAttribPointer函数的参数非常多,下面逐一介绍它们:

第一个参数指定要配置的顶点属性。在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location),它把顶点属性的位置值设置为0。因为希望把数据传递到这一个顶点属性中,所以这里传入0。

第二个参数指定顶点属性的大小。顶点属性是一个vec3,也就是说它由3个float值组成,所以大小是3。

第三个参数指定数据的类型,这里是GL_FLOAT,指出第二个属性值所对应的单位。

第四个参数定义是否希望数据被标准化(Normalize)。如果设置为true,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。这里由于原始的coords已经做好标准化映射所以不需要再对其进行标准化映射,故把它设置为flase。

第五个参数叫做步长(Stride),它表示在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float) = 12 (这边一个float的大小是4个字节)。要注意的是由于知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)。如果有更多的顶点属性,就必须更小心地定义每个顶点属性之间的间隔。

最后一个参数表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。

每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO(程序中可以有多个VBO)获取则是通过在调用glVetexAttribPointer时绑定到GL_ARRAY_BUFFER的VBO决定的。由于在调用glVetexAttribPointer之前绑定的是先前定义的VBOids中第一个对象,顶点属性location 0现在会链接到它的顶点数据。

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

10、绘制顶点数组

经过了前面一系列操作,距离最后显示三角形只有一步之遥了。要想绘制想要的物体,OpenGL ES 提供了glDrawArrays(),它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。glDrawArrays()第一个参数是打算绘制的OpenGL ES图元的类型。这里传递GL_TRIANGLES给它。第二个参数指定了顶点数组的起始索引,这里填0。最后一个参数指定打算绘制多少个顶点,这里是3。下面将此时完整的Triangle.kt展示出来


class Triangle {

    private val vertexShaderCode =

            "#version 300 es \n" +

                    " layout (location = 0) in vec3 aPos;" +

                    "void main() {" +

                    " gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);" +

                    "}"

    private val fragmentShaderCode = (

            "#version 300 es \n " +

                    "#ifdef GL_ES\n"+

                    "precision highp float;\n"+

                    "#endif\n"+

                    "out vec4 FragColor; " +

                    "void main() {" +

                    "  FragColor = vec4(1.0f,0.5f,0.2f,1.0f);" +

                    "}")

    private val mProgram: Int

    private val VBOids: IntBuffer

    init {

        VBOids = IntBuffer.allocate(1);

        GLES30.glGenBuffers(1, VBOids)

        Log.d(TAG, "VBO:" + VBOids.get(0))

        var vertexShader = MyGLRenderer.loadShader(GLES30.GL_VERTEX_SHADER,

                vertexShaderCode)

        var success: IntBuffer = IntBuffer.allocate(1)

        GLES30.glGetShaderiv(vertexShader, GLES30.GL_COMPILE_STATUS, success)

        if (success.get(0) == 0) {

            Log.e(TAG, GLES30.glGetShaderInfoLog(vertexShader));

            GLES30.glDeleteShader(vertexShader);

            vertexShader = 0

        }

        var fragmentShader = MyGLRenderer.loadShader(GLES30.GL_FRAGMENT_SHADER,

                fragmentShaderCode)

        GLES30.glGetShaderiv(fragmentShader, GLES30.GL_COMPILE_STATUS, success)

        if (success.get(0) == 0) {

            Log.e(TAG, GLES30.glGetShaderInfoLog(fragmentShader))

            GLES30.glDeleteShader(fragmentShader)

            fragmentShader = 0

        }

        mProgram = GLES30.glCreateProgram()

        GLES30.glAttachShader(mProgram, vertexShader)

        GLES30.glAttachShader(mProgram, fragmentShader)

        GLES30.glLinkProgram(mProgram)

        GLES30.glGetProgramiv(mProgram, GLES30.GL_COMPILE_STATUS, success)

        if (success.get(0) == 0) {

            Log.e(TAG, GLES30.glGetProgramInfoLog(mProgram))

            GLES30.glDeleteProgram(mProgram)

        }

        GLES30.glDeleteShader(vertexShader);

        GLES30.glDeleteShader(fragmentShader);

        GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, VBOids.get(0))

        GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER,

                coords.size * 4,

                FloatBuffer.wrap(coords)

                , GLES30.GL_STATIC_DRAW)

        GLES30.glVertexAttribPointer ( aPoslocation, 3, GLES30.GL_FLOAT, false, vertexStride,0)

    }

    fun draw() {

        GLES30.glEnableVertexAttribArray(aPoslocation);

        GLES30.glUseProgram(mProgram)

        GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, vertexCount)

        GLES30.glDisableVertexAttribArray(aPoslocation)

    }

    companion object {

        internal val COORDS_PER_VERTEX = 3

        internal val vertexStride = COORDS_PER_VERTEX * 4

        internal val aPoslocation =0

        internal var coords = floatArrayOf(// 按逆时针顺序

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

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

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

        )

        internal val vertexCount =coords.size /COORDS_PER_VERTEX

    }

}

显示三角形

11、顶点数组对象

每当绘制一个物体之前都必须绑定VBO。但如果有超过5个顶点属性,上百个不同物体。绑定正确的缓冲对象,为每个物体配置所有顶点属性就会变成一件麻烦事。这时候就需要通过顶点数组对象来把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态。

顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中。

一个顶点数组对象会储存以下这些内容:

通过glVertexAttribPointer设置的顶点属性配置。

通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

VOA与VBO关系

创建VAO和创建一个VBO很类似:


private val VAOids: IntBuffer

init {

        ...

        VAO = IntBuffer.allocate(1);

        GLES30.glGenBuffers(1, VAOids)

        ...

}

要想使用VAO,要做的只是使用glBindVertexArray绑定VAO。从绑定之后起,应该绑定和配置对应的VBO和属性指针,之后解绑VAO供之后使用。当打算绘制一个物体的时候,只要在绘制物体前简单地把VAO绑定到希望使用的设定上就行了。


init {

        ...

        // 绑定VAO

        GLES30.glBindVertexArray(VAOids.get(0)

        // 把顶点数组复制到缓冲中功OpenGL ES 使用

        GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, VBOids.get(0))

        GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER,

                coords.size * 4,

                FloatBuffer.wrap(coords)

                , GLES30.GL_STATIC_DRAW)

        // 设置顶点属性指针
        GLES30.glVertexAttribPointer(aPoslocation,3,GLES30.GL_FLOAT,false,vertexStride,0)

}

fun draw() {

        GLES30.glEnableVertexAttribArray(aPoslocation)

        GLES30.glUseProgram(mProgram)

        GLES30.glBindVertexArray(VAOids.get(0))

        GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, vertexCount)

        GLES30.glDisableVertexAttribArray(aPoslocation)

    }

上面的操作储存了顶点属性配置和应使用的VBO的顶点数组对象。一般当打算绘制多个物体时,首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用。当打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。解绑操作同样是调用 GLES30.glBindVertexArray(0) 但里面的参数传入0即可。

12、索引缓冲对象

在渲染顶点这一话题上还有一个需要讨论的东西——索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)。假设不再绘制一个三角形而是绘制一个矩形。可以通过绘制两个三角形来组成一个矩形。这会生成下面的顶点的集合:


companion object {

        ...

internal var coords =floatArrayOf(

        // 第一个三角形

        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个顶点就能绘制矩形了,之后只要指定绘制的顺序就行了。索引缓冲对象的工作方式正是这样的。和顶点缓冲对象一样,EBO也是一个缓冲,它专门储存索引,OpenGL ES调用这些顶点的索引来决定该绘制哪个顶点,就是所谓的索引绘制。首先,要定义(不重复的)顶点,和绘制出矩形所需的索引。


internal var coords = floatArrayOf(// 按逆时针顺序

                0.5f, 0.5f, 0.0f,  // 右上角

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

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

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

        )

        internal var indices = intArrayOf(// 按逆时针顺序

                0, 1, 3,  // 第一个三角形

                1, 2, 3 // 第二个三角形

        )

当时用索引的时候,只定义了4个顶点,而不是6个。下一步创建索引缓冲对象:


private val EBO: IntBuffer

init{

        ...

        EBO = IntBuffer.allocate(1)

        GLES30.glGenBuffers(1, EBO)

        ...

}

与VBO类似,先绑定EBO然后用glBufferData把索引复制到缓冲里。同样,和VBO类似,会把这些函数调用放在绑定和解绑函数调用之间,只不过这次把缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER。


init{

...

        GLES30.glBindBuffer(GLES30.GL_ELEMENT_ARRAY_BUFFER,EBO.get(0))

        GLES30.glBufferData(GLES30.GL_ELEMENT_ARRAY_BUFFER,

        indices.size *4,

        IntBuffer.wrap(indices)

        , GLES30.GL_STATIC_DRAW)

...

}

传递了GL_ELEMENT_ARRAY_BUFFER当作缓冲目标。最后一件要做的事是用glDrawElements()来替换glDrawArrays(),来指明从索引缓冲渲染。使用glDrawElements()时,会使用当前绑定的索引缓冲对象中的索引进行绘制:


// Triangle.kt
fun draw() {

        GLES30.glEnableVertexAttribArray(aPoslocation)

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

        GLES30.glUseProgram(mProgram)

        GLES30.glBindVertexArray(VAOids.get(0))

        //GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, vertexCount)

        GLES30.glDrawElements(GLES30.GL_TRIANGLES, 6, GLES30.GL_UNSIGNED_INT, 0);

        // 禁用顶点

        GLES30.glDisableVertexAttribArray(aPoslocation)

    }

第一个参数指定了绘制的模式,这个和glDrawArrays()的一样。第二个参数是打算绘制顶点的个数,这里填6,也就是说一共需要绘制6个顶点。第三个参数是索引的类型,这里是GL_UNSIGNED_INT。最后一个参数里指定EBO中的偏移量这里填写0。

glDrawElements()从当前绑定到GL_ELEMENT_ARRAY_BUFFER目标的EBO中获取索引。这意味着必须在每次要用索引渲染一个物体时绑定相应的EBO,这还是有点麻烦。不过顶点数组对象同样可以保存索引缓冲对象的绑定状态。VAO绑定时正在绑定的索引缓冲对象会被保存为VAO的元素缓冲对象。绑定VAO的同时也会自动绑定EBO。当目标是GL_ELEMENT_ARRAY_BUFFER的时候,VAO会储存glBindBuffer()调用。这也意味着它也会储存解绑调用,所以需要确保没有在解绑VAO之前解绑索引数组缓冲,否则它就没有这个EBO配置了。

VAO与EBO关系

运行程序会获得下面这样的图片的结果:

显示矩形

推荐阅读更多精彩内容