Android传输摄像头视频数据到U3D的优化实战

2019.1.5 更新:因为时间有限不能详细回答朋友问题,现开源了核心代码,请见:https://github.com/ifinver/FinEngine
Happy New Year!


Unity中有很多内置插件可以自动操控摄像头,编译到移动平台时,使用这些插件可以满足大部分场景,不需要额外编程,但是当需要:

  • 检测摄像头视频数据中的人脸
  • 自定义美颜(CPU美颜算法)

的时候,使用Unity内置或扩展插件就难以完成这个任务了。

这时候,就需要

Android和iOS原生代码里面操作摄像头->获取视频流数据->人脸检测或美颜->传输给Unity。

Unity可以接受的纹理格式,和Android摄像头可以输出的纹理格式,只有一个匹配的:YUY2
如果你可以用这个格式进行人脸检测或者美颜的话,这个工作链不会有什么问题,千元以下低端机的性能表现也算良好。
如果你们的人脸检测模块不支持这种格式的话,就要进行转换了。我们的人脸检测模块就只支持NV21格式(YUV420SP).
所以就需要摄像头输出NV21的视频数据,经过人脸检测之后,把格式转换为unity可以接受的格式(一般为RGB24)再传进去。

方法是:

Camera.Parameters params = mCamera.getParameters();
//设置为NV21格式
params.setPreviewFormat(ImageFormat.NV21);

然后在回调中处理:

@Override
    public void onPreviewFrame(byte[] data, Camera camera) {
        //解析人脸数据
        long facePtr = FaceDetector.process(data,mFrameWidth,mFrameHeight);
        //转换为RGB格式
        byte[] rgbData = VideoConverter.convert2(data,VideoConverter.RGB24);
        //传输人脸数据和摄像头数据给u3d
        UnityTransfer.onVideoData(facePtr,rgbData,mFrameWidth,mFrameHeight);
    }

(摄像头操作、以及处理线程优化提速另开文章叙述)

这种处理方式只能跑通流程,实测手机发热耗电严重、卡顿明显,千元一下的Android机基本可以放弃治疗了
上面的处理环节中,最耗时的地方莫过于

 //转换为RGB格式
 byte[] rgbData = VideoConverter.convert2(data,VideoConverter.RGB24);

如果能不转换,直接丢yuv数据给u3d生成纹理就好了。OpenGLES是可以直接渲染yuv数据的(OpenGLES渲染YUV数据),那u3d怎么实现呢?

需要先详细了解一下NV21格式(见常见视频格式 ),y通道的数据是摄像头视频数据的灰度图,在U3D中可以拿它单独生成一个透明度的纹理

mYTex = new Texture2D (1, 1, TextureFormat.Alpha8, false, true);
mYTex.Resize (mVideoData.width, mVideoData.height, TextureFormat.Alpha8, false);

然后上载数据到GPU

mYTex.LoadRawTextureData (mVideoData.yPtr, mDataLen);
mYTex.Apply ();

当用uv通道生成纹理的时候会有一个棘手的问题,OpenGLES中可以使用GL_LUMINANCE_ALPHA生成uv的纹理,但是u3d中没有"每个像素点占两个字节"的双通道纹理格式...。
这里只能使用折中的方法,使用TexutreFormat.RGB24纹理格式来生成uv纹理,在每个uv数据之后补一个0,以将2通道的数据扩充为3通道

uvPtr = new unsigned char[width * height * 3 / 4];
int yLen = width * height;
int yuvLen = yLen * 3 / 2;
int dstPtr = 0;
for (int i = yLen; i < yuvLen; i += 2) {
    uvPtr[dstPtr++] = (unsigned char) (data[i] );
    uvPtr[dstPtr++] = (unsigned char) (data[i + 1] );
    uvPtr[dstPtr++] = 0;
}

扩充之后的uv数据大小为1/2*width*1/2*height*3=width*height*3/4,3是三通道的意思,每个像素占3个字节。这样就把2通道的uv数据变成了3通道的uv数据,每个像素占3个字节,每个字节8位共24位,符合了u3d中TextureFormat.RGB24格式。下一步就把uv数据的指针传到u3d中生成纹理:

mUvTex = new Texture2D (1, 1, TextureFormat.RGB24, false, true);
mUvTex.Resize (mVideoData.width / 2, mVideoData.height / 2, UV_TEXTURE_FORMAT, false);
mUvTex.LoadRawTextureData (mVideoData.uvPtr, mDataLen * 3 / 4);
mUvTex.Apply ();//upload to gpu

纹理准备完毕,现在以这两个纹理作为渲染材质的输入变量:

mPreview.material.SetTexture ("_yTex", mYTex);
mPreview.material.SetTexture ("_uvTex", mUvTex);

材质的核心shader代码:

Shader "my/yuv" {
    Properties {
    }
    SubShader {
        pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #pragma target 3.0
            #include "unitycg.cginc"

            uniform sampler2D _MainTex;//without using
            uniform sampler2D _yTex;
            uniform sampler2D _uvTex;

            struct v2f
            {
                float4 pos : POSITION;
                float2 uv : TEXTCOORD0;
            };

            v2f vert(appdata_full v)
            {
                v2f ret;
                ret.pos = mul(UNITY_MATRIX_MVP, v.vertex);
                if (_frontCurrent == 0)
                {
                    ret.uv = float2(v.texcoord.x, 1 - v.texcoord.y);
                }
                else if(_frontCurrent == 1)
                {
                    ret.uv = v.texcoord.xy;
                }

                return ret;
            }

            fixed4 frag(v2f IN) : COLOR
            {
                float4 col;
                float r, g, b, y, u, v;

                // get yData
                float4 yColor = tex2D(_yTex, IN.uv);
                y = yColor.a;

                // get uvData
                float4 uvColor = tex2D(_uvTex, IN.uv);
                //only two components r used,the third channel ‘b’ is 0
                u = uvColor.g - 0.5;
                v = uvColor.r - 0.5;

                r = y + 1.13983 * v;
                g = y - 0.39465 * u - 0.58060 * v;
                b = y + 2.03211 * u;

                return fixed4(r,g,b,1);
            }
            ENDCG
        }
    }
}

OK,到这里大功告成,几个需要注意的地方:

  1. Unity操作材质和纹理的API必须run在主线程,所以Android原生传给Unity数据的时候可以在子线程,但是处理的时候必须在主线程

    public void CameraRender (IntPtr param){
       mVideoData = (VideoData)Marshal.PtrToStructure (param, typeof(VideoData));
       //此方法运行在子线程,接收好数据时标记状态
       mIsDataChanged = true;
      }
    

    Update()中进行处理:

     void Update (){
         if (mIsDataChanged && mVideoData.isNotNull ()) {
              //consume the data
               mIsDataChanged = false;
               //do texture operation below
               //...
         }
     }
    
  2. 因为是异步,在原生代码处理好一帧数据并丢给u3d之后,会开始下一帧的处理,如果上一帧还没来得及被u3d消费,处理下一帧的数据的时候会复写bytes数组,导致在显示的过程中视频图像裂帧、错位,所以需要使用缓冲策略,分别开辟2-3个bytes数组和uv转换数组,丢给u3d之后暂时不操作刚刚丢过去的数组。实测3个数组缓冲在800元左右的机器上运行良好。

到这里并没完..

上面把2通道的uv扩充到3通道的uv,虽有遍历赋值操作,比转换数据帧格式的消耗减少了好几个台阶,这个操作的消耗可以忽略了。
剩下最耗时的操作就是把数据帧的bytes数组上传到GPU了,1280*720的预览,每一帧需要上传1280*720*3/2字节即1.31MB,每秒30帧的话,每秒钟需要从CPU拷贝到GPU39.5MB的数据,而且摄像头的预览onPreviewFrame()方法的回传也涉及到从GPU拷贝到CPU的操作,所以上面的方法依然性能很低。流程图简示如下:

数据从GPU下载和上传

耗时的地方有数据的下载和上传,以及人脸检测和格式转换。
Android的Camera API是可以把预览显示到一个GPU Surface上的,那能不能直接在GPU中操作避免CPU介入呢?答案是可以的。之前我们把摄像头的数据取到CPU中,无非就是为了解析出人脸数据,高能的流程应该是:

传输GPU纹理减少与CPU的数据传输

可以看到这个方案不用把视频数据从CPU上载到GPU了,只传输一个人脸数据,节省了很多电能..
Android2.2就开始支持OpenGl ES 2.0了,es的扩展可以跨线程共享Surface。纹理格式为GL_TEXTURE_EXTERNAL_OES定义在gl2ext.h中,java层代码进行了封装,在GLES11Ext.GL_TEXTURE_EXTERNAL_OES中。

但是u3d是不支持这个纹理格式的,u3d官方有篇硬件API文档和一个Demo示例有说明,意思是他们正在努力支持Android和iOS的原生纹理,只是目前还不支持..

u3d支持的是标准的OpenGLTEXTURE_2D纹理,所以需要把Android原生支持的OES纹理转换为OpenGL纹理。

首先新建一个TextureSurface用于接收摄像头的数据

mCameraInputSurface = new SurfaceTexture(0);
mCameraInputSurface.setOnFrameAvailableListener(this);
mCameraInputSurface.setDefaultBufferSize(mFrameWidth, mFrameHeight);
mOutputSurfaceTexture.setOnFrameAvailableListener(this);

设置摄像机的预览到这个Surface,并开始预览

mCamera.setPreviewTexture(mCameraInputSurface);
mCamera.startPreview();

然后这个SurfaceTexture就可以跨线程共享硬件纹理数据了。

在u3d的主线程中获取OpenGL的共享Context

void Start (){
    mUnityTextureBridge = new AndroidJavaClass ("com.ifinver.unitytransfer.UnityTextureBridge");
    mUnityTextureBridge.CallStatic ("create");
}

AndroidUnityTextureBridge端代码

//本方法运行在Unity主线程
public static void create() {
    //创建用于共享的纹理
    if (mOutputTex == null) {
        mOutputTex = new int[1];
        //在u3d主线程中创建纹理
        GLES20.glGenTextures(1, mOutputTex, 0);
        //获取u3d的opengl context
        EGLContext sharedContext = EGL14.eglGetCurrentContext();
        //创建共享线程,使用shaderContext初始化共享线程,就可以共享这个mOutputTex纹理了
        mBridgeThread.create(sharedContext);
        mBridgeThread.start();
    }
}

mBridgeThread在初始化创建OpenGl的上下文时,需要用刚刚获取的sharedContext作为共享线程

@Override
    public void run() {
        initGL();
        initProgram();
        initBuffer();
        initFBO();
        while (!release) {
            //do draw
            nativeRender();
        }
        releaseThread();
    }

    private void initGL() {
        mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
        if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) {
            throw new RuntimeException("unable to get EGL14 display");
        }
        int[] version = new int[2];
        if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) {
            mEGLDisplay = null;
            throw new RuntimeException("unable to initialize EGL14");
        }
        mEGLConfig = getConfig(2);
        if (mEGLConfig == null) {
            throw new RuntimeException("Unable to find a suitable EGLConfig");
        }
        int[] attrib2_list = {
                EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
                EGL14.EGL_NONE
        };
        mEGLContext = EGL14.eglCreateContext(mEGLDisplay, mEGLConfig, mSharedContext,
                attrib2_list, 0);
        mEglSurface = createOffscreenSurface(16, 16);//不需要宽高,

        if (!EGL14.eglMakeCurrent(mEGLDisplay, mEglSurface, mEglSurface, mEGLContext)) {
            checkGlError("make current");
            throw new RuntimeException("eglMakeCurrent(draw,read) failed");
        }
    }

然后这个线程对mOutputTex的绘制,在u3d主线程中就可以使用了。
先用这个mOutputTex绑定好FBO

    private boolean initFBO() {
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, outTex[0]);
            GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGB, mInputWidth, mInputHeight, 0, GLES20.GL_RGB, GLES20.GL_UNSIGNED_BYTE, null);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);

            mFrameBuffer = new int[1];
            GLES20.glGenFramebuffers(1, mFrameBuffer, 0);

            GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffer[0]);
            GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, outTex[0], 0);
            int status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER);
            if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) {
                Log.e(TAG, "bind FBO failed!");
                return false;
            }
            return true;
    }

然后把上面mCameraInputSurface的图像渲染到FBO中

    private boolean nativeRender() {
        //切换缓冲区
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffer[0]);
        //绑定纹理
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        GLES20.glGenTextures(1, mInputTex, 0);
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, mInputTex[0]);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
        GLES20.glUniform1i(sTextureLoc, 0);
        //绑定到当前线程的Gl Context
        mInputSurface.attachToGLContext(mInputTex[0]);
        mInputSurface.updateTexImage();
        //调整视口
        GLES20.glViewport(0, 0, mInputWidth, mInputHeight);
        //输入定点坐标
        GLES20.glEnableVertexAttribArray(aPositionLoc);
        GLES20.glVertexAttribPointer(aPositionLoc, 2, GLES20.GL_FLOAT, false, 0, mVertexBuffer);
        //输入纹理坐标
        GLES20.glEnableVertexAttribArray(aTexCoordLoc);
        GLES20.glVertexAttribPointer(aTexCoordLoc, 2, GLES20.GL_FLOAT, false, 0, mTexCoordBuffer);
        //绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, 0, 4);
        //善后
        GLES20.glDisableVertexAttribArray(aPositionLoc);
        GLES20.glDisableVertexAttribArray(aTexCoordLoc);
        GLES20.glFinish();
        //从当前的GL Context中脱离
        mInputSurface.detachFromGLContext();
        return true;
    }

OK渲染完成,通知u3d的主线程,可以使用这张纹理进行渲染了。

需要注意的地方:

  1. 同上面传输bytes[]数据的方式类似,在把一帧数据渲染到mOutputTex上之后,通知u3d进行处理,在u3d还没来得及处理时又进入了下一次渲染。 为了避免这个问题:
    a. 需要建立输出缓冲区,创建2-3个mOutputTex,分别绑定FBO并在渲染之前切换
    b. 控制摄像头输出的帧率,一般25-30帧即可满足视觉要求了。
  2. 由于现在视频数据是高速硬件处理了,速度很快,而人脸数据的解析仍然是跑在CPU的,所以在性能好的机器上会造成人脸数据跟不上视频的渲染速度。

解决这个问题需要从mCameraInputSurfaceonFrameAvailable()回调中入手。

 在摄像头把视频数据渲染到`mCameraInputSurface`上时,就会通过`onFrameAvailable`通知,并等待`mCameraInputSurface .updateTexImage()`消费,消费之后的下一次渲染好时又会回调。

这个消费-通知-消费的过程是很快速的,所以需要在人脸解析完成之后再去通知`UnityTextureBridge`消费,这样可以基本保证视屏画面和人脸数据的同步。

到这里算是完了,期间的坑还是很多的。项目代码涉密暂时不能透露,不过核心部分已经叙述清楚了,有不懂的地方可以百度补一补课,实现起来基本是跑的通的。

推荐阅读更多精彩内容