第1章
准 备 开 始
在本章里,我们会直奔主题,创建我们的第一个Android OpenGL应用程序。在阅读本书每一章的过程中,你会发现,每一章都会以概述作为开始,然后浏览“游戏计划”,也就是那一章的开发计划。下面就是本章的“游戏计划”:
首先,我们会安装和配置开发环境;
然后,创建我们的第一个OpenGL应用程序,它将初始化OpenGL,并且处理Android的Activity组件的生命周期;关于生命周期,后文会做详细介绍。
这些将作为我们需要在屏幕上绘制东西时的基础。
准备好了?出发!
1.1安装工具
下面是开发Android OpenGL所需要的基本工具列表:
一台运行Windows、OS X或者Linux的计算机
Java开发包(JDK)
Android软件开发包(SDK)
集成开发环境(IDE)
一个手机、平板电脑或者支持OpenGL ES 2.0的模拟器
你需要的第一个工具是一台适合开发的个人计算机;任何较新的计算机都可以,它需要能运行Windows、OS X或者Linux。在这台计算机上,你需要安装Java开发包,可以从Oracle的网站下载;当前,Google指定JDK 6作为Android开发的版本,但后面的版本默认情况下应该也可以工作,万一出现问题,要检查编译器的兼容版本是否设置成了1.6。
你还需要安装Android软件开发包,可以从Android开发者网站下载;这个开发包包括Android开发所需要的一切工具,包括支持OpenGL ES 2.0的模拟器和集成开发环境;一旦你的计算机已经安装完了JDK,你就可以继续把Android SDK解压到任何选择的文件夹中。
Android SDK包里附带了Eclipse,这是一个流行的集成开发环境,也是Google的Android开发工具集(ADT)官方支持的IDE。本书会一直使用Eclipse,不过,如果你想使用其他的开发环境,IntelliJ的IDEA社区版(Community Edition)是个不错的选择;Google最近发布了Android Studio,它是IntelliJ的一个分支,包括了专为支持 Android开发的新工具和功能。
1.1.1配置新的模拟器
既然工具安装完成了,就可以使用Android虚拟设备(AVD)管理器创建新的虚拟设备。
1.进入安装Android SDK的文件夹。如果是Windows系统,运行“SDK Manager.exe”打开Android SDK管理器;如果是其他操作系统, 运行“sdk/tools/android”。
. 2.选择菜单“Tools→ Manage AVDs”,打开Android虚拟设备管理器。
3.选择“New”,弹出“Create new Android Virtual Device(AVD)”对话框。
4.选择“Galaxy Nexus”作为设备(device)。
5.勾选“Use Host GPU(graphics procession unit)”旁边的复选框。
6.给虚拟设备命名,其他设置保留默认值;窗口看起来如图1-1所示。
7.单击“OK”按钮,创建新的模拟器映像,如图1-1所示。
现在你可以关闭AVD和SDK管理器。
使用x86模拟器
如果你的计算机支持硬件虚拟化,你可能想尝试一下x86模拟器。你需要先下载Intel x86 Atom系统映像,它可以从SDK管理器中最新的Android SDK版本里得到;你还要安装Intel的硬件加速执行管理器,展开“Extras”选项,在最底端就能发现那个管
理器。
一旦安装完这些软件包,下一步就是配置硬件加速执行管理器。你需要运行一个安装器,它在Android SDK安装目录下的“etras/intel/Hardware_Accelerated_Execution_Manager”,运行那个文件夹中的可执行文件,并遵照其安装指导即可。你可能需要确保计算机BIOS系统设置里的“Intel Virtualization Technology (VT-x)”选项是使能的。
现在,你只需要按照1.1.1节中所描述的步骤配置这个模拟器,只是这次要选择x86模拟器而不是ARM模拟器。在Android开发网站上可以得到更多关于虚拟机(VM)加速的指导。
1.1.2使用模拟器
可以使用AVD管理器启动一个模拟器实例,或者,也可以让IDE在需要时自动启动;如果系统里没有其他模拟器运行,Eclipse中的Android开发工具插件会启动那个模拟器。让一个模拟器实例持续运行是个不错的主意,这样你就不必每次都等待它启动了。
1.1.3获取支持OpenGL ES 2.0的设备
可以使用模拟器,但是最好有一台真实的设备,因为模拟器不能准确反映真实的性能和结果,而且,即使你的计算机硬件性能很好,模拟器也会显得很慢。Nexus 7是个很好但不贵的选择,可以从Google Play在线购买。
1.2创建第一个程序
我们已经安装并配置了工具,现在来创建第一个OpenGL程序。这个程序非常简单:它要做的只是初始化OpenGL并不停地清空屏幕;要得到一个可以实际做点什么的OpenGL程序,这是我们需要的最少的内容。
Joe 问:为什么要不停地清空屏幕
如果已经在每一帧的屏幕上都绘制了内容,清空屏幕好像是浪费的,那为什么还要这样做呢?
回到一切都用软件渲染的时代,清空屏幕总是一种浪费;开发者总是要假定所有东西都已经绘制好了,并能覆盖前一帧的内容,而不必清空它,这样就能达到优化的目的;可以节省清空屏幕的处理时间。有时,这种优化会导致一些游戏中常见的著名的“镜子大厅”(Hall Of Mirrors)效应,如Doom:它导致的视觉效果就像站在镜子大厅中间,旧的内容一遍又一遍重复出现。
这种优化不再有效了;最新的GPU以不同的方式工作,他们使用特殊的渲染技术,如果屏幕是干净的,他们能工作得更快。通过让GPU清空屏幕,可以节省帧拷贝浪费的时间。因为GPU的这种工作方式,清空屏幕可以帮助避免很多问题,如闪烁或者有物品没有绘制;保留旧的内容容易看到不期望或者不理想的结果。
通过下面的链接,可以学到更多的内容:
? http://developer.amd.com/gpu_assets/gdc2008_ribble_maurice_TileBasedGpus.pdf
? http://www.beyond3d.com/content/articles/38/
如果你想查看源代码,这本书的所有源代码和相关数据都可以从本书的主页上下载。
新建项目
接下来按照下面的步骤新建项目:
1.在Eclipse菜单中选择“ File→New→Android Application Project”,当对话框弹出时,输入如下信息。
应用程序名(Application Name):
输入“First OpenGL Project”。
包名(Package Name):
包名是一个项目独一无二的标识符;按照惯例,要输入一个Java样式的包名,因此,让我们输入“com.firstopenglproject.android”。
最低SDK版本(Minimum SDK):
选择“API 10: Android 2.3.3 (Gingerbread)”;这是完全支持OpenGL ES 2.0的最低版本号。
2.其余选项使用默认值,那个表格看起来应该与图1-2类似。
3.单击“Next”按钮,不要勾选“Create custom launcher icon”,并且确认勾选了“Create Activity”;你可以为这个项目选择一个不同的目录。
4.再单击“Next”按钮就到了创建Activity的界面;要保证“Blank Activity”被选中了,再单击“Next”按钮,新的“Blank Activity”配置界面就出现了;设置Activity名字为“FirstOpenGLProjectActivity”;你的界面看上去应该与图1-3相似。
5.单击“Finish”按钮,继续并构建这个项目。
图1-2在Eclipse中创建新的Android项目
当单击“Finish”按钮后,Eclipse会工作一会儿,然后新项目就创建好了。
1.3初始化OpenGL
下一步就是使用一个特殊的类GLSurfaceView初始化OpenGL。GLSurfaceView会处理OpenGL初始化过程中比较基本的操作,如配置显示设备(display)以及在后台线程中渲染;渲染是在显示设备中一个称为“surface”的特定区域完成的,有时也称为视口(viewport)。
GLSurfaceView类也使得处理标准Android Activity生命周期变得容易了;在Android里,任何Activity都可以创建(create)和销毁(destroy),并且在用户切换到其他Activity时暂停(pause),在切换回来时继续(resume)。根据这个生命周期,在Activity被暂停的时候,我们需要释放OpenGL的资源;GLSurfaceView为此提供了很多辅助方法。
在EDBurnette所写的《Hello, Android》里,你可以学到更多关于Activity生命周期的内容。
图1-3创建新的Android项目:配置Activity
纹理视图
在幕后,GLSurfaceView实际上为它自己创建了一个窗口(window),并在视图层次(View Hierarchy)上穿了个“洞”,让底层的OpenGL surface显示出来。对于大多数使用情况,这就足够了;但是,GLSurfaceview与常规视图(view)不同,它没有动画或者变形特效,因为GLSurfaceView是窗口(window)的一部分。
从Android 4.0 Ice Cream Sandwich开始,Android提供了一个纹理视图(TextureView),它可以渲染OpenGL而不用创建单独的窗口或打洞了,这就意味着,这个视图像一个常规窗口一样,可以被操作,且有动画和变形特效。但是,TextureView类没有内置OpenGL初始化操作,要想使用TextureView,一种方法是执行自定义的OpenGL初始化,并在TextureView上运行,另外一种方法是把GLSurfaceView的源代码拿出来,把它适配到TextureView上。
1.3.1创建GLSurfaceView实例
打开前面自动生成的Activity类FirstOpenGLProjectActivity。按下“Ctrl+Shift+T”快捷键,“Open Type”对话框就会弹出来,然后输入“FirstOpenGLProjectActivity”,当那个类显示出来时,就选择它。
Eclipse键盘快捷键
在Mac或者Linux平台上,快捷键可能与Windows平台不同;比如,快捷键“Ctrl+Shift+O”,在Windows平台上,它是用来组织和查找新的Java 导入(import)的;在Mac平台上就是。可以通过菜单“Help→Key Assist”选择按键辅助功能查看各个平台的键绑定。
现在看起来没有多少代码:
我们给这个Activity添加一个GLSurfaceView实例,这样就可以初始化OpenGL。让我们在类的顶部加入如下两个新的成员变量:
我们需要导入GLSurfaceView类,因此,按“Ctrl+Shift+O”快捷键组织导入,并把这个新类放到代码里;每次加入一个新的类引用,并且它需要被导入时,我们都要做这一步。我们将用rendererSet记住GLSurfaceView是否处于有效状态。让我们从代码中移除setContentView()的调用,并加入如下代码初始化glSurfaceView:
1.3.2检查系统是否支持OpenGL ES 2.0
因为我们只为2.0版本写代码,我们要做的下一件事就是检查系统是否实际支持OpenGL ES 2.0。让我们添加如下几行代码到onCreate()函数中:
首先,我们需要Android ActivityManager的一个引用,用它获取设备配置信息,然后,取出reqGlEsVersion变量检查OpenGL ES版本号。如果版本号为0×20000或后续版本,我们就可以使用 OpenGL ES 2.0的API了。
但是这段代码实际上不能在模拟器上工作,因为GPU模拟部分有缺陷;为了使代码在模拟器上正常工作,我们要按如下代码修改那个检查条件:
这段代码测试当前设备是不是模拟器,如果是,就假定它支持OpenGL ES 2.0。要确保程序能运行,模拟器一定要配置OpenGL ES 2.0,参见1.1.1节。
1.3.3为OpenGL ES 2.0配置渲染表面
下一步就是配置渲染表面(rendering surface),添加如下几行代码:
如果设备支持OpenGL ES 2.0,我们就通过调用setEGLContextClientVersion(2)配置这个surface视图;然后调用setRenderer()传进自定义Renderer类的一个新实例,稍后,我们会看到如何创建这个Renderer类,同时这段代码通过设置rendererSet为true记住渲染器(renderer)已经设置过了。当surface创建或者发生变化的时候,以及要绘制一幅新帧时,渲染器都会被GLSurfaceView调用。
如果设备不支持OpenGL ES 2.0,怎么办呢?也可以添加一个支持OpenGL ES 1.0的回退的渲染器,但是,这种情形现在已经很少见了,可能不值得付出这样的努力。根据Android开发者统计板,仅有大约9%的设备只支持OpenGL ES 1.1,这个数字还在随着时间不断缩减。如果一个设备不支持OpenGL ES 2.0,公开发布的应用应该在这个设备的应用程序市场中被隐藏起来,在14.1.6节中,我们会学到这点。
我们需要添加另外一个调用把GLSurfaceView加入到这个activity中,并把它显示到屏幕上,在onCreate()函数结尾处,用如下代码替换旧的调用setContentView():
1.3.4处理Android Activity生命周期的事件
我们还需要处理Android Activity生命周期的事件,否则,如果用户切换到另外一个应用,应用就会崩溃;加入如下方法让这个Activity类变得完整:
这些方法非常重要,有了它们 ,这个surface视图才能正确暂停并继续后台渲染线程,同时释放和续用OpenGL上下文。如果它没有做这些,应用程序可能会崩溃,并被Android终止;我们还要保证渲染器也设置了,否则调用这些方法也会引起程序崩溃。
1.4创建Renderer类
现在要定义一个渲染器,以便我们开始清空屏幕。
让我们快速浏览一下渲染器接口定义的方法:
onSurfaceCreated(GL10 glUnused, EGLConfig config)
当Surface被创建的时候,GLSurfaceView会调用这个方法;这发生在应用程序第一次运行的时候,并且,当设备被唤醒或者用户从其他activity切换回来时,这个方法也可能会被调用。在实践中,这意味着,当应用程序运行时,本方法可能会被调用多次。
onSurfaceChanged(GL10 glUnused, int width, int height)
在Surface被创建以后,每次Surface尺寸变化时,这个方法都会被GLSurfaceView调用到。在横屏、竖屏来回切换的时候,Surface尺寸会发生变化。
onDrawFrame(GL10 glUnused)
当绘制一帧时,这个方法会被GLSurfaceView调用。在这个方法中,我们一定要绘制一些东西,即使只是清空屏幕;因为,在这个方法返回后,渲染缓冲区会被交换并显示在屏幕上,如果什么都没画,可能会看到糟糕的闪烁效果。
怎么会有一个未被使用的参数类型GL10呢?它是OpenGL ES 1.0的API(应用程序编程接口)遗留下来的;如果要编写使用OpenGL ES 1.0的渲染器,就要使用这个参数;但是,对于OpenGL ES 2.0,GLES20类提供了静态方法来存取。
在后台线程中渲染
GLSurfaceView会在一个单独的线程中调用渲染器的方法。默认情况下,GLSurfaceView会以显示设备的刷新频率不断地渲染,当然,它也可以配置为按请求渲染,只需要用
GlSurfaceView.RENDERMODE_WHEN_DIRTY作为参数调用GLSurfaceView.setRenderMode()即可。
既然Android的GLSurfaceView在后台线程中执行渲染,就必须要小心,只能在这个渲染线程中调用OpenGL,在Android主线程中使用UI(用户界面)相关的调用;两个线程之间的通信可以用如下方法:在主线程中的GLSurfaceView实例可以调用queueEvent()方法传递一个Runnable给后台渲染线程,渲染线程可以调用Activity的runOnUIThread()来传递事件(event)给主线程。
新建渲染器
在同一个包里创建一个各为FirstOpenGLProjectRender的新类,用来实现Renderer接口;为了创建这个新类,在包浏览器(package Explorer)里,鼠标右键单击“com.firstopenglproject.android”,接着选择“New→Class”;在弹出的“New Java Class”窗口中输入“FirstOpenGLProjectRenderer”作为名字,并单击“Finish”按钮。
我们会以下面的代码作为开始,并加入我们的第一个方法OnSurfaceCreated():
首先,在onSurfaceCreated()中调用glClearColor(1.0f, 0.0f, 0.0f, 0.0f)设置清空屏幕用的颜色;前三个参数分别对应红色、绿色和蓝色,最后的参数对应一个特殊的分量,称为阿尔法(alpha),它经常用来表示半透明度或透明度。通过把第一个分量设为1,其余设为0,我们把红色设置为最大强度,当屏幕被清空时,它就会显示红色。2.6节会更详细地讨论这个颜色模型。
下一步就是设置视口(viewport)尺寸了,让我们加入如下代码:
在onSurfaceChanged()方法中,调用glViewPort(0, 0, width, height)设置了视口尺寸,这就告诉了OpenGL可以用来渲染的surface的大小。
要用如下代码完成这个渲染器类:
在onDrawFame()中调用glClear(GL_COLOR_BUFFER_BIT)清空屏幕;这会擦除屏幕上的所有颜色,并用之前glClearColor()调用定义的颜色填充整个屏幕。
现在测试一下代码,看看会发生什么。按“Ctrl+F11”键运行这个程序。你会看到一个空白的红色屏幕,如图1-4所示。
尝试改变清空的颜色,然后再运行程序看看会发生什么!你应该看到屏幕上的颜色与代码的改动完全匹配。
如果你使用的是模拟器,且程序不工作,而你也检查过模拟器的配置选项“Use Host GPU”已经被勾选了,那就试试在glSurfaceView.setRenderer()调用之前加入glSurfaceView.setEGLConfigChooser(8,8,8,8,16,0)的调用。
1.5使用静态导入
这是我们第一次使用静态导入(static import)指令。我们会在代码中大量地使用它,因为这个指令帮助我们极大地减少了冗长的代码,比如调用GLE20.glClear (GLE20.GL_COLOR_BUFFER_BIT)缩减成glClear(GL_COL_OR_BUFFER_BIT);当有相当大量的代码使用OpenGL和其他实用程序(utility)的时候,这会非常有用。
遗憾的是,Eclipse对静态导入支持得并不是很好。为了使事情容易些,我推荐你选择菜单“Window→Preferences”,然后选择“Java→Editor→Content Assist→Favorites”,并添加如下类型:
android.opengl.GLES20
android.opengl.GLUtils
android.opengl.Matrix
这对于自动完成代码编写有帮助,但是,这仍然不能修复“组织导入”(Organize Imports)的缺陷。把下面的代码粘贴到类的顶端可以修复它:
现在当你组织这些导入时,所有需要的静态导入都会自动被引入;无论何时你需要添加一个对象引用,如果这个对象需要一个新的导入,你就可以容易地修复它,只需到类的顶端,用一个星号(*)替换一个静态导入的结尾字符,并再次运行“组织导入”。
1.6小结
在本章,我们学习了如何创建一个新的OpenGL项目和清空屏幕。我们安装及配置了开发环境、创建了一个新项目、初始化了OpenGL、响应了Android Activity的生命周期,最后清空了屏幕!
我们现在有一个基础了,后面的所有项目都能以此为基础来构建。休息片刻。在下面的两章里,我们会继续在这个基础上构建,学习如何为GPU编程,并加入更多特性。准备好了吗?让我们继续学习下一章。
第一部分
一个简单的空气曲棍球游戏
■?第2章定义顶点和着色器
■?第3章编译着色器及在屏幕上绘图
■?第4章增加颜色和着色
■?第5章调整屏幕的宽高比
■?第6章进入第三维
■?第7章用纹理增加细节
■?第8章构建简单物体
■?第9章增加触控反馈:与空气曲棍球游戏交互
第2章
定义顶点和着色器
本章介绍我们的第一个项目:一个简单的空气曲棍球游戏。在我们开发这个项目的过程中,会学习到一些主要的OpenGL组件。
作为起始,我们会学习如何使用独立的点集合构建物体,这些点称为顶点;之后,我们会学习怎样使用着色器绘制这些物体,以及告诉OpenGL如何绘制这些物体的一些小程序。顶点和着色器这两个概念极其重要,因为每个物体的构建都是通过顶点的聚合形成点、直线和三角形,并且这些基本图形都要使用着色器绘制。
我们会首先学习顶点,这样就可以构建空气曲棍球游戏中用到的桌子,并且使用OpenGL的坐标系统定义这个桌子在现实世界中的位置,接下来我们就要创建一套非常基本的着色器在屏幕上绘制这个桌子。在下一章,我们也会学习如何在屏幕上绘制顶点作为点、直线和三角形,并在随后的章节里,学习颜色、平滑着色、纹理和触控交互,以及平行和透视投影。
完成后,我们的空气曲棍球游戏就会如图2-1所示。
2.1为什么选择空气曲棍球
空气曲棍球是一个简单、流行的游戏,经常可以在保龄球馆和酒吧里见到它。尽管简单,但非常容易让人上瘾。在Android的应用市场——Google Play里,一些顶级游戏都是基于该游戏或者其他令人愉快的游戏的变种。
随着空气曲棍球游戏开发的讲解,我们会学习很多OpenGL概念。我们会学习如何定义和绘制一个可以玩游戏的桌子,以及如何添加一些细节,如颜色、着色器和纹理。我们也会学习如何通过执行触屏事件响应用户操作。
游戏规则
要玩空气曲棍球游戏,我们需要一个有两个球门的长方形桌子(每端一个),一个冰球和两个用来击打冰球的木槌。在每个回合开赛前,都把冰球放在桌子中间;每个玩家要尽力把冰球击进对方的球门,同时要防御对方的进攻;第一个射进7个球的玩家获胜。
作为游戏开发计划的一部分,我们需要做的第一件事情就是要学会如何定义空气曲棍球桌子的结构,以及如何编写可以在屏幕上绘制这张桌子的代码。同时,要构建一个框架作为后续章节的基础。我们暂时让事情简单一点,只定义一个长方形作为桌子,并在桌子中间定义一条分隔线把两边的玩家分开。
我们也需要某种形式用来代表冰球和球门;我们先用独立的点定义它们。在本章结束时,就可以得到所需的结构,只要增加命令就能在屏幕上实际绘制这个桌子了。
2.2不要从头开始
我们可以重用第1章的项目作为起点。
1.在Eclipse里,选择“FirstOpenGLProject”,要确保项目是打开的,然后按“Ctrl+C”快捷键;再按“Ctrl+V”快捷键复制这个项目。
2.出现提示框时,输入“AirHockey1”作为项目名,你可以自己选择项目目录。
3.打开新项目,展开“src”文件夹,找到我们在第1章中定义的两个类文件:FirstOpenGLProjectActivity.java和FirstOpenGLProjectRenderer.java。
4.重命名我们定义过的这些类,先选择FirstOpenGLProjectActivity.java,按“Alt+Shift+ R”快捷键打开重命名对话框,输入“AirHockeyActivity”(没有.java后缀)作为新名字,Eclipse会自动把后缀附到文件名上;单击“Finish”按钮结束重命名过程。
5.重复上述步骤把FirstOpenGLProjectRenderer.java重命名为“AirHockeyRenderer.java”。
6.打开“res/values/strings.xml”,把“app_name”定义的字符串值改为“Air Hockey”。
7.在项目树上展开“src”,并且选择“com.firstopenglproject.android”包,按“Alt+Shift+R”快捷键把它重命名为“com.airhockey.android”。
8.打开AndroidManifest.xml,改其包名为“com.airhockey.android”;同时把这个activity的属性“android:name”改为“com.airhockey.android.AirHockeyActivity”。
9. Eclipse可能把“import com.firstopenglproject.android.R”放在了AirHockeyActivity的顶部,并且用下划线标记为错误;如果看到它,就把那行删掉。
我们已经准备好开发这个新项目了。
2.3定义空气曲棍球桌子的结构
在桌子被绘制到屏幕之前,我们需要告诉OpenGL要画什么。开发过程中的第一步是以OpenGL能理解的形式定义一个桌子结构。在OpenGL里,所有东西的结构都是从一个顶点开始。
2.3.1介绍顶点
简单来说,一个顶点就是一个代表几何对象的拐角的点,这个点有很多附加属性;最重要的属性就是位置,它代表了这个顶点在空间中的定位。
2.3.2用顶点构建游戏中的桌子
我们曾说过要暂时保持事物简单,那什么是能表达空气曲棍球桌子结构的最基本的图形呢?我们可以使用长方形。既然一个长方形有4个拐角,我们就需要4个顶点。长方形是一个二维物体,因此每个顶点都需要一个位置,这个位置在每个维度上都要有一个坐标。
如果我们要把它画到一张图纸上,我们会得到与图2-2类似的图形。
2.3.3在代码中定义顶点
让我们继续,并写一些代码存储这些顶点;这些顶点会表示为一个浮点数列表;因为它们定义在二维坐标系里,所以每个顶点要用两个浮点数进行标记:一个标记x轴的位置,另外一个标记y轴的位置。
因为一个顶点有两个分量,所以首先创建一个常量用来记住这一事实;打开AirHockeyRenderer类,并在它的顶端加入如下常量:
现在,在onSurfaceCreated()的前面加入如下构造函数(constructor):
我们采用了浮点数的顺序列表定义顶点数据,因此,我们可以用有小数点的十进制数存储这些位置;这个数组通常称为顶点属性(attribute)数组。到目前为止,只有位置属性储存了,但是,我们稍后也会把颜色及其他属性用同样的概念储存起来。
2.3.4点、直线及三角形
还记得前面曾说过表示曲棍球桌子的最容易的方式是长方形吗?然而,令人感到混乱的一点是:在OpenGL里,只能绘制点、直线以及三角形。
三角形是最基本的几何图形;因为它的结构如此稳定,它随处可见,比如桥梁的结构化构件;它有三条边用来连接它的三个顶点,如果我们拿掉其中一个顶点,剩下的就是一条直线,如果我们再拿掉一个点,就只剩下一个点了。
点和直线可以用于某些效果,但是,只有三角形才能用来构建拥有复杂的对象和纹理的场景。在OpenGL里,我们把单独的点放在一个组里构建出三角形,再告诉OpenGL如何连接这些点。我们想要构建的所有东西都要用点、直线和三角形定义;如果想构建更复杂的图形,例如拱形,那我们就需要用足够的点拟合这样的曲线。
如果不能使用长方形,我们怎么定义空气曲棍球的桌子呢?事实上,我们可以认为桌子是由两个三角形连接在一起的,如图2-3所示。
让我们改变代码以反映这一事实,我们现在使用两个三角形而不是一个长方形:
三角形的卷曲顺序
你可能注意到了,当我们定义三角形的时候,我们总是以逆时针的顺序排列顶点;这称为卷曲顺序(winding order)。因为在任何地方都使用这种一致的卷曲顺序,可以优化性能:使用卷曲顺序可以指出一个三角形属于任何给定物体的前面或者后面,OpenGL可以忽略那些无论如何都无法被看到的后面的三角形。
在12.5节中我们会学到更多内容。
这个数组表示用六个顶点表示两个三角形;第一个三角形由(0, 0)、(9, 14)及(0, 14)连接组成,第二个三角形共用了其中的两个点,它由(0, 0)、(9,0)及(9, 14)构成。
无论何时,如果我们想表示一个OpenGL中的物体,都要考虑如何用点、直线及三角形把它组合出来。
2.3.5添加中间线和两个木槌
我们几乎要完成所有顶点的定义了;我们只需要为中间线和两个木槌添加一些顶点。最后,会得到与图2-4相似的图形。
我们将用一条直线定义中间线,并用一个点表示每个木槌。在数组的最后添加一个分号,然后加入下面这些新的
顶点:
如你所见,由于这个数组由浮点值组合而成,我们可以使用带小数的十进制坐标。为了让Java编译器工作,需要在每个数的后面加上小写f,它告诉Java编译器把这个数字解释为浮点数(float),而不是双精度浮点数(double);双精度浮点数有大约两倍的精度(这是其名字的由来),如果我们不加f,Java编译器会把它当作精度丢失的转换,会要求增加显式的类型转换(cast)。
2.4使数据可以被OpenGL存取
我们已经完成顶点的定义了,但是,在OpenGL可以存取它们之前,我们仍然需要完成另外一步。主要的问题是这些代码运行的环境与OpenGL运行的环境使用了不同的语言,我们需要理解如下两个主要的概念。
1.当我们在模拟器或者设备上编译和运行Java代码的时候,它并不是直接运行在硬件上的;相反,它运行在一个特殊的环境上,即Dalvik虚拟机(Dalvik virtual machine);运行在虚拟机上的代码不能直接访问本地环境(native environment),除非通过特定的API。
2. Davik虚拟机还使用了垃圾回收(garbage collection)机制。这意味着,当虚拟机检测到一个变量、对象或者其他内存片段不再被使用时,就会把这些内存释放掉以备重用;它也能腾挪内存以提高空间使用效率。
本地环境并不是这样工作的,它不期望内存块会被移来移去或者被自动释放。
Android之所以这样设计,是因为开发者在开发程序的时候不必关心特定的CPU或者机器架构,也不必关心底层的内存管理。这通常都能工作得很好,除非要与本地系统交互,比如OpenGL。OpenGL作为本地系统库直接运行在硬件上;没有虚拟机,也没有垃圾回收或内存压缩。
2.4.1从Java调用本地代码
Dalvik方案是Android的主要特点之一,但是,如果代码运行在虚拟机内部,那它怎么与OpenGL通信呢?有两种技术,第一种技术是使用Java本地接口(JNI),这个技术已经由Android软件开发包提供了;当调用android.opengl.GLES20包里的方法时,软件开发包实际上就是在后台使用JNI调用本地系统库的。
2.4.2把内存从Java堆复制到本地堆
第二种技术就是改变内存分配的方式,Java有一个特殊的类集合,它们可以分配本地内存块,并且把Java的数据复制到本地内存。本地内存可以被本地环境存取,而不受垃圾回收器的管控。
我们需要按如图2-5所示传输数据;在类的顶部,在构造函数之前加入如下代码:
图2-5从Davik到OpenGL传输数据
这里加入了一个整型常量BYTES_PERFLOAT和一个FloatBuffer类型变量;一个Java的浮点数(float)有32位(bit)精度,而一个字节(byte)只有8位精度;这点可能看起来很明显,每个浮点数都占用4个字节;在后面的开发讲解中,很多地方都会提到这点。那个FloatBuffer用来在本地内存中存储数据。
让我们加入更多的代码,这次加在构造函数体的结尾处:
让我们看一下代码的每一个部分。首先,我们使用ByteBuffer.allocateDirect()分配了一块本地内存,这块内存不会被垃圾回收器管理。这个方法需要知道要分配多少字节的内存块;因为顶点都存储在一个浮点数组里,并且每个浮点数有4个字节,所以这块内存的大小应该是tableVerticesWithTriangles.length * BYTES_PER_FLOAT。
下一行告诉字节缓冲区(byte buffer)按照本地字节序(native byte order)组织它的内容;本地字节序是指,当一个值占用多个字节时,比如32位整型数,字节按照从最重要位到最不重要位或者相反顺序排列;可以认为这与从左到右或者从右到左写一个数类似。知道这个排序并不重要,重要的是作为一个平台要使用同样的排序;调用order(ByteOrder.nativeOrder())可以保证这一点。
最后,我们不愿直接操作单独的字节,而是希望使用浮点数,因此,调用asFloatBuffer()得到一个可以反映底层字节的FloatBuffer类实例;然后就可以调用vertexData.put(tableVerticesWithTriangles)把数据从Dalvik的内存复制到本地内存了。当进程结束的时候,这块内存会被释放掉,所以,我们一般情况下不用关心它;但是,如果你在编写代码的时候,创建了很多ByteBuffer,或者随着程序运行产生了很多ByteBuffer,你也许想学习一些堆碎片化以及内存管理的技术。
为了把数据从Dalvik传进OpenGL,用了这么多步骤,但是在继续讲解之前,理解它是如何工作的是很重要的;就像文化和风俗一样,国与国有很多不同,我们也要知道跨越本地代码边界时的差异。
2.5引入OpenGL管道
现在,我们已经定义了曲棍球桌子的结构,并且把这些数据复制到了OpenGL可以存取的本地内存;在把曲棍球桌子画到屏幕上之前,它需要在OpenGL的管道(pipeline)中传递,这就需要使用称为着色器(shader)的子例程(见图2-6)。这些着色器会告诉图形处理单元(GPU)如何绘制数据。有两种类型的着色器,在绘制任何内容到屏幕之前,需要定义它们。
Joe问:什么是字节序
字节序(Endianness)是描述一个硬件架构是如何组织位(bit)和字节(byte)的方式,它们在底层组成一个数字。现实中,最常见的就是多字节数,既可以把它们按大头序(big endian order)排列,即把最重要的字节放在前面;或者按小头序(little endian order)排列,即把最不重要的字节放前面。
举个例子,有个十进制数10 000,如果把它转成二进制数,就是10011100010000。在大头的架构上,这些位就会排列成:
0010011100010000
在小头的架构上,它们就会排列为:
0001000000100111
这次使用十六进制再来看一下。十进制数10 000就是十六进制系统中的2710;因为每两个字符对应一个8位字节,在看计算机代码时,十六进制用起来更好些;在大头的架构上,这个数字会另存为:
27 10
而在小头的架构上,同样的数字会另存为:
10 27
正常情况下,我们不需要担心字节序。当使用ByteBuffer时,只需要保证它和硬件使用了同样的字节序;否则结果就会出现莫名其妙的错误。在Wikipedia上有更多关于字节序的内容。
1.顶点着色器(vertex shader)生成每个顶点的最终位置,针对每个顶点,它都会执行一次;一旦最终位置确定了,OpenGL就可以把这些可见顶点的集合组装成点、直线以及三角形。
2.片段着色器(fragment shader)为组成点、直线或者三角形的每个片段生成最终的颜色,针对每个片段,它都会执行一次;一个片段是一个小的、单一颜色的长方形区域,类似于计算机屏幕上的一个像素。
一旦最后的颜色生成了,OpenGL就会把它们写到一块称为帧缓冲区(frame buffer)的内存块中,然后,Android会把这个帧缓冲区显示到屏幕上。
图2-6OpenGL管道概述
Joe问:为什么使用着色器
在着色器出现之前,OpenGL只能使用一个固定的方法集合控制很少而有限的事情,比如场景里有多少光线或者加多少雾;这些固定的API很容易使用,但是它们很难扩展。你只能实现API提供的效果,而且仅此而已;几乎不能添加如卡通着色一样的自定义
效果。
随着时间的推移,底层的硬件有了很大提高;设计OpenGL的人意识到这些API需要演进,并跟上这些变化。在OpenGL ES 2.0里,他们使用着色器加入了可编程API;为了保持简洁,他们把那些固定的API完全删除了,因此,用户必须使用着色器。
我们现在用着色器控制每个顶点应该如何画到屏幕上,我们也控制所有点、直线和三角形上的每个片段应该如何绘制;这打开了一个新的、充满了无限可能的新世界。我们现在可以按每个像素实现光照和其他优美的效果,如卡通着色。只要我们可以用着色器语言表达出来,就可以加入任何理想的自定义效果。
作为OpenGL和着色器的快速参考,khronos.org提供了一个很好的快速参考卡片,可以把它打印出来,并且随时查看。
2.5.1创建第一个顶点着色器
让我们创建一个简单的顶点着色器,它会分配在代码中定义的那些位置;为此,首先需要按照下面的步骤为这个着色器创建一个新的文件:
1.首先,需要创建一个新文件夹;右键单击项目中的“res”文件夹,选择“New”,再选择“Folder”,并把这个新文件夹命名为“raw”。
2.现在,需要创建一个新文件;右键单击刚刚创建的新文件夹,选择“New”,再选择“File”,并把新文件命名为“simple_vertex_shader.glsl”。
既然着色器的新文件已经创建好了,让我们在其中加入如下代码:
这些着色器使用GLSL定义,GLSL是OpenGL的着色语言;这个着色语言的语法结构与C语言相似。更多的信息可以参考前文提到的快速参考卡片或者完整的规范。
对于我们定义过的每个单一的顶点,顶点着色器都会被调用一次;当它被调用的时候,它会在a_Position属性里接收当前顶点的位置,这个属性被定义成vec4类型。
一个vec4是包含4个分量的向量;在位置的上下文中,可以认为这4个分量是x、y、z和w坐标,x、y和z对应一个三维位置,而w是一个特殊的坐标,第6章会讲述更多关于w的细节。如果没有指定,默认情况下,OpenGL都是把向量的前三个坐标设为0,并把最后一个坐标设为1。
还记得曾经讲过一个顶点会有几个属性,比如颜色和位置么?关键词“attribute”就是把这些属性放进着色器的手段。
之后,可以定义main(),这是着色器的主要入口点;它所做的就是把前面定义过的位置复制到指定的输出变量gl_Position;这个着色器一定要给gl_Position赋值;OpenGL会把gl_Position中存储的值作为当前顶点的最终位置,并把这些顶点组装成点、直线和三角形。
2.5.2创建第一个片段着色器
既然已经创建了一个顶点着色器,就有了为每个顶点生成最终位置的子例程;我们仍然需要创建一个为每个片段生成最终颜色的子例程。在此之前,让我们花些时间了解一下什么是片段,以及一个片段是怎么产生的。
光栅化(Rasterization)技术
移动设备的显示屏由成千上百万个小的、独立的部件组成,它们称为像素(pixel);这些像素中的每一个都有能力显示几百万种不同颜色范围中的一种颜色。然而,这实际上是一种视觉技巧:大多数显示器并不能真正创造几百万种颜色,所以每个像素通常由三个单独的子组件构成,它们发出红色、绿色和蓝色的光,因为每个像素都非常小,人的眼睛会把红色、绿色及蓝色的光混合在一起,从而创造出巨量的颜色范围;把足够多的单独的像素放在一起,就能显示出一页文本或者蒙娜丽莎像。
OpenGL通过“光栅化”的过程把每个点、直线及三角形分解成大量的小片段,它们可以映射到移动设备显示屏的像素上,从而生成一幅图像。这些片段类似于显示屏上的像素,每一个都包含单一的纯色。为了表示颜色,每个片段都有4个分量:其中红色、绿色、蓝色用来表示颜色,阿尔法(alpha)分量用于表示透明度;关于这个颜色模型是如何工作的,我们将在2.6节中讨论更多的细节。
在图2-7中,可以见到OpenGL怎样把一条直线光栅化为一个片段集合。显示系统通常会把这些片段直接映射到屏幕上的像素,结果一个片段就对应一个像素;然而,并不总是这样的:一个超高分辨率的设备可能需要使用较大的片段,以减少GPU的工作负荷。
编写代码
片段着色器的主要目的就是告诉GPU每个片段的最终颜色应该是什么。对于基本图元的每个片段,片段着色器都会被调用一次,因此,如果一个三角形被映射到10 000个片段,片段着色器就会被调用10 000次。
让我们继续并编写这个片段着色器;在项目中创建一个新的文件——“/res/raw/simple_fragment_shader.glsl”,并加入如下代码:
精度限定符
在这个片段着色器中,文件顶部的第一行代码定义了所有浮点数据类型的默认精度。这就像在Java代码中选择浮点数还是双精度浮点数一样。
可以选择lowp、mediump和highp,它们分别对应低精度、中等精度及高精度;然而,只有某些硬件实现支持在片段着色器中使用highp。
为什么顶点着色器没有定义精度呢?顶点着色器同样可以改变其默认的精度,但是,对于一个顶点的位置而言,精确度是最重要的,OpenGL设计者决定把顶点着色器的精度默认设置成最高级——highp。
你可能已经猜到了,高精度数据类型更加精确,但是这是以降低性能为代价的;对于片段着色器,出于最大兼容性的考虑,选择了mediump,这也是基于速度和质量的权衡。
生成片段的颜色
这个片段着色器的剩余部分与早前定义的顶点着色器一样。不过这次我们要传递一个uniform,它叫做u_Color。它不像属性,每个顶点都要设置一个;一个uniform会让每个顶点都使用同一个值,除非我们再次改变它。如顶点着色器中的位置所使用的属性一样,u_Color也是一个四分量向量,但在颜色的上下文中,这四个分量分别对应红色、绿色、蓝色和阿尔法。
接着我们定义了main(),它是这个着色器的主入口点,它把我们在uniform里定义的颜色复制到那个特殊的输出变量——gl_FragColor。着色器一定要给gl_GragColor赋值,OpenGL会使用这个颜色作为当前片段的最终颜色。
2.6OpenGL颜色模型
OpenGL使用累加RGB颜色模型,它只用了三种基本颜色:红色、绿色和蓝色。许多颜色都是通过把这三种基本颜色按不同比例混合在一起而创造的。例如,红色和绿色放在一起会生成黄色,红色和蓝色放在一起可以产生品红色,而蓝色和绿色放在一起就会创造出青色,把红色、绿色和蓝色放在一起,就能看见白色,如图2-8所示。
这个模型的工作原理与你可能在学校里学过的减色绘画模型(subtractive paint model)不同:在减色绘画模型里,加入蓝色和黄色制作出绿色,而加入很多颜色会产生黑棕色或者黑色。这是因为颜料不发光,而是吸收光;画上使用的颜色越多,光被吸收得越多,这幅画就会表现得越暗。
累加RGB模型遵循光本身的属性,当两柱不同颜色的光线混合在一起时,不会看见更暗的颜色,而是更亮的颜色。一场大雨过后,当我们观察天空中的彩虹时,我们实际上看到了可见光光谱中所有不同的颜色,它们可以合并成白色。
如果感兴趣,Wikipedia有更多的细节。