AVCaptureMultiCamSession で取得したカメラの映像をMetalで合成&表示する


iOS13で複数カメラの映像を同時に扱えるようになりました。
AVCaptureMultiCamSession - AVFoundation | Apple Developer Documentation
これができると、例えば車載とか街歩き系の配信で風景と一緒にワイプも載っけたりできて面白そうですね。

ということでAppleのサンプルを参考に自分でも触ってみました。
AVMultiCamPiP: Capturing from Multiple Cameras

今回作ったサンプルの処理概要は以下のようになります。

  1. AVCaptureMultiCamSession を使ってリアカメラとフロントカメラの映像を取得
  2. CMSampleBuffer から MTLTexture に変換
  3. Metalシェーダで2つのTextureを合成
  4. MTKView に描画

では処理を順に追っていきます。
この記事内ではポイントだけコードを載せているので全体流れはこちらをあわせて見てみてください。

AVCaptureMultiCamSession を使う

AVCaptureSession と使い方は変わりません。

override func viewDidLoad() {
    super.viewDidLoad()

    // マルチカメラは A12X か A12 を搭載している端末でしか使えません
    guard AVCaptureMultiCamSession.isMultiCamSupported else {
      assertionFailure("not supported")
      return
    }

    configure()

    // 設定が終わったら startRunning でスタート
    session.startRunning()
}

func configure() {
    // 設定は beginConfiguration と commitConfiguration の間で行う
    session.beginConfiguration()
    configureBackCamera()
    configureFrontCamera()
    session.commitConfiguration()
}

func configureBackCamera() {
    // デバイスを初期化
    guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),

    // 入力をセッションに追加
    let input = try? AVCaptureDeviceInput(device: device) else { return }
    if session.canAddInput(input) {
        session.addInputWithNoConnections(input)
    }

    // 出力をセッションに追加
    backCameraVideoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
    backCameraVideoDataOutput.setSampleBufferDelegate(self, queue: outputQueue)
    if session.canAddOutput(backCameraVideoDataOutput) {
        session.addOutputWithNoConnections(backCameraVideoDataOutput)
    }

    // 入力と出力を接続
    let port = input.ports(for: .video, sourceDeviceType: device.deviceType, sourceDevicePosition: device.position)
    let connection = AVCaptureConnection(inputPorts: port, output: backCameraVideoDataOutput)
    connection.videoOrientation = .portrait

    if session.canAddConnection(connection) {
        session.addConnection(connection)
    }
}

func configureFrontCamera() {
    // configureBackCameraと同様なので省略
}

captureOutput(_:didOutput:from:) でフレーム毎のデータを受け取ることが出来ます。
各フレームがどのカメラのものなのかはこのメソッドの中で判定できます。
リア/フロントそれぞれの AVCaptureVideoDataOutput をプロパティとして保持しておいて、delegateで渡される AVCaptureVideoDataOutput と比較をすることでどちらのフレームなのか判定します。

if output == backCameraVideoDataOutput {
    // リアカメラ
}
else if output == frontCameraVideoDataOutput {
    // フロントカメラ
}

それぞれのフレームデータは順番に来るため、サンプルではフロントカメラの映像はプロパティに持っておいて、リアカメラのデータが来たときに、取っておいたフロントカメラの映像と合成して表示する流れになっています。

let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)

if output == backCameraVideoDataOutput {
    guard let currentFrontBuffer = currentFrontBuffer else { return }

    // captureOutput(_:didOutput:from:) は指定したスレッドで実行されるので、描画処理はメインスレッドへ
    DispatchQueue.main.async {
        // pixelBuffer と currentFrontBuffer を合成&描画
    }
}
else if output == frontCameraVideoDataOutput {
    currentFrontBuffer = pixelBuffer
}

CMSampleBuffer から MTLTexture に変換

2つの AVCaptureVideoPreviewLayer を用意してそれぞれに映像を表示する方法もありますが、それだとすぐ終わってしまうのでMetalで2つの映像を合成して MTKView に表示させてみました。

そのためにまず上述の captureOutput(_:didOutput:from:) で受け取った CMSampleBuffer をMetalのテクスチャに変換します。
MTLTexture に変換する前に、一度 CVPixelBuffer に変換します。

let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)

ここで何故か CMSampleBufferGetImageBuffer が nil を返してきてなんでかなーと悩んだのですが、保管で出てきた
captureOutput(:didDrop:from:) の方を間違えて使っていました。同じように nil が返ってくる場合はメソッドをご確認ください。

上で生成した CVPixelBuffer から MTLTexture を生成します。

func createMetalTexture(from buffer: CVPixelBuffer) -> MTLTexture? {
    guard let textureCache = textureCache else { return nil }

    var cvMetalTexture: CVMetalTexture?
    CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                              textureCache,
                                              pixelBuffer,
                                              nil,
                                              .bgra8Unorm,
                                              CVPixelBufferGetWidth(pixelBuffer),
                                              CVPixelBufferGetHeight(pixelBuffer),
                                              0,
                                              &cvMetalTexture)

    guard let texture = cvMetalTexture else { return nil }

    return CVMetalTextureGetTexture(texture)
}

Metalシェーダで2つのTextureを合成

合成はMetalシェーダを使います。
AppleのサンプルではUI上は AVCaptureVideoPreviewLayer を使って表示していますが、録画する際にシェーダを使って合成したものを書き込んでいました。
そのシェーダを拝借して

kernel void mix(texture2d<half, access::read> mainTexture [[ texture(0) ]],
                texture2d<half, access::sample> subTexture [[ texture(1) ]],
                texture2d<half, access::write> outputTexture [[ texture(2) ]],
                uint2 id [[thread_position_in_grid]]) {
    float scale = 0.25;
    float2 origin = float2(50, 100);
    float2 size = float2(mainTexture.get_width(), mainTexture.get_height()) * scale;

    half4 output;

    if ((id.x >= origin.x && id.x <= origin.x + size.x) &&
        (id.y >= origin.y && id.y <= origin.y + size.y)) {
        constexpr sampler textureSampler (filter::linear, coord::pixel);
        float2 sampleCoord = (float2(id) - origin)/scale;
        output = subTexture.sample(textureSampler, sampleCoord);
    } else {
        output = mainTexture.read(id);
    }

    outputTexture.write(output, id);
}

今回はワイプの映像は固定位置になるように簡略化しています。
thread_position_in_grid は、 dispatchThreadgroups(_: threadsPerThreadgroup:) で指定するサイズによってグリッドの範囲が決まります。
thread_position_in_grid を使って各ピクセルデータへアクセスしています。
スレッドグループとグリッドサイズについてはこちらも参考になります。
Calculating Threadgroup and Grid Sizes

テクスチャの座標を順に見ていき、ワイプの場所ならワイプ用のTextureから、それ以外はメインのTextureからピクセルデータを取得して outputTexture に書き込んでいます。
ワイプの方で sample を使っているのは、ワイプの場合描画する領域がテクスチャのサイズより小さいので指定された座標を囲むピクセルから色を平均化して求めるためです。
サンプリングについては以下に詳しく書いてありました。
Creating and Sampling Textures
Metal Shading Language Specification の 2.9 Samplers

MTKView に描画

上で作成したMetalシェーダを MTKViewdraw(_ rect: CGRect) 内で実行します。
Metalシェーダの実行に関しては以前別記事で書いたことがあるので詳細は省きますがポイントはここです。

override func draw(_ rect: CGRect) {
    ・・・

    let currentDrawable = currentDrawable else { return }

    let encoder = commandBuffer?.makeComputeCommandEncoder()
    encoder?.setComputePipelineState(pipelineState)
    encoder?.setTexture(main, index: 0)
    encoder?.setTexture(sub, index: 1)
    encoder?.setTexture(currentDrawable.texture, index: 2)

    ・・・

先程作成したmix関数の第一引数にリアカメラのテクスチャ、第二引数にフロントカメラのテクスチャ、そして第三引数に描画対象となる MTKView#currentDrawable#texture を指定しています。
すると、上で見た通りシェーダ内で第三引数に渡したテクスチャに書き込んでくれます。

これで完成ですー。

まとめ

最初は AVCaptureMultiCamSession を触ってみようという感じだったのですが、あまりに簡単だったのでMetalを絡ませてみました。
今回はリア/フロントとも .builtInWideAngleCamera の組み合わせでマルチカメラを実装しましたが、有効なカメラの組み合わせについては supportedMultiCamDeviceSets を使って調べることができます。

https://developer.apple.com/videos/play/wwdc2019/225/

2つだけでなく、3つのカメラを同時に使うこともできるみたいです。
対応端末が限られますが、配信アプリとかに需要ありそうですね。