前面兩篇文章主要了解了 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 實現視頻渲染非常簡單,定義頂點坐標和紋理坐標如下:
// 頂點坐標
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 並激活綁定,如下:
/**
* 生成紋理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
供片段著色器使用,具體如下:
// 頂點著色器
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 鏈接、使用的代碼這裡省略,使用方式之前的文章中介紹過,或者直接文末查看源碼,渲染器定義如下:
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
的修改,主要是頂點頂點著色器的變化,如下:
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
來生成投影矩陣,計算方式如下:
// 計算視頻縮放比例(投影矩陣)
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 遠遠超出了螢幕寬度,故按照視頻寬度來進行適配,下面看下相機位置設置:
// 設置相機位置(視圖矩陣)
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
:
1// 計算投影和視圖變換
2Matrix.multiplyMM(vPMatrix, 0, projectionMatrix, 0, viewMatrix, 0)
為了畫面矯正需要使用到原始視頻的大小,可在MediaPlayer
的onVideoSizeChanged
回調中獲取視頻寬高並初始化矩陣數據,下面來看下畫面矯正後的效果:
到此使用 OpenGL ES 視頻渲染完成,可以獲取關鍵字【RenderVideo】獲取源代碼。