Android Camera系列(八):MediaCodec视频编码下-OpenGL ES离屏渲染

所有随风而逝的都是属于昨天的,所有历经风雨留下来的才是面向未来的

本系列主要讲述Android开发中Camera的相关操作、预览方式、视频录制等。项目结构简单、代码耦合性低,旨在帮助大家能从中有所收获(方便copy :) ),对于个人来说也是一个总结的好机会。

引言

上一篇中我们采用了共享EGLContext的方式,实现了将SurfaceTexture纹理绘制到不同线程的Surface中。这种方式需要采用多线程,实现起来不够线性,能不能在单一线程中将纹理绘制到多个Surface上呢?

还记得我们Camera2章节吗,Camera2是如何实现一份数据绘制到不同的Surface中的呢?查看源码我们可知,他是通过创建N个EGLSurface,每个EGLSurface绑定不同的Surface,具体要绘制到哪个Surface就通过makeCurrent方法进行切换控制。

离屏渲染

要将渲染的结果输出到不同的目标,我们需要使用一种称为离屏渲染的技术。

什么是离屏渲染呢?顾名思意,就是让OpenGL不将渲染的结果直接输出到屏幕上,而是输出到一个中间缓冲区(一块GPU空间),然后再将中间缓冲区的内容输出到屏幕或编码器等目标上,这就称为离屏渲染。

在Android系统下可以使用三种方法实现同时将OpenGL ES的内容输出给多个目标(屏幕和编码器)。第一种方法是二次渲染法 ;第二种方法是FBO ;第三种是使用BlitFramebuffer

一. 二次渲染

想通过二次渲染实现OpenGL ES将渲染结果送给屏幕和编码器,我们必须自定义EGL环境,创建屏幕预览EGLSurface和编码器EGLSurface。Android Camera系列(四):TextureView+OpenGL ES+Camera不熟悉自定义OpenGL ES环境,请查看该章节。我们在自己创建的OpenGL ES线程中使用EGL API,通过多次渲染将结果输出给多个目标Surface来实现二次渲染,架构图如下:

上图我们看到有SurfaceView,MediaCodec,Camera,OpenGL/EGL等组件。

  • SurfaceView用于展示OpenGL的渲染结果
  • MediaCodec用于编码,关联的Surface用于接收需要编码的数据
  • Camera用于采集视频数据,采集到数据后通知渲染线程,渲染线程通过SurfaceTexture从BufferQueue中取走数据交由OpenGL处理
  • OpenGL/EGL 用于渲染,它收到数据后调用Shader程序进行渲染;将渲染结果输出到SurfaceView中显示到屏幕;然后我们需要切换当前渲染的EGLSurface,通过调用EGL的eglMakeCurrent方法,将默认SurfaceView的EGLSurface切换到MediaCodec的EGLSurface上,然后再次调用Shader程序进行渲染,并将渲染结果输出给MediaCodec的Surface进行编码。

通过上面的流程我们知道,二次渲染就是调用了两次Shader程序进行渲染,每次渲染后的结果输送给不同的Surface,因此称为二次渲染法

渲染核心代码实现如下:

java 复制代码
/**
 * 二次渲染方式
 *
 * @param recordWindowSurface
 * @return
 */
private boolean drawTwice(WindowSurface recordWindowSurface) {
    boolean swapResult;
    // 先绘制到屏幕上
    mPreviewTexture.getTransformMatrix(mDisplayProjectionMatrix);
    mCameraFilter.draw(mTextureId, mDisplayProjectionMatrix);
    swapResult = mWindowSurface.swapBuffers();

    // 再绘制到视频Surface中
    mVideoEncoder.frameAvailable();
    recordWindowSurface.makeCurrent();
    GLES20.glViewport(0, 0,
            mVideoEncoder.getVideoWidth(), mVideoEncoder.getVideoHeight());
    mCameraFilter.draw(mTextureId, mDisplayProjectionMatrix);
    recordWindowSurface.setPresentationTime(mPreviewTexture.getTimestamp());
    recordWindowSurface.swapBuffers();

    // Restore
    GLES20.glViewport(0, 0, mWindowSurface.getWidth(), mWindowSurface.getHeight());
    mWindowSurface.makeCurrent();
    return swapResult;
}

代码中我们有两个WindowSurface,一个是预览的WindowSurface,一个是编码WindowSurface,代码实现和上面的流程完全一样。

WindowSurface 是我们在 Android Camera系列(四)中对EGL进行了封装,CameraFilter 我们在Android OpenGLES2.0开发(八)中定义的Shader程序。

二. FBO

上面的代码流程我们知道,CameraFilter程序会调用两次draw方法,将同一个纹理绘制到不同的Surface上,看起来貌似没有问题。如果CameraFilter中要对图像进行变换,如美颜、高斯模糊等操作,那么我们就要对同一个纹理进行两次计算,这无疑是对GPU的浪费,且效率低下。那么我们如何只计算一次,把结果输出给不同的Surface呢?

OpenGL ES为我们提供了一种高效的办法,即FBO(FrameBufferObject)。接下里我们看看FBO是如何将渲染结果输送给多个目标的

FBO法中我们操作步骤如下:

  1. 将渲染结果绘制到FBO中
  2. 将FBO数据输送到屏幕中
  3. 将FBO数据输送到编码器

FBO法中,我们不直接将OpenGL ES的渲染结果输送给不同的Surface,而是将结果输出到FBO中,FBO可以理解为一块显存区域,用于存放OpenGL ES的渲染结果

我们知道CameraFilter着色器程序是将SurfaceTexture纹理渲染到EGLSurface中的,如何将纹理渲染到FBO帧缓冲区中呢,我们需要一个渲染到FBO中的着色器程序。

渲染结果输出到FBO后,我们可以将FBO结果分别输出给不同的目标,FBO->屏幕,FBO->MediaCodec。而FBO输出到不同的目标也需要一个新的着色器去绘制。

1. 创建FBO着色器

由上面的概念我们知道FBO就是一块缓冲区,那么我们就需要创建出这块缓冲区出来,我们需要对CameraFilter进行改造

java 复制代码
/**
 * 渲染Camera数据,可离屏渲染到FBO中
 */
public class CameraFilter implements AFilter {
    //FBO id
    protected int[] mFrameBuffers;
    //fbo 纹理id
    protected int[] mFrameBufferTextures;
    // 是否使用离屏渲染
    protected boolean isFBO;
    
    public void setFBO(boolean FBO) {
        isFBO = FBO;
    }
    
    /**
     * 创建帧缓冲区(FBO)
     *
     * @param width
     * @param height
     */
    public void createFrameBuffers(int width, int height) {
        if (mFrameBuffers != null) {
            destroyFrameBuffers();
        }
        //fbo的创建 (缓存)
        //1、创建fbo (离屏屏幕)
        mFrameBuffers = new int[1];
        // 1、创建几个fbo 2、保存fbo id的数据 3、从这个数组的第几个开始保存
        GLES20.glGenFramebuffers(mFrameBuffers.length, mFrameBuffers, 0);

        //2、创建属于fbo的纹理
        mFrameBufferTextures = new int[1]; //用来记录纹理id
        //创建纹理
        int textureId = GLESUtils.create2DTexture();
        mFrameBufferTextures[0] = textureId;

        //让fbo与 纹理发生关系
        //创建一个 2d的图像
        // 目标 2d纹理+等级 + 格式 +宽、高+ 格式 + 数据类型(byte) + 像素数据
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mFrameBufferTextures[0]);
        GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height,
                0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);
        // 让fbo与纹理绑定起来 , 后续的操作就是在操作fbo与这个纹理上了
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[0]);
        GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
                GLES20.GL_TEXTURE_2D, mFrameBufferTextures[0], 0);
        //解绑
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
    }

    /**
     * 销毁帧缓冲区(FBO)
     */
    public void destroyFrameBuffers() {
        //删除fbo的纹理
        if (mFrameBufferTextures != null) {
            GLES20.glDeleteTextures(1, mFrameBufferTextures, 0);
            mFrameBufferTextures = null;
        }
        //删除fbo
        if (mFrameBuffers != null) {
            GLES20.glDeleteFramebuffers(1, mFrameBuffers, 0);
            mFrameBuffers = null;
        }
    }

    @Override
    public void surfaceChanged(int width, int height) {
		...
        if (isFBO) {
            createFrameBuffers(width, height);
        }
    }

   @Override
    public int draw(int textureId, float[] matrix) {
        GLESUtils.checkGlError("draw start");
        ...

        if (isFBO) {
            GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
            //返回fbo的纹理id
            return mFrameBufferTextures[0];
        } else {
            return textureId;
        }
    }

    @Override
    public void release() {
        GLES20.glDeleteProgram(mProgram);
        mProgram = -1;
        destroyFrameBuffers();
    }
}

我们对CameraFilter进行改造,现在他既能将纹理绘制到屏幕中,也能将纹理绘制到FBO中。

2. FBO渲染到Surface

FBO中的数据渲染到Surface又该用哪中着色器程序呢,实际上FBO中数据就是RGBA,可以理解为就是2D纹理。渲染2D纹理我们熟啊,Android OpenGLES2.0开发(七):纹理贴图之显示图片章节我们成功将Bitmap转化为2D纹理绘制到GLSurfaceView上。而现在FBO就可以获取2D纹理对象,所以我们需要一个绘制2D纹理的着色器程序,将Image绘制图片着色器拷贝过来,重命名为Texture2DFilter将Bitmap转换为2D纹理代码删除即可。

java 复制代码
/**
 * 将离屏渲染的数据绘制到屏幕中
 */
public class Texture2DFilter implements AFilter {

    /**
     * 绘制的流程
     * 1.顶点着色程序 - 用于渲染形状的顶点的 OpenGL ES 图形代码
     * 2.片段着色器 - 用于渲染具有特定颜色或形状的 OpenGL ES 代码 纹理。
     * 3.程序 - 包含您想要用于绘制的着色器的 OpenGL ES 对象 一个或多个形状
     * <p>
     * 您至少需要一个顶点着色器来绘制形状,以及一个 片段着色器来为该形状着色。
     * 这些着色器必须经过编译,然后添加到 OpenGL ES 程序中,该程序随后用于绘制 形状。
     */

    // 顶点着色器代码
    private final String vertexShaderCode =
            // This matrix member variable provides a hook to manipulate
            // the coordinates of the objects that use this vertex shader
            "attribute vec4 vPosition;\n" +
                    "attribute vec2 vTexCoordinate;\n" +
                    "varying vec2 aTexCoordinate;\n" +
                    "void main() {\n" +
                    // the matrix must be included as a modifier of gl_Position
                    // Note that the uMVPMatrix factor *must be first* in order
                    // for the matrix multiplication product to be correct.
                    "  gl_Position = vPosition;\n" +
                    "  aTexCoordinate = vTexCoordinate;\n" +
                    "}";

    // 片段着色器代码
    private final String fragmentShaderCode =
            "precision mediump float;\n" +
                    "uniform sampler2D vTexture;\n" +
                    "varying vec2 aTexCoordinate;\n" +
                    "void main() {\n" +
                    "  gl_FragColor = texture2D(vTexture, aTexCoordinate);\n" +
                    "}\n";

    /**
     * OpenGL程序句柄
     */
    private int mProgram;

    /**
     * 顶点坐标缓冲区
     */
    private FloatBuffer mVertexBuffer;
    /**
     * 纹理坐标缓冲区
     */
    private FloatBuffer mTextureBuffer;

    /**
     * 此数组中每个顶点的坐标数
     */
    static final int COORDS_PER_VERTEX = 2;

    /**
     * 顶点坐标数组
     * 顶点坐标系中原点(0,0)在画布中心
     * 向左为x轴正方向
     * 向上为y轴正方向
     * 画布四个角坐标如下:
     * (-1, 1),(1, 1)
     * (-1,-1),(1,-1)
     */
    private float vertexCoords[] = {
            -1.0f, 1.0f,  // 左上
            -1.0f, -1.0f, // 左下
            1.0f, 1.0f,   // 右上
            1.0f, -1.0f   // 右下
    };

    /**
     * 纹理坐标数组
     * 这里我们需要注意纹理坐标系,原点(0,0s)在画布左下角
     * 向左为x轴正方向
     * 向上为y轴正方向
     * 画布四个角坐标如下:
     * (0,1),(1,1)
     * (0,0),(1,0)
     */
    private float textureCoords[] = {
            0.0f, 1.0f, // 左上
            0.0f, 0.0f, // 左下
            1.0f, 1.0f, // 右上
            1.0f, 0.0f, // 右下
    };

    /**
     * 顶点坐标句柄
     */
    private int mPositionHandle;
    /**
     * 纹理坐标句柄
     */
    private int mTexCoordinateHandle;
    /**
     * 纹理贴图句柄
     */
    private int mTexHandle;

    private final int vertexCount = vertexCoords.length / COORDS_PER_VERTEX;
    private final int vertexStride = COORDS_PER_VERTEX * 4; // 4 bytes per vertex

    public Texture2DFilter() {
        // 初始化形状坐标的顶点字节缓冲区
        mVertexBuffer = ByteBuffer.allocateDirect(vertexCoords.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(vertexCoords);
        mVertexBuffer.position(0);

        // 初始化纹理坐标顶点字节缓冲区
        mTextureBuffer = ByteBuffer.allocateDirect(textureCoords.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(textureCoords);
        mTextureBuffer.position(0);
    }

    @Override
    public void surfaceCreated() {
        // 创建OpenGLES程序
        mProgram = GLESUtils.createProgram(vertexShaderCode, fragmentShaderCode);

        // 获取顶点着色器vPosition成员的句柄
        mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition");
        // 获取顶点着色器中纹理坐标的句柄
        mTexCoordinateHandle = GLES20.glGetAttribLocation(mProgram, "vTexCoordinate");
        // 获取Texture句柄
        mTexHandle = GLES20.glGetUniformLocation(mProgram, "vTexture");
    }

    @Override
    public void surfaceChanged(int width, int height) {
        GLES20.glViewport(0, 0, width, height);
    }

    @Override
    public int draw(int textureId, float[] matrix) {
        // 将程序添加到OpenGL ES环境
        GLES20.glUseProgram(mProgram);
        GLESUtils.checkGlError("glUseProgram");

        // 为正方形顶点启用控制句柄
        GLES20.glEnableVertexAttribArray(mPositionHandle);
        GLESUtils.checkGlError("glEnableVertexAttribArray");
        // 写入坐标数据
        GLES20.glVertexAttribPointer(mPositionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, vertexStride, mVertexBuffer);
        GLESUtils.checkGlError("glVertexAttribPointer");

        // 启用纹理坐标控制句柄
        GLES20.glEnableVertexAttribArray(mTexCoordinateHandle);
        GLESUtils.checkGlError("glEnableVertexAttribArray");
        // 写入坐标数据
        GLES20.glVertexAttribPointer(mTexCoordinateHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, vertexStride, mTextureBuffer);
        GLESUtils.checkGlError("glVertexAttribPointer");

        // 激活纹理编号0
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        // 绑定纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
        // 设置纹理采样器编号,该编号和glActiveTexture中设置的编号相同
        GLES20.glUniform1i(mTexHandle, 0);

        // 绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        GLESUtils.checkGlError("glDrawArrays");

        // 取消绑定纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

        // 禁用顶点阵列
        GLES20.glDisableVertexAttribArray(mPositionHandle);
        GLES20.glDisableVertexAttribArray(mTexCoordinateHandle);

        return textureId;
    }

    @Override
    public void release() {
        GLES20.glDeleteProgram(mProgram);
        mProgram = -1;
    }
}

3. FBO渲染流程

FBO核心渲染代码如下,drawFBO中的代码和流程图描述一致

java 复制代码
/**
 * OpenGL ES渲染Camera预览数据的线程
 *
 * @author xiaozhi
 * @since 2024/8/22
 */
public class RenderThread extends Thread

    private CameraFilter mFBOFilter;
    private Texture2DFilter mScreenFilter;
    
    public RenderThread(Context context, TextureMovieEncoder2 textureMovieEncoder) {
    	...
        mFBOFilter = new CameraFilter();
        mFBOFilter.setFBO(true);
        mScreenFilter = new Texture2DFilter();
    }
    
    /**
     * 离屏渲染
     *
     * @param recordWindowSurface
     * @return
     */
    private boolean drawFBO(WindowSurface recordWindowSurface) {
        boolean swapResult;
        // 将数据绘制到FBO Buffer中
        mPreviewTexture.getTransformMatrix(mDisplayProjectionMatrix);
        int fboId = mFBOFilter.draw(mTextureId, mDisplayProjectionMatrix);

        // 将离屏FrameBuffer绘制到视频Surface中
        mVideoEncoder.frameAvailable();
        recordWindowSurface.makeCurrent();
        GLES20.glViewport(0, 0,
                mVideoEncoder.getVideoWidth(), mVideoEncoder.getVideoHeight());
        mScreenFilter.draw(fboId, mDisplayProjectionMatrix);
        recordWindowSurface.setPresentationTime(mPreviewTexture.getTimestamp());
        recordWindowSurface.swapBuffers();

        // 将离屏FrameBuffer绘制到屏幕Surface中
        mWindowSurface.makeCurrent();
        GLES20.glViewport(0, 0, mWindowSurface.getWidth(), mWindowSurface.getHeight());
        mScreenFilter.draw(fboId, mDisplayProjectionMatrix);
        swapResult = mWindowSurface.swapBuffers();

        return swapResult;
    }
}

FBO高效的原理在于,纹理渲染到FBO中的前置操作。如果需要对纹理进行美颜、高斯模糊等复杂的计算,FBO确实能提高效率。如果没有复杂的计算,那么FBO和二次渲染法效率差不多。

三. BlitFramebuffer

上面的方式,相当于我们引入了一个中间区域用来中转,似乎显的复杂了一些。有没有更加简洁高效的方式,只渲染一次就能实现多次拷贝呢?OpenGL ES3.0出现了一种更高效的方法,即BlitFramebuffer

来直接看流程图:

该方式不再使用FBO做缓存,而是像二次渲染法一样,先将渲染的内容输出到当前Surface中,但并不展示到屏幕上。我们来看下这种方式的流程:

  1. 先将渲染结果输送给SurfaceViewSurface
  2. 切换SurfaceMediaCodecSurface,此时当前Surface为MediaCodec的
  3. 利用OpenGL3.0提供的API BlitFramebuffer从原来的Surface拷贝数据到当前Surface中,再调用EGL的eglSwapBuffers将Surface中的内容送编码器编码
  4. 最后将当前Surface切回原来的Surface,也就是SurfaceView的Surface,同样调用EGL的eglSwapBuffers方法,将其内容显示到屏幕上

配合代码享用更佳:

java 复制代码
/**
 * BlitFramebuffer方式
 *
 * @param recordWindowSurface
 * @return
 */
private boolean drawBlitFrameBuffer(WindowSurface recordWindowSurface) {
    boolean swapResult;
    // 先绘制到屏幕上
    mPreviewTexture.getTransformMatrix(mDisplayProjectionMatrix);
    mCameraFilter.draw(mTextureId, mDisplayProjectionMatrix);

    mVideoEncoder.frameAvailable();
    // 把屏幕Surface渲染数据拷贝到视频Surface中
    // 该方式的效率是最高的,一次渲染输出给多个目标,但是只有OpenGL3.0才有该方法
    recordWindowSurface.makeCurrentReadFrom(mWindowSurface);
    GLES30.glBlitFramebuffer(
            0, 0, mWindowSurface.getWidth(), mWindowSurface.getHeight(),
            0, 0, mVideoEncoder.getVideoWidth(), mVideoEncoder.getVideoHeight(),
            GLES30.GL_COLOR_BUFFER_BIT, GLES30.GL_NEAREST);
    int err;
    if ((err = GLES30.glGetError()) != GLES30.GL_NO_ERROR) {
        Log.w(TAG, "ERROR: glBlitFramebuffer failed: 0x" +
                Integer.toHexString(err));
    }
    recordWindowSurface.setPresentationTime(mPreviewTexture.getTimestamp());
    recordWindowSurface.swapBuffers();

    // 切换为屏幕Surface
    mWindowSurface.makeCurrent();
    swapResult = mWindowSurface.swapBuffers();
    return swapResult;
}

该方式的效率是最高的,一次渲染输出给多个目标,但是只有OpenGL3.0才有该方法

最后

MediaCodec硬编码总算是讲完了,我们用了3个章节由浅入深的介绍了Android下是如何硬编码的,对YUV数据进行编码,也能使用OpenGL ES渲染到Surface上进行编码。但这仅仅只是一个开始,还有更多的内容需要深入学习、探讨。

不知不觉CameraOpenGL ES系列都已经写了不少章节了,不管有多少人看,首先写这些系列是对自己的一个交代,也是对过去的总结,更是对未来的憧憬。希望自己不断前行,变得更好,也希望这个世界变得更好。

lib-camera库包结构如下:

说明
camera camera相关操作功能包,包括Camera和Camera2。以及各种预览视图
encoder MediaCdoec录制视频相关,包括对ByteBuffer和Surface的录制
gles opengles操作相关
permission 权限相关
util 工具类

每个包都可独立使用做到最低的耦合,方便白嫖

github地址:github.com/xiaozhi003/...如果对你有帮助可以star下,万分感谢^_^

参考:

  1. github.com/saki4510t/U...
  2. github.com/google/graf...
  3. www.imooc.com/article/340...,以上流程图引入自该文章
相关推荐
梓仁沐白42 分钟前
Android清单文件
android
董可伦3 小时前
Dinky 安装部署并配置提交 Flink Yarn 任务
android·adb·flink
每次的天空4 小时前
Android学习总结之Glide自定义三级缓存(面试篇)
android·学习·glide
恋猫de小郭4 小时前
如何查看项目是否支持最新 Android 16K Page Size 一文汇总
android·开发语言·javascript·kotlin
flying robot5 小时前
小结:Android系统架构
android·系统架构
xiaogai_gai5 小时前
有效的聚水潭数据集成到MySQL案例
android·数据库·mysql
鹅鹅鹅呢6 小时前
mysql 登录报错:ERROR 1045(28000):Access denied for user ‘root‘@‘localhost‘ (using password Yes)
android·数据库·mysql
在人间负债^6 小时前
假装自己是个小白 ---- 重新认识MySQL
android·数据库·mysql
Unity官方开发者社区6 小时前
Android App View——团结引擎车机版实现安卓应用原生嵌入 3D 开发场景
android·3d·团结引擎1.5·团结引擎车机版
进击的CJR9 小时前
MySQL 8.0 OCP 英文题库解析(三)
android·mysql·开闭原则