banner
jzman

jzman

Coding、思考、自觉。
github

OpenGL ESで動画をレンダリング再生

前面二つの記事では、OpenGL ES の基本的な使用法とその座標系のマッピングについて理解しました。以下の通りです:

次に、MediaPlayerと OpenGL ES を使用して基本的な動画レンダリングと動画画面の補正を実現します。主な内容は以下の通りです:

  1. SurfaceTexture
  2. 動画のレンダリング
  3. 画面補正

SurfaceTexture#

SurfaceTextureは Android 3.0 から追加され、画像ストリームの処理は直接表示されず、画像ストリームからフレームをキャプチャして OpenGL の外部テクスチャとして使用されます。画像ストリームは主にカメラのプレビューや動画のデコードから来ており、画像ストリームに対してフィルターやエフェクトなどの二次処理を行うことができます。SurfaceTextureSurfaceと 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("OESテクスチャの作成に失敗しました, ${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: テクスチャ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);  
}

シェーダーのコンパイル、プログラムのリンク、使用に関するコードはここでは省略します。使用方法は以前の記事で紹介されているか、直接文末でソースコードを確認できます。レンダラーは以下のように定義されています:

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 を使用して動画レンダリングを行う際には、SurfaceTextureupdateTexImageメソッドを呼び出して画像フレームを更新する必要があります。このメソッドは OpenGL ES コンテキスト内で使用する必要があります。GLSurfaceViewのレンダリングモードをRENDERMODE_WHEN_DIRTYに設定して、常に描画されないようにし、onFrameAvailableが呼び出されるとき、つまり利用可能なデータが得られた後にrequestRenderを行い、必要な消耗を減らします。

原始的な動画レンダリングの効果を見てみましょう:

image

画面補正#

上記の動画は全画面で再生されていますが、画面の解像度と動画の解像度が異なるため、動画画面が引き伸ばされています。これにより、画面の解像度と動画の解像度の大きさに基づいて適切な動画画面の大きさを計算する必要があります。この記事では座標のマッピングについて紹介されており、三角形の変形に基本的に適応しています。動画も同様で、矩形に相当します。

投影には主に正射影と透視投影があります。正射影は一般的に 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 軸の正方向を示します。最後に投影とビューの変換を計算します。以下のように行列の乗算を通じてprojectionMatrixviewMatrixを合成してvPMatrixを得ます:

// 投影とビュー変換を計算  
Matrix.multiplyMM(vPMatrix, 0, projectionMatrix, 0, viewMatrix, 0)

画面補正には元の動画のサイズが必要です。MediaPlayeronVideoSizeChangedコールバックで動画の幅と高さを取得し、行列データを初期化します。次に、補正後の画面の効果を見てみましょう:

image

これで OpenGL ES による動画レンダリングが完了しました。キーワード【RenderVideo】を取得してソースコードを入手できます。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。