Android中视频渲染有几种方式,之前的文章使用的是nativewindow(包括softwareRender)。今天介绍另一总视频渲染的方式——OpenGL ES。
阅读本文之前需要对OpenGL有一定的了解,可以参考https://www.jianshu.com/p/99daa25b4573
在Android中使用OpenGL的方法有两种,一种是在native层使用EGL+OpenGL来实现,另一种则是GLSurfaceView。
本文将使用GLSurfaceView+MediaPlayer实现播放,并通过OpenGL进行简单的滤镜处理,以此来说明如何使用GLSurfaceView。
题外话:nativewindow和OpenGL渲染视频的代码,可以参考ijkplayer的实现。
OpenGL
OpenGL引擎渲染图像的流程比较复杂,简单来说是以下几步。
但我们最主要先了解顶点处理阶段及片元处理阶段。
阶段一:指定几何对象
所谓几何对象,就是点,直线,三角形,这里将根据具体执行的指令绘制几何图元。比如,OpenGL提供给开发者的绘制方法glDrawArrays,这个方法里的第一个参数是mode,就是制定绘制方式,可选值有一下几种。GL_POINT:以点的形式进行绘制,通常用在绘制粒子效果的场景中。
GL_LINES:以线的形式进行绘制,通常用在绘制直线的场景中。
GL_TRIANGLE_STRIP:以三角形的形式进行绘制,所有二维图像的渲染都会使用这种方式。阶段二:顶点处理
不论以上的几何对象是如何指定的,所有的几何数据都将会经过这个阶段。这个阶段所做的操作就是,根据模型视图和投影矩阵进行变换来改变顶点的位置,根据纹理坐标与纹理矩阵来改变纹理坐标的位置,如果涉及三维的渲染,那么这里还要处理光照计算与法线变换。
一般输出是以gl_Position来表示具体的顶点位置的,如果是以点来绘制几何图元,那么还应该输出gl_PointSize。阶段三:图元组装
在经过阶段二的顶点处理操作之后,还是纹理坐标都是已经确定好了的。在这个阶段,顶点将会根据应用程序送往图元的规则(如GL_POINT、GL_TRIANGLE_STRIP),将纹理组装成图元。阶段四:栅格化操作
由阶段三传递过来的图元数据,在此将会分解成更小的单元并对应于帧缓冲区的各个像素。这些单元称为片元,一个片元可能包含窗口颜色、纹理坐标等属性。片元的属性是根据顶点坐标利用插值来确定的,这就是栅格化操作,也就是确认好每一个片元是什么。阶段五:片元处理
通过纹理坐标取得纹理(texture)中相对应的片元像素值(texel),根据自己的业务处理(比如提亮、饱和度调节、对比度调节、高斯模糊)来变换这个片元的颜色。这里的输出是gl_FragColor,用于表示修改之后的像素的最终结果。阶段六:帧缓冲操作
该阶段主要执行帧缓冲的写入操作,这也是渲染管线的最后一步,负责将最终的像素值写入到帧缓冲区中。
OpenGL ES提供了可编程的着色器来代替渲染管线的某个阶段。
Vertex Shader(顶点着色器)用来替代顶点处理阶段。
Fragment Shader(片元着色器,又称为像素着色器)用来替换片元处理阶段。
简单来讲就是OpenGL会在顶点着色器确定顶点的位置,然后这些顶点连起来就是我们想要的图形。接着在片元着色器里面给这些图形上色:
GLSurfaceView
GLSurfaceView看名字就是可以使用OpenGL的SurfaceView,也确实如此,它继承自SurfaceView,具备SurfaceView的特性,并加入了EGL的管理,它自带了一个GLThread绘制线程(EGLContext创建GL环境所在线程即为GL线程),绘制的工作直接通过OpenGL在绘制线程进行,不会阻塞主线程,绘制的结果输出到SurfaceView所提供的Surface上。
所以为什么我们不直接用surfaceView来进行播放呢?有以下两个好处:
- 通过GLSurfaceView进行视频渲染,可以使用GPU加速,相对于SurfaceView使用画布进行绘制,OpenGL的绘制关联到GPU,效率更高。
- 可以定制render(渲染器),从而可以实现定制效果。
使用流程:
创建一个GLSurfaceView用来承载视频
->设置render(实现OpenGL着色器代码)
->创建SurfaceTexture,绑定的外部Texture
->将SurfaceTexture的surface设置给MediaPlayer,启动播放
->在render的onDrawFrame中更新Texture,绘制新画面。
其中,render是最核心部分。
1、创建GLSurfaceView
<android.opengl.GLSurfaceView
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
glView = findViewById(R.id.surface_view);
glView.setEGLContextClientVersion(2);
MyGLRender glVideoRenderer = new MyGLRender();//创建renderer
glView.setRenderer(glVideoRenderer);//设置renderer
创建GLSurfaceView后,设置其OpenGL版本为2.0,然后设置render。下面介绍MyGLRender。
2、创建render
render需要实现GLSurfaceView.Renderer的三个接口:
public interface Renderer {
void onSurfaceCreated(GL10 var1, EGLConfig var2);
void onSurfaceChanged(GL10 var1, int var2, int var3);
void onDrawFrame(GL10 var1);
}
onSurfaceCreated进行渲染程序的初始化,创建Surface,启动MediaPlayer
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
initGLProgram();
Surface surface = crateSurface();
// mediaplayer play
try {
mPlayer.setSurface(surface);
mPlayer.prepare();
mPlayer.start();
} catch (IOException e) {
e.printStackTrace();
}
}
渲染程序的初始化
initGLProgram()中创建顶点着色器和片元着色器代码,一步步看:
顶点着色器
private final String VSH_CODE = "uniform mat4 uSTMatrix;\n"+
"attribute vec4 aPosition;\n"+
"attribute vec4 aTexCoord;\n"+
"varying vec2 vTexCoord;\n"+
"void main(){\n"+
"vTexCoord = (uSTMatrix*aTexCoord).xy;\n"+
"gl_Position = aPosition;\n"+
"}";
OpenGL会将每个顶点的坐标传递给顶点着色器,我们可以在这里改变顶点的位置。例如我们给每个顶点都加上一个偏移,就能实现整个图形的移动。
aPosition为顶点坐标,赋值给gl_Position ,表示物体位置,构成图元,可由外部传入。
aTexCoord为纹理坐标,纹理坐标描述纹理该如何在图元上贴图,可由外部传入。
vTexCoord为最终要传递给片元着色器的纹理坐标,为什么要在aTexCoord的基础上进行矩阵转换呢?这是因为计算机图像坐标与纹理坐标的表示是不一致的。如下图:
因为我们使用的texture是从外部得到的,其对应的是计算机坐标系,所以需要矩阵转换,这个矩阵可通过SurfaceTexture.getTransformMatrix函数获取到。
片元着色器
private final String FSH_CODE = "#extension GL_OES_EGL_image_external : require\n"+
"precision mediump float;\n"+
"varying vec2 vTexCoord;\n"+
"uniform mat4 uColorMatrix;\n"+
"uniform samplerExternalOES sTexture;\n"+
"void main() {\n"+
"gl_FragColor=uColorMatrix*texture2D(sTexture, vTexCoord).rgba;\n"+
//"gl_FragColor = texture2D(sTexture, vTexCoord);\n"+
"}";
片元着色器要注意的是#extension GL_OES_EGL_image_external : require
,因为使用的是外部纹理samplerExternalOES类型的纹理sTexture,所以需要加上。
vTexCoord是从顶点着色器传过来的纹理坐标。
texture2D函数可以从该坐标获取到对应的颜色,这里我们加入了颜色转换矩阵uColorMatrix,这样就能进行一些效果处理。最后将颜色赋值给gl_FragColor。
颜色效果矩阵如下:
private static float[] COLOR_MATRIX3 = {
// 怀旧效果矩阵
0.393f,0.349f, 0.272f,0.0f ,
0.769f,0.686f,0.534f,0.0f,
0.189f,0.168f,0.131f,0.0f,
0.0f,0.0f,0.0f,1.0f
};
创建渲染程序
如何将两个着色器代码替换到渲染管线中呢,基本流程如下图:
编译shader程序(compileShader代码)
- glCreateShader创建shader,参数为类型,指定顶点着色器还是片元着色器;
- glShaderSource加载shader代码;
- glCompileShader编译代码,并glGetShaderiv通过GL_COMPILE_STATUS获取编译是否正确;
- 得到一个shader程序的ID。
创建渲染程序(buildProgram代码)
- glCreateProgram创建program;
- glAttachShader通过shader程序的ID,把shader程序附进来;
- glLinkProgram链接程序,并glGetProgramiv通过GL_LINK_STATUS获取链接是否正确。
- 得到一个渲染程序的ID。
最后调用glUseProgram,传入渲染程序的ID就可以了。
代码如下:
//创建shader
private int compileShader(int type, String code){
int shaderObjectId = GLES20.glCreateShader(type);
if (shaderObjectId == 0){
Log.d(TAG, "compileShader: glCreateShader err");
return 0;
}
GLES20.glShaderSource(shaderObjectId, code);
GLES20.glCompileShader(shaderObjectId);
int[] compileStatus = new int[1];
GLES20.glGetShaderiv(shaderObjectId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if (compileStatus[0] == 0){
// if it failed, delete the shader object
Log.d(TAG, "compileShader: glCompileShader err");
GLES20.glDeleteShader(shaderObjectId);
return 0;
}
Log.d(TAG, "compileShader: success: "+shaderObjectId);
return shaderObjectId;
}
//创建渲染程序
private int buildProgram(int vertexShaderId, int fragmentShaderId){
int programObjectId = GLES20.glCreateProgram();
if(programObjectId == 0){
Log.d(TAG, "buildProgram: glCreateProgram err");
return 0;
}
GLES20.glAttachShader(programObjectId, vertexShaderId);
GLES20.glAttachShader(programObjectId, fragmentShaderId);
GLES20.glLinkProgram(programObjectId);
int[] linkStatus = new int[1];
GLES20.glGetProgramiv(programObjectId, GLES20.GL_LINK_STATUS, linkStatus, 0);
if (linkStatus[0] == 0){
// if it failed, delete the shader object
GLES20.glDeleteProgram(programObjectId);
Log.d(TAG, "buildProgram: glLinkProgram err");
return 0;
}
Log.d(TAG, "buildProgram: success: "+programObjectId);
return programObjectId;
}
填充顶点坐标及纹理坐标
完成顶点着色器及片元着色器后,创建渲染程序,接下来我们要填充顶点信息:
顶点着色器中,aPosition表示物体位置坐标,坐标系中x轴从左到右是从-1到1变化的,y轴从下到上是从-1到1变化的,物体的中心点恰好是(0,0)的位置。
aTexCoord描述纹理坐标(如上图OpenGL二维纹理坐标),我们现在要把纹理按照,左下->右下->左上->右上的顺序,贴到物体上。所以对应的顶点坐标及纹理坐标数据为:
//顶点着色器坐标,z为0
float[] vers = {
-1.0f, -1.0f, 0.0f,
1.0f, -1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 0.0f,
};
//纹理坐标,texture坐标ST,需要根据图像进行转换
float[] txts = {
0.0f, 0.0f,
1.0f, 0.0f,
0.0f, 1.0f,
1.0f, 1.0f
};
通过 GLES20.glEnableVertexAttribArray及GLES20.glVertexAttribPointer两个函数,完成顶点信息设置。
设置颜色效果
通过glGetUniformLocation获取到uColorMatrix矩阵的句柄,将颜色矩阵设赋值给它就行。这样就会在片元着色器中生效。
//设置颜色效果
int colorMatrixHandle = GLES20.glGetUniformLocation(programId, "uColorMatrix");
GLES20.glUniformMatrix4fv(colorMatrixHandle, 1, false, COLOR_MATRIX3, 0);
完整代码:
private void initGLProgram(){
int vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, VSH_CODE);
int fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, FSH_CODE);
int programId = buildProgram(vertexShader, fragmentShader);
if(programId == 0)
return;
GLES20.glUseProgram(programId);
mSTMatrixHandle = GLES20.glGetUniformLocation(programId, "uSTMatrix");//转换矩阵
//顶点着色器坐标
float[] vers = {
-1.0f, -1.0f, 0.0f,
1.0f, -1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 0.0f,
};
FloatBuffer vertexBuffer = ByteBuffer.allocateDirect(vers.length * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vers);
vertexBuffer.position(0);
//纹理坐标,texture坐标ST,需要根据图像进行转换
float[] txts = {
0.0f, 0.0f,
1.0f, 0.0f,
0.0f, 1.0f,
1.0f, 1.0f
};
FloatBuffer textureVertexBuffer = ByteBuffer.allocateDirect(txts.length * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(txts);
textureVertexBuffer.position(0);
//设置顶点坐标和纹理坐标
int apos = GLES20.glGetAttribLocation(programId, "aPosition");
GLES20.glEnableVertexAttribArray(apos);
GLES20.glVertexAttribPointer(apos, 3, GLES20.GL_FLOAT, false, 12, vertexBuffer);
int atex = GLES20.glGetAttribLocation(programId, "aTexCoord");
GLES20.glEnableVertexAttribArray(atex);
GLES20.glVertexAttribPointer(atex, 2, GLES20.GL_FLOAT, false, 8, textureVertexBuffer);
//设置颜色效果
int colorMatrixHandle = GLES20.glGetUniformLocation(programId, "uColorMatrix");
GLES20.glUniformMatrix4fv(colorMatrixHandle, 1, false, COLOR_MATRIX3, 0);
}
3、创建SurfaceTexture,绑定外部纹理
glGenTextures创建Texture,我们使用的是外部纹理,所以只需要一个即可。
glBindTexture绑定纹理,要注意这里需要设置GL_TEXTURE_EXTERNAL_OES标志。
glTexParameterf设置一些属性,这里设置的是缩放的算法。
然后根据mTextureID创建SurfaceTexture,然后创建Surface,Surface就可以设置给MeidaPlayer。
完整代码:
private Surface crateSurface(){
// Create SurfaceTexture that will feed this textureId and pass to MediaPlayer
int[] textures = new int[1];//just one texures,use external mode
GLES20.glGenTextures(1, textures, 0);
mTextureID = textures[0];
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureID);
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
mSurfaceTexture = new SurfaceTexture(mTextureID);
mSurfaceTexture.setOnFrameAvailableListener(this);
Surface surface = new Surface(mSurfaceTexture);
return surface;
}
4、Surface设置给MediaPlayer,启动播放
没什么可以说道的,就是把上面创建的surface设置给播放器,同步的prepare,加上start。
// mediaplayer play
try {
mPlayer.setSurface(surface);
mPlayer.prepare();
mPlayer.start();
} catch (IOException e) {
e.printStackTrace();
}
5、onDrawFrame中更新Texture,绘制新画面
上面创SurfaceTexture时通过setOnFrameAvailableListener设置了监听器,监听纹理的更新,更新了,我们就设置isFrameUpdate为true。
onDrawFrame是render进行绘制时会调用,当isFrameUpdate为true,意味着我们可以进行绘制了。
先通过SurfaceTexture.updateTexImage()更新纹理,然后glViewport设置绘制的窗口大小。
OpenGL虽然是在Surface上绘制,但我们可以不铺满整个Surface,可以只在它的某部分绘制,例如我们可以用下面代码只用TextureSurface的左下角的四分之一去显示OpenGL的画面:
//width、height是TextureView的宽高 GLES20.glViewport(0, 0, width/2, height/2);
我们这里还是铺满整个View,宽高可以在onSurfaceChanged中获取到。
绘制前先清除上一帧,
//clear
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
当然这里还可以再清空片元着色器的外部纹理。
设置纹理变换矩阵,矩阵在SurfaceTexture.getTransformMatrix获取到
激活绑定纹理,然后就可以绘制了。
绘制采用的三角形方式GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
render缺省模式是 RENDERMODE_CONTINUOUSLY,就是说 surface绘制线程不停循环调用onDrawFrame。所以帧频控制取决于每帧的绘制时间,通常都是在onDrawFrame里加延时来控制的。
当设置为RENDERMODE_WHEN_DIRTY时,就是通常的事件驱动模式来绘制。画面重新显示出来或 requestRender()时才会调用onDrawFrame.
完整代码如下:
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
screenWidth = width;
screenHeight = height;
}
@Override
public void onDrawFrame(GL10 gl10) {
synchronized (this){
if(isFrameUpdate){
mSurfaceTexture.updateTexImage();
mSurfaceTexture.getTransformMatrix(mSTMatrix);
isFrameUpdate = false;
}
}
//update width and height
GLES20.glViewport(0, 0, screenWidth, screenHeight);
//clear
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
//update st mat4
GLES20.glUniformMatrix4fv(mSTMatrixHandle, 1, false, mSTMatrix, 0);
//bind and active, juest one time
{
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureID);
}
//draw
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
}
@Override
public void onFrameAvailable(SurfaceTexture surfaceTexture) {
isFrameUpdate = true;
}
总结
播放效果如下:
下一章会描述如何在native层使用EGL和OpenGL,这样会对Android OpenGL ES视频渲染有更深入的了解。