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,到这里大功告成,几个需要注意的地方:
-
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 //... } }
因为是异步,在原生代码处理好一帧数据并丢给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的操作,所以上面的方法依然性能很低。流程图简示如下:
耗时的地方有数据的下载和上传,以及人脸检测和格式转换。
Android的Camera API是可以把预览显示到一个GPU Surface上的,那能不能直接在GPU中操作避免CPU介入呢?答案是可以的。之前我们把摄像头的数据取到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的主线程,可以使用这张纹理进行渲染了。
需要注意的地方:
- 同上面传输bytes[]数据的方式类似,在把一帧数据渲染到
mOutputTex
上之后,通知u3d进行处理,在u3d还没来得及处理时又进入了下一次渲染。 为了避免这个问题:
a. 需要建立输出缓冲区,创建2-3个mOutputTex
,分别绑定FBO并在渲染之前切换
b. 控制摄像头输出的帧率,一般25-30帧即可满足视觉要求了。 - 由于现在视频数据是高速硬件处理了,速度很快,而人脸数据的解析仍然是跑在CPU的,所以在性能好的机器上会造成人脸数据跟不上视频的渲染速度。
解决这个问题需要从mCameraInputSurface
的onFrameAvailable()
回调中入手。
在摄像头把视频数据渲染到`mCameraInputSurface`上时,就会通过`onFrameAvailable`通知,并等待`mCameraInputSurface .updateTexImage()`消费,消费之后的下一次渲染好时又会回调。
这个消费-通知-消费的过程是很快速的,所以需要在人脸解析完成之后再去通知`UnityTextureBridge`消费,这样可以基本保证视屏画面和人脸数据的同步。
到这里算是完了,期间的坑还是很多的。项目代码涉密暂时不能透露,不过核心部分已经叙述清楚了,有不懂的地方可以百度补一补课,实现起来基本是跑的通的。