【OpenGL ES】入门及绘制一个三角形

简介

OpenGL

OpenGL(全写Open Graphics Library)是指定义了一个跨编程语言、跨平台的编程接口规格的专业的图形程序接口。它用于三维图像(二维亦可),是一个功能强大,调用方便的底层图形库。此处L代表的是Library而不是Language。

OpenGL在不同的平台上有不同的实现,但是它定义好了专业的程序接口,不同的平台都是遵照该接口来进行实现的,思想完全相同,方法名也是一致的,所以使用时也基本一致,只需要根据不同的语言环境稍有不同而已。

OpenGL ES

OpenGL ES (OpenGL for Embedded Systems) 是 OpenGL 三维图形 API 的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。

OpenGL ES相对于OpenGL来说,减少了许多不是必须的方法和数据类型,去掉了不必须的功能,对代价大的功能做了限制,比OpenGL更为轻量。在OpenGL ES的世界里,没有四边形、多边形,无论多复杂的图形都是由点、线和三角形组成的,也去除了glBegin/glEnd等方法。

EGL

EGL 是 OpenGL ES 渲染 API 和本地窗口系统(native platform window system)之间的一个中间接口层,它主要由系统制造商实现。

EGL提供如下机制:

  • 与设备的原生窗口系统通信
  • 查询绘图表面的可用类型和配置
  • 创建绘图表面
  • 在OpenGL ES 和其他图形渲染API之间同步渲染
  • 管理纹理贴图等渲染资源
  • 为了让OpenGL ES能够绘制在当前设备上,我们需要EGL作为OpenGL ES与设备的桥梁。

在Android上层是用GLSurfaceView可以方便快捷地结合Renderer进行渲染,GLSurfaceView的源码实现其实就是创建与使用EGL(Display、Context等等)的过程。而当需要与相机或MediaCodec进行结合渲染时,就需要直接与EGL打交道。GLSurfaceView只是帮我们做了一些事情而已。

ES与EGL的关系

我们来思考一下画家绘画的过程:首先要有一名懂得各种绘画技艺的画家,然后他需要一张画布,一些笔,一些颜料,一些辅助工具(尺、模板、橡皮、调色板等等),然后他在画布上绘制第一幅画,完成之后展示给人们看;在人们观赏第一幅画的时候,他可以在第二张画布上绘制第二幅画,绘制完成后收回第一幅画,将第二幅画展现给人们看;接着使用工具擦除第一幅画,在同一张画布上绘制第三幅画;周而复始,人们便看到了一幅接一幅的画(这里就涉及到离屏渲染的概念,会在后面FBO相关章节中讲述)。

对比 OpenGL ES/EGL,各要素的对应关系大体如下:

  • 画家:编程人员
  • 笔、颜料、辅助工具:OpenGL ES API
  • 画布:EGL 创建的 Surface

所以计算机绘画的本质就是选择图像显示的像素格式,申请一块内存(画布),填充像素(颜色),绘制完成之后,通知计算机显示到屏幕上(按比例发射RGB光),最终就看到了所绘制的画面。之所以要先选择像素格式,是因为无论是所申请内存的大小,还是硬件驱动解析这块内存的方式,都是由像素格式决定的。

为什么要使用OpenGL ES

通常来说,计算机系统中 CPU、GPU 是协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。所以,尽可能让 CPU 和 GPU 各司其职发挥作用是提高渲染效率的关键。

正如我们之前提到过,OpenGL 正是给我们提供了访问 GPU 的能力,不仅如此,它还引入了缓存(Buffer)这个概念,大大提高了处理效率。

图中的剪头,代表着数据交换,也是主要的性能瓶颈。

从一个内存区域复制到另一个内存区域的速度是相对较慢的,并且在内存复制的过程中,CPU 和 GPU 都不能处理这区域内存,避免引起错误。此外,CPU / GPU 执行计算的速度是很快的,而内存的访问是相对较慢的,这也导致处理器的性能处于次优状态,这种状态叫做 数据饥饿,简单来说就是空有一身本事却无用武之地。

针对此,OpenGL 为了提升渲染的性能,为两个内存区域间的数据交换定义了缓存。缓存是指 GPU 能够控制和管理的连续 RAM。程序从 CPU 的内存复制数据到 OpenGL ES 的缓存。通过独占缓存,GPU 能够尽可能以有效的方式读写内存。 这也意味着 GPU 使用缓存中的数据工作的同时,运行在 CPU 中的程序可以继续执行。

总结

OpenGL的使用需要涉及着色器语言,但是其自身并不是一种语言,严格来说它本身只是一个协议规范,定义了一套可以供上层应用程序进行调用的 API,它抽象了 GPU 的功能,使应用开发者不必关心底层的 GPU 类型和具体实现。而EGL就是OpenGL与本地窗口系统之间的一个抽象的中间接口层。

即使在我们日常Android开发中没有直接与OpenGL进行接触,但是在Android的绘制实现中其实就是通过EGL来直接进行的,例如查看SurfaceFlinger的native层代码就可以发现其内部就有许多相关操作。

OpenGL ES 目前最新版本为3.0,3.0支持了新版的着色语言,纹理MSAA抗锯齿等强大功能,但是目前大多数在用的仍然是2.0版本,也出于主流设备的考虑,本系列教程将基于2.0进行,同时暂不涉及对1.x固定管线渲染方式(简单讲就是2.0通过顶点着色器取代了OpenGL ES 1.x中的变换和光照阶段片元着色器取代了纹理颜色和环境求和Alpha测试等阶段。这使得原来由OpenGL ES 1.x固定的阶段需要用户自己开发着色器处理,虽然在一定的程度上增加了代码复杂度,但是灵活性却大大增加,同时也能够处理OpenGL ES 1.x中难以完成的处理任务。)的介绍以及开发环境的搭建。但是这里仍然附上经典的固定渲染图与可编程渲染管线图:

OpenGL ES可以做什么

  • 图片处理。比如图片色调转换、美颜等。
  • 摄像头预览效果处理。比如美颜相机、恶搞相机等。
  • 视频处理。摄像头预览效果处理可以,这个自然也不在话下了。
  • 3D游戏。比如神庙逃亡、都市赛车等。
  • 。。。

OpenGL ES 2.0 基本概念

图元

在OpenGL中,任何复杂的三维模型都是由基本的几何图元:点、线段和多边形组成的,有了这些图元,就可以建立比较复杂的模型。所有的图元都是由一系列有顺序的顶点集合来描述的。OpenGL ES中的图元只有点、线、三角形,精简了多边形等其他图元,各种复杂的几何形状都是由三角形构成的。包括正方形、圆形、正方体、球体等。但是其他更为复杂的物体,我们不可能都自己去用三角形构建,这个时候就需要通过加载利用其他软件(比如3DMax)构建的3D模型。之后在模型加载章节再详细讲述。

纹理

纹理是一个用来保存图像的色值的 OpenGL ES 缓存

现实生活中,纹理最通常的作用是装饰我们的物体模型,它就像是贴纸一样贴在物体表面,使得物体表面拥有图案。

但实际上在 OpenGL 中,纹理的作用不仅限于此,它可以用来存储大量的数据。一个典型的例子就是利用纹理存储画笔笔刷的mask信息。

纹理坐标在 x 和 y 轴上,范围为 0 到 1 之间(我们使用的是 2D 纹理图像)。使用纹理坐标获取纹理颜色叫做采样。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终始于(1, 1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上。

正因为纹理坐标的与众不同,所以OpenGL纹理渲染中有一种常见的“BUG”,就是纹理颠倒(垂直镜像翻转),有兴趣的同学可以自己想想为什么会出现这种现象,解决方案很简单,通过垂直镜像翻转我们的纹理或者顶点坐标就可以解决,后续在FBO或纹理章节有涉及到再讲述。

着色器

着色器(Shader)是在GPU上运行的小程序,此程序使用OpenGL ES SL语言来编写。它是一个描述顶点或像素特性的简单程序。

顶点着色器

顶点着色器对每个顶点执行一次运算,它可以使用顶点数据来计算该顶点的坐标,纹理的坐标等,在渲染管线中每个顶点都是独立地被执行。

在顶点着色器中最主要的任务是执行顶点坐标变换,应用程序中设置的图元顶点坐标通常是针对本地坐标系的。本地坐标系简化了程序中的坐标计算,但是 GL 并不识别本地坐标系,所以可以在顶点着色器中对本地坐标执行模型视图变换,将本地坐标转化为裁剪坐标系的坐标值。

顶点着色器的另一个功能是向后面的片段着色器提供一组易变变量(varying)。易变变量会在图元装配阶段(简单说,图元装配之后,所有 3D 的图元将被转化为屏幕上 2D 的图元。)之后被执行插值计算,如果是单重采样,其插值点为片段的中心,如果多重采样,其插值点可能为多个采样片段中的任意一个位置。易变变量可以用来保存插值计算片段的颜色,纹理坐标等信息

顶点着色器的输入输出模型如下:

片元着色器

可编程的片段着色器是实现一些高级特效如纹理贴图,光照,环境光,阴影等功能的基础。片段着色器的主要作用是计算每一个片段最终的颜色值(或者丢弃该片段)

在片段着色器之前的阶段,渲染管线都只是在和顶点,图元打交道。在 3D 图形程序开发中,贴图是最重要的部分,程序可以通过 GL 命令上传纹理数据至 GL 内存中,这些纹理可以被片段着色器使用。片段着色器可以根据顶点着色器输出的顶点纹理坐标对纹理进行采样,以计算该片段的颜色值。

另外,片段着色器也是执行光照等高级特效的地方,比如可以传给片段着色器一个光源位置和光源颜色,可以根据一定的公式计算出一个新的颜色值,这样就可以实现光照特效。

片元着色器的输入输出模型如下:

着色器语言

着色器语言(Shading Language)是一种高级的图形编程语言,仅适合于GPU编程,其源自应用广泛的C语言。对于顶点着色器和片元着色器的开发都需要用到着色器语言进行开发。它是面向过程的而非面向对象。关于着色器语言也会放在之后专门的章节中讲述。

坐标系

首先说明一点,网上很多文章说OpenGL的坐标系是右手坐标系是不完全正确的,他们所指的是除归一化设备坐标系(NDC)之外的坐标系,OpenGL一共有模型坐标系、世界坐标系、裁剪坐标系、照相机坐标系、规范化设备坐标系、屏幕坐标系等多个坐标系,会在之后坐标系与坐标变换的章节中讲述。

这里只需要了解OpenGL中归一化设备坐标系在不做任何设置的情况下是左手坐标系,其他坐标系都是右手坐标系,而这些坐标系中我们现在所需要知道的就是归一化设备坐标系(NDC),该坐标系经过视口变换后就转为屏幕坐标系,也就是我们手机屏幕上的坐标系,而我们设置的顶点坐标其实是模型坐标系下的坐标,但如果不经过任何模型变换、投影变换等操作的话,那么就可以将其视为所设置的是NDC坐标系下的坐标。

标准化设备坐标是一个 x、y 和 z 值在 -1.0 到 1.0 的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。

投影

OpenGL ES 的世界是3D的,但是手机屏幕能够给我展示的终究是一个平面,只不过是在绘制的过程中利用色彩和线条让画面呈现出3D的效果。OpenGL ES将这种从3D到2D的转换过程利用投影的方式使计算相对使用者来说变得简单可设置。
OpenGL ES中有两种投影方式:正交投影和透视投影。正交投影,物体不会随距离观测点的位置而大小发生变化。而透视投影,距离观测点越远,物体越小,距离观测点越近,物体越大。

光栅化

在光栅化阶段,基本图元被转换为供片段着色器使用的片段(Fragment)Fragment 可以简单理解为能被渲染到屏幕上的像素,它包含位置,颜色,纹理坐标等信息,这些值是由图元的顶点信息进行插值计算得到的。这些片元接着被送到片元着色器中处理。这是从顶点数据到可渲染在显示设备上的像素的质变过程。

在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

片元在成为像素之前,还会做多种测试(比如深度测试、透明度测试、模板测试等,这些测试目前接触到的一般在3D图像中更常使用,比如深度测试进行物体的遮挡效果的渲染,模板测试可以用于描边等,2D中应用较少)以决定其最终是否会被显示为像素。所以,严格来说,“片元”和“像素”并不是一一对应的。

状态机

OpenGL 是一个状态机,它维持自己的状态,并根据用户调用的函数来改变自己的状态。根据状态的不同,调用同样的函数也可能产生不同的效果。

在 OpenGL 的世界里,大多数元素都可以用状态来描述,比如:

  • 颜色、纹理坐标、光源的各种参数…
  • 是否启用了光照、是否启用了纹理、是否启用了混合、是否启用了深度测试
  • 。。。

OpenGL 会保持状态,除非我们调用 OpenGL 函数来改变它。比如你用glEnableXXX开启了一个状态,在以后的渲染中将一直保留并应用这个状态,除非你调用glDisableXXX及同类函数来改变该状态或程序退出。

又或者当前颜色是一个状态变量,可以把当前颜色设置为白色、红色或其他任何颜色,在此之后绘制的所有物体都将使用这种颜色,直到把当前颜色设置为其他颜色。

介绍状态机是因为OpenGL 当中很多 API,其实仅仅是向 OpenGL 这个状态机传数据或者读数据。而这个操作在之后的OpenGL操作中非常普遍。

上下文

上面提到的各种状态值,将保存在对应的上下文(Context)中。

通过放置这些状态到上下文中,上下文可以跟踪用于渲染的帧缓存、用于几何数据、颜色等的缓存。还会决定是否使用如纹理、灯光等功能以及会为渲染定义当前的坐标系统等。并且在多任务的情况下,就能很容易的共享硬件设备,而互不影响各自的状态。

因此渲染的时候,要指定对应的当前上下文,也就是在按要求创建一系列诸如EGLSurface、EGLDisplay等对象,调用glMakeCurrent之后,当前线程便拥有了OpenGL的绘图能力,而在此之后才能使用OpenGL绘图等操作,否则会出错。在GLSurfaceView中我们可以看到一个GLThread,也就是所谓的GL线程,其实这个线程和我们的普通线程没有区别,但是其内部封装了OpenGL绘制所需要的整个完整过程,并且按照这个流程正确地执行,这就是我们所说的具有了OpenGL的绘图能力。

渲染管线

在 OpenGL 中,任何事物都在 3D 空间中,而屏幕和窗口却是 2D 像素数组,这导致 OpenGL 的大部分工作都是关于把 3D 坐标转变为适应你屏幕的 2D 像素。3D 坐标转为 2D 坐标的处理过程是由 OpenGL 的图形渲染管线(Graphics Pipeline,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D 坐标转换为 2D 坐标,第二部分是把 2D 坐标转变为实际的有颜色的像素。

2D 坐标和像素也是不同的,2D 坐标精确表示一个点在 2D 空间中的位置,而 2D 像素是这个点的近似值,2D 像素受到你的屏幕/窗口分辨率的限制。

图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。它的工作过程和车间流水线一致,各个模块各司其职但是又相互依赖。
下图就是渲染管线:

OpenGL ES 采用服务器/客户端编程模型,客户端运行在 CPU 上,服务端运行在 GPU 上,调用 OpenGL ES 函数的时,由客户端发送至服务器端,并被服务端转换成底层图形硬件支持的绘制命令。

其他

其它更多的诸如3D模型加载、阴影、粒子、混合与雾、标志板、天空盒和与天空穹等内容等后面具体应用时再详细介绍。

渲染过程

OpenGL ES 2.0的渲染过程可以简单叙述为:

读取顶点数据——执行顶点着色器——组装图元——光栅化图元——执行片元着色器——写入帧缓冲区——显示到屏幕上。

OpenGL作为本地库直接运行在硬件上,没有虚拟机,也没有垃圾回收或者内存压缩。在Java层定义图像的数据需要能被OpenGL存取,因此,需要把内存从Java堆复制到本地堆

顶点着色器是针对每个顶点都会执行的程序,是确定每个顶点的位置。同理,片元着色器是针对每个片元都会执行的程序,确定每个片元的颜色。

着色器需要进行编译,然后链接到OpenGL程序(Program)中。一个OpenGL的程序就是把一个顶点着色器和一个片段着色器链接在一起变成单个对象。

绘制一个三角形

正如我们学习Java、C++等编程语言时大多数教程都会先告诉你怎么写出一句Hello World,OpenGL的教程大多数第一课也是教你如何绘制一个简单三角形。接下来我们就按照上述所说的渲染过程,讲解一下如何通过OpenGL ES的API在Android手机上显示出一个三角形。

在Demo中我们创建一个TriangleActivity作为我们的界面,使用Android自带的GLSurfaceView作为渲染的载体(现在自己创建EGLSurface还为时过早),同时我们创建一个TriangleRenderer作为GLSurfaceView的Renderer,在其里面实现实际的渲染操作。为了便于理解,着色器、顶点数组等将全部放于该Renderer内,后续的例子再进行封装。

第一个Renderer

首先我们现在创建并实现整个渲染过程中最核心的部分TriangleRenderer,并让其实现Renderer接口。

Renderer接口中有三个需要实现的方法,分别是onSurfaceCreatedonSurfaceChanged以及onDrawFrame ,前两个方法如果有接触过SurfaceView及SurfaceHolder的话就比较熟悉,分别是Surface创建时的回调以及SUrface如宽高变化时的回调,onSurfaceCreated主要用于初始化等,onSurfaceChanged主要用于做模型视图转换等操作,而onDrawFrame就是当OpenGL渲染每一帧的回调方法,我们的实际绘制操作就在这里进行。

这三个方法我们先放着,先来按照渲染流程,我们创建绘制一个三角形所需要的顶点数据
顶点数据是一个包含了所绘制图像放置在OpenGL坐标系中后,其各个顶点的三维坐标的数组(其实顶点数据还可以放置颜色等,通过偏移来获取不同类型的数据)。那么刚刚在坐标系中说了,OpenGL里有多个坐标系,但是和我们目前关系最大的是NDC,NDC坐标系:

即NDC坐标系的原点(0,0)默认位置在屏幕中心,x,y,z轴范围为[-1,1],而Android屏幕坐标系原点在左上角,x,y轴范围为[0,各轴分辨率]。

现在我们要绘制一个三角形,顶点在y轴正向最大值位置,左下角在x轴负向最大值位置,右下角在x轴正向最大值位置,那么对应的顶点数组为:


//设置三角形顶点数组
private static final float TRIANGLE_COORDS[] = {  
     //默认按逆时针方向绘制??
    0.0f, 1.0f, 0.0f, // 顶点
    -1.0f, -0.0f, 0.0f, // 左下角
    1.0f, -0.0f, 0.0f  // 右下角
};
接下来我们开始编写顶点着色器:

private static final String VERTEX_SHADER =
"//根据所设置的顶点数据,插值在光栅化阶段进行\n" +
"attribute vec4 vPosition;" +
"void main() {" +
"  //设置最终坐标\n"
"  gl_Position = vPosition;" +
"}";
对于上述着色器只需要知道`vPosition`就是我们所设置的顶点数据,而`gl_Position`是OpenGL的内置变量,代表着当前这个片元最终所处的坐标。而`vec`是代表向量,坐标使用`vec4`而不是`vec3`的原因是因为`齐次坐标`的关系,但是这个在这里不是重点。 `组装图`,`光栅化图元`OpenGL会自动进行,这里我们不管,接下来我们开始编写片元着色器,来为这个三角形加上颜色:

private static final String FRAGMENT_SHADER =
    "//设置float类型默认精度,顶点着色器默认highp,片元着色器需要用户声明\n" +
    "precision mediump float;" +
    "//颜色值,vec4代表四维向量,此处由用户传入,数据格式为{r,g,b,a}\n" +
    "uniform vec4 vColor;" +
    "void main() {" +
    "//该片元最终颜色值\n" +
    "gl_FragColor = vColor;" +
"}";
在上述着色器代码中,首先我们声明了片元着色器中默认float类型变量的精度(中等),在顶点着色器中默认精度为highp,而片元着色器中必须自己设置。 之后我们声明了一个`uniform`类型的四维向量,用以存储用户所设置的三角形颜色,`gl_FragColor`也是OpenGL的内置变量,表示片元最终的颜色值,这里我们直接将`vColor`作为最终颜色。 从片元着色器中可以看到,有一个数据还需要用户自己设定,那就是三角形的颜色值,格式是{r,g,b,a},设置如下:

// 设置三角形颜色和透明度(r,g,b,a)
private static final float COLOR[] = {1.0f, 0.0f, 0f, 1.0f};//红色不透明
最后`写入帧缓冲区`,`显示到屏幕上`也是由OpenGL自动完成,那么至此我们已经完成了顶点着色器和片元着色器的实现,也提供了这两个着色器所需要的顶点数据和颜色数据,那么接下来就是怎么将这些数据与着色器内的变量相绑定,并且告知OpenGL什么时候开始渲染以及怎么渲染。 让我们回到Renderer那三个未实现的接口上,首先我们在`onSurfaceCreated`调用时,也就是Surface正式创建后,做一些初始化操作:

private int mProgramId;
private int mColorId;
private int mPositionId;
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
    //编译着色器并链接顶点与片元着色器生成OpenGL程序句柄
    mProgramId = OpenGlUtils.loadProgram(VERTEX_SHADER, FRAGMENT_SHADER);
    //通过OpenGL程序句柄查找获取顶点着色器中的位置句柄
    mPositionId = GLES20.glGetAttribLocation(mProgramId, "vPosition");
    //通过OpenGL程序句柄查找获取片元着色器中的颜色句柄
    mColorId = GLES20.glGetUniformLocation(mProgramId, "vColor");
}
上述代码中我们借助了一个封装了编译着色器并链接生成当前OpenGL程序句柄的工具类,我们先来简单介绍一下OpenGL中编译着色器并链接至最终的Program上的流程:

public static int loadShader(final String strSource, final int iType) {
    int[] compiled = new int[1];
    //创建指定类型的着色器
    int iShader = GLES20.glCreateShader(iType);
    //将源码添加到iShader并编译它
    GLES20.glShaderSource(iShader, strSource);
    GLES20.glCompileShader(iShader);
    //获取编译后着色器句柄存在在compiled数组容器中
    GLES20.glGetShaderiv(iShader, GLES20.GL_COMPILE_STATUS, compiled, 0);
    //容错判断
    if (compiled[0] == 0) {
        Log.d("Load Shader Failed", "Compilation\n" + GLES20.glGetShaderInfoLog(iShader));
        return 0;
    }
    return iShader;
}

public static int loadProgram(final String strVSource, final String strFSource) {
    int iVShader;
    int iFShader;
    int iProgId;
    int[] link = new int[1];
    //获取编译后的顶点着色器句柄
    iVShader = loadShader(strVSource, GLES20.GL_VERTEX_SHADER);
    if (iVShader == 0) {
        Log.d("Load Program", "Vertex Shader Failed");
        return 0;
    }
    //获取编译后的片元着色器句柄
    iFShader = loadShader(strFSource, GLES20.GL_FRAGMENT_SHADER);
    if (iFShader == 0) {
        Log.d("Load Program", "Fragment Shader Failed");
        return 0;
    }
    //创建一个Program
    iProgId = GLES20.glCreateProgram();
    //添加顶点着色器与片元着色器到Program中
    GLES20.glAttachShader(iProgId, iVShader);
    GLES20.glAttachShader(iProgId, iFShader);
    //链接生成可执行的Program
    GLES20.glLinkProgram(iProgId);
    //获取Program句柄,并存在在link数组容器中
    GLES20.glGetProgramiv(iProgId, GLES20.GL_LINK_STATUS, link, 0);
    //容错
    if (link[0] <= 0) {
      Log.d("Load Program", "Linking Failed");
      return 0;
    }
    //删除已链接后的着色器
    GLES20.glDeleteShader(iVShader);
    GLES20.glDeleteShader(iFShader);
    return iProgId;
}
上述代码中关键代码点都有注释,这里可以发现OpenGL的很多接口调用方式与C非常相似,比如获取句柄的方式是将句柄存入一个数组容器中,与C的指针有点相像。 到这里我们已经获取到了一个`Program`,还有后续需要绑定我们数据的`vColor`,`vPosition`的地址。接下来我们需要设置视口来告诉OpenGL我们想要显示在屏幕的哪个区域内:

@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
    GLES20.glViewport(0, 0, width, height);
}
当我们设置GLSurfaceView为全屏的时候,那么上述的`width`就是屏幕宽度,`height`就是屏幕高度,上述设置的意思就是我们当前渲染的视口区域从屏幕左上角原点(0,0)开始,宽高为全屏。 至此就万事俱备了,接下来我们便要在OpenGL开始渲染的回调接口`onDrawFrame()`中进行我们最后的渲染操作了:

//设置每个顶点的坐标数
private static final int COORDS_PER_VERTEX = 3;
//下一个顶点与上一个顶点之间的不长,以字节为单位,每个float类型变量为4字节
private final int VERTEX_STRID = COORDS_PER_VERTEX * 4;
//顶点个数
private final int VERTEX_COUNT = TRIANGLE_COORDS.length / COORDS_PER_VERTEX;

@Override
public void onDrawFrame(GL10 gl10) {
     //这里网上很多博客说是设置背景色,其实更严格来说是通过所设置的颜色来清空颜色缓冲区,改变背景色只是其作用之一
    GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f);//白色不透明
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
    //告知OpenGL所要使用的Program
    GLES20.glUseProgram(mProgramId);
    //启用指向三角形顶点数据的句柄
    GLES20.glEnableVertexAttribArray(mPositionId);
    //绑定三角形的坐标数据
    GLES20.glVertexAttribPointer(mPositionId, COORDS_PER_VERTEX,
            GLES20.GL_FLOAT, false,
            VERTEX_STRID, mVertexBuffer);
    //绑定颜色数据
    GLES20.glUniform4fv(mColorId, 1, TRIANGLE_COORDS, 0);
    //绘制三角形
    GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, VERTEX_COUNT);
    //禁用指向三角形的顶点数据
    GLES20.glDisableVertexAttribArray(mPositionId);
}
首先这里我们注意到除了注释之外,我们的代码少了一个变量,就是`mVertexBuffer`,这个变量是一个FloatBuffer类型的变量,用于开辟处一块内存缓冲区来存储供OpenGL使用的顶点数据,在我们这个Demo中顶点数据不会发生变化,所以我们直接在`onSurfaceCreated()`接口的最后加上如下代码进行初始化即可:

//初始化顶点字节缓冲区,用于存放三角的顶点数据
ByteBuffer bb = ByteBuffer.allocateDirect(
    //(每个浮点数占用4个字节
    TRIANGLE_COORDS.length * 4);
//设置使用设备硬件的原生字节序
bb.order(ByteOrder.nativeOrder());
//从ByteBuffer中创建一个浮点缓冲区
mVertexBuffer = bb.asFloatBuffer();
//把坐标都添加到FloatBuffer中
mVertexBuffer.put(TRIANGLE_COORDS);
//设置buffer从第一个位置开始读
//因为在每次调用put加入数据后position都会加1,因此要将position重置为0
mVertexBuffer.position(0);
还有记得在接口外面声明变量:

private FloatBuffer vertexBuffer;
为什么使用java的nio包下的Buffer作为内存缓冲区的形式一方面是出于性能等方面的考虑,另一方面 OpenGL 是一个非常底层的绘制接口,它所使用的缓冲区存储结构是和我们的 Java 程序中不相同的(Java 是大端字节序(BigEdian),而 OpenGL 所需要的数据是小端字节序(LittleEdian))。所以,我们在将 Java 的缓冲区转化为 OpenGL 可用的缓冲区时需要作这样的一些工作。 而颜色的绑定我们看到就简单得多,只需要调用接口就可以实现,因为两者的在这个Demo中变量类型不同(`attribute`只能在顶点着色器中使用,通常用于表示顶点坐标、纹理坐标等,而`uniform`常用于表示常量形式的颜色、矩阵、材质等,两者设置接口也不同,具体会在后续着色器章节中讲述)。我们也可以通过将颜色与顶点数据放置一起,然后一起转为FloatBuffer来传递给OpenGL,并且设置每个顶点的颜色不同,通过`glVertexPointer`与`glColorPointer`两个接口配合使用来绘制出如下的三角形,这也就是之前一直讲的插值的含义,OpenGL会自动对顶点间坐标以及颜色进行插值计算:
至此我们已经完成`TriangleRender`的实现,最后只需要加上一个回收资源的方法:

public void destroy() {
    GLES20.glDeleteProgram(mProgramId);
}

GLSurfaceView

前面我们完成了TriangleRender的实现,那么接下来我们将其与GLSurfaceView绑定起来以便于看到我们渲染的结果。

为了简单起见,我们直接TriangleActivity的布局文件中加入GLSurfaceView,在onCreate()中加入如下代码:


GLSurfaceView glSurfaceView = findViewById(R.id.gl_surface);
// 创建OpenGL ES 2.0的上下文
glSurfaceView.setEGLContextClientVersion(2);
//设置Renderer用于绘图
glSurfaceView.setRenderer(new TriangleRender());
//只有在绘制数据改变时才绘制view,可以防止GLSurfaceView帧重绘
//该种模式下当需要重绘时需要我们手动调用glSurfaceView.requestRender();
glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);

最后我们跑一下项目,就可以在手机上看到如下的三角形了:

总结

这章我们介绍了OpenGL(ES)以及EGL的相关内容和一些基本概念,同时通过绘制一个简单的三角形来了解了OpenGL的常见绘制流程以及部分接口,感兴趣的可以自己尝试一下如何绘制一个渐变的三角形或者一个正方形等较简单的几何图形。使用OpenGL进行绘制的确比直接使用Android自带的绘图API繁琐一些,出现了问题也比较难以排查,因为更接近于底层所以理解上很多地方不太一样,但是对于图形渲染或者处理,OpenGL无论是性能还是可以实现的效果都是胜出一筹的。

下一章将深入讲解一下纹理的相关内容,同时绘制一个简单的纹理。该系列教程Demo见OpenGLESLesson

答疑

Q:顶点着色器执行过程中是否会插值?
第九课 插值
答案是不会。插值是发生在光栅化阶段的,这时候会对顶点执行插值,从而获取当前像素的位置,然后执行片元着色器为其着色。比如纹理坐标,我们也是传入顶点着色器,然后通过varying类型变量先持有,进而输出到片元着色器,在输出到片元着色器之前,顶点坐标、纹理坐标都将被插值,即此时输出到片元着色器的值就是插值后的纹理坐标值。

参考

OpenGL ES 基础概念
OpenGL ES 开篇
Android OpenGLES2.0(一)——了解OpenGLES2.0


版权声明

![Creative Commons BY-NC-ND 4.0 International License](/images/cc.png)

Lam’s Blog by Binghe Lin is licensed under a Creative Commons BY-NC-ND 4.0 International License.
林炳河创作并维护的Lam’s Blog采用创作共用保留署名-非商业-禁止演绎4.0国际许可证

本文首发于Lam’s Blog - Knowledeg as Action,版权所有,侵权必究。

本文永久链接:http://linbinghe.com/2018/b8f62c8f.html