OpenGL 的基本形状是三角形,无论是绘制形状还是填充,都是对于图形进行操作
对于一个平面图形,绘制的结果是有正反面的,
着色器语言(GLSL)主要包括两部分:Vertex shader(定点着色器,负责定点位置与坐标变换,即决定显示哪个部分,以何种位置/姿态显示),Fragment shader(片元着色器,负责纹理的填充与转换,即决定显示成什么样子)
OpenGL ES的屏幕坐标系
OpenGL ES是一个三维的图形库,但是三维的图像要在二维平面显示,就要经过一定的投影变换,将三维的空间以一定的方式显示在二维屏幕上。
上图显示的是OpenGL ES的屏幕坐标系,无论是X还是Y轴,取值范围都是[-1,1],也就是说,即便你的手机是16:9的屏幕,对于OpenGL ES来说也是一个正方形的绘制范围(是不是很奇怪)
初始化OpenGL ES环境
OpenGL ES的使用,一般包括如下几个步骤:
- EGL Context初始化
- OpenGL ES初始化
- OpenGL ES设置选项与绘制
- OpenGL ES资源释放(可选)
- EGL资源释放
Android平台提供了一个GLSurfaceView,来帮助使用者完成第一步和第五步,由于释放EGL资源时会自动释放之前申请的OpenGL ES资源,所以需要我们自己做的就只有2和3。
使用GLSurfaceView
我们在主布局中引入一个GLSurfaceView,并让他充满整个布局,并在Activity中获取他的实例
<android.opengl.GLSurfaceView
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
public class MainActivity extends AppCompatActivity {
private GLSurfaceView glSurfaceView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
glSurfaceView= (GLSurfaceView) findViewById(R.id.surface_view);
}
}
获取实例以后,我们就可以对于这个GLSurfaceView进行配置:
glSurfaceView.setEGLContextClientVersion(2);//设置EGL上下文的客户端版本,因为我们使用的是OpenGL ES 2.0,所以设置为2
glSurfaceView.setRenderer(new GLRenderer());
glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);//代表渲染模式,选项有两种(RENDERMODE_WHEN_DIRTY,RENDERMODE_CONTINUOUSLY),一个是需要渲染(触控事件,渲染请求)才渲染,一个是不断渲染。
GLSurfaceView.Renderer接口
GLRenderer是本文中的关键类,实现了GLSurfaceView.Renderer这个接口,用来完成绘制操作。现在我们来看看这个类的定义:
public class GLRenderer implements GLSurfaceView.Renderer {
//这个函数在Surface被创建的时候调用,每次我们将应用切换到其他地方,再切换回来的时候都有可能被调用,在这个函数中,我们需要完成一些OpenGL ES相关变量的初始化
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
}
//每当屏幕尺寸发生变化时,这个函数会被调用(包括刚打开时以及横屏、竖屏切换),width和height就是绘制区域的宽和高(上图黑色区域)
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
}
//每一次绘制时这个函数都会被调用,之前设置了GLSurfaceView.RENDERMODE_CONTINUOUSLY,也就是说按照正常的速度,每秒这个函数会被调用60次,虽然我们还什么都没做
@Override
public void onDrawFrame(GL10 gl) {
}
}
private Context context;
public GLRenderer(Context context) {
this.context = context;
}
glSurfaceView.setRenderer(new GLRenderer(this));
//用来读取raw中的文本文件,并且以String的形式返回
public static String readRawTextFile(Context context, int resId) {
InputStream inputStream = context.getResources().openRawResource(resId);
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
reader.close();
return sb.toString();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
我们在raw文件夹中创建两个文件,fragment_shader.glsl和vertex_shader.glsl,他们分别是片元着色器和顶点着色器的脚本,之前说的可编程管线,就是指OpenGL ES 2.0可以即时编译这些脚本,来实现丰富的功能,两个文件的内容如下:
vertex_shader.glsl
attribute vec4 aPosition;
void main() {
gl_Position = aPosition;
}
- vec4是一个包含4个浮点数(float,我们约定,在OpenGL中提到的浮点数都是指float类型)的向量,
- attribute表示变元,用来在Java程序和OpenGL间传递经常变化的数据,
- gl_Position 是OpenGL ES的内建变量,表示顶点坐标(xyzw,w是用来进行投影变换的归一化变量),我们会通过aPosition把要绘制的顶点坐标传递给gl_Position
fragment_shader.glsl
precision mediump float;
void main() {
gl_FragColor = vec4(0,0.5,0.5,1);
}
- precision mediump float用来指定运算的精度以提高效率(因为运算量还是蛮大的),
- gl_FragColor 也是一个内建的变量,表示颜色,以rgba的方式排布,范围是[0,1]的浮点数
先完成onSurfaceCreated的代码,使用readRawTextFile把文件读进来,然后创建一个OpenGL ES程序
String vertexShader = ShaderUtils.readRawTextFile(context, R.raw.vertex_shader);
String fragmentShader= ShaderUtils.readRawTextFile(context, R.raw.fragment_shader);
programId=ShaderUtils.createProgram(vertexShader,fragmentShader);
读取文件应该好理解,创建程序就比较复杂了,具体的步骤是这样的,我们先看创建程序之前要做的事情:
- 创建一个新的着色器对象
- 上传和编译着色器代码,就是我们之前读进来的String
- 读取编译状态(可选)
public static int createProgram(String vertexSource, String fragmentSource) {
//我们先创建顶点着色器和片元着色器
int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource);
if (vertexShader == 0) {
return 0;
}
int pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
if (pixelShader == 0) {
return 0;
}
//然后用GLES20.glCreateProgram()创建程序,如果创建成功,会返回一个非零的值
int program = GLES20.glCreateProgram();
if (program != 0) {
GLES20.glAttachShader(program, vertexShader);//把程序和着色器绑定起来
checkGlError("glAttachShader");
GLES20.glAttachShader(program, pixelShader);
checkGlError("glAttachShader");
//然后用GLES20.glLinkProgram(program)链接程序(编译链接)
GLES20.glLinkProgram(program);
int[] linkStatus = new int[1];
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);//和之前的类似,是用来获取链接状态的
if (linkStatus[0] != GLES20.GL_TRUE) {
Log.e(TAG, "Could not link program: ");
Log.e(TAG, GLES20.glGetProgramInfoLog(program));
GLES20.glDeleteProgram(program);
program = 0;
}
}
return program;
}
//shaderType用来指定着色器类型,取值有GLES20.GL_VERTEX_SHADER和GLES20.GL_FRAGMENT_SHADER
//source就是刚才读入的代码
public static int loadShader(int shaderType, String source) {
//如果创建成功,那么shader会是一个非零的值
int shader = GLES20.glCreateShader(shaderType);
if (shader != 0) {
GLES20.glShaderSource(shader, source);
GLES20.glCompileShader(shader);
int[] compiled = new int[1];
//我们用GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)来获取编译的状态
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
if (compiled[0] == 0) {
Log.e(TAG, "Could not compile shader " + shaderType + ":");
Log.e(TAG, GLES20.glGetShaderInfoLog(shader));
//如果创建失败,就删除这个着色器
GLES20.glDeleteShader(shader);
shader = 0;
}
}
return shader;
}
另外还有一个打印错误日志的功能函数:
public static void checkGlError(String label) {
int error;
while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) {
Log.e(TAG, label + ": glError " + error);
throw new RuntimeException(label + ": glError " + error);
}
}
创建好了程序之后,我们获取之前顶点着色器中,aPosition的引用,以便于传送顶点数据
aPositionHandle= GLES20.glGetAttribLocation(programId,"aPosition");
完成向OpenGL的数据传送OpenGL ES工作在native层(C、C++),如果要传送数据,我们需要使用特殊的方法把数据复制过去。
首先定义一个顶点数组,这是我们要绘制的三角形的三个顶点坐标(逆时针),三个浮点数分别代表xyz,因为是在平面上绘制,我们把z设置为0
private final float[] vertexData = {
0f,0f,0f,
1f,-1f,0f,
1f,1f,0f
};
如果程序正常工作,那么我们的三角形应该出现在这个区域(见下图):
我们使用一个FloatBuffer将数据传递到本地内存
我们在类的构造函数中把顶点数据传递过去:
//ByteBuffer用来在本地内存分配足够的大小
vertexBuffer = ByteBuffer.allocateDirect(vertexData.length * 4)//申请一个内存,大小是data*4个字节,因为data里是float,一个float是4个字节
//设置存储顺序为nativeOrder(关于存储顺序的更多资料可以在维基百科上找到)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertexData);//把vertexData放进去
vertexBuffer.position(0);//设定索引位
完成onDrawFrame
完成了上述工作以后,我们就可以画个三角形,试试手
@Override
public void onDrawFrame(GL10 gl) {
//清空颜色缓冲区和深度缓冲区
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT |GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(programId);//指定使用刚才创建的那个程序
//启用顶点数组,aPositionHandle就是我们传送数据的目标位置
GLES20.glEnableVertexAttribArray(aPositionHandle);
GLES20.glVertexAttribPointer(aPositionHandle, 3, GLES20.GL_FLOAT, false,
12, vertexBuffer);
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);
}
- GLES20.glVertexAttribPointer的原型是这样的:
glVertexAttribPointer(
int indx,
int size,
int type,
boolean normalized,
int stride,
java.nio.Buffer ptr
)
stride表示步长,因为一个顶点三个坐标,一个坐标是float(4字节),所以步长是12字节
(当然,这个只在一个数组中同时包含多个属性时才有作用,例如同时包含纹理坐标和顶点坐标,在只有一种属性时(例如现在),和传递0是相同效果)
- 最后,我们用GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);把三角形画出来,glDrawArrays的原型如下
public static native void glDrawArrays(
int mode,
int first,
int count
);
因为OpenGL会把整个屏幕(其实是整个可以绘制的区域,也就是前面黑色的区域)当成输出,所以我们画出来的三角形出现了变形。那么横屏的情况下是什么样的呢? 来看一下:
解决变形问题
要解决变形,要先掌握Projection Matrix(投影矩阵)的概念,在OpenGL中,投影矩阵用来改变场景在屏幕上的显示方式(近大远小,平行投影等等)。
因为我们绘制的是二维平面,所以问题还是比较好解决的,假如我们的屏幕是16:9的(横屏情况),那么只要让OpenGL的绘制范围也是16:9的就好了,如下图所示:
可以看到,我们把OpenGL的绘制区域横向拉长了,拉长的比例就是(16/9 约1.777)
更多关于透视和投影变换
可以参考http://blog.csdn.net/popy007/article/details/1797121
那么代码要如何实现呢?答案是矩阵,进行正交投影的操作,我们并不需要去推导正交矩阵如何求出来,Android 提供的Matrix类中包含这个方法。
我们先声明一个长度16的float数组,这是Matrix的标准尺寸
private final float[] projectionMatrix=new float[16];
注意,在OpenGL中,数组是row-major的,如下所示:
/**
* Matrix math utilities. These methods operate on OpenGL ES format
* matrices and vectors stored in float arrays.
* <p>
* Matrices are 4 x 4 column-vector matrices stored in column-major
* order:
* <pre>
* m[offset + 0] m[offset + 4] m[offset + 8] m[offset + 12]
* m[offset + 1] m[offset + 5] m[offset + 9] m[offset + 13]
* m[offset + 2] m[offset + 6] m[offset + 10] m[offset + 14]
* m[offset + 3] m[offset + 7] m[offset + 11] m[offset + 15]</pre>
*/
创建了projectionMatrix以后,我们还需要更新glsl中顶点着色器的代码,以便把这个变换用的矩阵传递过去,如下所示:
attribute vec4 aPosition;
uniform mat4 uMatrix;
void main() {
gl_Position = uMatrix*aPosition;
}
uniform 是GLSL中的常量类型,之前的attribute类型是用来在Java代码和顶点着色器(Vertex Shader)传递变量用的,uniform则是给顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)传递常量用的。
我们把uMatrix和aPosition做矩阵乘法,就得到了一个新的顶点位置。
类似的,我们也需要一个入口,以便给这个矩阵传递数据
uMatrixHandle=GLES20.glGetUniformLocation(programId,"uMatrix");
完成正交投影
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
float ratio=width>height?(float)width/height:(float)height/width;
if (width>height){
Matrix.orthoM(projectionMatrix,0,-ratio,ratio,-1f,1f,-1f,1f);
}else Matrix.orthoM(projectionMatrix,0,-1f,1f,-ratio,ratio,-1f,1f);
}
在onSurfaceChanged中,我们获取了屏幕的宽和高,所以我们在这里计算缩放的比例,需要注意的是横屏和竖屏的时候是刚好相反的处理(一个改变x,一个改变y)
正交投影方法 : Matrix.orthoM() 方法设置正交投影;
public static void orthoM(float[] m, int mOffset,float left, float right, float bottom, float top,float near, float far)
左右(x)下上(y)近远(z)
更新onDrawFrame
@Override
public void onDrawFrame(GL10 gl) {
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(programId);
GLES20.glUniformMatrix4fv(uMatrixHandle,1,false,projectionMatrix,0);
GLES20.glEnableVertexAttribArray(aPositionHandle);
GLES20.glVertexAttribPointer(aPositionHandle, 3, GLES20.GL_FLOAT, false,
12, vertexBuffer);
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);
}
看一下效果吧:
如果是竖屏的情况,那么应该是这样子的:
下面我们来显示一张图片,图片可以看做一个矩形,所以我们先来画一个矩形
之前提到OpenGL的基本形状是三角形,一个矩形可以看成由4个三角形构成,如果我们一个一个画,那需要12个顶点,36个坐标,效率不高,所以我们采用另外一种方式——顶点索引与glDrawElements配合使用。
什么是顶点索引呢?顶点索引就是给出顶点的下标而不给出具体的顶点坐标,看代码:
private final float[] vertexData = {
0f,0f,0f,
1f,1f,0f,
-1f,1f,0f,
-1f,-1f,0f,
1f,-1f,0f
};
private final short[] indexData = {
0,1,2,
0,2,3,
0,3,4,
0,4,1
};
我们的绘制区域是(-1,-1)到(1,1)的平面区域,vertexData给出了5个顶点,indexData给出了4个三角形的描述:
声明一个ShortBuffer ,用来存放顶点的索引数据
private ShortBuffer indexBuffer;
indexBuffer = ByteBuffer.allocateDirect(indexData.length * 2)
.order(ByteOrder.nativeOrder())
.asShortBuffer()
.put(indexData);
indexBuffer.position(0);
然后,使用GLES20.glDrawElements把三角形画出来,注意如果我们之前的数组类型是byte,那么就应该使用GLES20.GL_UNSIGNED_BYTE,总之类型要对齐
GLES20.glDrawElements(GLES20.GL_TRIANGLES,indexData.length,GLES20.GL_UNSIGNED_SHORT,indexBuffer);
创建一个纹理
纹理的创建比较复杂,我们创建一个新的工具类,并且加入如下代码:
public class TextureHelper {
private static final String TAG="TextureHelper";
public static int loadTexture(Context context,int resourceId){
final int[] textureObjectIds=new int[1];
GLES20.glGenTextures(1,textureObjectIds,0);
if (textureObjectIds[0]==0){
Log.d(TAG,"生成纹理对象失败");
return 0;
}
BitmapFactory.Options options=new BitmapFactory.Options();
options.inScaled=false;
Bitmap bitmap=BitmapFactory.decodeResource(context.getResources(),resourceId,options);
if (bitmap==null){
Log.d(TAG,"加载位图失败");
GLES20.glDeleteTextures(1,textureObjectIds,0);
return 0;
}
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);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D,0,bitmap,0);
bitmap.recycle();
//为与target相关联的纹理图像生成一组完整的mipmap
GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0);
return textureObjectIds[0];
}
}
GLES20.glGenTextures(1,textureObjectIds,0);生成一个纹理,放入textureObjectIds中,同样地,如果生成成功,那么就会返回一个非零值
然后我们将bitmap从raw中读进来,这个就不解释了
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureObjectIds[0]);的作用是将我们刚生成的纹理和OpenGL的2D纹理绑定,告诉OpenGL这是一个2D的纹理(贴图)
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_LINE AR);
这两句话用来设置纹理过滤的方式,GL_TEXTURE_MIN_FILTER是指缩小时的过滤方式,GL_TEXTURE_MAG_FILTER则是放大的
关于放大和缩小时的可用方式,参见下图:GLUtils.texImage2D(GLES20.GL_TEXTURE_2D,0,bitmap,0);
然后将纹理加载到OpenGL中,并且及时回收bitmapGLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0);用于解除和纹理的绑定,等使用时再绑定
OpenGL纹理坐标系
又是一个新的坐标系,
S-T坐标系(橙色)就是OpenGL纹理坐标系,蓝色的是OpenGL屏幕坐标系,对比一下,明显的区别有:
- 原点的位置不一样
- 取值的范围不一样
- Y轴的方向也刚好和T轴相反
- 如果我们要将一张图贴到整个显示区域,即(-1,-1,0)-(1,1,0),那么对应的关系就如图中所示
vertex_shader.glsl
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
uniform mat4 uMatrix;
void main() {
vTexCoord=aTexCoord;
gl_Position = uMatrix*aPosition;
}
- 首先,aTexCoord是一个二维向量,表示纹理的坐标,
- varying这个变量是用来在vertex_shader和fragment_shader之间传递值用的,所以名称要相同,我们把aTexCoord赋值给vTexCoord,
然后来看片元着色器的代码
fragment_shader.glsl
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D sTexture;
void main() {
//gl_FragColor = vec4(0,0.5,0.5,1);
gl_FragColor = texture2D(sTexture,vTexCoord);
}
在片元着色器中,我们声明了一个uniform常量,类型是sampler2D,这个类型是指一个二维的纹理数据数组
使用texture2D来处理被插值的纹理坐标vTexCoord和纹理数据sTexture,得到的颜色值就是要显示的颜色,交给gl_FragColor
更新Renderer类
首先,利用刚才写的类来获得一个纹理ID,这句话放在onSurfaceCreated里面:
textureId=TextureHelper.loadTexture(context,R.raw.demo_pic);
然后我们加入纹理坐标数据(参见上图的对应关系)
private final float[] textureVertexData = {
0.5f,0.5f,
1f,0f,
0f,0f,
0f,1f,
1f,1f
};
并把它复制到OpenGL的本地内存中
textureVertexBuffer = ByteBuffer.allocateDirect(textureVertexData.length * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(textureVertexData);
textureVertexBuffer.position(0);
类似的,我们要获得刚才的变量、常量引用(handle):
uTextureSamplerHandle=GLES20.glGetUniformLocation(programId,"sTexture");
aTextureCoordHandle=GLES20.glGetAttribLocation(programId,"aTexCoord");
更新onDrawFrame
首先,把纹理坐标用类似的方法传递过去:
GLES20.glEnableVertexAttribArray(aTextureCoordHandle);
GLES20.glVertexAttribPointer(aTextureCoordHandle,2,GLES20.GL_FLOAT,false,8,textureVertexBuffer);
然后,我们启用一个纹理,并把它和刚才生成的纹理ID绑定,再把纹理数据引用传过去,因为我们启用的是GL_TEXTURE0,所以在glUniform1i中第二个参数是0(大家可以都改成1试一下,在这里应该是一样的效果):
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureId);
GLES20.glUniform1i(uTextureSamplerHandle,0);
目前onDrawFrame的代码如下:
@Override
public void onDrawFrame(GL10 gl) {
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(programId);
GLES20.glUniformMatrix4fv(uMatrixHandle,1,false,projectionMatrix,0);
GLES20.glEnableVertexAttribArray(aPositionHandle);
GLES20.glVertexAttribPointer(aPositionHandle, 3, GLES20.GL_FLOAT, false,
12, vertexBuffer);
GLES20.glEnableVertexAttribArray(aTextureCoordHandle);
GLES20.glVertexAttribPointer(aTextureCoordHandle,2,GLES20.GL_FLOAT,false,8,textureVertexBuffer);
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureId);
GLES20.glUniform1i(uTextureSamplerHandle,0);
GLES20.glDrawElements(GLES20.GL_TRIANGLES,indexData.length,GLES20.GL_UNSIGNED_SHORT,indexBuffer);
}
运行一下,显示图片