概述
这是一个新的系列,学习OpengGl Es,其实是《OpenGl Es 应用开发实践指南 Android卷》的学习笔记,感兴趣的可以直接看这本书,当然这个会记录自己的理解,以下只作为笔记,以防以后忘记
之后会对本书的前九章依次分析记录
Android OpenGl Es 学习(一):创建一个OpenGl es程序
Android OpenGl Es 学习(二):定义顶点和着色器
Android OpenGl Es 学习(六):进入三维
Android OpenGl Es 学习(七):使用纹理
Android OpenGl Es 学习(八):构建简单物体
Android OpenGl Es 学习(九):增添触摸反馈
最终是要实现一个曲棍球的简单游戏,类似这样的
线性代数基础
opengl使用了大量的向量和矩阵,矩阵重要的用途是建立正交和透视投影,从本质上来讲,使用矩阵做投影只涉及对一组数据执行大量的加发和乘法,这些运算在在现代的GPU上都执行的很快,下面我们复习一些基础知识
向量
一个向量是一个多个元素的一维数组,在OpenGl里一个位置通常有4个分量x,y,z,w。一个颜色也是4个r,g,b,a
矩阵
矩阵是一个矩形数学表达式,下面是一个2*3矩阵,表示2行3列
矩阵相乘
- 矩阵相乘是有限制的,只有m×n和n×m的矩阵才可以相乘,也就是说俩个矩阵的列和行相等,结果为m×m的矩阵
- 矩阵相乘不符合乘法交换律,也就是A×B ≠ B×A
矩阵和向量相乘
向量其实就是一个4×1矩阵,而opengl里的矩阵一版是4×4矩阵,所以向量和矩阵可以相乘,那么向量乘矩阵到底可以干什么呢?
其实很多有趣的2D/3D变换都可以放在矩阵中,然后用这个矩阵乘向量,可以变换这个向量
单位矩阵
单位矩阵是除了左上角,其他地方都是0的N×N矩阵,一个矩阵和单位矩阵相乘,结果还是该矩阵不变
平移矩阵
在原向量加上另一个向量,从而获得不同位置的新向量,从而达到把原始向量平移的效果
从上图可以开出,原始向量是(x,y,z),然后和平移矩阵相乘后,结果(x+Tx,y+Ty,z+Tz),达到平移的效果
缩放矩阵
下面我们构造一个缩放矩阵,来达到缩放的功能
旋转矩阵
在3D空间旋转需要定义一个旋转角度和旋转轴,物体会围着指定的旋转轴转动指定的角度,2D向量在3D空间旋转,我们把z轴作为旋转轴
在三角学,给定一个角度,把目前向量变换为一个旋转后的新向量,这通常使用正弦和余弦函数(sin/cos)
沿x轴旋转
沿y轴旋转
沿z轴旋转
利用旋转矩阵我们可以把任意位置的向量,沿一个旋转轴进行旋转,也可以多个矩阵复合,比如先沿x轴旋转然后沿y轴旋转
这里我们可以简单理解为,一个向量和一个特定的矩阵相乘,可以实现按照某个轴旋转
矩阵组合
我们可以把多个变换组合到一个矩阵中,假如我们有个向量(x,y,z),我们要他先缩放2倍,然后平移(1,2,3)单位,我们需要一个缩放和位移矩阵来实现这个功能
当矩阵相乘时,我们要先写位移然后写缩放,由于矩阵相乘是不遵守乘法交换律的,这就意味他们的位置很重要,最右边是最先和向量相乘的,所以我们要从右向左看这个式子,如果不按照这个顺序,可能导致混乱,比如:先位移,然后缩放,这样的话位移的向量也会被缩放(比如位移2m,可能缩放成位移1m)
这个式子最后就是先缩放2倍,然后位移(1,2,3)
优化宽高比
我们打开上篇文章写的项目,切换下横竖屏看下效果
我们可以看到当横屏时,矩形被压扁了,之所以会这样,是因为我们直接把坐标传递给了opengl,没有考虑屏幕宽高比,每一个二维和三维程序都会有同样的一个问题,怎么决定在屏幕显示什么,如何针对屏幕尺寸做调整
这个问题在opengl中有一个通用的解决方案,我们可以使用投影把真实世界的一部分,映射到屏幕上,这种方式的,这种方式的映射会使他在不同的屏幕尺寸或方向上看起来总是正确的
下面我们学习下怎么修复这个被压扁的问题
宽高比问题
在opengl中渲染所有物体,都要映射到xy轴的[-1 ,1]中,z轴也一样,这个坐标称为归一化设备坐标,他独立于屏幕实际尺寸或形状
由于他独立于实际的屏幕尺寸,所以如果直接使用,会导致矩形被压扁的情况
假设屏幕分辨率是1280×720,opengl占用真个显示屏,也就是说在竖屏的情况[-1,1]对应1280高,720宽,那么x轴就会扁平,反之亦然
归一化设备坐标假定空间是一个正方形,然而实现显示不可能是一个正方形,图像就会在一个方向上被拉伸,在另一个方向上被压扁
设配宽高比
我们需要调整空间坐标,把屏幕的形状也考虑在内,可行的方法是把较小的范围固定在[-1,1]中,然后按照屏幕尺寸调整较大的范围
比如,把720固定在[-1,1]的范围内,把高度范围固定在[1280/720,1280/720]也就是[1.78,1.78]
画的有点丑,但是能看懂就行,通过调整已有的坐标空间,最终可以改变可用空间,这样不论横屏竖屏 无题都不会变形了
使用虚拟坐标空间
上面说到我们需要调整坐标空间,以便我们把屏幕形状考虑出来,所以我们需要停止直接在归一化设备坐标上工作,我们需要在虚拟坐标空间工作,然后我们需要找到可以把虚拟坐标转化为归一化设备坐标的方法,这种转换应该把屏幕形状考虑在内,可以让opengl正确的渲染图形,避免横竖屏切换变形
我们需要找到的这种转换方法,叫做正交投影,不管多远多近,所有的物体看上去大小总是相同的,我们可以想象一下我们在空中俯瞰火车道的场景,他看起来应该是这样的
从虚拟坐标到归一化坐标
当我们使用正交投影把虚拟坐标转化为归一化坐标时,实际上是定义了三维世界的一个区域,这个区域内的所有东西都会显示在屏幕上,而区域外的都会被忽略,如图,一个完整的三维世界(5-3),当我们用正交投影把这个立方体投影到屏幕上(5-4),我们在屏幕只看到了其中一个面,而其他面我们都会忽略
利用正交投影矩阵改变立方体的大小,可以让我们在屏幕上看到或多或少的场景,我们也可以改变立方体的形状设配屏幕宽高
定义正交投影
定义正交投影,需要用到Android中的Matrix类,是android.opengl包中的,它里面有一个方法orthoM
方法,用于生成正交投影,我们看下这个方法的参数
orthoM(float[] m, int mOffset,
float left, float right, 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坐标
左手坐标系和右手坐标系
-
右手坐标系:伸开右手,大拇指指向X轴正方向,食指指向Y轴正方向,其他三个手指指向Z轴正方向
-
左手坐标系:伸开左手,大拇指指向X轴正方向,食指指向Y轴正方向,其他三个手指指向Z轴正方向
这俩者的区别就是Z轴相反
opengl中使用的右手坐标系,而归一化设备坐标使用的是左手坐标系,所以Android 的Matrix会默认反转z轴
使用正交投影
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
//设置屏幕的大小
GLES20.glViewport(0, 0, width, height);
float a = width > height ? (float) width / (float) height : (float) height / (float) width;
if (width > height) {
Matrix.orthoM(mProjectionMatrix, 0, -a, a, -1f, 1f, -1f, 1f);
} else {
Matrix.orthoM(mProjectionMatrix, 0, -1f, 1f, -a, a, -1f, 1f);
}
}
这个段代码其实就是根据宽高比,创建正交矩阵,我们看下创建矩阵到底是什么样的,加入宽高为780×1920,我们计算一下(1,1,1,1)坐标,经过正交投影的变化
由于我们的宽高780×1920,所以如果不处理的话,高度比较大,也就是y轴会拉伸
而我们经过正交投影处理后(1,1,1,1)变成了(1,1/1.78,1,1),y轴本来是要被拉伸,经过处理后,y轴会被压缩,这样y轴就不会被拉伸
创建一个正常的正交投影
下面说一个有实际用途的正交投影,我们可以改变屏幕看到场景的大小,我们定义的顶点是[-1,1]范围内的,我们打算把[0,1]范围内的场景投影到屏幕,也就是投影到归一化坐标的[-1,1],也就是说我们传入的左,下,近都是0
向量和矩阵相乘之前,我们指定了z轴负值,而不是一个正值,是为了上方那个反转z轴做的调整
上边图片可以看到,我们虚拟坐标[-1,1]投影后的归一化坐标为[-3,1],而归一化坐标只显示[-1,1],所以[-3,-1]都显示不了,而[-1,-3]对应的虚拟坐标为[-1,0]
最后的效果为虚拟坐标的[0,1]投影在屏幕上,[-1,0]则不显示
改代码,实现效果
上边说了这么多现在我们基于上篇文章的代码,再次改动适配宽高比
首先改动着色器,增加矩阵
我们打开vertex_shader.glsl
,并更新
attribute vec4 a_Position;
attribute vec4 a_Color;
uniform mat4 u_Matrix;
varying vec4 v_Color;
void main() {
v_Color=a_Color;
gl_Position = u_Matrix * a_Position;
gl_PointSize=10.0;
}
我们新添加了uniform
的u_Matrix
,并定义为mat4
矩阵,意思是这个代表一个4×4矩阵
然后
gl_Position = u_Matrix * a_Position;
这次我们没有直接把顶点数组的值作为顶点,而是把顶点数组和矩阵的乘积作为顶点,这就意味着,顶点数组不在作为归一化设备坐标,他只代表虚拟坐标,而数组和矩阵的乘积将成为归一化设备坐标,这个矩阵会把虚拟坐标转化为归一化设备坐标
代码添加新的glsl属性
在onSurfaceCreated
中添加
u_matrix = GLES20.glGetUniformLocation(program, "u_Matrix");
定义数组存储正交矩阵
private float[] mProjectionMatrix = new float[16];
代码使用正交矩阵
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
//在Surface创建以后,每次surface尺寸大小发生变化,这个方法会被调用到,比如横竖屏切换
//设置屏幕的大小
GLES20.glViewport(0, 0, width, height);
float a = width > height ? (float) width / (float) height : (float) height / (float) width;
if (width > height) {
Matrix.orthoM(mProjectionMatrix, 0, -a, a, -1f, 1f, -1f, 1f);
} else {
Matrix.orthoM(mProjectionMatrix, 0, -1f, 1f, -a, a, -1f, 1f);
}
}
传递矩阵给着色器
GLES20.glUniformMatrix4fv(u_matrix,1,false,mProjectionMatrix,0);
调整顶点
由于上篇文章的顶点其实画的是一个正方形,由于宽高比问题,看起来像矩形,现在我们修复了宽高比问题,那么我们也该更新顶点坐标,让他组成一个真正的矩形
float[] tableVertices = {
//顶点
0f, 0f,
//顶点颜色值
1f, 1f, 1f,
-0.5f, -0.8f,
0.7f, 0.7f, 0.7f,
0.5f, -0.8f,
0.7f, 0.7f, 0.7f,
0.5f, 0.8f,
0.7f, 0.7f, 0.7f,
-0.5f, 0.8f,
0.7f, 0.7f, 0.7f,
-0.5f, -0.8f,
0.7f, 0.7f, 0.7f,
//线
-0.5f, 0f,
1f, 0f, 0f,
0.5f, 0f,
0f, 1f, 0f,
//点
0f, -0.4f,
1f, 0f, 0f,
0f, 0.4f,
0f, 0f, 1f
};
看下效果
完整代码
public class AirHockKeyRender2 implements GLSurfaceView.Renderer {
//调整宽高比
private final FloatBuffer verticeData;
private final int BYTES_PER_FLOAT = 4;
private int POSITION_COMPONENT_COUNT = 2;
private final int COLOR_COMPONENT_COUNT = 3;
private final int STRIDE = (POSITION_COMPONENT_COUNT + COLOR_COMPONENT_COUNT) * BYTES_PER_FLOAT;
private final Context mContext;
//逆时针绘制三角形
float[] tableVertices = {
//顶点
0f, 0f,
//顶点颜色值
1f, 1f, 1f,
-0.5f, -0.8f,
0.7f, 0.7f, 0.7f,
0.5f, -0.8f,
0.7f, 0.7f, 0.7f,
0.5f, 0.8f,
0.7f, 0.7f, 0.7f,
-0.5f, 0.8f,
0.7f, 0.7f, 0.7f,
-0.5f, -0.8f,
0.7f, 0.7f, 0.7f,
//线
-0.5f, 0f,
1f, 0f, 0f,
0.5f, 0f,
0f, 1f, 0f,
//点
0f, -0.4f,
1f, 0f, 0f,
0f, 0.4f,
0f, 0f, 1f
};
private int a_position;
private int a_color;
private float[] mProjectionMatrix = new float[16];
private int u_matrix;
public AirHockKeyRender2(Context context) {
this.mContext=context;
//把float加载到本地内存
verticeData = ByteBuffer.allocateDirect(tableVertices.length * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(tableVertices);
verticeData.position(0);
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
//当surface被创建时,GlsurfaceView会调用这个方法,这个发生在应用程序
// 第一次运行的时候或者从其他Activity回来的时候也会调用
//清空屏幕
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
//读取着色器源码
String fragment_shader_source = ReadResouceText.readResoucetText(mContext, R.raw.fragment_shader1);
String vertex_shader_source = ReadResouceText.readResoucetText(mContext, R.raw.vertex_shader2);
//编译着色器源码
int mVertexshader = ShaderHelper.compileShader(GLES20.GL_VERTEX_SHADER, vertex_shader_source);
int mFragmentshader = ShaderHelper.compileShader(GLES20.GL_FRAGMENT_SHADER, fragment_shader_source);
//链接程序
int program = ShaderHelper.linkProgram(mVertexshader, mFragmentshader);
//验证opengl对象
ShaderHelper.volidateProgram(program);
//使用程序
GLES20.glUseProgram(program);
//获取shader属性
a_position = GLES20.glGetAttribLocation(program, "a_Position");
a_color = GLES20.glGetAttribLocation(program, "a_Color");
u_matrix = GLES20.glGetUniformLocation(program, "u_Matrix");
//绑定a_position和verticeData顶点位置
/**
* 第一个参数,这个就是shader属性
* 第二个参数,每个顶点有多少分量,我们这个只有来个分量
* 第三个参数,数据类型
* 第四个参数,只有整形才有意义,忽略
* 第5个参数,一个数组有多个属性才有意义,我们只有一个属性,传0
* 第六个参数,opengl从哪里读取数据
*/
verticeData.position(0);
GLES20.glVertexAttribPointer(a_position, POSITION_COMPONENT_COUNT, GLES20.GL_FLOAT,
false, STRIDE, verticeData);
//开启顶点
GLES20.glEnableVertexAttribArray(a_position);
verticeData.position(POSITION_COMPONENT_COUNT);
GLES20.glVertexAttribPointer(a_color, COLOR_COMPONENT_COUNT, GLES20.GL_FLOAT,
false, STRIDE, verticeData);
//开启顶点
GLES20.glEnableVertexAttribArray(a_color);
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
//在Surface创建以后,每次surface尺寸大小发生变化,这个方法会被调用到,比如横竖屏切换
//设置屏幕的大小
GLES20.glViewport(0, 0, width, height);
float a = width > height ? (float) width / (float) height : (float) height / (float) width;
if (width > height) {
Matrix.orthoM(mProjectionMatrix, 0, -a, a, -1f, 1f, -1f, 1f);
} else {
Matrix.orthoM(mProjectionMatrix, 0, -1f, 1f, -a, a, -1f, 1f);
}
}
@Override
public void onDrawFrame(GL10 gl) {
//当绘制每一帧数据的时候,会调用这个放方法,这个方法一定要绘制一些东西,即使只是清空屏幕
//因为这个方法返回后,渲染区的数据会被交换并显示在屏幕上,如果什么都没有话,会看到闪烁效果
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUniformMatrix4fv(u_matrix,1,false,mProjectionMatrix,0);
//绘制长方形
//指定着色器u_color的颜色为白色
/**
* 第一个参数:绘制绘制三角形
* 第二个参数:从顶点数组0索引开始读
* 第三个参数:读入6个顶点
*
* 最终绘制俩个三角形,组成矩形
*/
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, 0, 6);
//绘制分割线
GLES20.glDrawArrays(GLES20.GL_LINES, 6, 2);
//绘制点
GLES20.glDrawArrays(GLES20.GL_POINTS, 8, 1);
GLES20.glDrawArrays(GLES20.GL_POINTS, 9, 1);
}
}