从零开始学Opengl,这里对学习过程作记录,包括学习资料,内容,基本知识。
学习资料: OpenGL ES 2.0 for Android,网络
内容:
1 Render的基本结构
onSurfaceCreated(GL10 glUnused, EGLConfig config)
顾名思义,surface被创建时,锁屏解锁回到当前界面时会回调该方法,换句话说,在程序运行期间,该回调方法同样会被回调很多次。
onSurfaceChanged(GL10 glUnused, int width, int height)
与View中onSizeChanged方法一样,只要surface尺寸发生变化时就会回调该方法,也包括第一次创建的时候,这也意味着,横竖屏切换也会导致该方法被回调。
onDrawFrame(GL10 glUnused)
与View中onDraw类似,负责实际的绘制,该方法会持续被调用,根据设备的刷新能力,极限一般为1s的时间内,调用60次,大约每隔16ms调用一次。要理解刷新率,可以参考View的移动动画,让View一次移动一个像素,持续执行该动画,就能看到该View的移动并不流畅,而如果开启GPU加速就可以达到非常流畅的移动效果。这里有写得非常好的一篇文章用于描述surfaceview。
import android.opengl.GLSurfaceView;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import static android.opengl.GLES20.GL_COLOR_BUFFER_BIT;
import static android.opengl.GLES20.glClear;
import static android.opengl.GLES20.glClearColor;
import static android.opengl.GLES20.glViewport;
/**
* Created by lawson on 16/11/29.
*/
public class FirstOpenGLProjectRenderer implements GLSurfaceView.Renderer {
@Override public void onSurfaceCreated(GL10 gl, EGLConfig config) {
/**
* 设置背景(底色),参数分别为红绿蓝alpha
*/
glClearColor(0.0f, 1f, 0.0f, 0.0f);
}
@Override public void onSurfaceChanged(GL10 gl, int width, int height) {
glViewport(0, 0, width, height);
}
@Override public void onDrawFrame(GL10 gl) {
/**
* 使用glClearColor设置的颜色值清除颜色缓冲
*/
glClear(GL_COLOR_BUFFER_BIT);
}
}
关于Clear这个词,个人理解,我想主要还是由于,比如横竖屏的切换时屏幕上还留有上一帧的画面,而切换后首先需要重置整个画面的背景为最初状态,这时估计它要做的就是用最初的背景清除或者说代替当前的背景,而glClearColor仅仅是作设置,真正执行的是glClear方法。
关于glViewport方法,目前只知道它是设置可视区域尺寸的,即窗口上哪块区域是我们的代码进行渲染的。
2 基本知识
Vertices(顶点)
比如我们要画上面这个矩形,首先要指定矩形的四个角的坐标,可以用如下数组表示:
float[] tableVertices = {
0f, 0f, 0f, 14f, 9f, 14f, 9f, 0f
};
但是,在opengl中,我们只能画点,线和三角形。一个顶点表示一个点,两个顶点可以表示一条线,三个顶点就可以表示一个三角形,而三角形就可以组成任意复杂的面。所以依靠这三个基本元素任意图形都可以画出来。
那么上面的矩形就变成了这样:
那么表示两个三角形的坐标数组就如下所示:
float[] tableVerticesWithTriangles = {
// Triangle 1
0f, 0f, 9f, 14f, 0f, 14f,
// Triangle 2
0f, 0f, 9f, 0f, 9f, 14f
};
winding order
不知道怎么翻译,但从上面的数组可以看到我们都是按照逆时针的顺序去取三角形顶点的,而这个顺序就称为winding order。它包括顺时针和逆时针两种,如下图所示:
它之所以在这里被提到,主要是因为如果尽量保持顺序的一致可以提高性能,我们通常会遇到判断一个三角形是属于一个给定物体的正面还是背面,如果是背面就可以不用渲染。更具体点的信息目前不知道。
在上面矩形的基础上添加点和线:
那么我们的坐标数组就变成了如下:
float[] tableVerticesWithTriangles = {
// Triangle 1
0f, 0f, 9f, 14f, 0f, 14f,
// Triangle 2
0f, 0f, 9f, 0f, 9f, 14f,
// Line 1
0f, 7f, 9f, 7f,
// Mallets
4.5f, 2f, 4.5f, 12f
};
当我们在设备上编译运行java代码,它并不会直接作用于硬件,而是运行在Dalvik虚拟机上,然后通过它的解释去分配Dalvik内存空间,而c/c++则会直接去分配native内存空间,另外,Dalvik使用了垃圾回收机制以实现对内存空间的重复使用等等管理。
Opengl是用c/c++实现的,其分配的内存都在native环境中,那么两者要交互该怎么办?通常会使用jni技术,以使用c/c++的malloc或者C++的new来进行对native内存进行直接操作且不受垃圾回收的影响自主管理。但opengl却使用了另外的方式,java由于垃圾回收机制可能会导致存储在内存上的字节随时都可能发生变化或者偏移,而opengl得益于c/c++的实现,操作系统在内存区域上执行IO操作,其内存区域是连续的字节,那么双方的数据在内存上顺序会不一致甚至完全不一样,因此nio的类就被用到了这里,因为direct缓冲区使用的内存,绕过了Dalvik堆,通过本地代码调用分配。
那么为了将我们在Dalvik内存中已经分配过的坐标数组tableVerticesWithTriangles(因为是在java中定义的)传递给opengl,首先会利用nio的ByteBuffer.allocateDirect()分配坐标数组大小的native环境内存,然后指定其顺序为native环境的字节顺序,这样这块区域就不会因为其他因素发生改变,剩下的就是把数据传入这块区域,而由于我们需要使用float数组,那么我们只需要创建一个FloatBuffer指向这块区域,如此就能与ByteBuffer共享同一块内存区域,最后通过向FloatBuffer复制一份,借由ByteBuffer在native环境开辟的内存进行直接的数据填充,这样,就实现了复制dalvik内存数据到native内存供opengl调用的过程。
这个过程看上去挺复杂的,但实现代码就这么几句:
FloatBuffer vertexData =
ByteBuffer.allocateDirect(tableVerticesWithTriangles.length * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer();
vertexData.put(tableVerticesWithTriangles);
数组长度乘以4是因为一个浮点型占4个字节。
参考链接:
why-floatbuffer-instead-of-float
4 Pipeline(管线)
上面学习了如何将坐标数据送到native内存供opengl使用,而管线就是opengl拿到这些数据后为了将这些坐标所代表的图形显示到屏幕上所执行的一系列步骤,就像将数据放到一个管道口,opengl会伸手从管道口拿到数据,然后经过一个又一个不同的管道,每个管道都会拿到上一个管道传递过来的数据进行处理和加工,最后送到屏幕上展示。非常像工厂的流水线。
像该图所描述的那样,第一个步骤是读取顶点数据,我们已经做了。接下来的流程就是opengl自己的操作了,而其中只有两个步骤我们可以自己定义如何从上一个步骤拿到的数据进行加工,一个是步骤execute vertex shader,另一个是execute fragment shader。注意这里是用的execute,也就是说vertex shader和fragment shader都是可执行的程序,个人理解这和继承一个抽象类很像,父类负责执行顺序并定义每一步的具体实现,而其中有这两个抽象方法的实现是调用一段可执行的脚本,我们只需要在这两个方法回调时把脚本准备好供父类运行就好了。
接着说我们在管线中唯一能做的提供“脚本”的事情,这个“脚本”采用的语言是GLSL语言,类似于c语言,是可以由opengl拿到并执行的。关于shader,个人理解就是前面说的可执行的“脚本”,而shader在管线中分为好几种,其中由我们提供的就是vertex shader和fragment shader。
vertex shader的意思就是顶点shader,它负责生成顶点的最终位置,而且我们传入的数据中有多少个顶点这个脚本就会执行多少次,然后opengl会拿到所有顶点最终位置的集合将其转换为点,线和三角形这样的基本元素,个人理解,opengl把这三者作为任意复杂图形的基本单元大概其中一个原因就是因为它这里需要统一,它自己提供一个光栅化过程实现就可以把任意图形进行光栅化,这点倒是和接口类型作为参数进行传递很相似。光栅化就是把我们需要画的图形的描述(比如要画一个圆)转换成屏幕上显示的像素点(很多个像素点组成一个圆)。
光栅化也可以看成是一个小程序,它的执行结果就是把从上一步拿到的点线三角形全部转换成很多个fragment。fragment可以理解为候选像素点(之所以是候选是因为后面可能这个点会被丢弃,这不还没到屏幕上嘛),而到目前这个步骤为止,从我们定义的坐标数据到每个顶点最终位置的确定,再到构成基本元素最后到生成屏幕上的候选像素点,个人理解关于图形在屏幕上展现的位置应该是处理完了。接下来就是每个像素的颜色的确定了。
fragment shader负责生成每个fragment(像素点)的颜色,如上图所示,由于从上个步骤拿到非常多的fragment(图中阶梯状的多个灰色矩形),那么在这一步,就需要为每一个fragment指定颜色,那么如果一个三角形由光栅化生成了10000个fragment传到这一步,那么这个“脚本”就要执行10000次为所有的fragment上色。指定完颜色就将这些fragment送到下一个步骤写到屏幕的framebuffer以供屏幕显示。我想,vertex shader的执行次数应该远远不及fragment shader。
补充:
上述管线流程加深理解,可参考下面一幅图:
(来源)
那么这两个shader该怎么写,以及在实际的代码里,shader是怎样被调用的,下次再记录。
参考链接: