OpenGL ES学习笔记(一)——基本用法、绘制流程与着色器编译
首先声明下,本文为笔者学习《OpenGL ES应用开发实践指南(Android卷)》的笔记,涉及的代码均出自原书,如有需要,请到原书指定源码地址下载。
在Android、iOS等移动平台上,开发者可以使用跨平台应用编程接口创建二维或者三维图形,或进行图像处理和计算机视觉应用,结合两者将能构建丰富有趣的交互体验。前者称为OpenGL,后者称为OpenCV,不过本文主要介绍前者,OpenCV在后续文章中涉及。OpenGL应用于桌面系统的历史已经很长了,但考虑到移动平台的特点(计算能力、性能等),将OpenGL应用与移动端使用的是一个特殊的嵌入式版本:OpenGL ES(OpenGL for Embedded System)。OpenGL ES有三个版本:版本1.0提供了一个不太灵活的、固定功能的管道;2.0版本推出了可编程的管道,提供我们所需的所有功能;3.0版本在2.0的基础上增加了一些新的特性,目前还没有广泛使用。《OpenGL ES应用开发实践指南(Android卷)》基于2.0版本进行说明,本文主要内容如下:
- OpenGL ES的基本用法,即HelloWorld实现
- 二维或者三维图形绘制流程
- 着色器(顶点和片段)编译
一、OpenGL ES的基本用法
OpenGL ES的HelloWorld实现非常简单,只需两步:(1)在xml布局文件或者代码中引入GLSurfaceView;(2)为GLSurfaceView设置Renderer。代码如下:
glSurfaceView = new GLSurfaceView(this); // Check if the system supports OpenGL ES 2.0. ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); ConfigurationInfo configurationInfo = activityManager .getDeviceConfigurationInfo(); // Even though the latest emulator supports OpenGL ES 2.0, // it has a bug where it doesn't set the reqGlEsVersion so // the above check doesn't work. The below will detect if the // app is running on an emulator, and assume that it supports // OpenGL ES 2.0. final boolean supportsEs2 = 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("Android SDK built for x86"))); if (supportsEs2) { // Request an OpenGL ES 2.0 compatible context. glSurfaceView.setEGLContextClientVersion(2); // Assign our renderer. glSurfaceView.setRenderer(new AirHockeyRenderer(this)); rendererSet = true; } else { /* * This is where you could create an OpenGL ES 1.x compatible * renderer if you wanted to support both ES 1 and ES 2. Since * we're not doing anything, the app will crash if the device * doesn't support OpenGL ES 2.0. If we publish on the market, we * should also add the following to AndroidManifest.xml: * * <uses-feature android:glEsVersion="0x00020000" * android:required="true" /> * * This hides our app from those devices which don't support OpenGL * ES 2.0. */ Toast.makeText(this, "This device does not support OpenGL ES 2.0.", Toast.LENGTH_LONG).show(); return; } setContentView(glSurfaceView);
首先通过GLSurfaceView的构造函数创建GLSurfaceView对象glSurfaceView,然后检查系统是否支持OpenGL ES 2.0版,支持就为glSurfaceView设置Renderer,否则弹出提示并返回,最后设置界面展示。
需要说明的是,使用GLSurfaceView还需要处理Activity的生命周期,否则,用户切换到另一个应用,应用就会奔溃。
@Override protected void onPause() { super.onPause(); if (rendererSet) { glSurfaceView.onPause(); } } @Override protected void onResume() { super.onResume(); if (rendererSet) { glSurfaceView.onResume(); } }
除了GLSurfaceView,另一个重点就是Renderer。Renderer是一个接口,定义了上如下方法:
public interface Renderer { void onSurfaceCreated(GL10 gl, EGLConfig config); void onSurfaceChanged(GL10 gl, int width, int height); void onDrawFrame(GL10 gl); }
onSurfaceCreated(GL10 gl, EGLConfig config)方法在Surface被创建的时候调用。但在实践中,设备被唤醒或者用户从其他activity切换回来时,这个方法也可能被调用。
onSurfaceChanged(GL10 gl, int width, int height)方法在Surface尺寸变化时调用,在横竖屏切换时,Surface尺寸都会变化。
onDrawFrame(GL10 gl)方法在绘制一帧时调用。在这个方法中,一定要绘制一些东西,即使只是清空屏幕,因为在这个方法返回后,渲染缓冲区会被交换并显示到屏幕上,如果什么都没画,可能看到糟糕的闪烁效果。
HelloWorld应用中使用了AirHockeyRenderer,该类实现了Renderer接口。
@Override public void onSurfaceCreated(GL10 glUnused, EGLConfig config) { glClearColor(0.0f, 0.0f, 0.0f, 0.0f); }
@Override public void onSurfaceChanged(GL10 glUnused, int width, int height) { // Set the OpenGL viewport to fill the entire surface. glViewport(0, 0, width, height); }
@Override public void onDrawFrame(GL10 glUnused) { // Clear the rendering surface. glClear(GL_COLOR_BUFFER_BIT); }
二、绘制流程
首先,在OpenGL ES中,只支持三种类型的绘制:点、直线以及三角形。所以需要在绘制图像之前,需要把一个图像分解为这三种图像的组合。
其次,OpenGL作为本地库直接运行在硬件上,没有虚拟机,也没有垃圾回收或者内存压缩。在Java层定义图像的数据需要能被OpenGL存取,因此,需要把内存从Java堆复制到本地堆。使用的方法是通过ByteBuffer:
private final FloatBuffer vertexData; vertexData = ByteBuffer .allocateDirect(tableVerticesWithTriangles.length * BYTES_PER_FLOAT) .order(ByteOrder.nativeOrder()) .asFloatBuffer(); vertexData.put(tableVerticesWithTriangles);
在上述代码中,首先使用ByteBuffer.allocateDirect()分配一块本地内存,这块本地内存不会被垃圾回收器管理。这个方法需要确定分配的内存的大小(单位为字节);因为顶点存储在浮点数组中,并且每个浮点数有4个字节,所以这块内存的大小为tableVerticesWithTriangles.length * BYTES_PER_FLOAT。order(ByteOrder.nativeOrder())的意思是使缓冲区按照本地字节序组织内容,字节序请移步这里。asFloatBuffer()方法得到一个可以反映底层字节的FloatBuffer类实例,因为我们直接操作的是浮点数,而不是单独的字节。
最后,通过在OpenGL管道传递数据,着色器告诉GPU如何绘制数据。着色器分为顶点着色器和片段着色器。
综上,整体流程为:读取顶点数据——执行顶点着色器——组装图元——光栅化图元——执行片段着色器——写入帧缓冲区——显示到屏幕上。
再介绍下着色器,顶点着色器生成每个顶点的最终位置,针对每个顶点,执行一次;片段着色器为组成点、直线和三角形的每个片段生成最终的颜色,针对每个片段,执行一次。
顶点着色器:
attribute vec4 a_Position; void main() { gl_Position = a_Position; gl_PointSize = 10.0; }
片段着色器:
precision mediump float; uniform vec4 u_Color; void main() { gl_FragColor = u_Color; }
着色器使用GLSL定义,GLSL是OpenGL的着色语言,语法结构与C语言相似。顶点着色器中,gl_Position接收当前顶点的位置,gl_PointSize设置顶点的大小;片段着色器中,gl_FragColor接收片段的颜色。precision mediump float为精度限定符,可以选择lowp、mediump和highp,分别对应低精度、中等精度和高精度。
三、着色器编译
首先,从资源中加载着色器文本。即将本地资源文件读流,然后将流转换为String:
public static String readTextFileFromResource(Context context, int resourceId) { StringBuilder body = new StringBuilder(); try { InputStream inputStream = context.getResources().openRawResource(resourceId); InputStreamReader inputStreamReader = new InputStreamReader(inputStream); BufferedReader 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); } return body.toString(); }
这段代码很简单,不做具体介绍了。
其次,编译着色器。
/** * Loads and compiles a vertex shader, returning the OpenGL object ID. */ public static int compileVertexShader(String shaderCode) { return compileShader(GL_VERTEX_SHADER, shaderCode); } /** * Loads and compiles a fragment shader, returning the OpenGL object ID. */ public static int compileFragmentShader(String shaderCode) { return compileShader(GL_FRAGMENT_SHADER, shaderCode); } /** * Compiles a shader, returning the OpenGL object ID. */ private static int compileShader(int type, String shaderCode) { // Create a new shader object. final int shaderObjectId = glCreateShader(type); if (shaderObjectId == 0) { if (LoggerConfig.ON) { Log.w(TAG, "Could not create new shader."); } return 0; } // Pass in the shader source. glShaderSource(shaderObjectId, shaderCode); // Compile the shader. glCompileShader(shaderObjectId); // Get the compilation status. final int[] compileStatus = new int[1]; glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0); if (LoggerConfig.ON) { // Print the shader info log to the Android log output. Log.v(TAG, "Results of compiling source:" + "\n" + shaderCode + "\n:" + glGetShaderInfoLog(shaderObjectId)); } // Verify the compile status. if (compileStatus[0] == 0) { // If it failed, delete the shader object. glDeleteShader(shaderObjectId); if (LoggerConfig.ON) { Log.w(TAG, "Compilation of shader failed."); } return 0; } // Return the shader object ID. return shaderObjectId; }
compileVertexShader和compileFragmentShader都是通过compileShader来实现的,编译顶点着色器和片段着色器通过type进行区分:GL_VERTEX_SHADER和GL_FRAGMENT_SHADER。
compileShader方法包含的步骤是:新建着色器对象(glCreateShader)——上传着色器源代码(glShaderSource)——取出编译状态(glCompileShader)——取出着色器信息日志(glGetShaderiv)——验证编译状态并返回着色器对象ID(compileStatus[0] == 0。
再次,把着色器一起链接进OpenGL的程序。
/** * Links a vertex shader and a fragment shader together into an OpenGL * program. Returns the OpenGL program object ID, or 0 if linking failed. */ public static int linkProgram(int vertexShaderId, int fragmentShaderId) { // Create a new program object. final int programObjectId = glCreateProgram(); if (programObjectId == 0) { if (LoggerConfig.ON) { Log.w(TAG, "Could not create new program"); } return 0; } // Attach the vertex shader to the program. glAttachShader(programObjectId, vertexShaderId); // Attach the fragment shader to the program. glAttachShader(programObjectId, fragmentShaderId); // Link the two shaders together into a program. glLinkProgram(programObjectId); // Get the link status. final int[] linkStatus = new int[1]; glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0); if (LoggerConfig.ON) { // Print the program info log to the Android log output. Log.v(TAG, "Results of linking program:\n" + glGetProgramInfoLog(programObjectId)); } // Verify the link status. if (linkStatus[0] == 0) { // If it failed, delete the program object. glDeleteProgram(programObjectId); if (LoggerConfig.ON) { Log.w(TAG, "Linking of program failed."); } return 0; } // Return the program object ID. return programObjectId; }
一个OpenGL的程序就是把一个顶点着色器和一个片段着色器链接在一起变成单个对象。链接与编译在流程上大致相同,主要流程为:新建程序并附上着色器对象——链接程序——验证链接状态并返回程序对象ID——给渲染类加入代码。
再次是验证OpenGL程序的对象:
/** * Validates an OpenGL program. Should only be called when developing the * application. */ public static boolean validateProgram(int programObjectId) { glValidateProgram(programObjectId); final int[] validateStatus = new int[1]; glGetProgramiv(programObjectId, GL_VALIDATE_STATUS, validateStatus, 0); Log.v(TAG, "Results of validating program: " + validateStatus[0] + "\nLog:" + glGetProgramInfoLog(programObjectId)); return validateStatus[0] != 0; }
调用glValidateProgram来验证这个程序,然后用GL_VALIDATE_STATUS参数调用glGetProgramiv()方法检测结果。需要说明的是,只有在开发和调试应用的时候才去验证程序。
在onSurfaceCreate()结尾处使用程序。glUseProgram告诉OpenGL在绘制任何东西到屏幕上的时候使用这里定义的程序。验证程序对象之后需要获取uniform的位置和属性的位置,最后关联属性与顶点数据的数组,使能顶点数组。上述流程走完之后,就可以正常绘制任何图像了,绘制图像的代码在onDrawFrame()处:
// Clear the rendering surface. glClear(GL_COLOR_BUFFER_BIT); // Draw the table. glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f); glDrawArrays(GL_TRIANGLES, 0, 6); // Draw the center dividing line. glUniform4f(uColorLocation, 1.0f, 0.0f, 0.0f, 1.0f); glDrawArrays(GL_LINES, 6, 2); // Draw the first mallet blue. glUniform4f(uColorLocation, 0.0f, 0.0f, 1.0f, 1.0f); glDrawArrays(GL_POINTS, 8, 1); // Draw the second mallet red. glUniform4f(uColorLocation, 1.0f, 0.0f, 0.0f, 1.0f); glDrawArrays(GL_POINTS, 9, 1);
glUniform4f更新着色器代码中的u_Color值,glDrawArrays代表绘制,GL_TRIANGLES表示要画三角形,0,6表示读入从开头的6个顶点。
总结:
(1)定义顶点属性数组,并将数组复制到本地内存;
(2)创建顶点着色器和片段着色器,着色器只是可以运行在GPU上的一个特殊类型的程序。
(3)如何创建和编译着色器;
(4)链接顶点着色器和片段着色器形成OpenGL程序对象;
(5)关联顶点着色器内部的属性变量与顶点属性数组。