OpenGL ES渲染播放视频

前面两篇文章主要了解了 OpenGL ES 的基本使用及其坐标系的映射，如下：

Android 中的 OpenGL

OpenGL ES 投影和视图变换

下面将使用 MediaPlayer 和 OpenGL ES 来实现基本视频渲染以及视频画面的矫正，主要内容如下：

SurfaceTexture

渲染视频

画面矫正

SurfaceTexture

SurfaceTexture 从 Android 3.0 开始加入，其对图像流的处理并不直接显示，而是从图像流中捕获帧作为 OpenGL 的外部纹理，图像流主要来自相机预览和视频解码，可对图像流进行二次处理，如滤镜以及特效等，可以理解为 SurfaceTexture 是 Surface 和 OpenGL ES 纹理的结合。

SurfaceTexture 创建的 Surface 是数据的生产者，而 SurfaceTexture 是对应的消费者，Surface 接收媒体数据并将数据发送到 SurfaceTexture，当调用 updateTexImage 的时候，创建 SurfaceTexture 的纹理对象相应的内容将更新为最新图像帧，也就是会将图像帧转换为 GL 纹理，并将该纹理绑定到GL_TEXTURE_EXTERNAL_OES 纹理对象上，updateTexImage 仅在 OpenGL ES 上下文线程中调用，一般在 onDrawFrame 中进行调用。

渲染视频

MediaPlayer如何播放视频大家应该非常熟悉，这里不在赘述，有了上面小节SurfaceTexture的介绍，使用 OpenGL ES 实现视频渲染非常简单，定义顶点坐标和纹理坐标如下：

Copy
// 顶点坐标
private val vertexCoordinates = floatArrayOf(
1.0f, 1.0f,
-1.0f, 1.0f,
-1.0f, -1.0f,
1.0f, -1.0f
)
// 纹理坐标
private val textureCoordinates = floatArrayOf(
1.0f, 0.0f,
0.0f, 0.0f,
0.0f, 1.0f,
1.0f, 1.0f
)

纹理坐标必须和顶点坐标对应，简单提一下，顶点坐标使用 OpenGL 坐标系，原点在屏幕中间，纹理坐标就是对应屏幕中的坐标，原点在左上角，生成纹理 ID 并激活绑定，如下：

Copy
/**
* 生成纹理ID
*/
fun createTextureId(): Int {
val tex = IntArray(1)
GLES20.glGenTextures(1, tex, 0)
if (tex[0] == 0) {
throw RuntimeException("create OES texture failed, ${Thread.currentThread().name}")
}
return tex[0]
}

/**
* 创建OES纹理
* YUV格式到RGB的自动转化
*/
fun activeBindOESTexture(textureId:Int) {
// 激活纹理单元
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
// 绑定纹理ID到纹理单元的纹理目标上
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
// 设置纹理参数
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST.toFloat())
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR.toFloat())
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE.toFloat())
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE.toFloat())
Log.d(TAG, "activeBindOESTexture: texture id $textureId")
}

将纹理 ID 绑定到纹理单元的纹理目标上，这里选择的纹理目标是GL_TEXTURE_EXTERNAL_OES，可以自动完成 YUV 格式到 RGB 的自动转换，下面来看下着色器，其中顶点着色器中接收纹理坐标并保存到vTextureCoordinate供片段着色器使用，具体如下：

Copy
// 顶点着色器
attribute vec4 aPosition; // 顶点坐标
attribute vec2 aCoordinate; // 纹理坐标
varying vec2 vTextureCoordinate;
void main() {
gl_Position = aPosition;
vTextureCoordinate = aCoordinate;
}

// 片段着色器
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoordinate;
uniform samplerExternalOES uTexture; // OES纹理
void main() {
gl_FragColor=texture2D(uTexture, vTextureCoordinate);
}

关于 Shader 编译、Program 链接、使用的代码这里省略，使用方式之前的文章中介绍过，或者直接文末查看源码，渲染器定义如下：

Copy
class PlayRenderer(
private var context: Context,
private var glSurfaceView: GLSurfaceView
) : GLSurfaceView.Renderer,
VideoRender.OnNotifyFrameUpdateListener, MediaPlayer.OnPreparedListener,
MediaPlayer.OnVideoSizeChangedListener, MediaPlayer.OnCompletionListener,
MediaPlayer.OnErrorListener {
companion object {
private const val TAG = "PlayRenderer"
}
private lateinit var videoRender: VideoRender
private lateinit var mediaPlayer: MediaPlayer
private val projectionMatrix = FloatArray(16)
private val viewMatrix = FloatArray(16)
private val vPMatrix = FloatArray(16)
// 用于视频比例的计算，详见下文
private var screenWidth: Int = -1
private var screenHeight: Int = -1
private var videoWidth: Int = -1
private var videoHeight: Int = -1

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
L.i(TAG, "onSurfaceCreated")
GLES20.glClearColor(0f, 0f, 0f, 0f)
videoRender = VideoRender(context)
videoRender.setTextureID(TextureHelper.createTextureId())
videoRender.onNotifyFrameUpdateListener = this
initMediaPlayer()
}

override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
L.i(TAG, "onSurfaceChanged > width:$width,height:$height")
screenWidth = width
screenHeight = height
GLES20.glViewport(0, 0, width, height)
}

override fun onDrawFrame(gl: GL10) {
L.i(TAG, "onDrawFrame")
gl.glClear(GL10.GL_COLOR_BUFFER_BIT or GL10.GL_DEPTH_BUFFER_BIT)
videoRender.draw(vPMatrix)
}

override fun onPrepared(mp: MediaPlayer?) {
L.i(OpenGLActivity.TAG, "onPrepared")
mediaPlayer.start()
}

override fun onVideoSizeChanged(mp: MediaPlayer?, width: Int, height: Int) {
L.i(OpenGLActivity.TAG, "onVideoSizeChanged > width:$width ,height:$height")
this.videoWidth = width
this.videoHeight = height
}

override fun onCompletion(mp: MediaPlayer?) {
L.i(OpenGLActivity.TAG, "onCompletion")
}

override fun onError(mp: MediaPlayer?, what: Int, extra: Int): Boolean {
L.i(OpenGLActivity.TAG, "error > what:$what,extra:$extra")
return true
}

private fun initMediaPlayer() {
mediaPlayer = MediaPlayer()
mediaPlayer.setOnPreparedListener(this)
mediaPlayer.setOnVideoSizeChangedListener(this)
mediaPlayer.setOnCompletionListener(this)
mediaPlayer.setOnErrorListener(this)
mediaPlayer.setDataSource(Environment.getExternalStorageDirectory().absolutePath + "/video.mp4")
mediaPlayer.setSurface(videoRender.getSurface())
mediaPlayer.prepareAsync()
}
// 通知请求渲染
override fun onNotifyUpdate() {
glSurfaceView.requestRender()
}

fun destroy() {
mediaPlayer.stop()
mediaPlayer.release()
}
}

上述代码中的VideoRender主要是渲染操作，这部分代码和上篇文章中的大同小异，这里就不贴了。

使用 OpenGL ES 进行使用视频渲染的时候，需调用SurfaceTetre的updateTexImage方法更新图像帧，该方法必须在 OpenGL ES 上下文中使用，可以设置GLSurfaceView的渲染模式为RENDERMODE_WHEN_DIRTY避免一直绘制，当onFrameAvailable会调的时候，也就是有了可用的数据之后再进行requestRender以减少必要的消耗。

看下原始的视频渲染效果图：

画面矫正

上面视频是全屏播放的，但是屏幕分辨率和视频分辨率不一样，导致视频画面被拉升，这就需要根据屏幕分辨率和视频分辨率的大小来计算合适视频画面大小，在这篇文章中介绍了坐标的映射，并且基本适配了三角形的变形，这里视频也是一样，它相当于矩形。

投影主要有正交投影和透视投影，正交投影一般用于渲染 2D 画面，比如普通视频的渲染，透视投影有近大远小的特点，一般用于 3D 画面渲染，比如 VR 的渲染，所以这里使用正交投影的方式来矫正画面。

先看下Shader的修改，主要是顶点顶点着色器的变化，如下：

Copy
attribute vec4 aPosition;
attribute vec2 aCoordinate;
uniform mat4 uMVPMatrix;
varying vec2 vTextureCoordinate;
void main() {
gl_Position =  uMVPMatrix * aPosition;
vTextureCoordinate = aCoordinate;
}

关键就是计算矩阵uMVPMatrix，而uMVPMatrix是投影矩阵和视图矩阵的乘积，投影矩阵的计算，OpenGL ES 使用Matrix来进行矩阵运算，正交投影使用Matrix.orthoM来生成投影矩阵，计算方式如下：

Copy
// 计算视频缩放比例（投影矩阵）
val screenRatio = screenWidth / screenHeight.toFloat()
val videoRatio = videoWidth / videoHeight.toFloat()
val ratio: Float
if (screenWidth > screenHeight) {
if (videoRatio >= screenRatio) {
ratio = videoRatio / screenRatio
Matrix.orthoM(
projectionMatrix, 0,
-1f, 1f, -ratio, ratio, 3f, 5f
)
} else {
ratio = screenRatio / videoRatio
Matrix.orthoM(
projectionMatrix, 0,
-ratio, ratio, -1f, 1f, 3f, 5f
)
}
} else {
if (videoRatio >= screenRatio) {
ratio = videoRatio / screenRatio
Matrix.orthoM(
projectionMatrix, 0,
-1f, 1f, -ratio, ratio, 3f, 5f
)
} else {
ratio = screenRatio / videoRatio
Matrix.orthoM(
projectionMatrix, 0,
-ratio, ratio, -1f, 1f, 3f, 5f
)
}
}

上面主要是根据屏幕比例和视频原始比例合适的投影矩阵参数，这个计算和图片的缩放差不多，可以自己计算下，有个原则就是视频画面必须完全显示在屏幕内部，上面的ratio就是就是正交投影视景体的边界，以本人手机为例计算一下ratio，这里为了方便计算屏幕宽等于视频宽，屏幕 1080 * 2260，视频 1080 * 540，则ratio为 2260 / 540 约等于 4.18，显然如果按照屏幕高度为基准，当视频高度为 2260 的时候，视频宽度为 4520 远远超出了屏幕宽度，故按照视频宽度来进行适配，下面看下相机位置设置：

Copy
// 设置相机位置（视图矩阵）
Matrix.setLookAtM(
viewMatrix, 0,
0.0f, 0.0f, 5.0f, // 相机位置
0.0f, 0.0f, 0.0f, // 目标位置
0.0f, 1.0f, 0.0f // 相机正上方向量
)

屏幕向外为 z 轴，相机位置 (0, 0, 5) 表示相机在距离屏幕为 5 的位置，即 z 轴方向，该值必须在视景体的 near 和 far 之间，否则是看不见的，比如该案例中该值应该在 3~5 之间，目标位置 (0, 0, 0) 表示的就是屏幕，也就是 x 和 y 轴构成的平面，相机正上方向量 (0, 1, 0) 表示沿着 y 轴正方向，最后是计算投影和视图变化，如下通过矩阵乘法将projectionMatrix和viewMatrix合并为vPMatrix:

Copy
1// 计算投影和视图变换
2Matrix.multiplyMM(vPMatrix, 0, projectionMatrix, 0, viewMatrix, 0)

为了画面矫正需要使用到原始视频的大小，可在MediaPlayer的onVideoSizeChanged回调中获取视频宽高并初始化矩阵数据，下面来看下画面矫正后的效果：

到此使用 OpenGL ES 视频渲染完成，可以获取关键字【RenderVideo】获取源代码。