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

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

Android Camera系列(一):SurfaceView+Camera

Android Camera系列(二):TextureView+Camera

Android Camera系列(三):GLSurfaceView+Camera

Android Camera系列(四):TextureView+OpenGL ES+Camera

Android Camera系列(五):Camera2

Android Camera系列(六):MediaCodec视频编码上-编码YUV

Android Camera系列(七):MediaCodec视频编码中-OpenGL ES多线程渲染

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地址:https://github.com/xiaozhi003/AndroidCamera如果对你有帮助可以star下,万分感谢^_^

参考:

  1. https://github.com/saki4510t/UVCCamera
  2. https://github.com/google/grafika
  3. https://www.imooc.com/article/340844,以上流程图引入自该文章
相关推荐
工程师老罗44 分钟前
我用AI学Android Jetpack Compose之开篇
android·android jetpack
热心市民运维小孙44 分钟前
Mysql8主从复制(兼容低高版本)
android·adb
佛系小嘟嘟1 小时前
Android Jetpack Compose开发小组件【入门篇】
android·开发语言·android jetpack·小组件
工程师老罗1 小时前
我用AI学Android Jetpack Compose之入门篇(1)
android·android jetpack
iteye_103921 小时前
ppt pptx转成pdf有什么好的java工具
音视频
思忖小下8 小时前
深入Android架构(从线程到AIDL)_08 认识Android的主线程
android·ui线程
G佳伟9 小时前
vue字符串的数字比较大小有问题
android·前端·vue.js
yoguo-21010 小时前
使用javacv获取海康威视rtsp流的详细教程
音视频
tanghongchang12310 小时前
Mac Android Studio 提升Mac的编译速度
android·macos·android studio
俊杰_10 小时前
rk3562 安卓14 -安卓15 锁屏图标显示异常
android