使用 OpenGL ES 2.0 绘制三角形
OpenGL ES 2.0 是 OpenGL 三维图形 API 的子集。是针对移动设备和嵌入式设备而设计的。可用来实现全面可编程的 3D 图形。在这篇文章中,我们将会初步了解一些概念,创建第一个关于 OpenGL ES 2.0 的程序,把整个编码流程走完,让你对整体有个了解。
后文中提到的的 OpenGL 一般是 OpenGL ES 2.0 的简称。
我们先来看一下demo 效果:
OpenGL ES_Triangle_demo.gif着色器
简单地说, OpenGL 程序就是把一个顶点着色器和一个片元着色器连接在一起变成一个 OpenGL 程序。着色器分为两种,顶点着色器和片段着色器。前者生成每个顶点的最终位置,OpenGL 可以将他们组装成点、线和三角形。后者是为点、线和三角形的每个片段着色。OpenGL 的绘制渲染,通过着色器程序,把一个物体的顶点和片段都由着色器处理放到管道中进行渲染成型。所以我们需要创建这两个着色器对象。
OpenGL ES 2.0 中,绘制的所有物体都是由点、线,三角面组成。比如四边形,就是由两个三角形组成。
创建着色器对象
在Android中,着色器语言不能像我们有编译器一样处理方便,从编译链接运行都由可视化界面完成,而是要我们进行手动工作,以字符串的形式,通过 OpenGL 相关 API 进行编译链接运行。
首先,我们在 main 包下创建 assets 文件夹,然后创建文件vertex.glsl
,在里面编写顶点着色器相关代码。创建 frag.glsl
,在里面编写片元着色器相关代码。
//assets/vertex.glsl
uniform mat4 uMVPMatrix; //总变换矩阵
attribute vec3 aPosition; //顶点位置
attribute vec4 aColor; //顶点颜色
varying vec4 aaColor; //用于传递给片元着色器的变量
void main()
{
gl_Position = uMVPMatrix * vec4(aPosition,1); //根据总变换矩阵计算此次绘制此顶点位置
aaColor = aColor;//将接收的颜色传递给片元着色器
}
//assets/frag.glsl
precision mediump float;
varying vec4 aaColor; //接收从顶点着色器传来的参数
void main(){
gl_FragColor = aaColor;//给此片元着色
}
上面顶点着色器中变换矩阵是为了我们在进行平移旋转缩放等操作时候,通过矩阵计算,得出变换后顶点的位置。在这篇文章里面不对着色器语言作较多的阐述,我们在这里主要是把整个绘制流程走完。
创建完成后,再在 java 中读取着色器程序文本,调用 OpenGL 的相关方法对他进行编译。为了程序的简洁和耦合性,我们可以把相关步骤提取到一个工具类中执行。
我们先创建一个 ShaderUtil 工具类,集中帮助我们处理对着色器程序的处理。
/**
* 加载着色器,编译着色器
* @param type 着色器类型 GLES20.GL_VERTEX_SHADER GLES20.GL_FRAGMENT_SHADER
* @param shaderString 着色器程序内容文本字符串
* @return 着色器程序id
*/
private static int loadShader(int type, String shaderString) {
//创建着色器对象
int shaderid = GLES20.glCreateShader(type);
if(shaderid != 0){//创建成功
//加载着色器代码到着色器对象
GLES20.glShaderSource(shaderid, shaderString);
//编译着色器
GLES20.glCompileShader(shaderid);
//存放编译成功Shader数量数组
int[] compileStatus = new int[1];
//获取shader编译情况
GLES20.glGetShaderiv(shaderid, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if(compileStatus[0] == 0){
//编译失败,显示日志并删除该对象
Log.e("GLES20", "Could not compile shader " + type + ":");
Log.e("GLES20", GLES20.glGetShaderInfoLog(shaderid));
GLES20.glDeleteShader(shaderid);
return 0;
}
}
return shaderid;
}
/**
* 从 assets 资源文件夹中读取着色器内容
*
* @param fname 着色器文件名称
* @param r
* @return 着色器内容
*/
public static String loadFromAssetsFile(String fname, Resources r) {
String result = null;
try {
InputStream in = r.getAssets().open(fname);
int ch = 0;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
while ((ch = in.read()) != -1) {
baos.write(ch);
}
byte[] buff = baos.toByteArray();
baos.close();
in.close();
result = new String(buff, "UTF-8");
result.replaceAll("\\r\\n", "\n");
} catch (IOException e) {
e.printStackTrace();
}
return result;
}
上面 loadShader()
这个方法就是将着色器程序内容文本字符串进行编译,并返回改着色器对象id。
另外可以看到我们创建了 compileStatus
数组。这是 Android 平台的 OpenGL 的一个通用模式。为了取出一个值,我们通常会创建一个长度为 1 的数组,并把这个数组传进 OpenGL 中调用,OpenGL 会将结果值赋值到这个数据里面。
链接程序和着色器对象
要创建 OpenGL 程序,只要调用 GLES20.glCreateProgram();
即可创建着色器程序对象 ,该方法会返回一个 id ,我们可以用过这个 id 对 OpenGL 程序进行各种操作。
但是这其实只是相当于声明了一个对象,还没初始化,我们要成功创建 OpenGL 程序,还要把着色器对象链接到到程序当中。链接完成后读取链接状态,成功后才能认为创建着色器程序成功。后面就可以拿该程序 id 进行操作使用。我们在 ShaderUtil 下继续添加一个方法,完成对 OpenGL 程序的创建。
//ShaderUtil.java
/**
* 创建着色器程序
* @param vertexShader
* @param fragmentShader
* @return
*/
public static int createProgram(String vertexShader, String fragmentShader) {
//加载顶点着色器
int vertexShaderId = loadShader(GLES20.GL_VERTEX_SHADER, vertexShader);
if(vertexShaderId == 0){
return 0;
}
//加载片元着色器
int fragShaderId = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShader);
if (fragShaderId == 0){
return 0;
}
//创建着色器程序
int program = GLES20.glCreateProgram();
//在程序中加入顶点着色器和片元着色器
if(program !=0 ){
//加入顶点着色器
GLES20.glAttachShader(program, vertexShaderId);
checkGlError("glAttachShader");
//加入片元着色器
GLES20.glAttachShader(program, fragShaderId);
checkGlError("glAttachShader");
//链接程序
GLES20.glLinkProgram(program);
//存放成功的程序
int[] linkStatus = new int[1];
//获取program链接情况
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
if(linkStatus[0] != GLES20.GL_TRUE){
//链接失败,删除程序
Log.e("ES20_ERROR", "Could not link program: ");
Log.e("ES20_ERROR", GLES20.glGetProgramInfoLog(program));
GLES20.glDeleteProgram(program);
return 0;
}
}
return program;
}
以上就完成了对着色器的创建,并成功返回 OpenGL 程序的 id。
创建物体
既然 OpenGL 程序的逻辑已经完成,那么就是要实现绘制一个图形了。我们先从简单的 2D 图形入手。我们要绘制三个三角形如文章开头所示,要怎么绘制呢?我们可以先绘制一个三角形,后面的三角形只是改变了 z 轴坐标,就可以改变他在前在后了。另外为了直观演示,我们再将第二个三角形旋转180度,并向 Y 轴平移一定的距离。这样基本就能实现 demo 的样子了。接下来我们说说如何去实现。
一个物体由多个顶点组成,每个顶点里面带有多种属性,包括了坐标,颜色,等等。在这里我们一般是使用数组进行存储。
创建数组
对物体创建顶点坐标和颜色的数组,里面就包含了物体的属性。
要绘制一个平面 2D 三角形,三角形有三个顶点,每个顶点的属性里面带有位置(x,y,z)
和颜色 (r,g,b,a)
,那么这个数组就有7*3个数据。其中每7个浮点数,组成的集合代表一个顶点。
最后,我们会将该数组传入到缓冲区,OpenGL将会读取缓冲区的内容赋值到着色器中,进行渲染绘制。
我们先创建一个物体类。关于这个物体的初始化等操作都在里面。
//Triangle.java
/**
* 初始化顶点数据
*/
private void initVertexData(float z) {
float[] vertexArray = {
// x, y, z, r,g,b,a
0f, -0.5f, z, 1f, 0f, 0f, 0f,
0.5f, 0.5f, z, 0f, 1f, 0f, 0f,
-0.5f, 0.5f, z, 0f, 0f, 1f, 0f
};
//
mTriangleBuffer = ByteBuffer.allocateDirect(vertexArray.length * 4)//一个float四个字节
.order(ByteOrder.nativeOrder())//设置字节顺序与操作系统一致
.asFloatBuffer()
.put(vertexArray);//放入缓存
mTriangleBuffer.position(0);
}
上面我们在数组中定义了这个三角形的 (x,y,z,r,g,b,a),由于 OpenGL 对坐标进行了归一化,所以你会看到上面的数据都在 [-1,1] 这个范围。这个坐标系的圆心是屏幕中央,右x正上y正(下一小节矩阵变换中有图例)。在这里你可以暂时的认为归一化就是把观察坐标范围,转为 [-1,1] 这个范围。
OpenGL 的坐标系统比较多,容易产生混淆,摊开来说又可以水一篇文章了,所以在这里就不一一累述了。
然后我们会将这个数组放入缓存中。mTriangleBuffer 的类型是 FloatBuffer,在后面绘制,或者是对 OpenGL 程序里面的数据修改,如对物体进行矩阵变换等都会用到。
OpenGL 中推荐顶点按逆时针顺序排列,其内部做了优化,让性能更佳。
初始化着色器
一个物体,除了基本数据,如果需要着色绘制,就少不了着色器。所以我们初始化完顶点数据后,就要初始化着色器了。我们在上面编写的 ShaderUtil 在这里就可以派上用场。
在这里我们初始化着色器,并把 OpenGL 程序 id 记录下来。然后我们就可以通过这个 id,获取到着色器代码里面的相关变量引用。这些变量帮助我们在后面绘制的时候,桥接 java 本地变量和着色器变量,让他们关联更新。
/**
* 初始化着色器
* @param mv
*/
private void initShader(MySurfaceView mv) {
//加载着色器内容
mVertexShader = ShaderUtils.loadFromAssetsFile("vertex.glsl", mv.getResources());
mFragmentShader = ShaderUtils.loadFromAssetsFile("frag.glsl", mv.getResources());
//创建OpenGL程序
mProgramId = ShaderUtils.createProgram(mVertexShader, mFragmentShader);
//获取程序中顶点位置属性引用id
mVertexPositionHandle = GLES20.glGetAttribLocation(mProgramId, "aPosition");
//获取程序中顶点颜色属性引用id
mVertexColorHandle = GLES20.glGetAttribLocation(mProgramId, "aColor");
//获取程序中总变换矩阵引用id
mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgramId, "uMVPMatrix");
}
以上两步,我们可以封装到物体对象的构造方法里面,这样每次构建一个物体的时候,就会自动创建数组和初始化着色器。
public Triangle(MySurfaceView mv, float z) {
//初始化顶点
initVertexData(z);
//初始化着色器
initShader(mv);
}
绘制
在 OpenGL 中,物体的移动,旋转,缩放等都是依赖于矩阵的变换。所以我们这里也会维护一个矩阵。关于矩阵我们会创建一个矩阵的工具类。在下一节说明。
然后我们就会将矩阵,顶点,颜色等数据,传递到OpenGL程序,并进行绘制。这部分代码可以抽取出来封装到方法drawSelf()
里面。
public void drawSelf() {
//使用某套OpenGL程序
GLES20.glUseProgram(mProgramId);
//将最终变换矩阵传入OpenGl程序
GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, MatrixState.getFinalMatrix(), 0);
//为画笔指定顶点位置数据
mTriangleBuffer.position(0);
GLES20.glVertexAttribPointer(mVertexPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE, mTriangleBuffer);
//为画笔指定颜色着色数据
mTriangleBuffer.position(3);
GLES20.glVertexAttribPointer(mVertexColorHandle, 3, GLES20.GL_FLOAT, false, STRIDE, mTriangleBuffer);
GLES20.glEnableVertexAttribArray(mVertexPositionHandle);
GLES20.glEnableVertexAttribArray(mVertexColorHandle);
//绘制内容
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 1);
}
上面代码就是通过 glVertexAttribPointer
将 buffer 传入到 OpenGL 程序中,最后通过 glDrawArrays
绘制出来。
由于我们物体构建的时候是一个数组,7个数据为1个顶点,其中前面的坐标是(x, y, z)
三个数据。所以可以看到为画笔指定颜色着色数据的时候,使用position将指针移动到了3。不然如果position指针指向0,会把坐标位置当做颜色来处理。
STRIDE 可以认为是 sizeOf(顶点),我们一个顶点有7个数据。每个数据都是一个浮点数。所以STRIDE = ( position(3) + color(4) ) * 4 = ( 3 + 4 ) * 4
。
矩阵变换
观察位置
三维世界,仅有物体是不能确定看到物体是哪一面的,你还需要一个观察的角度,距离,视点。
我们可以把这些需要的东西,当做是一台相机。相机的摄像头就是视点,摄像头对准的方向就是观察方向,对焦的地方就是观察的目标点,相机还需要一个向上的向量,标记你相机倾斜角度,仰视俯视之类的。OpenGL 中有 Matrix 类的 setLookAtM() 方法。
Matrix.setLookAtM(
mVMatrix,0,
cx,cy,cz,
tx,ty,tz,
upx,upy,upz);
c就是camera,标记的是相机的(x,y,z)坐标。t就是target,标记的是目标点的(x,y,z)坐标。up标记的就是向上的向量。标记的是向上的方向。下图可以帮助你理解这三个分量。最后就是将这些数据存入到 mVMatrix 中。第二个参数是偏移量,我们更多情况默认为0。下图可以帮助你理解。
观察位置.png正交投影
确定了物体,观察位置,然后就要说这个投影空间了。OpenGL 中,根据应用程序提供的投影矩阵,来确定一个可视空间。投影主要分为正交投影和透视投影两种。在这里暂且只说正交投影。
正交投影是没有近大远小效果的。可以认为是有平行光从远平面射过来,物体在可视空间内,产生投影到近平面的影子,就是我看在视点看到的部分了。
正交投影.png同样地,OpenGL 在 Matrix 类中也有方法 orthoM,对着上图我们可以看出各参数含义。
Matrix.orthoM(mProjectMatrix, 0, left, right, bottom, top, near, far);
变换
我们熟知的变换方式有 平移,旋转,缩放。Matrix 类中都有对应的方法进行操作。我们可以创建一个4阶矩阵 mCurrMatrix,保存每一步的运动变换。
MatrixState 辅助类
以上概念了解完后,我们可以编写一个简单的 Matrix 辅助类,后期可以继续完善。
public class MatrixState {
/**
* 投影矩阵
*/
private static float[] mProjectMatrix = new float[16];
/**
* 摄像机位置朝向9参数矩阵
*/
private static float[] mVMatrix = new float[16];
/**
* 当前变换矩阵
*/
private static float[] mCurrMatrix;
/**
* 最后起作用的总变换矩阵
*/
private static float[] mMVPMatrix;
public static Stack<float[]> mStack = new Stack<float[]>();//保护变换矩阵的栈
public static void setInitStack()//获取不变换初始矩阵
{
mCurrMatrix = new float[16];
Matrix.setRotateM(mCurrMatrix, 0, 0, 1, 0, 0);
}
public static void pushMatrix()//保护变换矩阵
{
mStack.push(mCurrMatrix.clone());
}
public static void popMatrix()//恢复变换矩阵
{
mCurrMatrix = mStack.pop();
}
/**
* 设置摄像机
*
* @param cx 摄像机位置x
* @param cy 摄像机位置y
* @param cz 摄像机位置z
* @param tx 摄像机目标点x
* @param ty 摄像机目标点y
* @param tz 摄像机目标点z
* @param upx 摄像机UP向量X分量
* @param upy 摄像机UP向量Y分量
* @param upz 摄像机UP向量Z分量
*/
public static void setCamera(
float cx,
float cy,
float cz,
float tx,
float ty,
float tz,
float upx,
float upy,
float upz
) {
Matrix.setLookAtM(
mVMatrix,
0,
cx,
cy,
cz,
tx,
ty,
tz,
upx,
upy,
upz
);
}
/**
* 设置正交投影矩阵
*
* @param left near面的left
* @param right near面的right
* @param bottom near面的bottom
* @param top near面的top
* @param near near面距离
* @param far far面距离
* @return
*/
public static void setProjectOrtho(
float left,
float right,
float bottom,
float top,
float near,
float far
) {
Matrix.orthoM(mProjectMatrix, 0, left, right, bottom, top, near, far);
}
public static void translate(float x, float y, float z)//设置沿xyz轴移动
{
Matrix.translateM(mCurrMatrix, 0, x, y, z);
}
public static void rotate(float angle, float x, float y, float z)//设置绕xyz轴移动
{
Matrix.rotateM(mCurrMatrix, 0, angle, x, y, z);
}
/**
* 获取具体物体的总变换矩阵
*
* @return
*/
public static float[] getFinalMatrix() {
mMVPMatrix = new float[16];
Matrix.multiplyMM(mMVPMatrix, 0, mVMatrix, 0, mCurrMatrix, 0);
Matrix.multiplyMM(mMVPMatrix, 0, mProjectMatrix, 0, mMVPMatrix, 0);
return mMVPMatrix;
}
}
上面这个矩阵辅助类中,维护了一个 mCurrMatrix 矩阵,这个矩阵是用于我们记录当前对坐标系做运动变换(平移旋转)。mProjectMatrix 则是记录了我们是进行平行投影还是透视投影。最后使用矩阵相乘,把结果放入到 mMVPMatrix 这个最终结果矩阵中。
此外,添加了一个栈对矩阵状态进行了维护,他可以让我们管理变换的时候更为便捷(可以联想到 Canvas.save & Canvas.restore)。
最后我们定义了 getFinalMatrix()
方法,可以得到这个最终矩阵,将其赋值到 OpenGL 程序的总变换矩阵。
GLSurfaceView
GLsurfaceView 作用就是设置一个窗口,把 OpenGL 渲染绘制的内容呈现到这个窗口上。此外我们与用户的交互,如触摸事件也往往在这里完成。
定义渲染器Renderer
在 GLSurfaceView 里面定义一个内部类实现 Renderer 接口。然后会让你实现三个方法。在这里我们就是将上面的着色器对象,创建物体做初始化,并进行渲染。
- onSurfaceCreated(GL10 gl, EGLConfig config) :做初始化操作
- onSurfaceChanged(GL10 gl, int width, int height):设置视图窗口大小
- onDrawFrame(GL10 gl) :渲染物体
需要注意的是,参数里面的GL10是1.0版本的 OpenGL API,我们不要使用。而是使用 GLES20 的相关API。
private class SceneRenderer implements Renderer {
Triangle[] triangles = new Triangle[3];
float xAngle = 0;
float yAngle = 0;
@Override
public void onDrawFrame(GL10 gl) {
//清除深度缓冲与颜色缓冲
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
//绘制物体
//记录初始状态
MatrixState.pushMatrix();
//设置旋转值
MatrixState.rotate(yAngle, 0, 1, 0);
MatrixState.rotate(xAngle, 1, 0, 0);
//开始绘制
triangles[0].drawSelf();
//记录旋转后的矩阵
MatrixState.pushMatrix();
//对第二个三角形进行180度旋转,且往y轴方向平移,让中间的三角形与其他两个三角形相错
MatrixState.rotate(180, 0, 0, 1);
MatrixState.translate(0, -0.5f, 0);
triangles[1].drawSelf();
//恢复
MatrixState.popMatrix();
triangles[2].drawSelf();
MatrixState.popMatrix();
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
//设置窗口大小和位置
GLES20.glViewport(0, 0, width, height);
//计算GLSurfaceView宽高比
float ratio = (float) width / (float) height;
//设置平行投影
MatrixState.setProjectOrtho(-ratio, ratio, -1, 1, 1, 10);
//调用此方法产生摄像机9参矩阵
MatrixState.setCamera(0, 0, 3f,
0, 0, 0f,
0, 1.0f, 0f);
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
//设置屏幕背景色
GLES20.glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
//创建各个对象
for (int i = 0; i < triangles.length; i++) {
triangles[i] = new Triangle(MySurfaceView.this, -0.3f * i);
}
//打开深度检测
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
//打开背面剪裁
GLES20.glEnable(GLES20.GL_CULL_FACE);
//初始化变换矩阵
MatrixState.setInitStack();
}
}
设置渲染器
定义好渲染器之后,我们可以在 GLSurfaceView 的构造方法里面做初始化,把渲染器设置到该View上。
public MySurfaceView(Context context) {
super(context);
//使用OpenGL2.0
this.setEGLContextClientVersion(2);
mRenderer = new SceneRenderer();
setRenderer(mRenderer);
//主动渲染模式,不断地,连续地
setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
}
触摸事件
想要相应用户触摸事件,还要重写拦截方法,在里面对创建物体的旋转角度做改变。我们创建了 MatrixState 矩阵辅助类,可以对坐标系做平移旋转变化,所以我们只需要改变矩阵的旋转值就可以了。
@Override
public boolean onTouchEvent(MotionEvent event) {
float y = event.getY();
float x = event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
float dy = y - mPreviousY;
float dx = x - mPreviousX;
mRenderer.yAngle += dy * TOUCH_SCALE_FACTOR;//设置绕y轴旋转角度
mRenderer.xAngle += dx * TOUCH_SCALE_FACTOR;//设置绕x轴旋转角度
requestRender();
break;
}
mPreviousX = x;
mPreviousY = y;
return true;
}
运行程序
最后当然就是运行程序了。我们只需要在 Activity 上创建该GLSurfaceView
对象,并在 setContentView(glSurfaceView)
传入,那么运行程序之后就可以看到绘制的物体了。
最后,附上 demo 地址