绘制一个三角形
正如我们学习一门编程语言时大多数教程都会先告诉你怎么写出一句 Hello World
,OpenGL的教程大多数第一课也是教你如何绘制一个简单三角形。接下来我们就按照上述所说的渲染过程,讲解一下如何通过OpenGL ES的API在Android手机上显示出一个三角形。
在Demo中我们创建一个 TriangleActivity
作为我们的界面,使用Android自带的 GLSurfaceView
作为渲染的载体(现在自己创建EGLSurface还为时过早),同时我们创建一个 Shape
作为GLSurfaceView的Renderer抽象基础类 ,让其实现Renderer,在其子类里面实现实际的渲染操作
public abstract class Shape implements GLSurfaceView.Renderer {
public Shape(){
}
public int loadShader(final String strSource, final int iType) {
//........
}
public int loadProgram(final String strVSource, final String strFSource) {
//.........
}
/**
* 回收资源
*/
public abstract void destroy() ;
}
可以看到我们在基类中定义了一个抽象方法destroy()
和两个伪代码实现的方法
loadShader()
和loadProgram()
我们先来简单介绍一下OpenGL中编译着色器并链接至最终的Program上的流程:
public int loadShader(final String strSource, final int iType) {
int[] compiled = new int[1];
//创建指定类型的着色器
int iShader = GLES20.glCreateShader(iType);
//将源码添加到iShader并编译它
GLES20.glShaderSource(iShader, strSource);
GLES20.glCompileShader(iShader);
//获取编译后着色器句柄存在在compiled数组容器中
GLES20.glGetShaderiv(iShader, GLES20.GL_COMPILE_STATUS, compiled, 0);
//容错判断
if (compiled[0] == 0) {
Log.d("Load Shader Failed", "Compilation\n" + GLES20.glGetShaderInfoLog(iShader));
return 0;
}
return iShader;
}
public int loadProgram(final String strVSource, final String strFSource) {
int iVShader;
int iFShader;
int iProgId;
int[] link = new int[1];
//获取编译后的顶点着色器句柄
iVShader = loadShader(strVSource, GLES20.GL_VERTEX_SHADER);
if (iVShader == 0) {
Log.d("Load Program", "Vertex Shader Failed");
return 0;
}
//获取编译后的片元着色器句柄
iFShader = loadShader(strFSource, GLES20.GL_FRAGMENT_SHADER);
if (iFShader == 0) {
Log.d("Load Program", "Fragment Shader Failed");
return 0;
}
//创建一个Program
iProgId = GLES20.glCreateProgram();
//添加顶点着色器与片元着色器到Program中
GLES20.glAttachShader(iProgId, iVShader);
GLES20.glAttachShader(iProgId, iFShader);
//链接生成可执行的Program
GLES20.glLinkProgram(iProgId);
//获取Program句柄,并存在在link数组容器中
GLES20.glGetProgramiv(iProgId, GLES20.GL_LINK_STATUS, link, 0);
//容错
if (link[0] <= 0) {
Log.d("Load Program", "Linking Failed");
return 0;
}
//删除已链接后的着色器
GLES20.glDeleteShader(iVShader);
GLES20.glDeleteShader(iFShader);
return iProgId;
}
上述代码中关键代码点都有注释。 到这里我们已经获取到了一个`Program`。
第一个Shape
首先我们现在创建并实现整个渲染过程中最核心的部分 Triangle
,并让其继承Shape类。
Renderer接口中有三个需要实现的方法,分别是 onSurfaceCreated
, onSurfaceChanged
以及 onDrawFrame
,前两个方法如果有接触过SurfaceView及SurfaceHolder的话就比较熟悉,分别是Surface创建时的回调以及SUrface如宽高变化时的回调, onSurfaceCreated
主要用于 初始化
等, onSurfaceChanged
主要用于做 模型视图转换
等操作,而 onDrawFrame
就是当OpenGL渲染每一帧的回调方法,我们的实际绘制操作就在这里进行。
这三个方法我们先放着,先来按照渲染流程,我们创建绘制一个三角形所需要的 顶点数据
。
顶点数据是一个包含了所绘制图像放置在OpenGL坐标系中后,其各个顶点的 三维坐标
的数组(其实顶点数据还可以放置颜色等,通过偏移来获取不同类型的数据)。那么刚刚在坐标系中说了,OpenGL里有多个坐标系,但是和我们目前关系最大的是NDC,NDC坐标系:
即NDC坐标系的原点(0,0)默认位置在屏幕中心,x,y,z轴范围为[-1,1],而Android屏幕坐标系原点在左上角,x,y轴范围为[0,各轴分辨率]。
现在我们要绘制一个三角形,顶点在y轴正向最大值位置,左下角在x轴负向最大值位置,右下角在x轴正向最大值位置,那么对应的顶点数组为:
//设置三角形顶点数组,默认按逆时针方向绘制
public static float[] triangleCoords = {
0.0f, 1.0f, 0.0f, // 顶点
-1.0f, -0.0f, 0.0f, // 左下角
1.0f, -0.0f, 0.0f // 右下角
};
接下来我们开始编写顶点着色器:
//顶点着色器
public static final String VERTEX_SHADER =
"//根据所设置的顶点数据,插值在光栅化阶段进行\n" +
"attribute vec4 vPosition;" +
"void main() {" +
" //设置最终坐标\n" +
" gl_Position = vPosition;" +
"}";
对于上述着色器只需要知道`vPosition`就是我们所设置的顶点数据,而`gl_Position`是OpenGL的内置变量,代表着当前这个片元最终所处的坐标。而`vec`是代表向量,坐标使用`vec4`而不是`vec3`的原因是因为`齐次坐标`的关系,但是这个在这里不是重点。 `组装图`,`光栅化图元`OpenGL会自动进行,这里我们不管,接下来我们开始编写片元着色器,来为这个三角形加上颜色:
//片元着色器
public static final String FRAGMENT_SHADER =
"//设置float类型默认精度,顶点着色器默认highp,片元着色器需要用户声明\n" +
"precision mediump float;" +
"//颜色值,vec4代表四维向量,此处由用户传入,数据格式为{r,g,b,a}\n" +
"uniform vec4 vColor;" +
"void main() {" +
"//该片元最终颜色值\n" +
"gl_FragColor = vColor;" +
"}";
在上述着色器代码中,首先我们声明了片元着色器中默认float类型变量的精度(中等),在顶点着色器中默认精度为highp,而片元着色器中必须自己设置。 之后我们声明了一个`uniform`类型的四维向量,用以存储用户所设置的三角形颜色,`gl_FragColor`也是OpenGL的内置变量,表示片元最终的颜色值,这里我们直接将`vColor`作为最终颜色。 从片元着色器中可以看到,有一个数据还需要用户自己设定,那就是三角形的颜色值,格式是{r,g,b,a},设置如下:
// 设置三角形颜色和透明度(r,g,b,a),绿色不透明
public static float[] color = {0.0f, 1.0f, 0f, 1.0f};
最后`写入帧缓冲区`,`显示到屏幕上`也是由OpenGL自动完成,那么至此我们已经完成了顶点着色器和片元着色器的实现,也提供了这两个着色器所需要的顶点数据和颜色数据,那么接下来就是怎么将这些数据与着色器内的变量相绑定,并且告知OpenGL什么时候开始渲染以及怎么渲染。 让我们回到Renderer那三个未实现的接口上,首先我们在`onSurfaceCreated`调用时,也就是Surface正式创建后,做一些初始化操作:
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
mProgramId = loadProgram(VERTEX_SHADER, FRAGMENT_SHADER);
//通过OpenGL程序句柄查找获取顶点着色器中的位置句柄
mPositionId = GLES20.glGetAttribLocation(mProgramId, "vPosition");
//通过OpenGL程序句柄查找获取片元着色器中的颜色句柄
mColorId = GLES20.glGetUniformLocation(mProgramId, "vColor");
}
还有后续需要绑定我们数据的vColor
,vPosition
的地址。接下来我们需要设置视口来告诉OpenGL我们想要显示在屏幕的哪个区域内:
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
GLES20.glViewport(0,0,width,height);
}
当我们设置GLSurfaceView为全屏的时候,那么上述的`width`就是屏幕宽度,`height`就是屏幕高度,上述设置的意思就是我们当前渲染的视口区域从屏幕左上角原点(0,0)开始,宽高为全屏。 至此就万事俱备了,接下来我们便要在OpenGL开始渲染的回调接口`onDrawFrame()`中进行我们最后的渲染操作了:
@Override
public void onDrawFrame(GL10 gl) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT|GLES20.GL_DEPTH_BUFFER_BIT);
//告知OpenGL所要使用的Program
GLES20.glUseProgram(mProgramId);
//启用指向三角形顶点数据的句柄
GLES20.glEnableVertexAttribArray(mPositionId);
//绑定三角形的坐标数据
GLES20.glVertexAttribPointer(mPositionId, COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRID, vertexBuffer);
//绑定颜色数据
GLES20.glUniform4fv(mColorId, 1, Triangle.color, 0);
//绘制三角形
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, VERTEX_COUNT);
//禁用指向三角形的顶点数据
GLES20.glDisableVertexAttribArray(mPositionId);
}
首先这里我们注意到除了注释之外,我们的代码少了一个变量,就是vertexBuffer,这个变量是一个FloatBuffer类型的变量,用于开辟处一块内存缓冲区来存储供OpenGL使用的顶点数据,在我们这个Demo中顶点数据不会发生变化,所以我们直接在onSurfaceCreated()的最后加上如下代码进行初始化即可:
// 初始化顶点字节缓冲区,用于存放形状的坐标,每个浮点数占用4个字节
ByteBuffer bb = ByteBuffer.allocateDirect(triangleCoords.length * 4);
//设置使用设备硬件的原生字节序
bb.order(ByteOrder.nativeOrder());
//从ByteBuffer中创建一个浮点缓冲区
vertexBuffer = bb.asFloatBuffer();
// 把坐标都添加到FloatBuffer中
vertexBuffer.put(triangleCoords);
//设置buffer从第一个坐标开始读
vertexBuffer.position(0);
还有记得在接口外面声明变量:
//folat缓冲区
public FloatBuffer vertexBuffer;
为什么使用java的nio包下的Buffer作为内存缓冲区的形式一方面是出于性能等方面的考虑,另一方面 OpenGL 是一个非常底层的绘制接口,它所使用的缓冲区存储结构是和我们的 Java 程序中不相同的(Java 是大端字节序(BigEdian),而 OpenGL 所需要的数据是小端字节序(LittleEdian))。所以,我们在将 Java 的缓冲区转化为 OpenGL 可用的缓冲区时需要作这样的一些工作。 而颜色的绑定我们看到就简单得多,只需要调用接口就可以实现,因为两者的在这个Demo中变量类型不同(`attribute`只能在顶点着色器中使用,通常用于表示顶点坐标、纹理坐标等,而`uniform`常用于表示常量形式的颜色、矩阵、材质等,两者设置接口也不同,具体会在后续着色器章节中讲述)。我们也可以通过将颜色与顶点数据放置一起,然后一起转为FloatBuffer来传递给OpenGL,并且设置每个顶点的颜色不同,通过`glVertexPointer`与`glColorPointer`两个接口配合使用来绘制出如下的三角形,这也就是之前一直讲的插值的含义,OpenGL会自动对顶点间坐标以及颜色进行插值计算:
至此我们已经完成`TriangleRender`的实现,最后只需要加上一个回收资源的方法:
@Override
public void destroy() {
GLES20.glDeleteProgram(mProgramId);
}
GLSurfaceView
前面我们完成了 Triangle
的实现,那么接下来我们将其与 GLSurfaceView
绑定起来以便于看到我们渲染的结果。
为了简单起见,我们直接 TriangleActivity
的布局文件中加入 GLSurfaceView
,在 onCreate()
中加入如下代码:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mGLView = findViewById(R.id.mGLView);
mGLView.setEGLContextClientVersion(2);
render = new Triangle();
mGLView.setRenderer(render);
mGLView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
}
最后我们跑一下项目,就可以在手机上看到三角形了,接下来的章节中我们再一起去绘制其他Shape,如正方形,原型,三维图形等
总结
这章我们介绍了OpenGL(ES)以及EGL的相关内容和一些基本概念,同时通过绘制一个简单的三角形来了解了OpenGL的常见绘制流程以及部分接口,感兴趣的可以自己尝试一下如何绘制一个渐变的三角形或者一个正方形等较简单的几何图形。使用OpenGL进行绘制的确比直接使用Android自带的绘图API繁琐一些,出现了问题也比较难以排查,因为更接近于底层所以理解上很多地方不太一样,但是对于图形渲染或者处理,OpenGL无论是性能还是可以实现的效果都是胜出一筹的。