全身3Dスキャンで"フリー素材"化した自分とARで共演してみた話

前回の記事「全身3Dスキャンで"フリー素材"化して、踊ったりしてみた話」で、自分自身をアニメーション付きの3Dモデルデータにしたところまで書きました。その後ARKit 3を使ってARコンテンツ化したので、だいぶ時間が経ってしまいましたが記事にしてみます。

完成したものはこんな感じ。

ソースコードはこちら。

https://github.com/miyabi/dancing-ar

やりたいこと

ARKitで3Dオブジェクトを描画するには、SceneKit、SpriteKit、iOS 13から追加されたRealityKitを使うか、あるいはMetalを使って独自に描画します。今回は手軽にARコンテンツを作成するために、SceneKitを使用します。

また、画面のタップした位置に3Dモデルを配置する仕様にします。リアルな3DモデルなのでARで表示するときもリアルに表示したいと思い、以下の実装もおこないます。

  • 3Dモデルに当たる照明と実際の照明をなじませる(Light estimation)
  • 影を描画する
  • 3Dモデルで手前の人物が隠れてしまうのを防ぐ(People occlusion)

準備

mixamoでアニメーションを付けた3DモデルはFBX(.fbx)かCollada(.dae)形式でダウンロードできます。.dae形式は特別なライブラリが不要で、Xcodeがビルド時に自動的にSceneKitで扱える形式に変換してくれるので、この形式でダウンロードしておきます。

SceneKitで扱える3Dモデルのフォーマット/アニメーションつき3DモデルをSceneKitで使う

XcodeのNewメニュー→Projectから「Augmented Reality App」を選択し、Content Technologyを「SceneKit」にします。

以下、コードはすべてViewController.swiftに実装しています。

モデルの配置

.dae形式の3Dモデル(とテクスチャー)をリソースとしてプロジェクトに追加します。viewDidLoadで3Dモデルを読み込みますが、あとで画面のタップした位置に配置したいのでシーンには追加せずにSceneKitのノードとして保持しておきます。

var baseNode: SCNNode!

override func viewDidLoad() {
    // ...

    baseNode = SCNScene(named: "Samba Dancing.dae")!.rootNode.childNode(withName: "Base", recursively: true)
}

ARWorldTrackingConfiguration.planeDetectionに.horizontalを指定して、平面を検出できるようにします。

override func viewWillAppear(_ animated: Bool) {
    // ...

    configuration.planeDetection = [.horizontal]

    // ...
}

Main.storyboardにTap Gesture Recognizerを追加し、画面のタップを受け取るアクションを追加します。

タップした位置に対応する現実空間の座標を取得するにはARFrame.hitTestを使用しますが、今回はiOS 13から追加されたARSCNView.raycastQueryとARSession.raycastを使って実装します。

@IBAction func screenDidTap(_ sender: UITapGestureRecognizer) {
    guard let view = sender.view else { return }
    
    if sender.state == .ended {
        let location = sender.location(in: view)
        guard let raycastQuery = sceneView.raycastQuery(from: location, allowing: .estimatedPlane, alignment: .horizontal) else { return }
        guard let raycastResult = sceneView.session.raycast(raycastQuery).first else { return }

        let position = SCNVector3Make(
            raycastResult.worldTransform.columns.3.x,
            raycastResult.worldTransform.columns.3.y,
            raycastResult.worldTransform.columns.3.z
        )

        let newBaseNode = baseNode.clone()
        newBaseNode.position = position
        sceneView.scene.rootNode.addChildNode(newBaseNode)
    }
}

まずraycastQueryにSCNView上での座標、raycastをヒットさせる対象とそのalignment(今回は検出された水平面にするのでそれぞれ.estimatedPlaneと.horizontal)を指定してクエリを作ります。そのクエリをARSession.raycastに渡すことによって、現実空間の座標を取得できます。ただしsimd_float4x4なので、これをSCNVector3に変換します。

保持しておいた3Dモデルのノードをcloneして、得られた座標を設定し、シーンに追加します。

Light estimation

Light estimationはキャプチャしたシーンの画像から照明を推定する機能で、初期のARKitから利用可能です。

まず、Light estimationを反映させるための環境光を追加します。

var ambientLightNode: SCNNode!

override func viewDidLoad() {
    // ...

    let ambientLight = SCNLight()
    ambientLight.type = .ambient
    ambientLight.shadowMode = .deferred
    ambientLightNode = SCNNode()
    ambientLightNode.light = ambientLight
    scene.rootNode.addChildNode(ambientLightNode)
}

ARWorldTrackingConfigurationのisLightEstimationEnabledとenvironmentTexturingを有効にします。

override func viewWillAppear(_ animated: Bool) {
    // ...

    configuration.isLightEstimationEnabled = true
    configuration.environmentTexturing = .automatic

    // ...
}

ARSCNViewを使用している場合、これだけで現実空間の照明の変化は自動的にシーンの照明に反映されます。

影の描画

単に3Dモデルを表示するだけでは"浮いて"見えてしまいます。そこで影を描画します。

まず、光源となるDirectional lightを追加します。この照明で影を描画するのでcastsShadowをtrueにします。また、レンダリングパスの最後に描画するのでshadowModeを.defferedに、shadowColorは半透明の黒にします。

少し影をぼかしてよりリアルにするため、shadowSampleCountとshadowRadiusの値を8に設定します。

最後に、Light estimationでは光源の方向を推定できないため、真上から光が当たるようにして、シーンに追加します。

var directionalLightNode: SCNNode!

override func viewDidLoad() {
    // ...

    let directionalLight = SCNLight()
    directionalLight.type = .directional
    directionalLight.intensity = 1000
    directionalLight.castsShadow = true
    directionalLight.shadowMode = .deferred
    directionalLight.shadowColor = UIColor.black.withAlphaComponent(0.5)
    directionalLight.shadowSampleCount = 8
    directionalLight.shadowRadius = 8
    directionalLightNode = SCNNode()
    directionalLightNode.light = directionalLight
    directionalLightNode.rotation = SCNVector4Make(1.0, 0.0, 0.0, -Float.pi / 2.0)
    scene.rootNode.addChildNode(directionalLightNode)
}

このままでは影が投影される平面がシーン上にないので、検出した現実空間の平面のジオメトリから作ります。このジオメトリのマテリアルのcolorBufferWriteMaskを空にしておくことで、透明で影だけ投影できる平面が作れます。

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
    guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
    
    let device = MTLCreateSystemDefaultDevice()!
    let planeGeometry = ARSCNPlaneGeometry(device: device)!
    planeGeometry.update(from: planeAnchor.geometry)
    planeGeometry.firstMaterial?.colorBufferWriteMask = []
    node.geometry = planeGeometry
}

People occlusion

通常ARコンテンツにおける3Dモデルは、現実空間をキャプチャした画像の上に描画されるため、手前にある現実の物体の奥に3Dモデルを表示することができず、違和感の元となっていました。

iOS 13から使用可能になったPeople occlusionによって、手前の人物をマスクすることができるようになり、この違和感を低減させることができます。

Occluding Virtual Content with People | Apple Developer Documentation

ただし、この機能にはA12 Bionic以降に搭載されている8コアのニューラルエンジンが必要なため、すべてのデバイスで使用することはできません。対応しているデバイスは、XR以降のiPhoneと、第3世代以降の12.9インチまたは11インチのiPad Pro、第3世代以降のiPad Air、第5世代以降のiPad miniとなっています。

People occlusionを有効にするには、ARWorldTrackingConfiguration.supportsFrameSemanticsで対応しているデバイスか確認したあと、ARWorldTrackingConfiguration.frameSemanticsに.personSegmentationWithDepthを設定します。

override func viewWillAppear(_ animated: Bool) {
    // ...

    if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
        configuration.frameSemantics.insert(.personSegmentationWithDepth)
    }

    // ...
}

ARSCNViewを使用している場合は、これで3Dモデルより手前の人物がマスクされます。

ARCoachingOverlayView

最後に本筋ではありませんがiOS 13で追加されたARCoachingOverlayViewを実装しておきます。

ARCoachingOverlayView - ARKit | Apple Developer Documentation

バイスに現実空間を認識させるためには、その仕組み上、デバイスを少し動かすなど一定の手順があります。これまでは開発者自身がそれをユーザーに伝える工夫をする必要がありましたが、それがOSの標準機能で可能になりました。

実装はこんな感じです。sessionに使用するARSession、goalに.horizontalPlaneを設定して(今回は水平面の検出なので)、ARSCNViewに追加します。

var coachingOverlayView: ARCoachingOverlayView!

override func viewDidLoad() {
    // ...

    coachingOverlayView = ARCoachingOverlayView()
    coachingOverlayView.session = sceneView.session
    coachingOverlayView.delegate = self
    coachingOverlayView.activatesAutomatically = true
    coachingOverlayView.goal = .horizontalPlane
    
    sceneView.addSubview(coachingOverlayView)

    coachingOverlayView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        coachingOverlayView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        coachingOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        coachingOverlayView.widthAnchor.constraint(equalTo: view.widthAnchor),
        coachingOverlayView.heightAnchor.constraint(equalTo: view.heightAnchor),
    ])
}

最後に

実際のプロジェクトでは、スケールを設定するスライダーとPeople occlusionの有効/無効を切り替えるスイッチを実装しています。

スケールを小さくしてたくさん3Dモデルを配置するとわらわらして楽しい感じになります。

スイッチの方は、People occlusionを有効にすると(ARWorldTrackingConfiguration.frameSemanticsに.personSegmentationWithDepthを設定すると)、なぜか平面に影が描画されないという現象があったので実装しました。
3Dモデル上には影が投影されているので、colorBufferWriteMaskあたりの問題なのかなあとは思っています。

こちらからは以上です。