【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】五、OpenGL FBO数据缓冲区

【声 明】

首先,这一系列文章均基于自己的理解和实践,可能有不对的地方,欢迎大家指正。
其次,这是一个入门系列,涉及的知识也仅限于够用,深入的知识网上也有许许多多的博文供大家学习了。
最后,写文章过程中,会借鉴参考其他人分享的文章,会在文章最后列出,感谢这些作者的分享。

码字不易,转载请注明出处!

教程代码:【Github传送门

目录

一、Android音视频硬解码篇:

二、使用OpenGL渲染视频画面篇

三、Android FFmpeg音视频解码篇


本文你可以了解到

本文将介绍如何使用FBO,FBO可以实现什么效果,以及如何在着色器中使用多个纹理单元。

先来看看利用FBO实现的灵魂出窍效果:

灵魂出窍

一、FBO与EGL的离屏渲染的区别

上一篇文章,讲解了如何使用EGL,并且提到EGL可以建立一个离屏渲染的缓冲区,这种离屏渲染的方式通常用于模拟整个渲染窗口,比如可以用于FFmpeg软编码,将显示在虚拟窗口中的画面编码成H264。

与此同时,OpenGL也提供另外一种离屏渲染方式,即FBO。FBO不仅可以实现离屏渲染整个OpenGL窗口,也可以用于处理碎片画面,即窗口中的小画面。

关于EGL的离屏渲染,将会在后面关于FFmpeg的文章中使用到,这里暂且不论。

而在视频编辑当中,FBO离屏渲染扮演着很重要的角色,许多的视频滤镜都会用到,接下来就来看看FBO如何使用吧。

二、FBO简介

OpenGL 在渲染到系统窗口之前,都会将数据送到 FBO 上,也就是说,FBO 其实一直在默默的为我们服务。
所以,OpenGL 在一开始就创建了一个默认的 FBO。

FBO:Frame Buffer Object,帧缓存对象。

从名字上看,往往很容易让人误解这是一个缓存空间,但实际上,FBO很重要的在最后面的Object上。这是一个缓存对象,包含了多个缓冲索引,分别为颜色缓冲(Color buffers), 深度缓冲(Depth buffer), 模板缓冲(Stencil buffer)

之所以说是缓冲索引,是因为FBO并不包含这些缓冲数据,仅仅保存了缓冲数据的索引地址。

FBO和这些缓冲区则通过附着点进行连接。

可以看到FBO中包含了:

1. 多个颜色附着点(GL_COLOR_ATTACHMENT0、GL_COLOR_ATTACHMENT1...)
2. 一个深度附着点(GL_DEPTH_ATTACHMENT)
3. 一个模板附着点(GL_STENCIL_ATTACHMENT)

可以划分为两类:

纹理附着(颜色附着):主要用于将颜色渲染到纹理中。

渲染缓冲对象RBO(Render Buffer Objecgt):主要用于渲染深度信息和模板信息。

在2D中,通常只用到了颜色附着,另外两种附着通常在3D渲染中使用。

上面说了,FBO可用于离屏渲染,下面就来看看如何通过FBO将画面渲染到一个“后台”的纹理中。

这里的后台,指不用于显示到窗口的纹理。

三、如何使用FBO

1. 新建纹理

fun createFBOTexture(width: Int, height: Int): IntArray {
    // 新建纹理ID
    val textures = IntArray(1)
    GLES20.glGenTextures(1, textures, 0)
    
    // 绑定纹理ID
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0])
    
    // 根据颜色参数,宽高等信息,为上面的纹理ID,生成一个2D纹理
    GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height,
        0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null)
        
    // 设置纹理边缘参数
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER,GLES20.GL_LINEAR.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S,GLES20.GL_CLAMP_TO_EDGE.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T,GLES20.GL_CLAMP_TO_EDGE.toFloat())
    
    // 解绑纹理ID
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0)
    return textures
}

生成一个用于FBO的纹理和普通的纹理其实差不多。

首先,生成一个纹理ID,并绑定到OpenGL中。

其次,给这个纹理ID生成对应的纹理。

这里使用的是 GLES20.glTexImage2D ,在渲染图片纹理的时候,使用的是 GLUtils.texImage2D

关于创建纹理的宽高问题,这里说明一下:
FBO创建的是一个虚拟的窗口,所以,大小是可以根据自己的需求设置的,可以比实际系统窗口大。为了视频画面比例正常,可以把OpenGL的窗口宽高,以及纹理的宽高都设置为视频的宽高。因此,OpenGL在渲染的时候,我们也把无需再通过矩阵变换来矫正比例,直接拉伸就可以。

最后,设置纹理边缘参数,然后解绑。

2. 新建FrameBuffer

fun createFrameBuffer(): Int {
    val fbs = IntArray(1)
    GLES20.glGenFramebuffers(1, fbs, 0)
    return fbs[0]
}

新建FrameBuffer类似新建纹理ID,最后返回FBO索引

3. 绑定FBO

fun bindFBO(fb: Int, textureId: Int) {
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fb)
    GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
        GLES20.GL_TEXTURE_2D, textureId, 0)
}

先绑定上面创建的FBO,接着将FBO和上面创建的纹理通过颜色附着点 GLES20.GL_COLOR_ATTACHMENT0 绑定起来。

4. 解绑FBO

fun unbindFBO() {
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_NONE)
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
}

解绑FBO比较简单,其实就是将FBO绑定到默认的窗口上。

这里的 GLES20.GL_NONE 其实就是 0 ,也就是系统默认的窗口的 FBO 。

5. 删除FBO

fun deleteFBO(frame: IntArray, texture:IntArray) {
    //删除Frame Buffer
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_NONE)
    GLES20.glDeleteFramebuffers(1, frame, 0)
    //删除纹理
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
    GLES20.glDeleteTextures(1, texture, 0)
}

以上,其实就是使用FBO的流程了:

  1. 新建纹理
  2. 新建FBO
  3. 绑定将纹理附着到FBO的颜色附着点上
  4. 【渲染】
  5. 解绑FBO
  6. 删除FBO

除了第4步以外,其他都是上面的封装好的方法。

那么接下就来看看,如何将画面渲染到FBO连接的纹理上。

为了更好的理解整个渲染的过程,下面通过一个非常经典的滤镜来演示这个渲染的流程。

三、使用FBO实现“灵魂出窍”滤镜

1. 如何实现灵魂出窍

  • 静态图灵魂出窍
灵魂出窍

这个效果可以拆分为3个效果:

  1. 底层静态图
  2. 上层放大
  3. 上层半透明

进而拆分为2个组合:

  1. 底层静态图
  2. 上层不断放大,并且随着放大增加透明度
  • 视频灵魂出窍

根据静态图的灵魂出窍效果,可以知道,上层的灵魂出窍效果是根据原图而来的,就是说,灵魂的基础图片是不会变化的。

而视频的每一帧都是在变化的。

所以,为了使上层的“灵魂”达到比较平滑的放大效果,需要把一帧保持住一段时间,让这一帧完成完整的放大过程。

这里就遇到了一个问题:如何保存视频的某一帧?

FBO 就是解决这个问题的关键。

2. 封装FBO工具

为了可以方便的使用FBO相关的方法,我们将上面的方法都封装在一个静态工具中 OpenGLTools

object OpenGLTools {
    fun createFBOTexture(width: Int, height: Int): IntArray {
        // 新建纹理ID
        val textures = IntArray(1)
        GLES20.glGenTextures(1, textures, 0)
        
        // 绑定纹理ID
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0])
        
        // 根据颜色参数,宽高等信息,为上面的纹理ID,生成一个2D纹理
        GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height,
            0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null)
            
        // 设置纹理边缘参数
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER,GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S,GLES20.GL_CLAMP_TO_EDGE.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T,GLES20.GL_CLAMP_TO_EDGE.toFloat())
        
        // 解绑纹理ID
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0)
        return textures
    }
    
    fun createFrameBuffer(): Int {
        val fbs = IntArray(1)
        GLES20.glGenFramebuffers(1, fbs, 0)
        return fbs[0]
    }
    
    fun bindFBO(fb: Int, textureId: Int) {
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fb)
        GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
            GLES20.GL_TEXTURE_2D, textureId, 0)
    }
    
    fun unbindFBO() {
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_NONE)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
    }
    
    fun deleteFBO(frame: IntArray, texture:IntArray) {
        //删除Frame Buffer
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_NONE)
        GLES20.glDeleteFramebuffers(1, frame, 0)
        //删除纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
        GLES20.glDeleteTextures(1, texture, 0)
    }
}

3. 在视频渲染器中,接入FBO

  • 新建渲染器 SoulVideoDrawer

这里将之前的VideoDrawer直接复制过来,如果大家阅读过之前的文章,相信对VideoDrawer应该不会陌生了。所以这里就不再贴完整代码了。详情请查看之前的文章,或者直接看源码:VideoDrawer

其实 SoulVideoDrawer 大部分代码和 VideoDrawer 一致,这里查看完整源码:SoulVideoDrawer

这次,不再像之前那样一次性贴出完整的代码,一步步来看下如何使用 FBO 。

class SoulVideoDrawer : IDrawer {

    // ......
    
    // 省略和VideoDrawer一样成员变量
    
    // ......

//-------------灵魂出窍相关的变量--------------

    /**上下颠倒的顶点矩阵*/
    private val mReserveVertexCoors = floatArrayOf(
        -1f, 1f,
        1f, 1f,
        -1f, -1f,
        1f, -1f
    )

    private val mDefVertexCoors = floatArrayOf(
        -1f, -1f,
        1f, -1f,
        -1f, 1f,
        1f, 1f
    )

    // 顶点坐标
    private var mVertexCoors = mDefVertexCoors
    
    // 灵魂帧缓冲
    private var mSoulFrameBuffer: Int = -1

    // 灵魂纹理ID
    private var mSoulTextureId: Int = -1

    // 灵魂纹理接收者
    private var mSoulTextureHandler: Int = -1

    // 灵魂缩放进度接收者
    private var mProgressHandler: Int = -1

    // 是否更新FBO纹理
    private var mDrawFbo: Int = 1

    // 更新FBO标记接收者
    private var mDrawFobHandler: Int = -1

    // 一帧灵魂的时间
    private var mModifyTime: Long = -1
    
    override fun draw() {
        if (mTextureId != -1) {
            initDefMatrix()
            //【步骤1: 创建、编译并启动OpenGL着色器】
            createGLPrg()
            
            // -------【步骤2:新增FBO部分】-----
            //【步骤2.1: 更新灵魂纹理】
            updateFBO()
            //【步骤2.2: 激活灵魂纹理单元】
            activateSoulTexture()
            // ---------------------------
            
            //【步骤3: 激活并绑定纹理单元】
            activateDefTexture()
            //【步骤4: 绑定图片到纹理单元】
            updateTexture()
            //【步骤5: 开始渲染绘制】
            doDraw()
        }
    }
    
    // ......
}

增加了和FBO、实现灵魂出窍效果相关的成员变量。

重点关注 draw 方法,有5个步骤,但真正增加的其实就是第2个步骤:

步骤2: 新增FBO部分
- 2.1: 更新灵魂纹理【updateFBO】
- 2.2: 激活灵魂纹理单元【activateSoulTexture】

先来看2.1。

  • 更新附着在FBO上的纹理
class SoulVideoDrawer : IDrawer {

    // ......
    
    private fun updateFBO() {
        //【1,创建FBO纹理】
        if (mSoulTextureId == -1) {
            mSoulTextureId = OpenGLTools.createFBOTexture(mVideoWidth, mVideoHeight)
        }
        // 【2,创建FBO】
        if (mSoulFrameBuffer == -1) {
            mSoulFrameBuffer = OpenGLTools.createFrameBuffer()
        }
        // 【3,渲染到FBO】
        if (System.currentTimeMillis() - mModifyTime > 500) {
            mModifyTime = System.currentTimeMillis()
            // 绑定FBO
            OpenGLTools.bindFBO(mSoulFrameBuffer, mSoulTextureId)
            // 配置FBO窗口
            configFboViewport()
            
//--------执行正常画面渲染,画面将渲染到FBO上--------------

            // 激活默认的纹理
            activateDefTexture()
            // 更新纹理
            updateTexture()
            // 绘制到FBO
            doDraw()
            
//---------------------------------------------------

            // 解绑FBO
            OpenGLTools.unbindFBO()
            // 恢复默认绘制窗口
            configDefViewport()
        }
    }

    /**
     * 配置FBO窗口
     */
    private fun configFboViewport() {
        mDrawFbo = 1
        // 将变换矩阵回复为单位矩阵(将画面拉升到整个窗口大小,设置窗口比例和FBO纹理比例一致,画面刚好可以正常绘制到FBO纹理上)
        Matrix.setIdentityM(mMatrix, 0)
        // 设置颠倒的顶点坐标
        mVertexCoors = mReserveVertexCoors
        //重新初始化顶点坐标
        initPos()
        GLES20.glViewport(0, 0, mVideoWidth, mVideoHeight)
        //设置一个颜色状态
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f)
        //使能颜色状态的值来清屏
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
    }

    /**
     * 配置默认显示的窗口
     */
    private fun configDefViewport() {
        mDrawFbo = 0
        mMatrix = null
        // 恢复顶点坐标
        mVertexCoors = mDefVertexCoors
        initPos()
        initDefMatrix()
        // 恢复窗口
        GLES20.glViewport(0, 0, mWorldWidth, mWorldHeight)
    }

    private fun activateDefTexture() {
        activateTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureId, 0, mTextureHandler)
    }

    private fun activateSoulTexture() {
        activateTexture(GLES11.GL_TEXTURE_2D, mSoulTextureId, 1, mSoulTextureHandler)
    }

    private fun activateTexture(type: Int, textureId: Int, index: Int, textureHandler: Int) {
        //激活指定纹理单元
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index)
        //绑定纹理ID到纹理单元
        GLES20.glBindTexture(type, textureId)
        //将激活的纹理单元传递到着色器里面
        GLES20.glUniform1i(textureHandler, index)
        //配置边缘过渡参数
        GLES20.glTexParameterf(type, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameterf(type, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameteri(type, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(type, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
    }
    
    // ......
}

updateFBO 方法,3个步骤:

  1. 创建纹理
  2. 创建FBO
  3. 将图像渲染到FBO的纹理上

前面2个步骤,在之前已经介绍过,不再赘述。

重点看第3步。

这里让一帧图像保持500ms,我们用一个变量 mModifyTime 来记录当前这一帧渲染时候的时间,只要过了500ms,就刷新一次画面。

来看渲染FBO的过程:

if (System.currentTimeMillis() - mModifyTime > 500) {
    // 记录时间
    mModifyTime = System.currentTimeMillis()
    // 绑定FBO
    OpenGLTools.bindFBO(mSoulFrameBuffer, mSoulTextureId)
    // 配置FBO窗口
    configFboViewport()
//--------执行正常画面渲染,画面将渲染到FBO上--------------
    // 激活默认的纹理
    activateDefTexture()
    // 更新纹理
    updateTexture()
    // 绘制到FBO
    doDraw()
//---------------------------------------------------
    // 解绑FBO
    OpenGLTools.unbindFBO()
    // 恢复默认绘制窗口
    configDefViewport()
}

i. 绑定FBO

当调用了 OpenGLTools.bindFBO 之后,所有对于OpenGL的操作都将影响到我们自己创建的FBO。也就是说,在调用 OpenGLTools.unbindFBO() 解绑FBO之前,下面所有的操作,都将作用在FBO上。

ii. 重新配置FBO窗口大小

将OpenGL窗口设置为视频大小,并且将矩阵变化重置(画面拉升到窗口大小),然后清屏。

至于为什么要重新设置窗口大小,前面设置纹理大小的时候已经说过了。

还有一点要注意的是,这里将纹理坐标 mVertexCoors 做了上下颠倒(其实就是恢复为OpenGL默认的坐标),这样渲染到FBO绑定的纹理上后,在片元着色器里面才能正常取色。

代码如下:

private fun configFboViewport() {
    mDrawFbo = 1
    // 将变换矩阵恢复为单位矩阵
    //(将画面拉升到整个窗口大小,
    // 设置窗口宽高和FBO纹理宽高一致,
    // 画面刚好可以正常绘制到FBO绑定的纹理上)
    Matrix.setIdentityM(mMatrix, 0)
    // 设置颠倒的顶点坐标
    mVertexCoors = mReserveVertexCoors
    //重新初始化顶点坐标
    initPos()
    // 设置窗口大小
    GLES20.glViewport(0, 0, mVideoWidth, mVideoHeight)
    //设置一个颜色状态
    GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f)
    //使能颜色状态的值来清屏
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
}

iii. 激活和更新视频原来的纹理

注意,这里是激活原来的渲染视频的纹理

iv. 渲染绘制

也就是说,在绑定了FBO以后,按照正常的渲染流程,就可以将画面渲染到FBO上了。

v. 解除FBO绑定,将窗口大小、纹理坐标、矩阵都恢复回原来的配置。

将渲染重新切换到原来的系统窗口上,画面将重新显示到系统窗口上。

通过以上步骤,就将画面渲染到FBO绑定的纹理 mSoulTextureId 上面了。

4. 实现灵魂出窍效果

前面,我们将一帧画面渲染到了 mSoulTextureId 这个纹理上, 接下来就要利用这个纹理,将画面放大、透明渐变实现灵魂效果。

回到draw方法中,来到2.2步骤。

override fun draw() {
    if (mTextureId != -1) {
        //【步骤1: 创建、编译并启动OpenGL着色器】
        // -------【步骤2:新增FBO部分】-----
        //【步骤2.1: 更新灵魂纹理】
        //【步骤2.2: 激活灵魂纹理单元】
        activateSoulTexture()
        // ---------------------------
        
        //【步骤3: 激活并绑定纹理单元】
        activateDefTexture()
        //【步骤4: 绑定图片到纹理单元】
        updateTexture()
        //【步骤5: 开始渲染绘制】
        doDraw()
    }
}

看下激活如何激活“灵魂”的纹理。

  • 传递多个纹理到着色器中
private fun activateSoulTexture() {
    activateTexture(GLES11.GL_TEXTURE_2D, mSoulTextureId, 1, mSoulTextureHandler)
}

private fun activateTexture(type: Int, textureId: Int, index: Int, textureHandler: Int) {
    //激活指定纹理单元
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index)
    //绑定纹理ID到纹理单元
    GLES20.glBindTexture(type, textureId)
    //将激活的纹理单元传递到着色器里面
    GLES20.glUniform1i(textureHandler, index)
    //配置边缘过渡参数
    GLES20.glTexParameterf(type, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
    GLES20.glTexParameterf(type, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
    GLES20.glTexParameteri(type, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
    GLES20.glTexParameteri(type, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
}

和之前文章稍微有点不同,以前参数都是直接写死的。这次改造了一下 activateTexture纹理类型纹理ID纹理单元索引 ,以及着色器对应的 纹理接收器 ,作为参数传递进来。

有2点要注意的:

  1. 关于纹理类型。在 activateSoulTexture 中,需要注意的是,纹理的类型为普通纹理类型 GLES11.GL_TEXTURE_2D , 而非扩展纹理 GLES11Ext.GL_TEXTURE_EXTERNAL_OES ,因为经过之前的渲染以后,画面已经是普通纹理了。
  2. 关于纹理单元。在OpenGL基础知识中说过,OpenGL内置了多个纹理单元,并且可以同时使用。 所以这里, 正常画面的纹理单元设置为默认的 GLES20.GL_TEXTURE0, “灵魂”的纹理单元为 GLES20.GL_TEXTURE1 = GLES20.GL_TEXTURE0 + 1

接着,激活默认的正常画面纹理 updateTexture() ,这样就可以在片元着色器中,同时接收这两个纹理单元。

private fun activateDefTexture() {
    activateTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mTextureId, 0, mTextureHandler)
}
  • 渲染绘制

最后,启动渲染绘制,进入到着色器中。

  • “灵魂出窍”着色器

前面做了这么多的铺垫,其实都是为了将一帧固定的视频画面传递到着色器中。真正实现“灵魂出窍”的效果,也是在片元着色器中。

着色器代码如下:

private fun getVertexShader(): String {
    return "attribute vec4 aPosition;" +
            "precision mediump float;" +
            "uniform mat4 uMatrix;" +
            "attribute vec2 aCoordinate;" +
            "varying vec2 vCoordinate;" +
            "attribute float alpha;" +
            "varying float inAlpha;" +
            "void main() {" +
            "    gl_Position = uMatrix*aPosition;" +
            "    vCoordinate = aCoordinate;" +
            "    inAlpha = alpha;" +
            "}"
}

private fun getFragmentShader(): String {
    //一定要加换行"\n",否则会和下一行的precision混在一起,导致编译出错
    return "#extension GL_OES_EGL_image_external : require\n" +
            "precision mediump float;" +
            "varying vec2 vCoordinate;" +
            "varying float inAlpha;" +
            "uniform samplerExternalOES uTexture;" +
            "uniform float progress;" +
            "uniform int drawFbo;" +
            "uniform sampler2D uSoulTexture;" +
            "void main() {" +
                // 透明度[0,0.4]
                "float alpha = 0.6 * (1.0 - progress);" +
                // 缩放比例[1.0,1.5]
                "float scale = 1.0 + (1.5 - 1.0) * progress;" +

                // 放大纹理坐标
                "float soulX = 0.5 + (vCoordinate.x - 0.5) / scale;\n" +
                "float soulY = 0.5 + (vCoordinate.y - 0.5) / scale;\n" +
                "vec2 soulTextureCoords = vec2(soulX, soulY);" +
                // 获取对应放大纹理坐标下的像素(颜色值rgba)
                "vec4 soulMask = texture2D(uSoulTexture, soulTextureCoords);" +

                "vec4 color = texture2D(uTexture, vCoordinate);" +

                "if (drawFbo == 0) {" +
                    // 颜色混合 默认颜色混合方程式 = mask * (1.0-alpha) + weakMask * alpha
                "    gl_FragColor = color * (1.0 - alpha) + soulMask * alpha;" +
                "} else {" +
                "   gl_FragColor = vec4(color.r, color.g, color.b, inAlpha);" +
                "}" +
            "}"
}

可以看到,顶点着色器 的代码和普通的渲染是一样的。

修改的都在 片元着色器中

简单分析一下:

i. 除了正常画面渲染需要的参数,另外新增了3个参数:

// 动画进度
uniform float progress;
// 是否绘制到FBO
uniform int drawFbo;
// 一帧固定的纹理
uniform sampler2D uSoulTexture;

ii. 跳过中间关于“灵魂”动画的部分,先看最后一个if/else

if (drawFbo == 0) {
    // 颜色混合 默认颜色混合方程式 = mask * (1.0-alpha) + weakMask * alpha
    gl_FragColor = color * (1.0 - alpha) + soulMask * alpha;" +
} else {
   gl_FragColor = vec4(color.r, color.g, color.b, inAlpha);
}

当一帧的时间超过500ms的时候,会重新获取一帧新的视频画面。

这里通过外部传进来的标记 drawFbo 如果为 1 时,渲染普通的画面,此时由于已经绑定了FBO,所以这一帧画面会渲染到FBO的 mSoulTextureID 上。

在下一次渲染的时候,这一帧纹理将传递给片元着色器的 uSoulTexture

iii. 中间的部分,关于“灵魂出窍”的核心。

// 透明度[0,0.4]
float alpha = 0.6 * (1.0 - progress);
// 缩放比例[1.0,1.5]
float scale = 1.0 + (1.5 - 1.0) * progress;

// 放大纹理坐标
float soulX = 0.5 + (vCoordinate.x - 0.5) / scale;
float soulY = 0.5 + (vCoordinate.y - 0.5) / scale;
vec2 soulTextureCoords = vec2(soulX, soulY);

// 获取对应放大纹理坐标下的像素(颜色值rgba)
vec4 soulMask = texture2D(uSoulTexture, soulTextureCoords);

首先,计算透明度。根据外面计算得到的 progress ,慢慢降低透明度,最大透明度为0.6。

然后,计算缩放后的坐标。随着 progress 的增加,scale 越大。最大放大1.5倍。利用 scale 分别计算 X,Y 的缩放。可以看到,scale 越大,soulX/soulY 反而更小。这是因为要达到放大的效果,当前要渲染的点,应该取更小的坐标对应的颜色(像素)。

最后,通过 soulX soulY ,到“灵魂”纹理 uSoulTexture 取到颜色。

iv. 混合底层正常画面和上层“灵魂”画面,采用常用的混合算法。

gl_FragColor = color * (1.0 - alpha) + soulMask * alpha;

5. 在页面中接入绘制器

class SoulPlayerActivity: AppCompatActivity() {
    val path = Environment.getExternalStorageDirectory().absolutePath + "/mvtest.mp4"
    lateinit var drawer: IDrawer

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

    private fun initRender() {
        // 使用“灵魂出窍”渲染器
        drawer = SoulVideoDrawer()
        drawer.setVideoSize(1920, 1080)
        drawer.getSurfaceTexture {
            initPlayer(Surface(it))
        }
        gl_surface.setEGLContextClientVersion(2)
        val render = SimpleRender()
        render.addDrawer(drawer)
        gl_surface.setRenderer(render)
    }

    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()
    }
}

使用和普通的使用OpenGL渲染器一模一样,不一样的只是把 VideoDrawer 换成 SoulVideoDrawer

最终得到了文章开头的效果:

灵魂出窍

四、总结

以上就是整个使用FBO的过程,使用也非常的简单。当然了,只关注了颜色附着的部分,另外的深度附着和模板附着有兴趣的可以自行探索学习。

可以看到,FBO为我们提供了一个实现视频处理的好方法,许多酷炫的效果得以实现,更多有趣的效果,等着大家去实现。

  • 参考文章

帧缓冲区对象(FBO) 实现渲染到纹理(Render To Texture/RTT)

DEPTH_TEST(深度缓冲测试)

Stencil_TEST(模板缓冲测试)

OpenGL ES入门:滤镜篇 - 缩放、灵魂出窍、抖动等

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容