9月5日(木) 〜 9月7日(土)にかけて開催されたiOSDC Japan 2019において、サイバーエージェントは今年もダイアモンドスポンサーを務め、 5名のエンジニアが登壇いたしました。

今日は登壇者の服部(@shmdevelop)に代わり「クロマキー合成を使い透過動画をAR空間に表示する」のセッションを下村(@_kzumu)が振り返ります。

クロマキー合成を使い透過動画をAR空間に表示する – Proposal

AbemaTV 服部 智(@shmdevelop)

サンプルコード – GitHub

Keynote(動画あり) – GoogleDrive

クロマキー合成に関する知識

クロマキー合成

クロマキー(Chroma key)もしくはクロマキー合成(クロマキーごうせい)はキーイングの一種で、特定の色の成分から映像の一部を透明にし、そこに別の映像を合成する技術。

https://ja.wikipedia.org/wiki/クロマキー

キーイング

キーイング(英: keying)は、映像編集技術の一つで、色・明暗などの成分から画像・映像の一部を抜き出すこと。ブルーバック撮影などをしてその色だけを取り除くクロマキー、明るさの情報から抜き出すルミナンスキーなどがある。

https://ja.wikipedia.org/wiki/キーイング

クロマキー合成の例

クロマキー合成の例の画像
Applying a Chroma Key Effect – developer.apple.com

今回作るもの

クロマキー合成行なった映像をAR空間上で表示します。

元動画

こちらの動画の緑色の部分を透過させ、AR空間上で表示するとこのような感じになります。

ARKitを使用した動画の再生

今回はARKitのARSCNViewを使用して実装を進めていきます。

SCNRendererでの描画やARSCNViewを使わずMetalのみでの実装も可能ですが、コード量が増えセッションの主題とずれてしまうため今回は使用していません。

今回利用する主要なクラス

  • SpriteKitのSKVideoNode
    • 動画を再生できる
  • SceneKitのSCNNode
    • SKVideoNodeを表示要素にできる
    • AR空間内に配置できる
  • MetalKitのMTKView
    • シェーダーを利用して透過部分を作成できる

SCNSceneにおける階層構造

SCNSceneにおける階層構造の画像

VideoNodeの作成と画面への表示

override func viewDidLoad() {
    super.viewDidLoad()

    sceneView.delegate = self
    sceneView.scene = SCNScene()

    let videoUrl = Bundle.main.url(forResource: "video001", withExtension: "mp4")!
    let videoNode = createVideoNode(size: 1, videoUrl: videoUrl)
    videoNode.position = SCNVector3(0, 0, -3.0)
    sceneView.scene.rootNode.addChildNode(videoNode)
}

func createVideoNode(size: CGFloat, videoUrl: URL) -> SCNNode {
    // サイズが小さいとビデオの解像度が落ちる
    let skSceneSize = CGSize(width: 1024, height: 1024)

    // AVPlayer生成
    let avPlayer = AVPlayer(url: videoUrl)

    // SKVideoNode生成
    let skVideoNode = SKVideoNode(avPlayer: avPlayer)
    skVideoNode.position = CGPoint(x: skSceneSize.width / 2.0, y: skSceneSize.height / 2.0)
    skVideoNode.size = skSceneSize
    skVideoNode.yScale = -1.0 // 座標系を上下逆にする
    skVideoNode.play()

    // SKScene生成
    let skScene = SKScene(size: skSceneSize)
    skScene.addChild(skVideoNode)

    // SCNMaterial生成
    let material = SCNMaterial()
    material.diffuse.contents = skScene
    material.isDoubleSided = true // AR空間上で反対側からは反転した映像が見れる

    // SCNNode生成
    let node = SCNNode()
    // SCNPlane(=SCNGeometryを継承したクラス)生成
    node.geometry = SCNPlane(width: size, height: size)
    node.geometry?.materials = [material]
    node.scale = SCNVector3(1, 0.5625, 1)
    return node
}

これほどの少ない実装量でAR空間内で動画を再生することができます。

SCNMaterial.isDoubleSided = true とすることで、AR空間上で反対側からは反転した映像が見ることができます。

透過動画を再生する

処理の流れ

MetalKitのMTKViewを継承した、VideoMetalViewを作成します。

VideoMetalViewの中ではざっくり以下の処理を行います。

  1. 現在の動画データを画像化
  2. 一時的にMTKViewにレンダリング
  3. クロマキーシェーダー処理

実装

/*-----1. 現在の動画データを画像化-----*/
private func makeCurrentVideoImage() -> CIImage? {
    guard let player = player,
        let videoItem = player.currentItem
        else { return nil }

    let time = videoItem.currentTime()

    // copyPixelBufferCIImageへ変換
    guard
        videoOutput.hasNewPixelBuffer(forItemTime: time),
        let pixelBuffer = videoOutput.copyPixelBuffer(
            forItemTime: time,
           itemTimeForDisplay: nil
        )
        else { return nil }

    return CIImage(cvPixelBuffer: pixelBuffer)
}
/*-----1. 現在の動画データを画像化-----*/

動画出力をcopyPixelBufferを通して、CIImage変換することで60fpsという高速な描画に耐えることができます。

draw(_ dirtyRect:) 内の処理を以下に記載します。

override func draw(_ dirtyRect: CGRect) {

    guard let device = device,
        let drawable = currentDrawable,
        let tempDrawable = bufferMtkView.currentDrawable,
        let image = makeCurrentVideoImage() else { return }

    /*-----2. 一時的にMTKViewにレンダリング-----*/
    ciContext.render(image, to: tempDrawable.texture, commandBuffer: nil, bounds: bounds, colorSpace: colorSpace)
    colorPixelFormat = tempDrawable.texture.pixelFormat
    /*-----2. 一時的にMTKViewにレンダリング-----*/

    let commandBuffer = commandQueue.makeCommandBuffer()!
    let commandEncoder = commandBuffer.makeComputeCommandEncoder()!

    commandEncoder.setComputePipelineState(pipelineState)

    /*-----3. クロマキーシェーダー処理-----*/
    // Shaderに各種パラメータを渡す
    commandEncoder.setTexture(tempDrawable.texture, index: 0)
    commandEncoder.setTexture(drawable.texture, index: 1)

    let factors: [Float] = [
        0,    // red
        1,    // green
        0,    // blue
        0.43, // threshold
        0.11  // smoothing
    ]
    for i in 0..<factors.count {
        var factor = factors[i]
        let size = max(MemoryLayout<Float>.size, 16)
        let buffer = device.makeBuffer(
            bytes: &factor,
            length: size,
            options: [.storageModeShared]
        )
        commandEncoder.setBuffer(buffer, offset: 0, index: i)
    }
    /*-----3. クロマキーシェーダー処理-----*/

    commandEncoder.dispatchThreadgroups(
        threadgroupsPerGrid,
        threadsPerThreadgroup: threadsPerThreadgroup
    )
    commandEncoder.endEncoding()

    commandBuffer.present(drawable)
    commandBuffer.commit()
}

クロマキー処理に利用するMetalシェーダー

シェーダーの関数には以下のような種類があります。

種類 概要
Vertex 各頂点の座標情報を返却するFunction
Fragment 各頂点の色情報を返却するFunction
Compute(Kernel) 大規模なデータを並列処理する際の汎用的なカスタムFunction

シェーダーでのクロマキー合成処理

シェーダーの処理は、Shaders.metalのKernel functionに記述していきます。処理は以下のような手順になります。

  1. maskColor(緑色)をYCrCbへ変換
  2. 座標のRGBをYCrCbへ変換
  3. YCrCb、閾値、緑色との距離により透過度を算出
  4. 座標のRGBを算出した透過度で出力する色を計算

実装

{
    const float4 inColor = inTexture.read(gid);

    // maskの対象の色。緑色を透過するためmaskColorはfloat3(0, 1, 0)になる
    const float3 maskColor = float3(*colorRed, *colorGreen, *colorBlue);

    // RGBからYへの射
    const float3 YVector = float3(0.2989, 0.5866, 0.1145);

    // 1. maskColor(緑色)をYCrCbへ変換
    const float maskY = dot(maskColor, YVector);
    const float maskCr = 0.7131 * (maskColor.r - maskY);
    const float maskCb = 0.5647 * (maskColor.b - maskY);

    // 2. 座標のRGBをYCrCbへ変換
    const float Y = dot(inColor.rgb, YVector);
    const float Cr = 0.7131 * (inColor.r - Y);
    const float Cb = 0.5647 * (inColor.b - Y);

    // 3. YCrCb、閾値、緑色との距離により透過度を算出
    const float alpha = smoothstep(
       *threshold,
       *threshold + *smoothing,
       distance(float2(Cr, Cb), float2(maskCr, maskCb))
    );

    // 4. 座標のRGBを算出した透過度で出力する色を計算
    const float4 outColor = alpha * float4(inColor.r, inColor.g, inColor.b, 1.0);
    outTexture.write(outColor, gid);
}

おまけ

1. UIViewの透過表示

先ほどの 動画を画像化する処理UIViewを画像化する処理 に置き換えるだけで表示ができます。

しかし、描画パフォーマンスは先ほどよりも落ちてしまいました。

実装

先ほどの makeCurrentVideoImage() -> CIImage? を以下の実装に置き換えます。

func makeCurrentViewImage(view: UIView) -> CIImage? {
    UIGraphicsBeginImageContextWithOptions(view.frame.size, true, 0)
    view.drawHierarchy(in: view.bounds, afterScreenUpdates: false)
    let image = UIGraphicsGetImageFromCurrentImageContext()!
    UIGraphicsEndImageContext();
    return CIImage(image: image)
}

2. WKWebViewの透過表示

WKWebViewの透過表示の画像

こちらのサイトの緑色の部分を透過させてみると、以下のような表示ができます。

3. WebRTC接続したViewの透過表示

自分の後ろの背景を透過させることで、以下のような表示ができます。

まとめ

クロマキー合成を使って透過動画をAR空間に表示する方法を解説しました。

この記事を読んで興味を持っていただければ幸いです。

さいごに

サイバーエージェントでは、学生インターンを受付しています。

ご興味をお持ちの方はこちらをご覧ください。

最後まで記事をご覧いただきありがとうございました。

下村一将
2019年新卒入社。CATSにて新規事業のiOSアプリ開発を行なっています。