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

iOS13で複数カメラの映像を同時に扱えるようになりました。
[AVCaptureMultiCamSession - AVFoundation | Apple Developer Documentation](https://developer.apple.com/documentation/avfoundation/avcapturemulticamsession?language=objc)
これができると、例えば車載とか街歩き系の配信で風景と一緒にワイプも載っけたりできて面白そうですね。

ということでAppleのサンプルを参考に自分でも触ってみました。
[AVMultiCamPiP: Capturing from Multiple Cameras](https://developer.apple.com/documentation/avfoundation/cameras_and_media_capture/avmulticampip_capturing_from_multiple_cameras)

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

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

では処理を順に追っていきます。
この記事内ではポイントだけコードを載せているので全体流れは[こちら](https://github.com/yoshimin/MultiCamera)をあわせて見てみてください。

### `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で２つの映像を合成して `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](https://developer.apple.com/documentation/metal/calculating_threadgroup_and_grid_sizes)

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

### `MTKView` に描画
上で作成したMetalシェーダを `MTKView` の `draw(_ rect: CGRect)` 内で実行します。
Metalシェーダの実行に関しては以前[別記事](https://qiita.com/yoshimin@github/items/3f13ebfca96ac7106555#%E5%AE%9F%E8%A3%85)で書いたことがあるので詳細は省きますがポイントはここです。

```
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` を指定しています。
すると、上で見た通りシェーダ内で第三引数に渡したテクスチャに書き込んでくれます。

これで完成ですー。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14751/f1210f47-a1f9-e4ea-1f72-b28ed51343f8.png" width=200>


### まとめ
最初は `AVCaptureMultiCamSession` を触ってみようという感じだったのですが、あまりに簡単だったのでMetalを絡ませてみました。
今回はリア/フロントとも `.builtInWideAngleCamera` の組み合わせでマルチカメラを実装しましたが、有効なカメラの組み合わせについては [supportedMultiCamDeviceSets](https://developer.apple.com/documentation/avfoundation/avcapturedevice/discoverysession/3153004-supportedmulticamdevicesets) を使って調べることができます。

https://developer.apple.com/videos/play/wwdc2019/225/
<img width="600" alt="スクリーンショット 2019-11-22 16.34.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14751/d27de36c-46b2-0638-81cd-e561a55066bc.png">

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