从零开始学OpenGLES开发——第一章

从零开始学OpenGLES开发——第一章

本教程并非翻译自某文献或者教程,完全是自己学习的总结,所以并不保证百分百正确,只代表个人的理解。
所涉及知识面并不全面,这和个人能力有限有关,只是希望能够起到参考价值。
OpenGL是一个画图的API集合,而OpenGLES是它的一个子集,本教程首先从OpenGL1.0开始入手,跑完第一个例子程序。
然后在将第一个例子程序中的某些用到的接口,换成OpenGLES2.0,这样读者能够清晰的明白1.0和2.0的主要区别在哪。


第1节 关于矩阵
矩阵从实际开发角度来看, 它就是一个4x4的数组,而数据类型跟具体的3D引擎有关,比如OpenGL一般为float,而在Direct3D中,它通常用一个结构体(里面有4x4的成员)来表示。
矩阵的作用,就是作为3D世界的各种变换的承载体。我们从一维的角度来理解矩阵的作用,然后在引申到三维。
想想一维坐标轴x上,有一个线段[1, 3]。在一维坐标轴上,这个线段能有哪几种变换方式呢?两种:1放大缩小,2位置平移。那么我们可以定义一个数组,两个元素float[2]。第一个表示放大缩小,1.0的时候表示不变。第二个元素表示位置平移,负数向左,正数向右。
将这个数组,应用到线段的过程,就是将线段上的坐标的值,和这个数组的值进行加减或者乘除操作。所得到的结果就是一个转换后的线段。


3D世界里也类似,一个3D物体代表着类似一个简单线段拥有众多的数据。而矩阵就像是前面说的那个两个值的数组。这个矩阵所表示的内容是一个变换过程,只不过由于3D世界的变换复杂得多,所以需要4x4个float来承载变换的参数。将这个矩阵应用到某个3D物体,那么这个3D物体就完成了这个变换过程。而将矩阵应用到某个3D的物体,其实实际开发中,就是将这个物体的各个点,乘以这个矩阵,得到新的点就行了。
以上内容只是希望读者对矩阵有一个模糊的认识,矩阵的算法是很高深的,我们暂时不需要去透彻研究它,只要知道它的数据是什么含义,怎么用即可。


矩阵只是一组变换的参数的承载体:

比如我要让一个物体旋转——让这个物体和我的旋转矩阵运算。

比如我要让一个物体放大——让这个物体和我的拉伸矩阵运算。

比如我要让一个物体平移——让这个物体和我的平移矩阵运算。

如果一个矩阵同时承载了旋转,拉伸和平移的变换参数,那么物体和它运算之后,就会同时进行旋转,拉伸和平移。


OpenGL我的理解是它内置了一个矩阵链,属于系统级的矩阵链,或者叫矩阵栈。反正就是一串矩阵。

任何你执行的绘制操作,默认都会进行这一串矩阵进行的变换。系统矩阵里面最基本的两个矩阵就是投影矩阵和视图矩阵(或者叫模型变换矩阵)后面会讲到。

投影矩阵决定的是最后眼睛看到的屏幕的长宽比例等,因为屏幕大小是不固定的,有些人是宽屏,有些人是标屏,有些人19寸,有些人17寸。手机上只有3-5寸等等。

为了保证最后物体看上去大小比例都是合适的,需要进行一个拉伸压缩的转换,这个转换就是投影矩阵干的事。

视图矩阵起的作用是决定视角的,玩过CS或者CF就晓得了,人在场景里面往前走,实际上专业叫法就是视角和视点在移动。而OpenGL里面没有专门的视角和视点的算法,它是通过将整个世界进行移动、旋转,来模拟出人在里面行走漫游的效果。比如你向前走100米,实际上和把整个世界往后移100米,最后看到的效果是一样的。


以上说的两个矩阵在代码里都会用到,后面有详细的代码演示


OpenGL的坐标系是是这样的


默认情况下,
x轴 从屏幕左到右 变大
y轴 从屏幕下到上 变大
z轴 从屏幕里到外 变大


第2节 OpenGLES1.0 环境以及初始化




首先需要用到的是OpenGLES需要绘制的控件GLSurfaceView。在OpenGL中有则复杂的初始化过程,需要将OpenGL引擎连接到当前操作系统的某个控件上是一个复杂的操作,而在android上,这个过程都由GLSurfaceView完成了。我们创建一个类,继承自GLSurfaceView,代码如下:
public class MyGLSurfaceView extends GLSurfaceView implements Renderer {

	public MyGLSurfaceView(Context context, AttributeSet attrs) {
		super(context, attrs);
		init();
	}
	public MyGLSurfaceView(Context context) {
		super(context);
		init();
	}
	
	private void init(){
	}
	public void onSurfaceCreated(GL10 gl, EGLConfig config) {
	}
	public void onSurfaceChanged(GL10 gl, int width, int height) {
	}
	public void onDrawFrame(GL10 gl) {
	}
}



可以看到,这个类函数都是空的。里面只有那个init是笔者加的,其他的都是接口以及父类里需要实现的函数。
创建主类Activity,使用这个MyGLSurfaceView,代码很简单。

public class MainActivity extends Activity {
	protected void onCreate(Bundle bundle) {
		super.onCreate(bundle);
		MyGLSurfaceView glsurface = new MyGLSurfaceView(this);
		this.setContentView(glsurface);
	}
}

后面不需要改动MainActivity,它只是负责显示GLSurfaceView而已。
接下来教大家怎么开始往MyGLSurfaceView里填入东西。
首先是init里面,需要做什么呢
	private void init(){
		this.setEGLContextClientVersion(1);
		this.setRenderer(this);
	}


第一句是可选的,因为当前使用的是OpenGLES1.0,而默认就是1。如果后面使用OpenGLES2.0了,那么第一个函数的参数就应该改为2。
第二句是必须的,第二句设置当前这个GLSurfaceView的render对象是自己(当前GLSurfaceView实现了Renderer接口,需要实现onDrawFrame函数),这个函数就是每一帧调用一次,具体复杂每一张图的绘制。类似MFC中的OnPaint,或者android中的onDraw。
两个函数顺序有讲究,设置version必须在绑定到render之前。

这时候其实已经可以启动程序了,你会看到屏幕上什么都没有,但是onDrawFrame已经会不停的执行了,执行频率又叫帧率fps(frame per sesond)。


第三节 添加模型。

public void onSurfaceCreated(GL10 gl, EGLConfig config) {
}
public void onSurfaceChanged(GL10 gl, int width, int height) {
}
这两个函数是初始化函数,系统自动调用。第一个只调用一次,第二个可能会调用多次,比如你的控件(GLSurfaceView)的大小改变了,就会触发onSurfaceChanged再调用一次。通常在第一个函数里初始化OpenGLES无关的数据,第二个函数里设置OpenGLES里当前窗口大小相关的参数。
onSurfaceChanged的函数填写内容之后,如下:
public void onSurfaceChanged(GL10 gl, int width, int height) {
	GLES10.glViewport(0, 0, width, height); // 设置视口宽度高度。
	// {修改投影矩阵
	GLES10.glMatrixMode(GLES10.GL_PROJECTION); // 修改投影矩阵
	GLES10.glLoadIdentity(); // 复位,将投影矩阵归零
	GLU.gluPerspective(gl, 60.0f, ((float) width) / height, 1.0f, 300.0f); // 最近和最远可以看到的距离,超过最远将不显示。
	// }
}


第一句GLES10.glViewport是设置当前视图窗口的宽和高,基本上这个函数调用的方式就是这样写死的,没有什么可讨论的。
后面三句代码含义比较复杂,我一一讲解
前面已经提过了矩阵的意思了,OpenGLES1.0里面内置了几个特殊的矩阵(注意OpenGLES2.0里面就没有这些了):
第一个,GLES10.GL_PROJECTION 投影矩阵  
第二个,GLES10.GL_MODELVIEW  模型转换矩阵


投影矩阵和视图窗口大小有关系,因为3D世界里面的物体,要显示到2D的平面上,需要有一个投影的过程,就像照相机照相。而照相机需要知道你的窗口有多大,也就是说你最后显示的照片的长宽比是多少。这样我可以在照相的时候适当的对画面进行拉伸,以达到你看到的是希望的效果。(反而言之就是如果不设置投影矩阵,最后看到的画面就是拉伸的变形了的,就像硬把400x300的电影全屏铺满到600x300的屏幕上看一样)。


模型转换矩阵后面会讲到,先继续讲解这三句函数调用的含义
第一个函数glMatrixMode,意思是指定我后面调用的修改矩阵的函数,都是针对的投影矩阵,而非别的内置矩阵。
第二个函数glLoadIdentity,意思是我将投影矩阵复位,复位的意思就是这个矩阵的转换结果就是“没有任何变化”,矩阵处于原始状态。

第三个函数gluPerspective,注意它前缀是glu,它不属于标准OpenGLES的函数,属于辅助函数。它用于用人们很容易理解的方式来构建参数,而得到一个投影矩阵。

因为投影矩阵依然是一个float[16]的数组,我知道它是干什么的,却不知道该怎么填他们的值来达到我期望的效果,这个函数就是干这个的,你告诉它你要的效果,它帮你生成矩阵。

这个函数的原型如下:

void gluPerspective(GL10 gl, float fovy, float aspect, float zNear, float zFar);

它参数的含义如下:

从零开始学OpenGLES开发——第一章_第1张图片

从图上可以看出来,near 的值,视角度数的值,一起决定了最后物体在屏幕显示多大,也决定了单位的长度。

public void glmPerspective(float[] mResult, float fovy, float aspect, float zNear, float zFar) {
        float top = zNear * (float) Math.tan(fovy * (Math.PI / 360.0));
        float bottom = -top;
        float left = bottom * aspect;
        float right = top * aspect;
        Matrix.frustumM(mResult, 0, left, right, bottom, top, zNear, zFar);
    }

以上为glmPerspective的实现。
标准OpenGL里面有glBegin + glEnd的输入方式,但是OpenGLES里面没有,OpenGLES只支持客户端缓存输入的方式,
我粗略的讲一下,OpenGL的工作原理是服务器+ 客户端的模式。你的程序就是客户端 OpenGL引擎就是服务器(可以理解为显卡)。而服务器是个状态机,状态机就是你服务器上的任何状态你这一次修改了,除非你再次修改,不然它会始终保持不变。举个例子GLES10.glMatrixMode(GLES10.GL_PROJECTION); 设置当前要修改的矩阵是投影矩阵,如果你后面不调用这个函数或者隐含调用这个函数的其他函数,那它始终保持为当前修改的矩阵为投影矩阵。
客户端传递指令和数据到服务器,服务器执行指令。
glBegin 意思是告诉服务器,接下来在glEnd之前,将要发送一串绘制的命令。比如glVertex3f传输一个定点坐标给服务器 glNormal3f 传递一个法线向量给服务器,这些函数都必须在glBegin 和 glEnd之间调用。glBegin + glEnd的代码样子大概是这样的:(C++)版本的

glBegin(GL_LINE_STRIP); //begin和end之间的顶点是画线
	glVertex3f(width,0,-world_depth/2);
	glVertex3f(width,0,world_depth/2);
glEnd();

可以看到,我画一条线,就需要调用4次函数。每一个点的和线的描述信息都要重复一次次的传递过去。实在是很复杂。
OpenGLES并不支持这种方式,只支持缓存一次批量输入的方式,但是缓存是放在客户端本地的,并没有传递到服务器。步骤是,1,构建一个数组放在客户端,2,调用一个函数制定传递缓存中多少数据到服务器,而具体底层传递的过程还是调用的 begin + end。目前我的理解,完全是为了我们少调用几个函数,好像没看到有效率什么的优化的文章(个人理解)


那我们看看OpenGLES里面是怎么做的,画三角形为例
因为是一次就构建好数据放在缓存里,所以就需要在onSurfaceCreated里构建数据,而不用去onDrawFrame里一次次的重复创建。begin + end的函数是在onDrawFrame里调用的,因为客户端(begin+end)向服务器传递的数据,只在当前这一帧画面有效,所以需要在onDrawFrame里调用。
	private FloatBuffer vertexBuffer = null ;
	
	public void onSurfaceCreated(GL10 gl, EGLConfig config) {
		
		float[] vertexs = new float[]{
				-5.0f,  5.0f, -20.0f, 
				 5.0f, -5.0f, -20.0f, 
				 5.0f,  5.0f, -20.0f, 
		};
		vertexBuffer = ByteBuffer.allocateDirect(vertexs.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
		vertexBuffer.put(vertexs);
		vertexBuffer.position(0);
	}


在onSurfaceCreated里构建了一个三角形(三个顶点,一个定点3个坐标,一共9个float数据,最后整理这9个float放入一个FloatBuffer里,OpenGLES支持FloatBuffer输入)。
里面顶点数字的大小,取决于自己定义的单位长度,如果前面的最远和最近距离设置成 0.1 - 30.0。那么这里这些坐标就全部除以10,最后看到的图是一样的效果。


在OpenGL中,三角形的绘制逆时针的面为正面。里面三个定点的位置和顺序是  左上->右下->右上。


vertexBuffer 作为成员变量,然后就可以在onDrawFrame里使用了,onDrawFrame代码如下

	public void onDrawFrame(GL10 gl) {
		GLES10.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // 清空场景为黑色。
		GLES10.glClear(GLES10.GL_COLOR_BUFFER_BIT | GLES10.GL_DEPTH_BUFFER_BIT);// 清空相关缓存。
		
		GLES10.glEnableClientState(GLES10.GL_VERTEX_ARRAY);
		GLES10.glVertexPointer(3, GLES10.GL_FLOAT, 0, vertexBuffer);
		
		GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 3);
		
		GLES10.glFlush();
	}



GLES10.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // 清空场景为黑色。
GLES10.glClear(GLES10.GL_COLOR_BUFFER_BIT | GLES10.GL_DEPTH_BUFFER_BIT);// 清空相关缓存。
这两句代码算是清空上一次绘制的遗留画面和数据。
第一句是清空背景的颜色,4个参数是 r,g,b,a四个颜色分量。
第二句是清空某一些绘制用过的缓存。
第三句glEnableClientState代码是开启客户端的顶点输入功能,就是客户端支持一缓存的方式批量输入顶点信息。
第四句glVertexPointer代码是设置当前定点输入功能,使用的缓存是vertexBuffer。第一个参数表示一个顶点有三个坐标,xyz。
第五句glDrawArrays代码调用的时候,客户端才开始将客户端缓存的那些数据发送到服务器,这些顶点的使用方式是画三角形。并且只发送3个顶点到服务器(缓存里面其实只有3个顶点的数据,多了也没有啊!)
第六句glFlush,就和写文件的操作的flush差不多。因为onDrawFrame最后完了系统会调用glFlush或者glFinish,所以对于简单的绘制,glFlush 这里其实用不用都没区别,但是写上帮助理解嘛。


可以试试启动程序,可以看到画面了噢。亲!
可以试试启动程序,可以看到画面了噢。亲!
可以试试启动程序,可以看到画面了噢。亲!


下面讲什么是视点,视线。对应到OpenGLES的具体技术点就是模型变换矩阵GLES10.GL_MODELVIEW 。


什么是视点? 就是3D世界中,眼睛所在的位置的坐标
什么是视线? 就是3D世界种,眼睛看出去的向量,就是屏幕正中间看出去的向量。
视线出了有看出去的向量,还有一个头顶方向向量,因为你只看一个方向不变,眼睛可以旋转啊,倒过来也可以朝同一个方向看去,所以还要确定一个头顶方向。


默认OpenGL的视点和视线是眼睛位于 (0,0,0)位置,头顶是(0,1,0),视线是(0,0,-1),还记得我说的OpenGL的坐标轴的定义不?
(这也就是为什么我前面定义的三个定点的z坐标都要是负数了,因为默认眼睛只能看到z的负半轴)


也就是说如果你在 glDrawArrays 前面调用一句代码
GLU.gluLookAt(gl, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f); 
因为参数和系统默认的视线参数是一样的,所以画面没有任何变化。


gluLookAt 这个函数实际上参数就是我前面说的,眼睛位置,视线向量,头顶向量。 自动产生一个矩阵并设置到系统的模型变换矩阵。
如果我把倒数第二个参数的1.0f改成 -1.0f,那意味着我们把头倒过来,朝下,应该看到一个倒过来的三角形噻,试试看!!

GLES10.glMatrixMode(GLES10.GL_MODELVIEW); // 修改模型变换矩阵
GLES10.glLoadIdentity(); // 复位,将投影矩阵归零
GLU.gluLookAt(gl, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, -1.0f, 0.0f); 

注意gluLookAt修改的是模型变换矩阵,在修改之前,需要将模型变换矩阵归位。(OpenGL的修改方式不是替换,而是将原来的矩阵乘以新的矩阵得到的结果替换原来的矩阵)
onDrawFrame的代码变为如下:

	public void onDrawFrame(GL10 gl) {
		GLES10.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // 清空场景为黑色。
		GLES10.glClear(GLES10.GL_COLOR_BUFFER_BIT | GLES10.GL_DEPTH_BUFFER_BIT);// 清空相关缓存。


		GLES10.glEnableClientState(GLES10.GL_VERTEX_ARRAY);
		GLES10.glVertexPointer(3, GLES10.GL_FLOAT, 0, vertexBuffer);
		
		GLES10.glMatrixMode(GLES10.GL_MODELVIEW); // 修改模型变换矩阵
		GLES10.glLoadIdentity(); // 复位,将投影矩阵归零
		GLU.gluLookAt(gl, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, -1.0f, 0.0f); 
		
		GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 3);
		
		GLES10.glFlush();
	}


下面说说前面的投影矩阵和模型变换矩阵的工作原理。
模型矩阵和投影矩阵都是针对整个世界来说的,就是3D场景中的所有物体都会经过那两个矩阵的变换。
也就是前面的那个三角形倒过来,并不是三角形颠倒了,而是整个世界颠倒了。颠倒之后实际上系统的y轴正方向已经是朝下了(初始状态是朝上的)


GLES10.glDrawArrays函数调用的时候,实际上是告诉系统我要画个三角形。而系统在画的过程中,会自动将这个三角形的所有坐标值,和模型变换矩阵和投影矩阵进行运算,把得到的结果才进行真正的绘制。也就是说,如果gluLookAt放在glDrawArrays的后面。那前面的glDrawArrays画的三角形将不会颠倒,因为它没有经过颠倒的这个变换。


我们来做个实验,来演示如何进行世界整体变换和某物体单独变换。为了实现参考,我们必须先定义个和世界保持一致的物体,它随世界变换而变换。


向下面这样,初始化两个三角形的数据
	private FloatBuffer vertexBuffer = null ;
	private FloatBuffer vertexBuffer2 = null ;
	
	public void onSurfaceCreated(GL10 gl, EGLConfig config) {
		
		float[] vertexs = new float[]{
				-5.0f,   0.0f, -30.0f, 
				 5.0f, -10.0f, -30.0f, 
				 5.0f,   0.0f, -30.0f, 
		};
		vertexBuffer = ByteBuffer.allocateDirect(vertexs.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
		vertexBuffer.put(vertexs);
		vertexBuffer.position(0);
		
		float[] vertexs2 = new float[]{
				-4.0f,  12.0f, -30.0f, 
				 4.0f,  4.0f,  -30.0f, 
				 4.0f,  12.0f, -30.0f, 
		};
		vertexBuffer2 = ByteBuffer.allocateDirect(vertexs2.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
		vertexBuffer2.put(vertexs2);
		vertexBuffer2.position(0);
	}


保证两个三角形的位置不会交叠,视线不会被挡住。


默认绘制的函数如下:
	public void onDrawFrame(GL10 gl) {
		GLES10.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // 清空场景为黑色。
		GLES10.glClear(GLES10.GL_COLOR_BUFFER_BIT | GLES10.GL_DEPTH_BUFFER_BIT);// 清空相关缓存。


		GLES10.glEnableClientState(GLES10.GL_VERTEX_ARRAY);
		
		GLES10.glVertexPointer(3, GLES10.GL_FLOAT, 0, vertexBuffer);
		GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 3);
		
		GLES10.glVertexPointer(3, GLES10.GL_FLOAT, 0, vertexBuffer2);
		GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 3);
		
		GLES10.glFlush();
	}


你可以看到两个三角形。默认的情况。


进行世界变换,在第一个glDrawArrays之前,加上代码


GLES10.glMatrixMode(GLES10.GL_MODELVIEW); // 修改模型变换矩阵
GLES10.glLoadIdentity(); // 复位,将投影矩阵归零
GLU.gluLookAt(gl, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, -1.0f, 0.0f); 

两个三角形都颠倒了吧。


现在我只让其中一个颠倒。修改上面三句代码的位置。像这样

	public void onDrawFrame(GL10 gl) {
		GLES10.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); // 清空场景为黑色。
		GLES10.glClear(GLES10.GL_COLOR_BUFFER_BIT | GLES10.GL_DEPTH_BUFFER_BIT);// 清空相关缓存。


		GLES10.glMatrixMode(GLES10.GL_MODELVIEW); // 修改模型变换矩阵
		GLES10.glLoadIdentity(); // 复位,将投影矩阵归零
		
		GLES10.glEnableClientState(GLES10.GL_VERTEX_ARRAY);
		
		GLES10.glVertexPointer(3, GLES10.GL_FLOAT, 0, vertexBuffer);
		GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 3);
		
		GLU.gluLookAt(gl, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, -1.0f, 0.0f); 
		
		GLES10.glVertexPointer(3, GLES10.GL_FLOAT, 0, vertexBuffer2);
		GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 3);
		
		GLES10.glFlush();
	}


因为模型变换矩阵,也是状态的。上一次的修改,下一次也要生效。所以像上面这个代码
第一个三角形绘制的时候,因为刚执行了glLoadIdentity。它会按照默认的情况绘制。
而第二个三角形。绘制的时候 gluLookAt调用了,视角进行了变换(其实本质是修改了模型变换矩阵而已),所以后面这个物体的绘制的时候,进行了模型矩阵所表示的变换过程。
gluLookAt 这个函数的名字看上去是修改视角,不要被欺骗了,看清它的本质(当前我目前用gluLookAt的方式是不合理的,一般不会这么用,这不是为了让您理解透彻嘛)


还有一种更高级的方式控制某些物体的移动,另一些不变换,是用 glPopMatrix glPushMatrix,具体的就去问度娘吧,我相信理解了矩阵运作的本质,其他的就容易多了。


第二章待续中,后面将更新基于OpenGLES1.0的纹理贴图。

你可能感兴趣的:(OpenGLES)