获取物理相机的预览流是AR开发的基础能力, 在 ARFoundation 和 AREngine 都是使用 CommandBuffer 来绘制预览流。 如果使用WebCameraTexture获取的预览流受限制比较多, 比如说去调整变焦、曝光度这些东西更多的还是去原生层去拿。

WebCameraTexture

unity中获取camera的图像一般都是去通过WenCameraTexture。

第一步获取访问相机的权限,需要在系统提示里获取确认之后。

第二步,得到WebCamDevice[]数组, 代表硬件camera的句柄。 如果你的手机内置的camera越多,此数组就越多。一般手机都会带前置和后置两个摄像头, 所以手机上获取的此数组一般都是大于2。

第三步,创建一个WebCamTexture, 用来接收从硬件传过来的图像信息。 最后使用的一个2D texture渲染出来。

  private WebCamTexture webCamTexture;
  private WebCamDevice[] devices;
  private int devID;
  public RawImage image;

  IEnumerator Start()
  {
      yield return Application.RequestUserAuthorization(UserAuthorization.WebCam);
      if (Application.HasUserAuthorization(UserAuthorization.WebCam))
      {
          devices = WebCamTexture.devices;
          if (devices?.Length > 0)
          {
              devID = 0;
              Play(devices[0].name);
          }
      }
  }

  private void OnGUI()
  {
      int len = devices?.Length ?? 0;
      for (int i = 0; i < len; i++)
      {
          if (devices != null)
          {
              var t = devices[i].name;
              if (GUI.Button(new Rect(100, 100 * i, 120, 80), t))
              {
                  Play(t);
              }
          }
      }
  }

private void Play(string devicename)
{
    webCamTexture = new WebCamTexture(devicename, 480, 640, 30)
    {
        wrapMode = TextureWrapMode.Repeat
    };
    image.texture = webCamTexture;
    webCamTexture.Play();
}

一般拿到的原始图像数据都是翻转的, 并且前置和后置翻转的方向也不一致, 并且Android 和 IOS 也不相同。 翻转过来, 最简单的处理方式在Shader在uv采样原始数据时, 对uv进行翻转。 比如说这样:

fixed4 frag (v2f i) : SV_Target
{
    float2 uv = i.uv.yx;
    uv.x = 1.0 - uv.x;
    fixed4 col = tex2D(_MainTex, uv);
    return col;
}

除了翻转图像, 我们往往在camera拿到的图像长宽比都是4:3,而一般手机的屏幕都是16:9的, 因此shader里除了进行翻转, 为了保持图像的不变形, 还要对宽方向上进行两边裁剪。

查阅WebCameraTexure的api, unity也只是提供一些访问camera的基础能力。 更复杂的能力, 也只好去原生层去获取, 最好的方式就是原生层修改了一下设置, 比如说焦距, unity的预览流就会自动发生改变。 经过逆向Unity 安装包中的classes.jar, 我们发现的 android 平台的实现还是基于 camera2.

Camera2 api 能力

Camera2 的 API 模型被设计成一个 Pipeline(管道),它按顺序处理每一帧的请求并返回请求结果给客户端。下面这张来自官方的图展示了 Pipeline 的工作流程,我们会通过一个简单的例子详细解释这张图。

整个拍摄流程如下:


1. 创建一个用于从 Pipeline 获取图片的 CaptureRequest。

2. 修改 CaptureRequest 的闪光灯配置,让闪光灯在拍照过程中亮起来。

3. 创建两个不同尺寸的 Surface 用于接收图片数据,并且将它们添加到 CaptureRequest 中。

4. 发送配置好的 CaptureRequest 到 Pipeline 中等待它返回拍照结果。

一个新的 CaptureRequest 会被放入一个被称作 Pending Request Queue 的队列中等待被执行,当 In-Flight Capture Queue 队列空闲的时候就会从 Pending Request Queue 获取若干个待处理的 CaptureRequest,并且根据每一个 CaptureRequest 的配置进行 Capture 操作。最后我们从不同尺寸的 Surface 中获取图片数据并且还会得到一个包含了很多与本次拍照相关的信息的 CaptureResult,流程结束。 更多的关于 Camera2 的详细介绍, 点击这里的 链接

private void openCamera() {
  // 1 创建相机管理器,调用系统相机
    cameraManager= (CameraManager) getSystemService(Context.CAMERA_SERVICE); 
  // 2 准备 相机状态回调对象为后面用
    cam_stateCallback=new CameraDevice.StateCallback() {
        @Override
        public void onOpened(@NonNull CameraDevice camera) {
            // 2.1 保存已开启的相机对象
            opened_camera=camera;
            try {
                // 2.2 构建请求对象(设置预览参数,和输出对象) 
                requestBuilder = opened_camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); // 设置参数:预览
                requestBuilder.addTarget(texture_surface); // 设置参数:目标容器
                request = requestBuilder.build();
                //2.3 创建会话的回调函数,后面用
                cam_capture_session_stateCallback=new CameraCaptureSession.StateCallback() {
                    @Override  //2.3.1  会话准备好了,在里面创建 预览或拍照请求
                    public void onConfigured(@NonNull CameraCaptureSession session) {
                        cameraCaptureSession=session;
                        try {
                            // 2.3.2 预览请求
                            session.setRepeatingRequest(request,null,null);
                        } catch (CameraAccessException e) {
                            e.printStackTrace();
                        }
                    }
                };
                // 2.3 创建会话
                opened_camera.createCaptureSession( Arrays.asList(texture_surface), cam_capture_session_stateCallback,null);
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        }
        /..../
    };
  // 4 检查相机权限 
    checkPermission();
  // 5 开启相机(传入:要开启的相机ID,和状态回调对象)
    try {
        cameraManager.openCamera(cameraManager.getCameraIdList()[0],cam_stateCallback,null);
    } catch (CameraAccessException e) {
        e.printStackTrace();
    }
}

Unity访问camera2

问题是unity也没有Camera.open()这样的api, 也没有提供 Camera getRunningCamera() 类似这样的api, 获取当前webCameraTexture在session的句柄。 通过解压安装包的 classes.jar 发现, 其实现也是基于 Camera2的api, 比如说 Camera2Wrapper 这个类就是对外访问的wrapper。 在Unity2018中的jar包中, 有个类(混淆过)叫a.class 存在包 com.unity3d.player 中, 可以看到完整的camera2的调用的过程。

这个a的实例就存放在 Camera2Wrapper.class, 而Camera2Wrapper的实例存在 这个类里了, 从上图可以看到很多成员都是私有/private的, 直接调用肯定是没有权限的, 但是c#里提供了AndroidJavaClass 却可以访问到, 这里猜测其内部实现通过反射拿到的, 效率肯定高不到哪里去, 但这种获取句柄的操作 往往是初始化的调用一次, 并不是每帧调用, 所以也不会有额外的功耗开销。

var pl_class = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
var currentActivity = pl_class.GetStatic<AndroidJavaObject>("currentActivity");
var player = currentActivity.Get<AndroidJavaObject>("mUnityPlayer");
wrapper = player.Get<AndroidJavaObject>("p"); // Camera2Wrapper
var b = wrapper.Get<AndroidJavaObject>("b");
var builder = b.Get<AndroidJavaObject>("t"); // CaptureRequest.Builder
var camMgr = b.GetStatic<AndroidJavaObject>("b"); // CameraManager
if (camMgr != null && builder != null)
{
    pl_class = new AndroidJavaClass("com.yun.webCam.Camera2Test");
    pl_class.CallStatic("SetMgrAndCharacteristics", camMgr, builder, devID.ToString());
}

作者就是这样拿到 CameraManager 和 CaptureRequest.Builder 的, 拿到之后传递到Camera2Test这个自己实现的类中, 然后就这可以做各种效果了。Camera2Test.java 类实现:

public static void SetMgrAndCharacteristics(CameraManager mgr, CaptureRequest.Builder b, String name)
{
    Log.d(TAG, "SetMgrAndCharacteristics: " + (b == null));
    mCameraManager = mgr;
    mPreviewRequestBuilder = b;
    try {
        mCameraCharacteristics = mCameraManager.getCameraCharacteristics(name);
        Zoom(mCameraCharacteristics);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

private static void Zoom(final CameraCharacteristics characteristics)
{
    mSensorSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE);
    if (mSensorSize == null) {
        maxZoom = DEFAULT_ZOOM_FACTOR;
        hasSupport = false;
        Log.e(TAG, "NOT SUPPORT");
        return;
    }

    final Float value = characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM);
    maxZoom = ((value == null) || (value < DEFAULT_ZOOM_FACTOR)) ? DEFAULT_ZOOM_FACTOR : value;
    hasSupport = (Float.compare(maxZoom, DEFAULT_ZOOM_FACTOR) > 0);
    Log.d(TAG, "maxZoom: " + maxZoom + " default:" + DEFAULT_ZOOM_FACTOR + "  hasSupport: " + hasSupport);
}


public static void SetZoom(float newZoom)
{
    if (!hasSupport) return;

    if (newZoom < DEFAULT_ZOOM_FACTOR) newZoom = DEFAULT_ZOOM_FACTOR;
    if (newZoom > maxZoom) newZoom = maxZoom;
    mCurrentZoomFactor = newZoom;
    final int centerX = mSensorSize.width() / 2;
    final int centerY = mSensorSize.height() / 2;
    final int deltaX = (int) ((0.5f * mSensorSize.width()) / mCurrentZoomFactor);
    final int deltaY = (int) ((0.5f * mSensorSize.height()) / mCurrentZoomFactor);
    mCropRegion.set(centerX - deltaX,
            centerY - deltaY,
            centerX + deltaX,
            centerY + deltaY);

    mPreviewRequestBuilder.set(CaptureRequest.SCALER_CROP_REGION, mCropRegion);
    Log.d(TAG, "SetZoom: " + mCurrentZoomFactor + "  rect: " + mCropRegion);
}

大致思路就是如此了, 需要注意的是 逆向 classes.jar, 每个版本的unity里的变量名都是一致的, 最好还是去安装包去看下实际的变量名, 然后在获取传递到原生层去。 项目的代码我已经上传到 github了。