坐标系问题:
openGL的坐标系,这个是openGL的二位坐标系(而不是手机屏幕的坐标系),openGL的二位坐标系是把一个显示器屏幕看作是一个归一化设备,就是宽高为2的显示平面,openGL坐标以屏幕中心为原点,x为向右为正,y为向上为正。各个角的坐标如下图所示。
而Android设备屏幕的坐标系实际是以左上角为原点,x向右为正,y向下为正。
基于以上的差别,所以在使用openGL绘制图片,设置顶点时,要以openGL坐标去绘制。然后openGL自然会根据Android设备的屏幕坐标系做一个变换,就是先去归一化。为了方便解说,就按720*1280的屏幕来讲,且是竖屏。如果openGL坐标为(0,0),那么变换为屏幕坐标就是 (720/2*0 ,1280/2*0),然后再做平移,向右下角平移,就是x平移720/2,y平移1280/2, 平移后的值就是(720/2+720/2*0, 1280/2+1280/2*0)。
openGL坐标系的归一化在不同宽高比的显示器上的显示问题。
比如一个在openGL中的坐标系,一个圆绘制到openGL的坐标系中,那么在opengl坐标系中也是圆的,但是在手机屏幕上,屏幕宽高并不是1:1的,例如720*1280的屏幕,如果在openGL坐标系中,直径为0.2,那么在屏幕中,横向的直径就是0.2*720,而竖向的直径就是0.2*1280,那么这个在openGL中的圆,绘制到手机屏幕上就是一个椭圆了。要解决这个问题,就是绘制到openGL坐标系时需要把手机屏幕的宽高比考虑进去,这样就可以保证绘制手机屏幕上是一个圆。
OpenGL管道概述
顶点着色器和片段着色器:
顶点就是构成点,线,面的顶点。一个方形的图片,可以有六个顶点构成(其中有2个是重复的)。一个面是由很多像素组成的,如果只有顶点着色器的话,那么就时能绘制六个顶点了,实际上肯定是要绘制很多方形包含的图片的所有点的,所以需要片段着色器,这个片段着色器就是用于绘制方形包含的所有像素点。首先一个顶点可以有位置属性和颜色属性。使用attribute关键字来修饰。方形中的各个点都是有坐标的,但是并不用去定义,只需定义顶点,而方行中的其他点,会自动插值,然后送到某个变量,然后顶点着色器可以使用这个插值变量中的值去做其他事。也可以让一个顶点对应某些向量,如果一个顶点对应一个纹理坐标,那么这些方形内的点插值时,也会根据顶点对应的那个纹理坐标,插值得出该点对应的纹理坐标,然后输出到顶点着色器的某个varying变量中,这个变量需要在代码中取出,然后告诉opengl,然后纹理坐标的插值就会送到这个varying变量,全局的varying变量是可以和片段着色器共享的,片段着色器就可以使用这个纹理坐标,去取纹理数据了,然后就可以读出纹理数据中的rbg值,然后就可以输出颜色,就可以逐个点亮图片(纹理)中的每个像素(片段)
顶点着色器绘制三角形
1.opengl绘制面的时候是用一个三角形作为面的单元的,而一个三角形需要三个顶点,在定义顶点数组时,重复的顶点不一定要重复定义,如三角扇的定义,就是三角形,定义六个点。每个顶点可以有其他属性,随便自己定义。自定义的属性(大多是向量,如vec3,vec4)跟每个顶点对应好,就是顶点的位置(vec3,vec4)跟自定义的属性一起定义。顶点的各种数据定义跟顶点着色器的定义是分开的。最后要把顶点数据和顶点着色器绑定。
顶点数据和顶点着色器绑定:
如一个数组时包含了位置信息和颜色信息的,排列顺序是x,y,r,b,g
那么需要将数组中的位置信息和颜色信息绑定顶点着色器的位置属性和颜色属性。
顶点着色器中有用于接收输入的变量,还有输出的变量,输出变量是输出给opengl的,我们不用管,只管把要输出的值赋值给定义好的那个输出变量就好了。
绑定位置属性:
首先要在顶点着色器中定义一个全局的用attribute修饰的vect4(虽然是二维的但是还是要使用vect4来接收,应该会填入两个值,然后其他两个默认为0,然后输出的时候如果是指定绘制2d的话估计会写入z=0,w=1),如:
着色器被链接后,我们就只需要加入一些代码去获取属性位置。
this.aPositionLocation = GLES20.glGetAttribLocation(program, A_POSITION);
然后把这个属性和数据绑定,即在调用顶点着色器时,把数据付给这个属性:
this.vertexData.position(0);
GLES20.glVertexAttribPointer(aPositionLocation, POSITION_COMPONENT_COUNT, GLES20.GL_FLOAT, false, STRIDE, this.vertexData);
其中vertexData是一个BufferFloat类型。首先要缓冲区的位置,就是要从缓冲区的哪个位置开始读。然后指定一个向量包含了多少个数据,如果是二维的,则一个位置向量包含了两个数据,就是(x,y)。如果顶点数组中不是所有数据都是位置信息的话,如在这里顶点数组就包含了颜色信息,那么要告诉opengl怎么跳过这些颜色信息去取下一个位置数据。STRIDE就是跳过多少个字节,跳过位置信息占字节数加上颜色信息占字节数。
同样的,把顶点数组中的颜色数据和顶点着色器中的颜色属性也是这样绑定。就两句搞定
看看如何定义顶点着色器的属性:
接着必须对应的建立对应的顶点着色器,假设raw文件夹下的顶点着色器的文件名是simple_vertex_shader.glsl:
attribute vec4 a_Position; //顶点数据绑定这个属性后,就会逐个把顶点的位置信息传给这个变量
attribute vec4 a_Color; //顶点数据绑定这个属性后,就会逐个把顶点的颜色信息传给这个变量
varying vec4 v_Color; //这是一个可以跟片段着色器共享的变量。而且如果某个属性(这里的a_Color)在main中赋值给这个变量,那么这个变量随着位置的变化(就是面中的点的位置),而不断插值,就是根据距离三个顶点的远近来对a_Color作线性插值,远近某个顶点,那么那个顶点对应的a_Color属性的权重就越大,就是说颜色会偏向a_Color(前提是片段着色器使用v_Color作为输出)
void main()
{
v_Color=a_Color;
gl_Position = a_Position; //gl_Position是一个输出变量,是opengl定义好的。opengl只认这个,所以要把输入处理后,传给这个输出变量
gl_PointSize = 10.0;//这也是一个输出变量
}
我们加入了一个新的属性a_Color,也加入了一个叫做v_Color的新varying。上一篇已经讲过varying是一个特殊的变量类型,它把给它的值进行混合并把这些混合的值发送给片段着色器。
我们把varying也加入片段着色器,在raw文件夹下创建simple_fragment_shader.glsl:
precision mediump float;
varying vec4 v_Color;//这是和顶点着色器共享的,就是这个值和顶点着色器的值是一个样的
void main()
{
gl_FragColor = v_Color;//gl_FragColor是片段颜色输出变量
}
线性代数-矩阵
矩阵相乘:A*B=C
相乘结果:相乘后的结果是等于C的行数为A的行数,C的列数为B的列数。C[0,0]=A的0行和B的0列相乘,然后乘积相加,就是A[0]*B[0]+A[1]*B[1]+...+A[Na][BNb]。很明显,如果两个矩阵要相乘的话,必须要A的列数等于B的行数,否则,就无法正确得到结果。
将向量作变换:如果一个向量需要作矩阵变换,需要用向量乘以一个矩阵。如果把向量放在乘号左边,那么向量应该要作为一行四列的矩阵,然后乘以一个四行四列的矩阵。如果向量放在乘号的右边,那么向量应该要作为四行一列的矩阵。
单位矩阵,就是任意矩阵乘以单位矩阵都等于原来的矩阵,所以单位必须是方形矩阵,就是行数等于列数,因为矩阵从单位矩阵左边或者右边乘都可以。单位矩阵还要保证一条对角线的元素全为1,其他元素为0。
作矩阵变换的技巧:因为矩阵相乘是行和列对应的元素的乘积相加的,如果想要增加偏移量的话,就在单位矩阵的基础上作些改动就好,将某个0改成非0。
三维空间:
使用透视法。原理就是,视野角度相同时,画面越远,看到的范围就越大,物体看起来就越小。透视法的原理就是使用w,w代表距离远近,距离越远,w越大,w默认为1。通过透视变换后的坐标是(x/w, y/w, z/w,w),就是x,y,z都除以w分量,那么本来不在画面范围的点,都向画面中心(渲染中心)靠近,就会使更多的点渲染到可见画面中,而远处的物体渲染到画面时也会缩小。
三维透视法:
这是一个透视投影使用到的投影矩阵。就是将透视锥视体中的三维物体的坐标归一化,就是投影到近平面上。
近平面:可以看做是屏幕,可以使用手机来做例子。
远平面:可以是无限远的平面。这个远平面的位置可以根据实际应用来确定(就是看想要把最远多远的地方的物体投影到近平面,按经验来说,并不是无线远的物理都能被看到的),就是根据需要去确定锥视体的大小。
视野:就是一个角度,每个摄像头都有固定的视角,这个视角小于180。
上图是视锥体的俯视图
由上面的解释可以知道,透视法,就是将锥视体中所有物体投影到近平面。其实就是成像。比如一张照片是3968*2240,那么近平面的大小就是3968*2240(这里使用像素作为尺寸单位)。那么可以把近平面大小作为常量,当视野变化时(视角边宽或变窄,以焦点为顶点的角等于视角的大小,就是表格中的a),焦点位置就会变化,焦距也会变化
看那个投影矩阵,对x和y的变换只与屏幕宽高比和视角大小有关,而对于z的变换,和f,n,w(w默认是1)有关,f和n就是焦点到远近平面的距离。w的变换跟z有关,变换后的w=-z。
注意:经过投影矩阵的变换后,还需要做透视除法,就是让x,y,z分别除以w。得到的坐标就是最终投影到近平面上的坐标。
正交投影:
要定义正交投影,我们将使用Android的Matrix类,它在android.opengl包中。这个类有一个称为orthoM()的方法,它可以为我们生成一个正交投影。
我们来看一下orthoM()参数:
orthom(float[] m,int mOffset,float left,float rigth,float bottom,float top,float near,float far)
float[] m:目标数组,这个数组长度至少有16个元素,这样它才能存储正交投影矩阵。
int mOffset:结果矩阵起始的偏移值。
float left:X轴的最小范围。
float right:X轴的最大范围。
float bottom:Y轴的最小范围。
float top:Y轴的最大范围。
float near:Z轴的最小范围。
float far:Z轴的最大范围。
当我们调用这个方法的时候,它应该产生下面的正交投影矩阵:
这个正交投影矩阵会把所有在左右之间,上下之间和远近之间的事物映射到归一化设备坐标中从-1到1的范围,在这个范围内所有事物在屏幕上都是可见的。
主要的区别就是Z轴有一个负值符号,它的效果是反转Z坐标。这就意味着,物体离得越远,Z坐标的负值会越来越小。之所以这样完全是历史和传统的原因。
更新onSurfaceChanged(),在GLES20.glViewport()调用后面加入如下代码:
final float aspectRatio=width>height?(float)width/(float)heigth:(float)height/(float)width;
if(width>height){
orthoM(projectionMatrix,0,-aspectRatio,aspectRatio,-1f,1f,-1f,1f);
}else{
orthoM(projectionMatrix,0,-1f,1f,-aspectRatio,aspectRatio,-1f,1f);
}
如果是2D的话,那么z的范围是[-1,1]。最终会把生成的正交投影矩阵放到projectionMatrix中。
绘制一个纹理的完整流程:
定义顶点数据:
添加顶点数据,如下代码定义顶点数据:
private static final float[] VERTEX_DATA={
//X,Y,S,T
0f,0f,0.5f,0.5f,
-0.5f,-0.8f,0f,0.9f,
0.5f,-0.8f,1f,0.9f,
0.5f,0.8f,1f,0.1f,
-0.5f,0.8f,0f,0.1f,
-0.5f,-0.8f,0f,0.9f
}
注意:这个数组是定义给竖屏的(宽高比,1:1.78)。这里使用的x,y坐标不是opengl的归一化设备坐标,而是根据屏幕宽高比而设定的虚拟坐标系。看前面的宽高比问题调整。也就是说这里的y的范围是[-1.78,1.78],x的范围还是[-1,1]。使用正交投影矩阵可以使y坐标归一化。在转化的时候,就是最终输出到opengl的y=y/1.78
上面的顶点数据包含了纹理坐标。最终各三角面中的片段(像素)的颜色是通过线性插值,获得某个顶点坐标对应的纹理坐标,然后通过这个纹理坐标去获取该纹理坐标对应的color数据。
获取一块本地缓存(这个内存空间不收虚拟机控制,不会被虚拟机回收,可以被opengl直接访问)
this.floatBuffer=ByteBuffer.allocateDirect(VertexData.length*BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertexData);
将顶点数组中的数据,分别绑定到顶点着色器中的属性,x,y数据绑定顶点坐标属性,而s,t数据绑定纹理坐标属性
this.floatBuffer.position(dataOffset); //告诉opengl数据从哪个位置开始取
GLES20.glVertexAttribPointer(attributeLocation,compontCount,GLES20.GL_FLOAT,false,stride,this.floatBuffer); //告诉//opengl,把floatBuffer中的某些数据绑定到顶点着色器中的attributeLocation属性。compontCount是指明一个属性有多少个数据构成,stride是告诉下一个属性要跳过几个字节,是一组(x,y,s,t)的字节数。
GLES20.glEnableVertexAttribArray(attributeLocation);//使能这个属性
this.floatBuffer.position(0);//复位
这样数据中的(x,y)或者(s,t)就会传给顶点着色器的对应属性。每个属性都绑定数组中的数据都要调用一次上面的代码。
获取一个纹理对象,这个对象是native对象,所以在java层只会持有该对象的long类型指针。loadTexture(Context context,int resourceId):
final int[] textureObjectIds=new int[1];
GLES20.glGenTextures(1,textureObjectIds,0);
if(textureObjectId[0]==0){
Log.w(TAG,"创建纹理失败!");
}
通过传递1作为第一个参数调用glGenTextures(),我们就创建了一个纹理对象。OpenGL会把那个生成的ID存储在textureObjectIds中。我们也检查了glGenTextures()调用是否成功,如果结果不等于0就继续,否则记录那个错误并返回0。
//加载位图数据并与纹理绑定
final BitmapFactory.Options options=new BitmapFactory.Options();
options.inScaled=false;
final Bitmap bitmap = BitmapFactory.decodeResource (context.getResource(), resourceId, options);
if(bitmap==null){
Log.w(TAG,"加载位图失败");
GLES20.glDeleteTexture(1,textureObjectIds,0);
return 0;
}
首先创建一个新的BitmapFactory.Options的实例,命名为“options”,并且设置inScaled为"false"。这告诉Android我们想要原始的图像数据,而不是这个图像的压缩版本。
接下来调用BitmapFactory.decodeResource()做实际的解码工作,把我们刚刚定义的Android上下文,资源ID和解码的options传递进去。这个调用会把解码后的图像存入bitmap,如果失败就会返回空值。我们检查了那个失败,如果位图是空值,那个OpenGL纹理对象会被删除。如果解码成功,就继续处理那个纹理。
在可以使用这个新生成的纹理对象做任何其他事之前,我们需要告诉OpenGL后面纹理的调用应该应用于这个纹理对象。我们为此使用一个glBindTexture()调用:
//第一个参数GL_TEXTURE_2D告诉OpenGL这应该被作为一个二位纹理对待,第二个//参数告诉OpenGL要绑定到哪个纹理对象的ID。
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureObjectIds[0]);
//下面的代码和缩小和放大有关
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_MIN_FILTER,GLES20.GL_LINEAR_MIPMAP_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_MAG_FILTER,GLES20.GL_LINEAR);
我们用一个glTexParameteri()调用设置每个过滤器:GL_TEXTURE_MIN_FILTER是指缩小的情况,而GL_TEXTURE_MAG_FILTER是指放大的情况。对于缩小的情况,我们选择GL_LINEAR_MIPMAP_LINEAR,它告诉OpenGL使用三线性过滤;我们设置放大过滤器为GL_LINEAR,它告诉OpenGL使用双线性过滤。
//这个调用告诉OpenGL读入bitmap定义的位图数据,并把它复制到当前绑定的纹理//对象。
//既然这些数据已经被加载进OpenGL了,我们就不需要持有Android的位图了。正常//情况下,释放这个位图数据也会花费Dalvik的几个垃圾回收周期,因此我们应该调//用bitmap对象的recycle()方法立即释放这些数据
GLUtil_texImage2D(GLES20.GL_TEXTURE_2D,0,bitmap,0);
bitmap.recycle();
//生成MIP贴图也是一件容易的事情。我们用一个快速的glGenerateMipmap()调用告诉OpenGL生成所有必要的级别:
GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);
//既然我们完成了纹理对象的加载,一个很好的实践就是解除与这个纹理的绑定,这//样我们就不会用其他纹理方法调用意外地改变这个纹理。之前说绑定只是为了调用//GLUtil_texImage2D可以把bitmap加载到被绑定的纹理对象中,既然目前已经将位图
//数据绑定到对应的纹理对象了,那么就可以解绑了,这样不能轻易使用GLES20的
//某些调用而对这个纹理对象误操作了,
GLES20.gl_BindTexture(GLES20.GL_TEXTURE_2D,0);//绑定了0,就相当于绑定了一个空的指针。
这个textureObjectIds[0],就是纹理对象的指针,后面需要用到。
一些需要用到的宏
protected static final String U_MATRIX="u_Matrix";
protected static final StringU_TEXTURE_UNIT="u_TextureUnit";
protected static final StringA_POSITION="a_Position";
protected static final StringA_COLOR="a_Color";
protected static final StringA_TEXTURE_COORDINATES="a_TextureCoordinates";
后面是集中精力搞着色器了。
先定义顶点着色器:
在项目中res/raw/目录下新建一个文件,命名为“texture_vertex_shader.glsl”,并加入如下内容:
uniform mat4 u_Matrix;
attribute vec4 a_Position;//位置属性
attribute vec2 a_TextureCoordinates;//纹理属性
varying vec2 v_TextureCoordinates;//给片段着色器使用
void main(){
v_TextureCoordinates=a_TextureCoordinates
gl_Position=u_Matrix*a_Position;//使用正交投影变换坐标,赋值给输出变量gl_Position
}
创建新的片段着色器
在同样的目录,创建一个叫做“texture_fragment_shader.glsl”的新文件,并加入如下代码:
precision mediump float;
uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates;//这个varying变量与顶点着色器同名,可以共享顶点着色器的值
void main(){
gl_FragColor=texture2D(u_TextureUnit,v_TextureCoordinates);
}
为了把纹理绘制到一个物体上,OpenGL会为每个片段都调用片段着色器,并且每个调用都接受v_TextureCoordinates的纹理坐标。片段着色器也通过uniform------u_TextureUnit接受实际的纹理数据,u_TextureUnit被定义为一个sampler2D, 这个变量类型指的是一个二维纹理数据的数组。
被插值的纹理坐标和纹理数据被传递给着色器函数texture2D(),它会读入纹理中那个特定的坐标处的颜色值。接着通过把结果赋值给gl_FragColor设置片段的颜色。
除了顶点坐标属性,纹理坐标属性之外(使用attribute修饰的),使用uniform 修饰的变量,同样需要绑定数据:
this.uMatrixLocation=GLES20.glGetUniformLocation(program,U_MATRIX);
this.uTextureUnitLocation=GLES20.glGetUniformLocation(program,U_TEXTURE_UNIT);
然后调用一下方法:
public void setUniforms(float[] matrix,int textureId){
GLES20.glUniformMatrix4fv(this.uMatrixLocation,1,false,matrix,0);
GLES20.glActiveTexture(GLES20.GL_TEXTURE_2D);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureId);
GLES20.glUniformli(this.uTextureUnitLocation,0);
}
第一步是传递矩阵(前面得到的projectionMatrix)给它的uniform,这足够简单明了。下一部分就是需要更多的解释了。当我们在OpenGL里使用纹理进行绘制时,我们不需要直接给着色器传递纹理。相反,我们使用纹理单元保存那个纹理。之所以这样做,是因为一个GPU只能同时绘制数量有限的纹理。它使用这些纹理表示当前正在被绘制的活动的纹理。
如果需要切换纹理,我们可以在纹理单元中来回切换纹理,但是,如果我们切换得太频繁,可能会渲染的速度。也可以同时用几个纹理单元绘制多个纹理。
通过调用glActiveTexture()把活动的纹理单元设置成为纹理单元0,我们以此开始,然后通过调用glBindTexture()把这个纹理绑定到这个单元,接着,通过调用glUniformli()把被选定的纹理单元传递给片段着色器中的u_TextureUnit。
编译着色器和链接着色器程序:
protected final int program;
protected ShaderProgram(Context context,int vertexShaderResourceId,int fragmentShaderReourceId){
this.program=ShaderHelper.buildProgram(
TextResourceReader.readTextFileFromResource(context,vertexShaderResourceId),
TextResourceReader.readTextFileFromResource(context,fragmentShaderReourceId));//调用了后面的程序;
}
public static int buildProgram(String vertexShaderSource,String fragmentShaderSource){
int program;
//分别加载顶点着色器文件和片段着色器文件并编译
int vertexShader=compileVertexShader(vertexShaderSource);
int fragmentShader=compileFragmentShader(fragmentShaderSource);
//链接顶点着色器程序和片段着色器程序,这样两个程序中的varying变量才可以共享
program=linkProgram(vertexShader,fragmentShader);
//验证这个链接好的程序
validateProgram(program);
return program;
}
public static int compileVertexShader(String shaderCode) {
return compileShader(GLES20.GL_VERTEX_SHADER, shaderCode);
}
public static int compileFragmentShader(String shaderCode) {
return compileShader(GLES20.GL_FRAGMENT_SHADER, shaderCode);
}
public static int compileShader(int type, String shaderCode) {
//跟创建纹理对象一样,在把程序编译好并绑定到shader对象前,先创建一个shader对象
final int shaderObjectId = GLES20.glCreateShader(type);
if (shaderObjectId == 0) {
if (LoggerConfig.ON) {
Log.w(TAG, "Counld not create new shader");
}
return 0;
}
GLES20.glShaderSource(shaderObjectId, shaderCode);//传入shader对象和shader程序
GLES20.glCompileShader(shaderObjectId);//编译shaderCode,并把编译好的程序绑定到shader对象
final int[] compileStatus = new int[1];//获得编译时的状态信息
GLES20.glGetShaderiv(shaderObjectId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if (LoggerConfig.ON) {
Log.v(TAG, GLES20.glGetShaderInfoLog(shaderObjectId));
}
if (compileStatus[0] == 0) {
GLES20.glDeleteShader(shaderObjectId);
if (LoggerConfig.ON) {
Log.w(TAG, "Compilation of shader failed");
}
return 0;
}
return shaderObjectId;
}
上面着色器写好了,纹理对象创建了,纹理加载了(把bitmap加载到纹理对象中),正交矩阵生成了,着色器程序链接好了,
顶点数组定义好了,顶点数据和顶点着色器的属性绑定好,矩阵和2D纹理也和着色器程序汇总的uniform变量绑定了。
那么就可以绘制了,我们清空了渲染表面。然后绘制桌子。我们首先调用GLES20.glUseProgram();告诉OpenGL使用这个程序,然后通过调用this.textureProgram.setUniforms(this.projectionMatrix,this.texture);把那些uniform传递进来。下一步是通过调用this.table.bindData(this.textureProgram);把顶点数组数据和着色器程序定起来,最后调用GLES20.glDrawArrays(GLES20.GL_POINTS,0,2);绘制桌子。
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(program)//现在所有东西都关联到这个shader程序了,可以绘制了。
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN,0,6);//使用上面的程序绘制。然后把顶点数据以三角扇的形式绘制。
------------------------------------------------------------------------------------------------------------------
GL_TRIANGLE_FAN(三角扇)
三角形扇的转换方式是共用一个顶点,如下图所示:
当转换为三角形时,第一个顶点是共用的,于是转换的三角形需要依次是[v0, v1, v2] [v0,v2,v3] [v0, v3, v4]
探讨透视法在opengl中的实现和使用:
假设顶点坐标和纹理坐标如下。
private static final float[] VERTEX_DATA = {//x,y,s,t
0f, 0f, 0.5f, 0.5f,
-0.5f, -0.8f, 0f, 0.9f,
0.5f, -0.8f, 1f, 0.9f,
0.5f, 0.8f, 1f, 0.1f,
-0.5f, 0.8f, 0f, 0.1f,
-0.5f, -0.8f, 0f, 0.9f
};
package com.champion.glsurfaceviewdemo;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;
import android.opengl.GLUtils;
import android.opengl.Matrix;
import android.util.Log;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
public class TextureRenderer3 implements GLSurfaceView.Renderer{
final String vertexShader =
"attribute vec4 a_position;" +
"uniform mat4 u_Matrix;" +
"attribute vec2 a_texture_coordinate;" +
"varying vec2 v_texture_coordinate;" +
"void main(){" +
"gl_Position=u_Matrix*a_position;" +
"v_texture_coordinate=a_texture_coordinate;" +
"}";
final String fragmentShader =
"precision mediump float;" +//这个是必须的,若没有这一行,将在shader编译发生错误
"uniform sampler2D u_textureUnit;" +
"varying vec2 v_texture_coordinate;" +//必须和vertex shader定义同一名称才能访问这个varying变量
"void main(){" +
"gl_FragColor=texture2D(u_textureUnit,v_texture_coordinate);" +
"}";
//这个纹理坐标s,t的定义将使显示的图片是倒置的,虽然才定义看上去,
// 确实是纹理上面的顶点对着vertex上面顶点,纹理下面的顶点对着vertex上面的顶点
//但是,显示出来的确实是倒置的图片。所以需要将纹理坐标旋转180°,即将四个纹理
//的对角顶点对换。
// final float[] vertex = {0.5f,0.5f,0f,1.0f,1.0f,//x,y,z,s,t
// 0.5f,-0.5f,0f,1.0f,0f,
// -0.5f,-0.5f,0f,0f,0f,
// -0.5f,0.5f,0f,0f,1.0f};
//四个纹理的对角顶点对换后的数组。将openGL的顶点坐标使用右上角那四个,便于对图片进行移位和缩放
final float[] vertex = {1.0f,1.0f,0f,1.0f,0f,//x,y,z,s,t
1.0f,0f,0f,1.0f,1.0f,
0f,0f,0f,0f,1.0f,
0f,1.0f,0f,0f,0f,};
final int vertexCount = 4;
final int coorPerVertex = 3;
final int coorPerTexture = 2;
final int vertexStride = (coorPerVertex+coorPerTexture)*4;
int vertexStartPosition = 0;
int textureStartPosition = 3;
int programId;
FloatBuffer vertexBuffer;
Context mContext;
int[] textureIds = new int[4];
float[] scaleMatrix = new float[16];
float[] mProjectionMatrix = new float[16];
float mSlotWidth;//每个图片的实际显示宽度
float mSlotHeight;//每个图片的实际显示高度
float xSlotCount = 4;//每行显示图片数
float ySlotCount = 2;//每列显示图片数
public TextureRenderer3(Context context){
mContext = context;
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
ByteBuffer buffer = ByteBuffer.allocateDirect(vertex.length*4);
buffer.order(ByteOrder.nativeOrder());
vertexBuffer = buffer.asFloatBuffer();
vertexBuffer.put(vertex);
vertexBuffer.position(0);
//编译顶点shader
int vertexShaderId = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER);
GLES20.glShaderSource(vertexShaderId, vertexShader);
GLES20.glCompileShader(vertexShaderId);
//编译很容易出错,必须检测编译的情况
Log.w("System.out:", GLES20.glGetShaderInfoLog(vertexShaderId));
int[] compileStatus = new int[1];
GLES20.glGetShaderiv(vertexShaderId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if (compileStatus[0] == 0) {
GLES20.glDeleteShader(vertexShaderId);
Log.w("System.out:", "Compilation of vertex shader failed");
return;
}
//编译片段shader
int fragmentShaderId = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER);
GLES20.glShaderSource(fragmentShaderId,fragmentShader);
GLES20.glCompileShader(fragmentShaderId);
Log.v("System.out:", GLES20.glGetShaderInfoLog(fragmentShaderId));
GLES20.glGetShaderiv(fragmentShaderId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if (compileStatus[0] == 0) {
GLES20.glDeleteShader(fragmentShaderId);
Log.w("System.out:", "Compilation of fragment shader failed");
return;
}
//编译并链接好的程序可以在多次绘制中重复使用,只须在绘制时绑定glsl中所需的不同数据
// 即可,上述glsl程序中,每次绘制只有u_Matrix和textureId是需要重新绑定的。而其中
// 纹理坐标和顶点坐标等数据每次绘制都是不变的。
programId = GLES20.glCreateProgram();
GLES20.glAttachShader(programId,vertexShaderId);
GLES20.glAttachShader(programId,fragmentShaderId);
GLES20.glLinkProgram(programId);
int[] linkStatus = new int[1];
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0);
Log.w("System.out:", GLES20.glGetProgramInfoLog(programId));
if (linkStatus[0] == 0) {
GLES20.glDeleteProgram(programId);
Log.w("System.out:", "linking of program failed");
return;
}
//生成多个textureId。并与对应的bitmap绑定,给onDrawFrame中使用
int[] textureObjectId = new int[1];
BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false;
Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(),R.drawable.youandme,options);
if(bitmap == null){
Log.e("system.out: ","bitmap == null!!!!!!");
return;
}
//已经和bitmap绑定的textureId可以多次使用,但是不用的时候应该要及时释放,否则占用大量内存。
for(int i=0; i<4; i++){
GLES20.glGenTextures(1,textureObjectId,0);
if(textureObjectId[0]==0){
Log.w("system.out: ","counld not generate a new OpenGL texture object.");
}
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureObjectId[0]);
//必须显示设置纹理过滤或者直接glGenerateMipmap纹理
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR_MIPMAP_LINEAR);//用于缩小的过滤算法
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);//用于放大的过滤算法
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D,0,bitmap,0);
GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);//贴图的配置和生成是必不可少的,否则无法显示
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0);//记得每次绑定textureId和bitmap后,要清掉GLES与textureId的绑定
textureIds[i] = textureObjectId[0];
}
bitmap.recycle();
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
//这里的坐标是左下角为原点,向上和向右为正。即与纹理坐标系是一致的。
//openGL的坐标的化整为零是以viewPort为基础的。即视整个viewPort为长宽为2的区域,
// 而无论viewPort区域大小
GLES20.glViewport(0,0,width,height);
mSlotWidth = width/xSlotCount;
mSlotHeight = height/ySlotCount;
Matrix.setIdentityM(scaleMatrix,0);
Matrix.scaleM(scaleMatrix,0,1,1f,1f);//这里相当于没有做变换,因为scale系数都为1
Log.w("sysmtem.out: ","width:" + width + " height: " + height);
}
//看这个方法前,先看顶点坐标,脑补一下在矩阵变换前
// 图片处在openGL坐标系中的位置及在屏幕中的位置
@Override
public void onDrawFrame(GL10 gl) {
float[] translateMatrix = new float[16];
Matrix.setIdentityM(translateMatrix,0);
Matrix.translateM(translateMatrix,0,-(xSlotCount/2+1)*mSlotWidth,0f,0);
float top;
float left;
float columnIndex;
for(int i=0; i<4; i++){
columnIndex = i%4;
left = -(xSlotCount/2)*mSlotWidth + columnIndex*mSlotWidth;
//根据图片index移动图片。
float[] m = new float[16];
System.arraycopy(scaleMatrix,0,m,0,16);
printM(m);
//该矩阵会一致叠加下去,就是每显示一个图片,都在原来的变换的基础上做移动变换
Matrix.translateM(translateMatrix,0,mSlotWidth,0f,0);
//下面的投影矩阵的x坐标范围大小是xSlotCount,默认的投影矩阵,坐标顶点指定范围是(0,1),
// 即投影矩阵x坐标范围大小为1时,能显示一张图片的完整宽度,如果投影矩阵x坐标范围为n时
// 则可以完整显示n张图片的宽度,创建投影矩阵时除了要考虑坐标范围大小,还需考虑具体投
// 影哪一个范围结合该程序,如果需要把图片放在从左边起的第一个格,则left为图片位移后的
// left坐标。如果为第二格,则起始位置为位移后的left坐标再向左移动1f。第三第四格以此类
// 推,就像拍照一样,你想把某个景物放在你的构图中的那个位置,就去移动你的相机。假如你
// 需要这个景物占据你的照片的1/4,就去调整你的视角大小,即把更大或者更小范围的景色显示在
// 屏幕中,这里范围就是投影矩阵的投影范围。最后你还想把那个景物放在距离左边界为1/4屏宽的
// 位置,那么你可以先把景物对齐左边界,在这里就是left,然后再把相机向左移动,直到景物
// 距离左边界为1/4屏宽这里就是-columnIndex部分。只要不调用GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
// onDrawFrame绘制的内容没有覆盖原来的内容,原来的内容就会一直存在,所以每绘制一张图片,
// 调整一个投影矩阵,对之前已绘制的图片没有影响。
Matrix.orthoM(mProjectionMatrix,0,-columnIndex+left,
-columnIndex+left+xSlotCount,-mSlotHeight/mSlotHeight,
mSlotHeight/mSlotHeight,-1f,1f);
Matrix.multiplyMM(m,0,mProjectionMatrix,0,translateMatrix,0);
//如果不把这句去掉,则会每次只能显示一张图片,就是每绘制一帧前都会清屏
// GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
//选择使用的glsl程序
GLES20.glUseProgram(programId);//编译并链接好的程序可以在多次绘制中重复使用
//绑定纹理
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureIds[i]);
int uTextureUnitId = GLES20.glGetUniformLocation(programId, "u_textureUnit");
GLES20.glUniform1i(uTextureUnitId,0);
//绑定顶点坐标
int positionId = GLES20.glGetAttribLocation(programId,"a_position");
vertexBuffer.position(vertexStartPosition);
GLES20.glVertexAttribPointer(positionId,coorPerVertex,GLES20.GL_FLOAT,false,vertexStride,vertexBuffer);
GLES20.glEnableVertexAttribArray(positionId);
//绑定texture纹理坐标
int textureCoordinatePositionId = GLES20.glGetAttribLocation(programId,"a_texture_coordinate");
vertexBuffer.position(textureStartPosition);
GLES20.glVertexAttribPointer(textureCoordinatePositionId,coorPerTexture,GLES20.GL_FLOAT,false,vertexStride,vertexBuffer);
GLES20.glEnableVertexAttribArray(textureCoordinatePositionId);
//应用矩阵
int uniformMatrixId = GLES20.glGetUniformLocation(programId,"u_Matrix");
GLES20.glUniformMatrix4fv(uniformMatrixId,1,false,m,0);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN,0,vertexCount);
// GLES20.glDisableVertexAttribArray(positionId);
}
}
public static void printM(float[] matrix){
for(int i=0; i<4; i++){
for(int j=0; j<4; j++)
System.out.print(matrix[i+4*j] + ", ");
System.out.println();
}
System.out.flush();
}
}
上面的代码是对顶点和纹理作透视矩阵变换和对z值作移位。作透视矩阵变换对2D纹理的影响只在于x,y和w。w又会作用于x,y,因为要做透视除法,就是最终x=x/w, y=y/w。因为透视矩阵变换相当于把眼睛放到焦点,往里看,如果是右手坐标,那么就是往z的负轴方向看,而焦点位置默认是原点。而2D纹理的z值默认为0,就相当于把图片贴着眼睛,是看不到的。所以要把图片往里移动(就是远离焦点),移到某个地方可以刚好看到图片全景,再往后移图片就在屏幕上缩小了。那么在n和f(near值和far值,就是近平面和远平面)是如何影响x,y的呢,n和f直接影响z值和w值。本来z值为0,那么顶点坐标变换后再(1,-1)范围都会显示的,就是说总有一部分可以显示出来。因为上面的矩形面试包含了(0,0),并且是以原点为中心的面。而n的值影响了z值,使其绝对值大于1,那么就无显示在屏幕上。所以对于2D纹理,变换后的z值只影响该纹理能否显示在屏幕上,而不能影响纹理显示的大小。其实f值也能影响z值,但是2D纹理的z值是0,所以变换时f无法对z值有影响。而做了透视矩阵变换后,会对x,y作一个放大,然后再除以w值,对x,y做放大缩小和除以w都对纹理显示大小有影响,而w是受f,n控制的。
实际的操作对应的显示情况是(只针对2D纹理):
1.n值决定了,要把图片推到那个地方才显示出来。
2.无论n和f怎么变,眼睛所在位置都是不变的,就是说焦点一直在原点。所以n值与对图片做z移位变换使用的z值的差值对图片显示的大小没有关系。
3.图片显示在屏幕上的大小跟图片和眼睛(焦点)的距离有关(也就是图片跟原点的z差值)有关,还跟视野角度有关。
假如一个顶点坐标的y值,作了透视矩阵的变换后(此时只视角和aspect(屏幕宽高比)的影响),要绘制的最大y值是0.5,也就是如上图的大小,如果要刚好整个图片都显示在屏幕上的话,就需要推到上图的位置,如果计算要推多远呢?根据三角形的知识,假如视角大小为a,那么变换使用的z=0.5/tan(a/2)
如何知道透视矩阵中视角和aspect(宽高比)如何影响x和y呢。要自己看透视矩阵是怎么写的。
在下面的矩阵中,是把尺寸大的一边看做1,那么小的半边根据比例来确定是多少,肯定小于1.所以看矩阵,y值之后视角影响。
上面计算要把图片推多远才能刚好完全显示出来时,需要考虑aspect,就是受m[0]的影响。
public static void perspectiveM(float[] m,float yFovInDegress,float aspect,float n,float f){
final float angleInRadians=(float)(yFovInDegress* Math.PI/180.0);
final float a=(float)(1.0/ Math.tan(angleInRadians/2.0));
m[0]=a/aspect;
m[1]=0f;
m[2]=0f;
m[3]=0f;
m[4]=0f;
m[5]=a;
m[6]=0f;
m[7]=0f;
m[8]=0f;
m[9]=0f;
m[10]=-((f+n)/(f-n));
m[11]=-1f;
m[12]=0f;
m[13]=0f;
m[14]=-((2f*f*n)/(f-n));
m[15]=0f;
}
反正就是使用透视矩阵变换后,z和w收到n,f的影响,而x,y 又受到了视角和宽高比,和w的综合影响。所以2D纹理在屏幕上的显示大小受到视角和宽高比,和w的综合影响。而能否显示出来受n的影响。
当纹理的大小被扩大或者缩小时,我们还需要使用纹理过滤明确说明会发生什么。当我们在渲染表面上绘制一个纹理时,那个纹理的纹理元素可能无法精确地映射到OpenGL生成的片段上。有两种情况:缩小和放大。当我们尽力把几个纹理元素挤进一个片段时,缩小就发生了;当我们把一个纹理元素扩展到许多片段时,方法就发生了。针对每一种情况,我们可以配置OpenGL使用一个纹理过滤器。
两个基本的过滤模式:最近邻过滤和双线性插值。
最近邻过滤
这个方式为每个片段选择最近的纹理元素。当我们放大纹理时,它的锯齿效果看起来相当明显
双线性过滤
双线性过滤使用双线性插值平滑像素之间的过渡,而不是为每个片段使用最近的纹理元素。这个算法过滤后的纹理看起来比最近邻过滤的平滑多了。尽管双线性过滤很适合处理放大,但是对于缩小到超过一定的大小时,它就不好用了。
MIP贴图
为了克服最近邻过滤和双线性插值的缺陷,可以使用MIP贴图技术,它可以用来生成一组优化过的不同大小的纹理。当生成这组纹理的时候,OpenGL会使用所有的纹理元素生成每个级别的纹理,当过滤纹理时,还要确保所有的纹理元素都能被使用。在渲染时,OpenGL会根据每个片段的纹理元素数量为每个片段选择最合适的级别。使用MIP贴图,会占用更多的内存,但是渲染也会更快,这是因为较小级别的纹理在GPU的纹理缓存中占用较少的空间。MIP贴图不是独立的过滤算法,而是必须配合最近邻过滤和双线性插值或其他过滤算法使用的,且只可以用于缩小时。即在确定一个纹理被放大缩小的倍数后,根据每个片段(对应物理显示屏中的一个像素)的纹理元素数量为每个片段选择最合适的级别,在这个级别的贴图基础上使用最近邻过滤或双线性过滤去放大或缩小至指定的大小。
下图是一组MIP贴图的纹理,把它们合并在一当个图上是为了方便对比
程序中纹理参数表
GLES20.glTextParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAX_FILTER,“纹理过滤模式”);第二个参数指放大的情况。
GLES20.glTextParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,“纹理过滤模式”);第二个参数指缩小的情况。
setIdentityM(float[] sm, int smOffset)
在scaleM或者translate前,一般使用这个对代表矩阵的16位数组进行初始化,即初始化位一个单位矩阵。创建正交投影矩阵前,无需这一操作。
scaleM(float[] m, int mOffset, float x, float y, float z)
相当于对对坐标系进行缩放。openGL的归一化坐标为x和y的范围均为(-1,1)。当向scaleM传入缩放系数(x,y)时,则归一化坐标的范围为(-x,x)和(-y,y)。如果没有加入正交投影矩阵,则默认显示的x和y坐标范围均为(-1,1)。此时整个屏幕将只显示实际内容宽度的1/x倍和高度的1/y倍。所以,假如x和y大于1,假设均为2,则整个屏幕只是显示实际内容的1/4,表现为放大。假设x,y小于1,则表现为缩小。缩放表现为以openGL坐标系原点为中心点的缩放。如果translateM在被scaleM处理过的矩阵上再位移,则位移参数会被scaleM的factor影响,假如scaleM的factor为2,则translateM的实际位移将乘以2。如果一个矩阵在被translateM处理后,scaleM对前面translate矩阵做缩放,则不影响translate的实际位移。
orthoM(float[] m, int mOffset, float left, float right, float bottom, float top, float near, float far)
指定某个坐标范围的图像被绘制到当前屏幕,也影响了所在范围内的图像在屏幕中的位置和实际占据了多少个像素。基本原理就是必须把当前想要绘制的内容的坐标包含在这个正交投影范围内,其次是确定想要的缩放大小,想要缩小绘制的图像,则要使这个投影范围加大,比如要缩小到原来的1/2,则投影范围大小为4,因为原来默认的是2(-1,1)。通俗的说就是需要被显示的内容多了,而屏幕大小不变,则原来的某一个物体在屏幕中的投影自然变小。在Android的应用中可以把x轴和y轴的投影大小设为screenWidth和screenHeight,这样会便于把openGl的坐标系当成是屏幕的坐标系。然后scaleM把坐标系适当放大,一面显示的内容太小。如果图片显示且占满了openGL的第一象限,然后投影范围是从原点开始的,比如:(0,screenWidht)(0,screenHeight)那么最终显示的内容大小,就是放大后的坐标系范围大小和投影范围大小的比值乘以屏幕大小。
translateM( float[] m, int mOffset, float x, float y, float z)
一般和scaleM或者orthoM配合使用。对一个矩阵进行移位,假如使用默认的投影矩阵(-1,1),那你不能把图像移除这个坐标范围,否则无法显示图像。如果translate是在scaleM的基础上变换的,那么scaleM会直接影响translateM,即translateM中的dx和dy总要乘以x和y的scale系数。比如x和y的scale系数为1/2,那么对translateM传入x=1,y=1,则相当于在x和y坐标移动了0.5。如果translate不是在某个被scale的矩阵的基础上位移的,则不会手scale影响。
multiplyMM(float[] result, int resultOffset, float[] lhs, int lhsOffset, float[] rhs, int rhsOffset)
可以用于对投影矩阵和scale、translate后的矩阵合并。然后传递给glsl程序。注意正交投影矩阵必须最后应用,且投影矩阵必须放在lhs,即作为左因子参与矩阵相乘,因为矩阵相乘是不符合交换律的,所以顺序会影响相乘后的结果。
参考 OpenGL ES应用开发实践指南
参考这个专栏:https://blog.csdn.net/liyuanjinglyj/article/list/3?
关于opengl的GLSL的基础知识:https://www.cnblogs.com/mazhenyu/p/3804518.html
OpenGL基本图元转换为GL_TRIANGLES