iOS13で複数カメラの映像を同時に扱えるようになりました。
AVCaptureMultiCamSession - AVFoundation | Apple Developer Documentation
これができると、例えば車載とか街歩き系の配信で風景と一緒にワイプも載っけたりできて面白そうですね。
ということでAppleのサンプルを参考に自分でも触ってみました。
AVMultiCamPiP: Capturing from Multiple Cameras
今回作ったサンプルの処理概要は以下のようになります。
-
AVCaptureMultiCamSession
を使ってリアカメラとフロントカメラの映像を取得 -
CMSampleBuffer
からMTLTexture
に変換 - Metalシェーダで2つのTextureを合成
-
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シェーダを MTKView
の draw(_ 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つのカメラを同時に使うこともできるみたいです。
対応端末が限られますが、配信アプリとかに需要ありそうですね。