Android 关于美颜/滤镜 利用PBO从OpenGL录制视频

前言


上次我写了一遍文章《Android 关于美颜/滤镜 从OpenGl录制视频的一种方案》,里面利用ImageReader来从获取Surface上获取数据,但是经过@熊皮皮的提醒,我发现多PBO的确可以实现跟ImageReader一样的效果,并且版本要求仅为Android4.3。

代码已上传至GitHub

滤镜部分来源于《Android图像处理之实时滤镜》

提示:工程需要下载NDK和CMake

正文


1.原理

什么是PBO?PBO就是PixelBufferObject(像素缓存对象),它跟VBO很相似,只不过一个存像素数据,一个存顶点数据,你可以通过《OpenGL像素缓冲区对象(PBO)》了解。

其实上篇文章里我列举的几个方法里面已经有PBO了,但是因为我之前用的是单个PBO,结果测试发现效率不行就放弃了。

单PBO获取像素信息如下:

//绑定到PBO
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(0));
//从FBO中读取数据写入到PBO中
GLES30.glReadPixels(0, 0, 480, 640, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE,0);
//将OpenGL缓存区映射到客户端内存
ByteBuffer byteBuffer = (ByteBuffer) GLES30.glMapBufferRange(GLES30.GL_PIXEL_PACK_BUFFER, 0, 480 * 640 * 4, GLES30.GL_MAP_READ_BIT);
//取消内存映射
GLES30.glUnmapBuffer(GLES30.GL_PIXEL_PACK_BUFFER);
//解除PBO绑定
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);

这上面代码其实没有什么问题,包括GLES30.glReadPixels()时间都已经降为0,但就是在执行函数 GLES30.glMapBufferRange()映射内存的时候非常慢。

后来经过提醒后我重新翻看了《OpenGL像素缓冲区对象(PBO)》后发现我之前忽略了二点。

第一个问题是 GLES30.glMapBufferRange()这个函数实际会等待GPU完成了对相应缓冲区对象的操作后才会返回,所以我使用单个PBO并不能显著的提高传输效率,而PBO的主要优点在于可以通过DMA(Direct Memory Access)进行异步传输数据,从而不影响CPU的时钟周期,所以使用2个PBO, 一个PBO拷贝数据、一个PBO映射内存,交替使用,效率将大大提高。

第二个问题就是字节对齐问题,OpenGLES默认以4字节对齐,也就是说我取得的rowStride应该是4的整数倍,计算公式如下:

int align = 4;//4字节对齐
int rowStride = (width * pixelStride + (align - 1)) & ~(align - 1);

而我在GLES30.glReadPixels()中使用的参数是GLES30.GL_RGBA,pixelStride应该等于4,那么就有(width * 4 + (4 - 1)) & ~(4 - 1) == width * 4,从这个道理上来讲,我的width无论取得什么应该都是内存对齐的,效率不应该会降低,事实上大部分机子都没有问题,但是在索尼Z2上效率下降了。

经过我实验后发现如果我是128字节对齐,那么效率不会降低,代码如下:

int align = 128;//128字节对齐
int rowStride = (width * mPixelStride + (align - 1)) & ~(align - 1);

事实上这里我很奇怪,理论上GLES20.glPixelStore()最大值应该是8,怎么都不可能是128,我怀疑这个值应该跟硬件和屏幕分辨率有关,因为ImageReader计算出来的rowStride和我计算出来的值不一样,但是我没有在网上找到相关的资料,如果有谁知道请留言告知我下,谢谢

关于内存对齐你可以通过《关于内存对齐的那些事》了解。

修改后多PBO获取像素信息如下:

//绑定到第一个PBO
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(mPboIndex));
//从FBO中读取数据写入到PBO中
GLES30.glReadPixels(0, 0, 480, 640, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE,0);
//绑定到第二个PBO
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(mPboNewIndex));
//将OpenGL缓存区映射到客户端内存
ByteBuffer byteBuffer = (ByteBuffer) GLES30.glMapBufferRange(GLES30.GL_PIXEL_PACK_BUFFER, 0, 480 * 640 * 4, GLES30.GL_MAP_READ_BIT);
//取消内存映射
GLES30.glUnmapBuffer(GLES30.GL_PIXEL_PACK_BUFFER);
//解除PBO绑定
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);
//交换索引
mPboIndex = (mPboIndex + 1) % 2;
mPboNewIndex = (mPboNewIndex + 1) % 2;

经过修改后,2个PBO轮流交替使用,就完全可以满足需求。

2.实现

实际上面讲完,这篇文章就可以结束了,但是我怎么会满足呢!所以我对MagicCamera进行了一些修改。

1.去除grafika方法

在使用PBO之后,grafika方法就已经失去作用了,并且在MagicCamera的写法中过了2次滤镜(绘制到本地窗口一次,绘制到Surface一次),所以开启录制后OpenGL的计算量将加倍。

这里直接删除encoder文件夹。

2.修改原来的绘制方案

原来的绘制方案是先将摄像头数据绘制到FBO,然后将返回的纹理经过滤镜后绘制到本地窗口。

但是因为要使用PBO,所以我先将摄像头数据过滤镜后绘制到FBO,然后以屏幕大小绘制到本地窗口,和以录制大小绘制到另一个FBO在通过PBO获取数据。

这样做的好处就是3个大小,屏幕大小、摄像头大小、录制大小可以各不相同。

流程图.png

这样需要注意一点因为屏幕大小和录制大小不相同,所以它们的顶点坐标和纹理坐标也不相同,需要重新计算屏幕坐标录制坐标

修改后代码请看CameraGlSurfaceView

3.开始绘制

接下来就可以开始绘制了,首先将摄像头数据经过滤镜后绘制到FBO。

1.初始化FBO,完整代码请看GPUImageFilter
//生成FBO
GLES20.glGenFramebuffers(1, mFrameBuffers, 0);
//生成纹理
GLES20.glGenTextures(1, mFrameBufferTextures, 0);
//绑定到纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mFrameBufferTextures[0]);

//...省略设置纹理参数

//将纹理关联到FBO
GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, mFrameBufferTextures[0], 0);
//解除绑定纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
//解除绑定FBO
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);

上面将纹理关联到FBO,这样就可以直接绘制到纹理上。

2.将摄像头数据经过滤镜后绘制到FBO,完整代码请看GPUImageFilter
//设定为摄像头大小
GLES20.glViewport(0, 0, 480, 640);
//绑定到FBO
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[0]);

//...省略其他代码

//设置矩阵,该矩阵从摄像头获得
GLES20.glUniformMatrix4fv(mTextureTransformMatrixLocation, 1, false, mTextureTransformMatrix, 0);

//选择活跃纹理
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
//绑定到纹理,这里需要注意GL_TEXTURE_EXTERNAL_OES是特殊的
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId);
GLES20.glUniform1i(mGLUniformTexture, 0);

//...省略其他代码

//解除绑定纹理
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0);
//解除绑定FBO
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
//设定为屏幕大小
GLES20.glViewport(0, 0, 1080, 1920);

上面的矩阵通过mSurfaceTexture.getTransformMatrix(mtx)获得,顶点着色器需要添加参数。

attribute vec4 position;
attribute vec4 inputTextureCoordinate;

varying vec2 textureCoordinate;

uniform mat4 textureTransform;

void main() {
    textureCoordinate = (textureTransform * inputTextureCoordinate).xy;
    gl_Position = position;
}

这里的GL_TEXTURE_EXTERNAL_OES必须要注意,当我们使用mSurfaceTexture.updateTexImage()时,图像会被隐式的绑定到GL_TEXTURE_EXTERNAL_OES,所以这里跟我们一般使用的纹理GL_TEXTURE_2D不同。

所以片段着色器也必须要修改,下面是没有滤镜的实现,其他的看Raw

#extension GL_OES_EGL_image_external : require

varying highp vec2 textureCoordinate;

uniform samplerExternalOES inputImageTexture;

void main(){
    gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
}`
3.将返回的纹理绘制到本地窗口,完整代码请看GPUImageFilter
//...省略其他代码

GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
//绑定纹理,这里的纹理是GL_TEXTURE_2D
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glUniform1i(mGLUniformTexture, 0);

//...省略其他代码

//解除绑定纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

这里的顶点着色器片段着色器需要去除矩阵和OES参数。

4.如果开始录制将返回的纹理绘制到FBO然后通过PBO获得数据,完整代码请看MagicRecordFilter

1.初始化PBO,完整代码请看MagicRecordFilter

final int align = 128;//128字节对齐
mRowStride = (width * mPixelStride + (align - 1)) & ~(align - 1);

mPboIds = IntBuffer.allocate(2);
//生成2个PBO
GLES30.glGenBuffers(2, mPboIds);

//绑定到第一个PBO
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(0));
//设置内存大小
GLES30.glBufferData(GLES30.GL_PIXEL_PACK_BUFFER, mPboSize, null,GLES30.GL_STATIC_READ);

//绑定到第而个PBO
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(1));
//设置内存大小
GLES30.glBufferData(GLES30.GL_PIXEL_PACK_BUFFER, mPboSize, null,GLES30.GL_STATIC_READ);

//解除绑定PBO
GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);

2.绘制2D纹理到FBO,完整代码请看MagicRecordFilter

//设定为录制大小
GLES20.glViewport(0, 0, 240, 320);
//绑定到FBO
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[0]);

//...省略其他代码

//设置矩阵
GLES20.glUniformMatrix4fv(mTextureTransformMatrixLocation, 1, false, mTextureTransformMatrix, 0);

//选择活跃纹理
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
//绑定到纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glUniform1i(mGLUniformTexture, 0);

//...省略其他代码

//解除绑定纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
//解除绑定FBO
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
//设定为屏幕大小
GLES20.glViewport(0, 0, 1080, 1920);

这里也需要设置矩阵,但是这个矩阵不是从摄像头获取的,而是我自己把它垂直翻转了下。

mTextureTransformMatrix = new float[]{
                -1f, 0f, 0f, 0f,
                0f, 1f, 0f, 0f,
                0f, 0f, 1f, 0f,
                1f, 0f, 0f, 1f});

为什么我要垂直翻转呢,因为RGB图像在内存中存储的时候是从下到上的,如果你直接把数据赋值给Bitmap,那么你将得到一张倒置的并且颜色为BGRA的图像,这也可以解释为什么我们最终要将BGRA转换为ARGB,因为Bitmap需要的是Bitmap.Config.ARGB_8888

这里你可以通过 《Image Stride(内存图像行跨度)》了解。

3.PBO获取数据,完整代码请看MagicRecordFilter

private void bindPixelBuffer() {
    //绑定到第一个PBO
    GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(mPboIndex));
    //调用glReadPixels获取数据,这里需要注意原生的Java里面没有与PBO配合的glReadPixels方法
    MagicJni.glReadPixels(0, 0, mRowStride, mInputHeight, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE);

    //第一帧没有数据跳出
    if (mInitRecord) {
        unbindPixelBuffer();
        mInitRecord = false;
        return;
    }

    //绑定到第二个PBO
    GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, mPboIds.get(mPboNewIndex));

    //glMapBufferRange会等待DMA传输完成,所以需要交替使用pbo
    //映射内存
    ByteBuffer byteBuffer = (ByteBuffer) GLES30.glMapBufferRange(GLES30.GL_PIXEL_PACK_BUFFER, 0, mPboSize, GLES30.GL_MAP_READ_BIT);

    //解除映射
    GLES30.glUnmapBuffer(GLES30.GL_PIXEL_PACK_BUFFER);
    unbindPixelBuffer();

    //交给mRecordHelper录制
    mRecordHelper.onRecord(byteBuffer, mInputWidth, mInputHeight, mRowStride, mLastTimestamp);
}

//解绑pbo
private void unbindPixelBuffer() {
    //解除绑定PBO
    GLES30.glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);

    //交换索引
    mPboIndex = (mPboIndex + 1) % 2;
    mPboNewIndex = (mPboNewIndex + 1) % 2;
}

这里必须要注意,要与PBO配合使用glReadPixels()最后一个参数必须为0,但是原生Java层的glReadPixels()最后一个参数是Buffer,而最后参数为int的glReadPixels()24版本才有,所以这里需要使用jni去调用原生的glReadPixels()方法,代码在MagicJni

关于RecordHelper我就不讲了,跟上篇一样,这里可以用libyuv代替,我这只是作为测试浏览用。

我这里JNI采用CMake编译,编译指令在CMakeLists.txt,更多可以参考谷歌官方文档《向您的项目添加 C 和 C++ 代码》

结尾


其实在篇文章我早就写完了,但是一直搞不清楚rowStride的计算方式,最终我决定还是不拖了,直接发布希望有谁知道的能指点下,谢谢。

最后,如果它有解决你的问题的话,请下点个赞,谢谢。

这是我个人的第四篇文章,发布于2017年5月15日。

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

推荐阅读更多精彩内容