欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

程序员文章站 2022-07-14 20:31:25
...

【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窗口的大小,也就是全屏铺满,接下来就看看如何矫正视频画面,让画面比例和实际一样。

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

二、画面比例矫正

投影

OpenGL的世界坐标是一个标准化的坐标体系,xyz坐标范围都在(-1~1),默认起始和结束位置分别对应世界坐标的平面的四个角。这时画面是铺满整个屏幕的,所以没有经过坐标变换的画面一般都会有变形的问题。

OpenGL提供两种方式,可以对画面比例进行调整,分别是透视投影正交投影

投影起到什么作用呢?

  1. 投影规定了裁剪空间的范围,也就是物体的可视空间范围
  2. 将裁剪空间内的物体投影到屏幕上

要讲清楚OpenGL的投影并不是一件简单的事,会涉及到OpenGL中关于各类空间的定义,这里简单列一下:

  • 局部空间:相对于物体本省的空间,原点在物体中间
  • 世界空间:OpenGL世界的坐标系
  • 观察空间:观察者(相机)的空间,相当于真实世界中的人的眼睛看到的空间,不同观察位置,看同一物体,也会不一样
  • 裁剪空间:可视空间,在这个空间内部的物体才能显示到屏幕上
  • 屏幕空间:屏幕坐标空间,也就是手机屏幕空间
透视投影

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

上图可以看到,透视投影的原理其实就是人眼看物体的成像原理。从相机向前看,有一个视角空间的,类似人眼的观察角度。人看到的物体是投影在视网膜上,相机看到的则是投影在*面(距离相机比较近的平面)上的成像。

- 相机位置和朝向

首先相机相机并不是固定的,可以根据自己需求移动的,那么就需要设置相机的位置和朝向,这关系到如何观察物体。

要知道的是,相机依然位于世界坐标空间中。所以设置的相机位置,是相对与世界坐标原点来说的。

  • 相机的位置

OpenGL 世界坐标系是一个右手坐标系,正 X 轴在右手边,正 Y 轴朝上,正 Z 轴穿过屏幕朝向你。

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

那么相机坐标可以是(0,0,5),也就是位于Z轴上的一个点。

  • 相机的朝向

设置了相机的位置以后,还需要设置相机的朝向,由三个方向向量upX,upY,uZ,起点为相机的坐标点,决定了相机的朝向。也就是说这三个向量的合成向量,就是相机正上方的方向。

如果将相机类比成人的头部,那么合成向量的方向就是头部正上方的朝向。

比如可以将相机的朝向设置为(0,1,0),这个时候,相机位于(0,0,5),向上方向为Y轴,这时候,相机正好看到XY组成的平面,是画面的正面。

如果相机的朝向设置为(0,-1,0),相当于人的头部往下,这是看到的画面和上面的画面是颠倒的。

  • *面和远平面

看回上面透视投影的图片,在相机的右边有两个平面,靠近相机的为*面,较远的一面为远平面。

  • 裁剪空间

可以看到远平面和*面的四边的连线最后都汇集到相机的位置。这些连线和两个平面包围组成的空间就是上面所说的裁剪空间,即可见空间。

在这个空间中的物体,其表面与相机位置的连线,穿过*面留下的点,组成的图像,就是物体在*面上的投影,也就是在手机屏幕看到的成像

并且,距离相机的位置越远,投影会越小,这和人眼的成像一模一样。

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

正因为透视投影这种和人眼一样的成像原理,所以经常应用在3D渲染中。

但是这种也比较复杂,本人也不是非常熟悉,为了避免错误传导,这里不做具体的应用讲解。大家可以看其他人的文章了解,比如【投影矩阵和视口变换矩阵】、【投影矩阵

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

正交投影

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

和透视投影一样,正交投影也有相机、*面和远平面,不同的是,相机的视线不在是聚焦在一点上,而是平行线。所以*面与远平面中间的可视窗体是一个长方体。

也就是说,正交投影的视觉不再像人眼了,所有在裁剪空间中的物体,无论远近,只要是大小一样,在*面上的投影都是一样的,不再有近大远小的效果。

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

这种效果非常适合用来渲染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)

除了设置*面和远平面的距离外,还要设置*面的上下左右距离,这四个参数对应着*面四边形的四条边与原点的垂直距离。也是矫正视频画面的关键所在。

  • 矩阵变换

在图像处理的世界中,图像变换使用最多的莫过与矩阵变换,这里需要一点点线性代数的知识。

首先来看一个简单的矩阵乘法:

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

矩阵乘法和普通的数字乘法是不一样的,用第一个矩阵的行乘以第二个矩阵的列,然后每个的乘积相加,作为结果的第一行第一列,即:

1x1 + 1x0 + 1x0 = 1

其他以此类推。

单位矩阵
可以发现,无论什么样的矩阵乘以右边的矩阵,结果都和第一个是一样的。就像无论什么数字乘以1,结果都不会变化一样。所以右边的这个矩阵称为单位矩阵

再来看一个矩阵乘法:

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

把右边矩阵前两个1缩小了一半,相乘的结果正好是原来的矩阵缩小了一半。

设想一下,如果把左边矩阵的三个数看成是坐标点的xyz呢?到这里你应该可以猜测到,如何矫正画面比例了。

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

既然视频画面是被拉伸了,那么最直接的方法就是通过缩放,把画面被拉伸的方向缩小回来,而矩阵乘法正好可以满足缩放的需求。

看回正交投影的方法:

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

下面是该方法生成的矩阵:

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

可以看到其实就是生成了一个缩放矩阵,由于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))
    }
    //......
    

至此,一个漂漂亮亮的画面终于可以正常的显示出来了。

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

改变相机位置

上文提到过,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学习脚印: 投影矩阵和视口变换矩阵

【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】二、使用OpenGL渲染视频画面

相关标签: 音视频开发