【Android 音視頻開發打怪升級:OpenGL渲染視頻畫面篇】二、使用OpenGL渲染視頻畫面

【聲 明】

首先,這一系列文章均基於自己的理解和實踐,可能有不對的地方,歡迎大家指正。
其次,這是一個入門系列,涉及的知識也僅限於夠用,深入的知識網上也有許許多多的博文供大家學習了。
最後,寫文章過程中,會借鑑參考其他人分享的文章,會在文章最後列出,感謝這些作者的分享。

碼字不易,轉載請註明出處!

教程代碼:【Github傳送門

目錄

一、Android音視頻硬解碼篇:
二、使用OpenGL渲染視頻畫面篇
  • 1,初步瞭解OpenGL ES
  • 2,使用OpenGL渲染視頻畫面
  • 3,OpenGL渲染多視頻,實現畫中畫
  • 4,深入瞭解OpenGL之EGL
  • 5,OpenGL FBO數據緩衝區
  • 6,Android音視頻硬編碼:生成一個MP4
三、Android FFmpeg音視頻解碼篇
  • 1,FFmpeg so庫編譯
  • 2,Android 引入FFmpeg
  • 3,Android FFmpeg視頻解碼播放
  • 4,Android FFmpeg+OpenSL ES音頻解碼播放
  • 5,Android FFmpeg+OpenGL ES播放視頻
  • 6,Android FFmpeg簡單合成MP4:視屏解封與重新封裝
  • 7,Android FFmpeg視頻編碼

本文你可以瞭解到

上文介紹了OpenGL ES 在 Android 上的簡單應用,本文將基於上文的基礎知識,使用 OpenGL 來渲染視頻畫面,並講解關於畫面投影相關的知識,解決畫面拉昇變形問題。

一、渲染視頻畫面

在第一篇文章【音視頻基礎知識】文章中,就介紹過,視頻其實就是一張張圖片組成的,在上文【初步瞭解OpenGL ES】中,介紹瞭如何通過OpenGL渲染一張圖片,可以猜想到,視頻的渲染和圖片的渲染應該是差不多的。話不多說,馬上就來看看。

1. 定義視頻渲染器

在上文中,定義了一下視頻渲染接口類

interface IDrawer {
    fun draw()
    fun setTextureID(id: Int)
    fun release()
}

先來實現以上接口,定義一個視頻渲染器

class VideoDrawer : IDrawer {

    // 頂點座標
    private val mVertexCoors = floatArrayOf(
        -1f, -1f,
        1f, -1f,
        -1f, 1f,
        1f, 1f
    )

    // 紋理座標
    private val mTextureCoors = floatArrayOf(
        0f, 1f,
        1f, 1f,
        0f, 0f,
        1f, 0f
    )

    private var mTextureId: Int = -1

    //OpenGL程序ID
    private var mProgram: Int = -1
    // 頂點座標接收者
    private var mVertexPosHandler: Int = -1
    // 紋理座標接收者
    private var mTexturePosHandler: Int = -1
    // 紋理接收者
    private var mTextureHandler: Int = -1

    private lateinit var mVertexBuffer: FloatBuffer
    private lateinit var mTextureBuffer: FloatBuffer

    init {
        //【步驟1: 初始化頂點座標】
        initPos()
    }

    private fun initPos() {
        val bb = ByteBuffer.allocateDirect(mVertexCoors.size * 4)
        bb.order(ByteOrder.nativeOrder())
        //將座標數據轉換爲FloatBuffer,用以傳入給OpenGL ES程序
        mVertexBuffer = bb.asFloatBuffer()
        mVertexBuffer.put(mVertexCoors)
        mVertexBuffer.position(0)

        val cc = ByteBuffer.allocateDirect(mTextureCoors.size * 4)
        cc.order(ByteOrder.nativeOrder())
        mTextureBuffer = cc.asFloatBuffer()
        mTextureBuffer.put(mTextureCoors)
        mTextureBuffer.position(0)
    }

    override fun setTextureID(id: Int) {
        mTextureId = id
    }

    override fun draw() {
        if (mTextureId != -1) {
            //【步驟2: 創建、編譯並啓動OpenGL着色器】
            createGLPrg()
            //【步驟3: 激活並綁定紋理單元】
            activateTexture()
            //【步驟4: 綁定圖片到紋理單元】
            updateTexture()
            //【步驟5: 開始渲染繪製】
            doDraw()
        }
    }

    private fun createGLPrg() {
        if (mProgram == -1) {
            val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, getVertexShader())
            val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, getFragmentShader())

            //創建OpenGL ES程序,注意:需要在OpenGL渲染線程中創建,否則無法渲染
            mProgram = GLES20.glCreateProgram()
            //將頂點着色器加入到程序
            GLES20.glAttachShader(mProgram, vertexShader)
            //將片元着色器加入到程序中
            GLES20.glAttachShader(mProgram, fragmentShader)
            //連接到着色器程序
            GLES20.glLinkProgram(mProgram)

            mVertexPosHandler = GLES20.glGetAttribLocation(mProgram, "aPosition")
            mTextureHandler = GLES20.glGetUniformLocation(mProgram, "uTexture")
            mTexturePosHandler = GLES20.glGetAttribLocation(mProgram, "aCoordinate")
        }
        //使用OpenGL程序
        GLES20.glUseProgram(mProgram)
    }

    private fun activateTexture() {
        //激活指定紋理單元
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        //綁定紋理ID到紋理單元
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureId)
        //將激活的紋理單元傳遞到着色器裏面
        GLES20.glUniform1i(mTextureHandler, 0)
        //配置邊緣過渡參數
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
    }

    private fun updateTexture() {
    }

    private fun doDraw() {
        //啓用頂點的句柄
        GLES20.glEnableVertexAttribArray(mVertexPosHandler)
        GLES20.glEnableVertexAttribArray(mTexturePosHandler)
        //設置着色器參數, 第二個參數表示一個頂點包含的數據數量,這裏爲xy,所以爲2
        GLES20.glVertexAttribPointer(mVertexPosHandler, 2, GLES20.GL_FLOAT, false, 0, mVertexBuffer)
        GLES20.glVertexAttribPointer(mTexturePosHandler, 2, GLES20.GL_FLOAT, false, 0, mTextureBuffer)
        //開始繪製
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    }

    override fun release() {
        GLES20.glDisableVertexAttribArray(mVertexPosHandler)
        GLES20.glDisableVertexAttribArray(mTexturePosHandler)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
        GLES20.glDeleteTextures(1, intArrayOf(mTextureId), 0)
        GLES20.glDeleteProgram(mProgram)
    }

    private fun getVertexShader(): String {
        return "attribute vec4 aPosition;" +
                "attribute vec2 aCoordinate;" +
                "varying vec2 vCoordinate;" +
                "void main() {" +
                "    gl_Position = aPosition;" +
                "    vCoordinate = aCoordinate;" +
                "}"
    }

    private fun getFragmentShader(): String {
        //一定要加換行"\n",否則會和下一行的precision混在一起,導致編譯出錯
        return "#extension GL_OES_EGL_image_external : require\n" +
                "precision mediump float;" +
                "varying vec2 vCoordinate;" +
                "uniform samplerExternalOES uTexture;" +
                "void main() {" +
                "  gl_FragColor=texture2D(uTexture, vCoordinate);" +
                "}"
    }

    private fun loadShader(type: Int, shaderCode: String): Int {
        //根據type創建頂點着色器或者片元着色器
        val shader = GLES20.glCreateShader(type)
        //將資源加入到着色器中,並編譯
        GLES20.glShaderSource(shader, shaderCode)
        GLES20.glCompileShader(shader)

        return shader
    }
}

咋一看,和渲染圖片一模一樣啊。別急,且聽我一一道來。看看這個draw的流程:


init {
    //【步驟1: 初始化頂點座標】
    initPos()
}

override fun draw() {
    if (mTextureId != -1) {
        //【步驟2: 創建、編譯並啓動OpenGL着色器】
        createGLPrg()
        //【步驟3: 激活並綁定紋理單元】
        activateTexture()
        //【步驟4: 綁定圖片到紋理單元】
        updateTexture()
        //【步驟5: 開始渲染繪製】
        doDraw()
    }
}
  • 一樣的地方:
  1. 頂點座標和紋理座標的設置
  2. 新建OpenGL Program,加載GLSL程序的流程。(但僅僅是流程一樣,細節是有區別的)
  3. draw的流程
  • 不一樣的地方:
  1. 片元着色器
//視頻片元着色器

private fun getFragmentShader(): String {
    //一定要加換行"\n",否則會和下一行的precision混在一起,導致編譯出錯
    return "#extension GL_OES_EGL_image_external : require\n" +
            "precision mediump float;" +
            "varying vec2 vCoordinate;" +
            "uniform samplerExternalOES uTexture;" +
            "void main() {" +
            "  gl_FragColor=texture2D(uTexture, vCoordinate);" +
            "}"
}

對比一下圖片的片元着色器

private fun getFragmentShader(): String {
    return "precision mediump float;" +
            "uniform sampler2D uTexture;" +
            "varying vec2 vCoordinate;" +
            "void main() {" +
            "  vec4 color = texture2D(uTexture, vCoordinate);" +
            "  gl_FragColor = color;" +
            "}"
}

吶,第一行加了一句:

#extension GL_OES_EGL_image_external : require

視頻畫面的渲染使用的是Android的拓展紋理

拓展紋理的作用?
我們已經知道,視頻的畫面色彩空間是YUV,而要顯示到屏幕上,畫面是RGB的,所以,要把視頻畫面渲染到屏幕上,必須把YUV轉換爲RGB。拓展紋理就起到了這個轉換的作用。

第四行的紋理單元也換成了拓展紋理單元。

uniform samplerExternalOES uTexture;
  1. 激活紋理單元
private fun activateTexture() {
    //激活指定紋理單元
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
    //綁定紋理ID到紋理單元
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureId)
    //將激活的紋理單元傳遞到着色器裏面
    GLES20.glUniform1i(mTextureHandler, 0)
    //配置邊緣過渡參數
    GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
    GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
}

同樣的,將普通的紋理單元全部換成拓展紋理單元GLES11Ext.GL_TEXTURE_EXTERNAL_OES

  1. 更新紋理單元
private fun updateTexture() {

}

和渲染圖片類似的,要把畫面顯示出來,要先把畫面(比如圖片的bitmap)綁定到紋理單元上。

但是現在爲啥是空的?因爲僅僅用上邊的流程,並不能把視頻顯示出來。

視頻的渲染需要通過SurfaceTexture來更新畫面。接下來看看如何生成。

class VideoDrawer: IDrawer {
    //......
    
    private var mSurfaceTexture: SurfaceTexture? = null
    
    
    override fun setTextureID(id: Int) {
        mTextureId = id
        mSurfaceTexture = SurfaceTexture(id)
    }
    //......
}

在VideoDrawer中新增了一個SurfaceTexture,並在setTextureID中,利用紋理ID初始化了這個SurfaceTexture。

在updateTexture方法中

private fun updateTexture() {
    mSurfaceTexture?.updateTexImage()
}

到這裏,你應該會想這回終於可以了,不過還差一步。

還記得在硬解碼第二篇封裝基礎解碼框架中,提到MediaCodec要提供一個Surface,作爲一個渲染表面。而Surface正需要一個SurfaceTexture。

因此,我們需要把這個SurfaceTexture傳給外部使用。先在IDrawer中添加一個方法


interface IDrawer {
    fun draw()
    fun setTextureID(id: Int)
    fun release()
    //新增接口,用於提供SurfaceTexture
    fun getSurfaceTexture(cb: (st: SurfaceTexture)->Unit) {}
}

通過一個高階函數參數,把SurfaceTexture回傳出去。具體如下:

class VideoDrawer: IDrawer {

    //......
    
    private var mSftCb: ((SurfaceTexture) -> Unit)? = null
    
    override fun setTextureID(id: Int) {
        mTextureId = id
        mSurfaceTexture = SurfaceTexture(id)
        mSftCb?.invoke(mSurfaceTexture!!)
    }

    override fun getSurfaceTexture(cb: (st: SurfaceTexture) -> Unit) {
        mSftCb = cb
    }
    
    //......
}

2. 使用OpenGL來播放視頻

新建一個頁面

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    <android.opengl.GLSurfaceView
            android:id="@+id/gl_surface"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
</android.support.constraint.ConstraintLayout>
class OpenGLPlayerActivity: AppCompatActivity() {
    val path = Environment.getExternalStorageDirectory().absolutePath + "/mvtest_2.mp4"
    lateinit var drawer: IDrawer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_opengl_player)
        initRender()
    }

    private fun initRender() {
        drawer = VideoDrawer()
        drawer.getSurfaceTexture {
            //使用SurfaceTexture初始化一個Surface,並傳遞給MediaCodec使用
            initPlayer(Surface(it))
        }
        gl_surface.setEGLContextClientVersion(2)
        gl_surface.setRenderer(SimpleRender(drawer))
    }

    private fun initPlayer(sf: Surface) {
        val threadPool = Executors.newFixedThreadPool(10)

        val videoDecoder = VideoDecoder(path, null, sf)
        threadPool.execute(videoDecoder)

        val audioDecoder = AudioDecoder(path)
        threadPool.execute(audioDecoder)

        videoDecoder.goOn()
        audioDecoder.goOn()
    }
}

基本與使用SurfaceView進行播放差不多,多了初始化OpenGL和Surface。很簡單,具體看以上代碼,不再解釋。

如果使用以上代碼開始播放視頻,你會發現,視頻畫面被拉伸到GLSurfaceView窗口的大小,也就是全屏鋪滿,接下來就看看如何矯正視頻畫面,讓畫面比例和實際一樣。

畫面被拉伸

二、畫面比例矯正

投影

OpenGL的世界座標是一個標準化的座標體系,xyz座標範圍都在(-1~1),默認起始和結束位置分別對應世界座標的平面的四個角。這時畫面是鋪滿整個屏幕的,所以沒有經過座標變換的畫面一般都會有變形的問題。

OpenGL提供兩種方式,可以對畫面比例進行調整,分別是透視投影正交投影

投影起到什麼作用呢?

  1. 投影規定了裁剪空間的範圍,也就是物體的可視空間範圍
  2. 將裁剪空間內的物體投影到屏幕上

要講清楚OpenGL的投影並不是一件簡單的事,會涉及到OpenGL中關於各類空間的定義,這裏簡單列一下:

  • 局部空間:相對於物體本省的空間,原點在物體中間
  • 世界空間:OpenGL世界的座標系
  • 觀察空間:觀察者(相機)的空間,相當於真實世界中的人的眼睛看到的空間,不同觀察位置,看同一物體,也會不一樣
  • 裁剪空間:可視空間,在這個空間內部的物體才能顯示到屏幕上
  • 屏幕空間:屏幕座標空間,也就是手機屏幕空間
透視投影

透視投影

上圖可以看到,透視投影的原理其實就是人眼看物體的成像原理。從相機向前看,有一個視角空間的,類似人眼的觀察角度。人看到的物體是投影在視網膜上,相機看到的則是投影在近平面(距離相機比較近的平面)上的成像。

- 相機位置和朝向

首先相機相機並不是固定的,可以根據自己需求移動的,那麼就需要設置相機的位置和朝向,這關係到如何觀察物體。

要知道的是,相機依然位於世界座標空間中。所以設置的相機位置,是相對與世界座標原點來說的。

  • 相機的位置

OpenGL 世界座標系是一個右手座標系,正 X 軸在右手邊,正 Y 軸朝上,正 Z 軸穿過屏幕朝向你。

OpenGL 世界座標系

那麼相機座標可以是(0,0,5),也就是位於Z軸上的一個點。

  • 相機的朝向

設置了相機的位置以後,還需要設置相機的朝向,由三個方向向量upX,upY,uZ,起點爲相機的座標點,決定了相機的朝向。也就是說這三個向量的合成向量,就是相機正上方的方向。

如果將相機類比成人的頭部,那麼合成向量的方向就是頭部正上方的朝向。

比如可以將相機的朝向設置爲(0,1,0),這個時候,相機位於(0,0,5),向上方向爲Y軸,這時候,相機正好看到XY組成的平面,是畫面的正面。

如果相機的朝向設置爲(0,-1,0),相當於人的頭部往下,這是看到的畫面和上面的畫面是顛倒的。

  • 近平面和遠平面

看回上面透視投影的圖片,在相機的右邊有兩個平面,靠近相機的爲近平面,較遠的一面爲遠平面。

  • 裁剪空間

可以看到遠平面和近平面的四邊的連線最後都彙集到相機的位置。這些連線和兩個平面包圍組成的空間就是上面所說的裁剪空間,即可見空間。

在這個空間中的物體,其表面與相機位置的連線,穿過近平面留下的點,組成的圖像,就是物體在近平面上的投影,也就是在手機屏幕看到的成像

並且,距離相機的位置越遠,投影會越小,這和人眼的成像一模一樣。

透視投影成像

正因爲透視投影這種和人眼一樣的成像原理,所以經常應用在3D渲染中。

但是這種也比較複雜,本人也不是非常熟悉,爲了避免錯誤傳導,這裏不做具體的應用講解。大家可以看其他人的文章瞭解,比如【投影矩陣和視口變換矩陣】、【投影矩陣

下面介紹在2D渲染中用的比較多的投影模式:正交投影。

正交投影

正交投影

和透視投影一樣,正交投影也有相機、近平面和遠平面,不同的是,相機的視線不在是聚焦在一點上,而是平行線。所以近平面與遠平面中間的可視窗體是一個長方體。

也就是說,正交投影的視覺不再像人眼了,所有在裁剪空間中的物體,無論遠近,只要是大小一樣,在近平面上的投影都是一樣的,不再有近大遠小的效果。

正交投影成像

這種效果非常適合用來渲染2D畫面。

OpenGL 提供了 Matrix.orthoM 函數來生成正交投影矩陣:

/**
 * Computes an orthographic projection matrix.
 *
 * @param m returns the result 正交投影矩陣
 * @param mOffset 偏移量,默認爲 0 ,不偏移
 * @param left 左平面距離
 * @param right 右平面距離
 * @param bottom 下平面距離
 * @param top 上平面距離
 * @param near 近平面距離
 * @param far 遠平面距離
 */
public static void orthoM(float[] m, int mOffset,
    float left, float right, float bottom, float top,
    float near, float far)

除了設置近平面和遠平面的距離外,還要設置近平面的上下左右距離,這四個參數對應着近平面四邊形的四條邊與原點的垂直距離。也是矯正視頻畫面的關鍵所在。

  • 矩陣變換

在圖像處理的世界中,圖像變換使用最多的莫過與矩陣變換,這裏需要一點點線性代數的知識。

首先來看一個簡單的矩陣乘法:

矩陣乘法

矩陣乘法和普通的數字乘法是不一樣的,用第一個矩陣的行乘以第二個矩陣的列,然後每個的乘積相加,作爲結果的第一行第一列,即:

1x1 + 1x0 + 1x0 = 1

其他以此類推。

單位矩陣
可以發現,無論什麼樣的矩陣乘以右邊的矩陣,結果都和第一個是一樣的。就像無論什麼數字乘以1,結果都不會變化一樣。所以右邊的這個矩陣稱爲單位矩陣

再來看一個矩陣乘法:

矩陣縮放

把右邊矩陣前兩個1縮小了一半,相乘的結果正好是原來的矩陣縮小了一半。

設想一下,如果把左邊矩陣的三個數看成是座標點的xyz呢?到這裏你應該可以猜測到,如何矯正畫面比例了。

矩陣縮放

既然視頻畫面是被拉伸了,那麼最直接的方法就是通過縮放,把畫面被拉伸的方向縮小回來,而矩陣乘法正好可以滿足縮放的需求。

看回正交投影的方法:

public static void orthoM(float[] m, int mOffset,
    float left, float right, float bottom, float top,
    float near, float far)

下面是該方法生成的矩陣:

正交投影矩陣

可以看到其實就是生成了一個縮放矩陣,由於z和w都是0,所以可以忽略後面兩列。重點來看看前面兩個: 2/(right - left)和2/(top - bottom),這兩個是分別與xy相乘的,起到縮放的作用。

  • 舉個栗子

假設,視頻的寬高爲1000x500,而GLSurfaceView的寬高爲1080x1920

這是一個橫向的視頻,如果用寬度做適應的話,500放大到1920,那麼爲了保持比例,寬就要放到到1000 x(1920/500)= 3840,超出了1080的寬度。所以,只能縮放高度,來保持視頻最終顯示的寬高不會超過GLSurfaceView的寬高。

正確縮放後的水平寬高爲:1080x540(500x1080/1000)

縮放了多少倍呢?是540/500 = 1.08嗎?錯!!!

如果不進行縮放處理的情況下,畫面被拉伸鋪滿,畫面的高度應該是1920,所以正確的縮放倍數應該是1920/540=3.555556(不能除盡)

接下來看看如何設置left、right、top、bottom。

通過上面的分析已經知道,視頻畫面的寬直接拉伸到窗口最大也就是默認爲left = -1;right = 1(tip:還記得OpenGL 世界座標原點在畫面中心嗎?)

這時

right - left = 2,那麼縮放矩陣第一個參數爲:
2/(right - left)= 1

也就是沒有縮放。

要讓高縮小3.555556倍,則

2 /(top - bottom)= 2 /(2x3.555556)= 1/3.555556

由於top和bottom互爲反數,所以top = - bottom = 3.555556

所以,正交投影的參數爲:

private var mPrjMatrix = FloatArray(16)
    
Matrix.orthoM(mPrjMatrix, 0, -1, 1, 3.555556, -3.555556, 1, 2)

//public static void orthoM(float[] m, int mOffset,
//    float left, float right, float bottom, float top,
//    float near, float far)

知道了計算的原理以後,我們再推導一下縮放倍數,如何通過GLSurfaceView和畫面原始寬高比例計算得出。

還是用上面的例子,縮放倍數1920/540=3.555556,相當於

1920/500*1080/1000-->

GL_Height /(Video_Height*(GL_Width/Video_Width)-->

(GL_Height/GL_Width) * (Video_Width/Video_Height) -->

(Video_Width/Video_Height) / (GL_Width/GL_Height) -->

Video_Ritio/GL_Ritio

可見,我們並不需要計算出實際的值是多少,只需根據視口和視頻畫面原始寬高就可以在代碼中自動推斷出縮放的比例。

當然,需要對具體情況做判斷,有四種情況:

1. 視口寬 > 高,並且視頻的寬高比 > 視口的寬高比:縮放高度(Video_Ritio/GL_Ritio)

2. 視口寬 > 高,並且視頻的寬高比 < 視口的寬高比:縮放寬度(GL_Ritio/Video_Ritio)

3. 視口寬 < 高,並且視頻的寬高比 > 視口的寬高比:縮放高度(Video_Ritio/GL_Ritio)

4. 視口寬 < 高,並且視頻的寬高比 < 視口的寬高比:縮放寬度(GL_Ritio/Video_Ritio)

以上例子屬於第3種情況。

剩餘的不再推導,有興趣可以自己推一下,加深理解。

接下來就看看在代碼中如何實現。

矯正畫面比例

IDrawer新增兩個接口,分別用於設置視頻的原始寬高,以及設置OpenGL窗口寬高

interface IDrawer {

    //設置視頻的原始寬高
    fun setVideoSize(videoW: Int, videoH: Int)
    //設置OpenGL窗口寬高
    fun setWorldSize(worldW: Int, worldH: Int)
    
    fun draw()
    fun setTextureID(id: Int)
    fun getSurfaceTexture(cb: (st: SurfaceTexture)->Unit) {}
    fun release()
}

VideoDrawer實現矯正流程如下:

class VideoDrawer: IDrawer {

    //......

    private var mWorldWidth: Int = -1
    private var mWorldHeight: Int = -1
    private var mVideoWidth: Int = -1
    private var mVideoHeight: Int = -1
    
    //座標變換矩陣
    private var mMatrix: FloatArray? = null
    
    //矩陣變換接收者
    private var mVertexMatrixHandler: Int = -1
    
    override fun setVideoSize(videoW: Int, videoH: Int) {
        mVideoWidth = videoW
        mVideoHeight = videoH
    }

    override fun setWorldSize(worldW: Int, worldH: Int) {
        mWorldWidth = worldW
        mWorldHeight = worldH
    }

    override fun draw() {
        if (mTextureId != -1) {
            //【新增1: 初始化矩陣方法】
            initDefMatrix()
            //【步驟2: 創建、編譯並啓動OpenGL着色器】
            createGLPrg()
            //【步驟3: 激活並綁定紋理單元】
            activateTexture()
            //【步驟4: 綁定圖片到紋理單元】
            updateTexture()
            //【步驟5: 開始渲染繪製】
            doDraw()
        }
    }
    
    private fun initDefMatrix() {
        if (mMatrix != null) return
        if (mVideoWidth != -1 && mVideoHeight != -1 &&
            mWorldWidth != -1 && mWorldHeight != -1) {
            mMatrix = FloatArray(16)
            var prjMatrix = FloatArray(16)
            val originRatio = mVideoWidth / mVideoHeight.toFloat()
            val worldRatio = mWorldWidth / mWorldHeight.toFloat()
            if (mWorldWidth > mWorldHeight) {
                if (originRatio > worldRatio) {
                    val actualRatio = originRatio / worldRatio
                    Matrix.orthoM(
                        prjMatrix, 0,
                        -1f, 1f, 
                        -actualRatio, actualRatio, 
                        -1f, 3f
                    )
                } else {// 原始比例小於窗口比例,縮放高度度會導致高度超出,因此,高度以窗口爲準,縮放寬度
                    val actualRatio = worldRatio / originRatio
                    Matrix.orthoM(
                        prjMatrix, 0,
                        -actualRatio, actualRatio,
                        -1f, 1f,
                        -1f, 3f
                    )
                }
            } else {
                if (originRatio > worldRatio) {
                    val actualRatio = originRatio / worldRatio
                    Matrix.orthoM(
                        prjMatrix, 0,
                        -1f, 1f,
                        -actualRatio, actualRatio,
                        -1f, 3f
                    )
                } else {// 原始比例小於窗口比例,縮放高度會導致高度超出,因此,高度以窗口爲準,縮放寬度
                    val actualRatio = worldRatio / originRatio
                    Matrix.orthoM(
                        prjMatrix, 0,
                        -actualRatio, actualRatio,
                        -1f, 1f,
                        -1f, 3f
                    )
                }
            }
        }
    }
    
    private fun createGLPrg() {
        if (mProgram == -1) {
            //省略加載shader代碼
            //......
            
            //【新增2: 獲取頂點着色器中的矩陣變量】
            mVertexMatrixHandler = GLES20.glGetUniformLocation(mProgram, "uMatrix")
            
            mVertexPosHandler = GLES20.glGetAttribLocation(mProgram, "aPosition")
            mTextureHandler = GLES20.glGetUniformLocation(mProgram, "uTexture")
            mTexturePosHandler = GLES20.glGetAttribLocation(mProgram, "aCoordinate")
        }
        //使用OpenGL程序
        GLES20.glUseProgram(mProgram)
    }
    
    private fun doDraw() {
        //啓用頂點的句柄
        GLES20.glEnableVertexAttribArray(mVertexPosHandler)
        GLES20.glEnableVertexAttribArray(mTexturePosHandler)
        
        // 【新增3: 將變換矩陣傳遞給頂點着色器】
        GLES20.glUniformMatrix4fv(mVertexMatrixHandler, 1, false, mMatrix, 0)
        
        //設置着色器參數, 第二個參數表示一個頂點包含的數據數量,這裏爲xy,所以爲2
        GLES20.glVertexAttribPointer(mVertexPosHandler, 2, GLES20.GL_FLOAT, false, 0, mVertexBuffer)
        GLES20.glVertexAttribPointer(mTexturePosHandler, 2, GLES20.GL_FLOAT, false, 0, mTextureBuffer)
        //開始繪製
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    }
    
    private fun getVertexShader(): String {
        return "attribute vec4 aPosition;" +
                //【新增4: 矩陣變量】
                "uniform mat4 uMatrix;" +
                "attribute vec2 aCoordinate;" +
                "varying vec2 vCoordinate;" +
                "void main() {" +
                //【新增5: 座標變換】
                "    gl_Position = aPosition*uMatrix;" +
                "    vCoordinate = aCoordinate;" +
                "}"
    }
    //......
}

新增的內容查看以上代碼:【新增x:】

可以看到,頂點着色器發生了變化,新增了一個矩陣變量,以及最後顯示的座標乘上了這個矩陣。

uniform mat4 uMatrix;
gl_Position = aPosition*uMatrix;

在代碼中也通過OpenGL的方法獲取了着色器中的矩陣變量,並計算好縮放矩陣,傳遞給頂點着色器。

通過兩個矩陣aPosition和uMatrix相乘,得到了畫面像素點正確的顯示位置。

至於縮放的原理,都已經在上面講清楚了,不在細說,只說關於近平面和遠平面的設置。

我們的頂點座標設置的z座標爲0,而相機的默認位置也在0的位置,爲了使頂點座標能夠被包含在裁剪空間中,near必須<=0,far必須>=0,並且不能同時等於0,即 near != far 。

注:near和far都是相對與相機座標點而言的,比如near = -1,實際近平面的z座標爲1,far = 1,遠平面z座標爲-1。z軸垂直與手機屏幕向外。

看看外部如何調用:

class SimpleRender(private val mDrawer: IDrawer): GLSurfaceView.Renderer {

    //......
    
    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
        //設置OpenGL窗口座標
        mDrawer.setWorldSize(width, height)
    }
    
    //......
    
}

class OpenGLPlayerActivity: AppCompatActivity() {

    //......
    
    private fun initRender() {
        drawer = VideoDrawer()
        //設置視頻寬高
        drawer.setVideoSize(1920, 1080)
        drawer.getSurfaceTexture {
            initPlayer(Surface(it))
        }
        gl_surface.setEGLContextClientVersion(2)
        gl_surface.setRenderer(SimpleRender(drawer))
    }
    //......
    

至此,一個漂漂亮亮的畫面終於可以正常的顯示出來了。

畫面正常.jpg

改變相機位置

上文提到過,OpenGL可以設置相機的位置和朝向,但是實際上,在上面的代碼並沒有設置,因爲相機默認在原點的位置。下面,就來看看另外一種設置遠近平面的方法。

/**
 * Defines a viewing transformation in terms of an eye point, a center of
 * view, and an up vector.
 *
 * @param rm returns the result
 * @param rmOffset index into rm where the result matrix starts
 * @param eyeX eye point X
 * @param eyeY eye point Y
 * @param eyeZ eye point Z
 * @param centerX center of view X
 * @param centerY center of view Y
 * @param centerZ center of view Z
 * @param upX up vector X
 * @param upY up vector Y
 * @param upZ up vector Z
 */
public static void setLookAtM(float[] rm, int rmOffset,
            float eyeX, float eyeY, float eyeZ,
            float centerX, float centerY, float centerZ, 
            float upX, float upY, float upZ)

(eyeX, eyeY, eyeZ)決定了相機的位置,(upX, upY, upZ)決定了相機的方向,(centerX, centerY, centerZ)是畫面的原點,一般爲(0,0,0)。

//設置相機位置
val viewMatrix = FloatArray(16)
Matrix.setLookAtM(viewMatrix, 0,
                  0f, 0f, 5.0f,
                  0f, 0f, 0f,
                  0f, 1.0f, 0f)

以上設置了相機的位置位於z軸距離原點5的地方。相機向上方向爲Y軸,面向xy平面。

這樣,如果頂點座標的z軸仍然爲0,那麼要使畫面被包含在裁剪空間中,就必須重新設置近平面和遠平面的位置。

比如:

Matrix.orthoM(mPrjMatrix, 0, -1, 1, 3.555556, -3.555556, 1, 6)

near = 1,far = 6 是如何得出來的?

相機位於z軸5的地方。那麼爲了包含 z=0 的點,那麼近平面距離相機點不能 > 5,遠平面距離相機點不能 < 5。同樣的,near != far。

三、視頻濾鏡

在很多視頻應用中都會看到濾鏡,可以改變視頻的風格。那麼這些濾鏡是怎麼實現的呢?

其實原理非常簡單,無非就是改變畫面圖片的顏色。

下面就實現一個非常簡單濾鏡:黑白畫面

只需改變片元着色器即可:

private fun getFragmentShader(): String {
    //一定要加換行"\n",否則會和下一行的precision混在一起,導致編譯出錯
    return "#extension GL_OES_EGL_image_external : require\n" +
            "precision mediump float;" +
            "varying vec2 vCoordinate;" +
            "uniform samplerExternalOES uTexture;" +
            "void main() {" +
            "  vec4 color = texture2D(uTexture, vCoordinate);" +
            "  float gray = (color.r + color.g + color.b)/3.0;" +
            "  gl_FragColor = vec4(gray, gray, gray, 1.0);" +
            "}"
}

關鍵代碼:

vec4 color = texture2D(uTexture, vCoordinate);
float gray = (color.r + color.g + color.b)/3.0;
gl_FragColor = vec4(gray, gray, gray, 1.0);

把rgb做了一個簡單的均值,然後賦值給rgb都賦值爲這個均值,就可以得到一個黑白的顏色。然後賦值給片元,一個簡單的黑白濾鏡就完成了。so easy~

當然了,很多濾鏡就沒那麼簡單了,可以看看其他人實現的濾鏡,比如【OpenGL ES入門:濾鏡篇 - 縮放、靈魂出竅、抖動】等。

四、參考文章

OpenGL 學習系列—座標系統

OpenGL 學習系列—投影矩陣

OpenGL學習腳印: 投影矩陣和視口變換矩陣

發佈了11 篇原創文章 · 獲贊 0 · 訪問量 2098
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章