Android OpenGL ES(二)绘制三角形

通过上篇文章的学习,现在已经了解到,要想在 Android 端使用 OpenGL ES 绘制图形,必须创建 OpenGL ES 环境和视图窗口,具体来说就是构建 EGL 环境,即 OpenGL ES 和 Android 底层平台视窗系统之间的接口。另外 OpenGL ES 2.0 版本为可编程管线,我们就可以编写着色器程序来确定绘制内容,即编写 Vertex Shader 顶点着色器和 Fragment Shader 片元着色器。

而这些工作可以通过 GLSurfaceView 非常简单的实现。

在介绍 GLSurfaceView 之前先来看下 Android 系统提供的与 OpenGL ES 相关的包:

  • javax.microedition.khronos.opengles: 存放 GL 绘图指令相关代码
  • javax.microedition.khronos.egl: 存放 EGL 管理相关代码,包括 Display、surface 等
  • android.opengl: 存放 GL 辅助类,连接 OpenGL 与 Android View,Activity 等

其中 GLSurfaceView 处于 android.opengl 包中,GLSurfaceView 具有以下特性:

  • 内置 EGL 管理,自带 GL 上下文环境和 GLThread 绘制线程
  • 起到连接 OpenGL ES 与 Android 的 View 层次结构之间的桥梁作用
  • 使得 OpenGL ES 库适应于 Activity 生命周期
  • 继承自 SurfaceView,拥有 SurfaceView 的全部特性,绘制结果会输出到 SurfaceView 所提供的 Surface 上
  • 提供了方便使用的调试工具来跟踪 OpenGL ES 函数调用以帮助检查错误

通过 GLSurfaceView 的 setRenderer 方法可设置要渲染的效果,即 GLSurfaceView.Renderer 渲染器接口,该接口方法:

  • onSurfaceCreated:渲染线程开启时调用,可做初始化背景色、初始化纹理资源等工作
  • onSurfaceChanged:窗口尺寸改变时调用,通常会设置视窗范围或投影矩阵等
  • onDrawFrame:外部请求渲染一次就调用一次,可在此载入着色器程序、激活绑定纹理以及调用绘制

下面来看具体如何绘制一个三角形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

public class Triangle implements GLSurfaceView.Renderer {

//顶点着色器
private static final String vertexShaderResource =
"attribute vec3 vPosition;" +
"void main() {" +
" gl_Position = vec4(vPosition.x, vPosition.y, vPosition.z, 1.0);" +
"}";
//片段着色器
private static final String fragmentShaderResource =
"precision mediump float;" +
"uniform vec4 vColor;" +
"void main() {" +
" gl_FragColor = vColor;" +
"}";
//顶点
private final float[] vertexCoords = new float[]{
0.0f, 0.5f, 0.0f, // top
-0.5f, -0.5f, 0.0f, // bottom left
0.5f, -0.5f, 0.0f // bottom right
};

private final float color[] = {1.0f, 0f, 0f, 1.0f}; //red

// 着色器程序
private int mProgram;
// 顶点坐标数据
private FloatBuffer vertexFloatBuffer;


@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
//设置清空屏幕后的背景色
GLES30.glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
//构建顶点着色器
int vertexShader = GLES30.glCreateShader(GLES30.GL_VERTEX_SHADER);
GLES30.glShaderSource(vertexShader, vertexShaderResource);
GLES30.glCompileShader(vertexShader);
//构建片段着色器
int fragmentShader = GLES30.glCreateShader(GLES30.GL_FRAGMENT_SHADER);
GLES30.glShaderSource(fragmentShader, fragmentShaderResource);
GLES30.glCompileShader(fragmentShader);
//构建着色器程序,并将顶点着色器和片段着色器链接进来
mProgram = GLES30.glCreateProgram();
GLES30.glAttachShader(mProgram, vertexShader);
GLES30.glAttachShader(mProgram, fragmentShader);
GLES30.glLinkProgram(mProgram);
//顶点着色器和片段着色器链接到着色器程序后就无用了
GLES30.glDeleteShader(vertexShader);
GLES30.glDeleteShader(fragmentShader);
//转换为需要的顶点数据格式
vertexFloatBuffer = floatToBuffer(vertexCoords);
}

@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
//设置视窗
GLES30.glViewport(0, 0, width, height);
}

@Override
public void onDrawFrame(GL10 gl) {
//清空屏幕,擦除屏幕上所有的颜色,用 glClearColor 定义的颜色填充
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT);
//在当前 EGL 环境激活着色器程序
GLES30.glUseProgram(mProgram);
//获取顶点着色器的 vPosition 成员句柄
int positionHandle = GLES30.glGetAttribLocation(mProgram, "vPosition");
//启用句柄
GLES30.glEnableVertexAttribArray(positionHandle);
//设置顶点坐标数据
GLES30.glVertexAttribPointer(positionHandle, 3, GLES30.GL_FLOAT,
false, 3 * 4, vertexFloatBuffer);
//获取片元着色器的 vColor 成员句柄
int colorHandle = GLES30.glGetUniformLocation(mProgram, "vColor");
//设置颜色
GLES30.glUniform4fv(colorHandle, 1, color, 0);
//绘制三角形
GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 3);
//禁止顶点数组的句柄
GLES30.glDisableVertexAttribArray(positionHandle);
}

private FloatBuffer floatToBuffer(float[] a) {
ByteBuffer buffer = ByteBuffer.allocateDirect(a.length * 4); //float占4个字节
buffer.order(ByteOrder.nativeOrder());
FloatBuffer byteBuffer = buffer.asFloatBuffer();
byteBuffer.put(a);
byteBuffer.position(0);
return byteBuffer;
}
}

顶点输入

要绘制一个三角形,就要确定三个顶点的 3D 坐标(OpenGL 是一个 3D 图形库,在 OpenGL 中指定的所有坐标都需要是 3D 坐标,即 x、y 和 z)。而顶点坐标起始于局部坐标,需要为标准化设备坐标,即 x、y、z 的范围限定于 -1 到 1 之间,任何落在范围外的坐标都会被丢弃。上面代码中输入的顶点数据为:

1
2
3
4
5
private final float[] vertexCoords = new float[]{
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f
};

这里将 z 坐标都设置为 0,表示三角形每一点的深度都为 0(通常深度可以理解为z坐标,它代表一个像素在空间中和你的距离,如果离你远就可能被别的像素遮挡,你就看不到它了,它会被丢弃,以节省资源),这样定义的顶点数据反应到标准化设备坐标系中就是这样的:

解释顶点数据

可以看到调用 glVertexAttribPointer 设置顶点数据时并不是直接把 float[] 数组传递进去,而是转换成 FloatBuffer 传入,所谓的解释顶点数据就是说明输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。对于 glVertexAttribPointer 函数:

1
2
3
4
5
6
7
8
public static void glVertexAttribPointer(
int indx,
int size,
int type,
boolean normalized,
int stride,
java.nio.Buffer ptr
)

  • indx:指定要配置的顶点属性,这里传入顶点着色器的 vPosition 成员句柄
  • size:指定顶点属性的大小,顶点属性是一个 vec3,它由 3 个值(x、y、z)组成,所以大小传入 3
  • type:指定数据的类型为 float 类型
  • normalized:是否希望数据被标准化
  • stride:指定连续的顶点数据之间的间隔,由于一个顶点数据长度为 3 个 float,所以把步长设置为 3 * 4(一个 float 占 4 个字节)
  • ptr:顶点数据

下图很好的阐释这个逻辑:

顶点着色器

1
2
3
4
5
private static final String vertexShaderResource =
"attribute vec3 vPosition;" +
"void main() {" +
" gl_Position = vec4(vPosition.x, vPosition.y, vPosition.z, 1.0);" +
"}";

由于每个顶点都有一个 3D 坐标,这里就创建一个 vec3 变量输入顶点坐标。而内置变量 gl_Position 为 vec4 类型,所以需要将三维向量转换为四维向量,最后 gl_Position 设置的值会成为该顶点着色器的输出。

onDrawFrame 方法中在获取顶点着色器的 vPosition 成员句柄后,需要调用 glEnableVertexAttribArray、glDisableVertexAttribArray 分别启用、禁止顶点数据,而片段着色器的 vColor 成员句柄就不需要。这是因为出于性能考虑,所有顶点着色器的属性默认都是关闭的。
glVertexAttribPointer 只是建立 CPU 和 GPU 之间的逻辑连接实现将 CPU 数据上传至 GPU,但是,数据在 GPU 端是否可见,即着色器能否读取到数据,还要取决于 glEnableVertexAttribArray 方法。

片段着色器

1
2
3
4
5
6
private static final String fragmentShaderResource =
"precision mediump float;" +
"uniform vec4 vColor;" +
"void main() {" +
" gl_FragColor = vColor;" +
"}";

在 OpenGL 中定义一个颜色的数据格式为 RGBA 四个 0.0 到 1.0 之间强度的分量,片段着色器所做的是计算像素最后的颜色输出,也只有 gl_FragColor 这一个输出变量。

编译着色器

1
2
3
int vertexShader = GLES30.glCreateShader(GLES30.GL_VERTEX_SHADER);
GLES30.glShaderSource(vertexShader, vertexShaderResource);
GLES30.glCompileShader(vertexShader);

为了让 OpenGL 能够使用我们编写的着色器源码,必须在运行时动态编译。首先通过 glCreateShader 创建一个着色器对象,返回该着色器的 ID,然后通过 glShaderSource、glCompileShader 方法将源码附着在着色器对象上并编译它。

编译着色器可能失败,一般编译时会通过如下方法判断是否编译成功并输出编译信息:

1
2
3
4
5
6
7
8
final int[] compileStatus = new int[1];
GLES20.glGetShaderiv(shaderObjectId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
Log.d(TAG, "glCompileStatus: " + compileStatus[0]
+ " log:" + GLES20.glGetShaderInfoLog(shaderObjectId));
if (compileStatus[0] == 0) { //编译失败
GLES20.glDeleteShader(shaderObjectId);
return 0;
}

着色器程序

1
2
3
4
mProgram = GLES30.glCreateProgram();
GLES30.glAttachShader(mProgram, vertexShader);
GLES30.glAttachShader(mProgram, fragmentShader);
GLES30.glLinkProgram(mProgram);

着色器程序对象是多个着色器合并之后并最终链接完成的版本,它将编译好的顶点着色器和片段着色器链接为一个着色器程序对象,链接后顶点着色器和片段着色器就没用了,可以通过 glDeleteShader 删除。就像着色器的编译一样,我们也可以检测链接着色器程序是否失败,并获取相应的日志:

1
2
3
4
5
6
7
8
final int[] linkStatus = new int[1];
GLES20.glGetProgramiv(mProgram, GLES20.GL_LINK_STATUS, linkStatus, 0);
Log.d(TAG, "glCompileStatus:" + linkStatus[0]
+ " log:" + GLES20.glGetProgramInfoLog(mProgram));
if (linkStatus[0] == 0) { //链接失败
GLES20.glDeleteProgram(mProgram);
return 0;
}

链接成功后,在渲染的时候通过 glUseProgram 方法激活着色器程序,已激活着色器程序的着色器就会在渲染时被使用,最后通过 glDrawArrays 方法触发绘制。

参考文章:

《音视频开发进阶指南 - 基于Android与IOS平台的实践》
Android GLSurfaceView详解
GLSurfaceView
你好,三角形
OpenGL ES 3.0 glEnableVertexAttribArray的作用