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

自分自身をアニメーション付きの3Dモデルデータにし、ARKitでARコンテンツ化してみた記録です（ブログにて2回に分けて書いていた記事をまとめて再構成してみました）。

まず完成したものがこちら。
<a href="https://youtu.be/8IVfsF_uhFI"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/f3842659-cd03-a81b-a225-287849169e0c.png" width="520"></a>
そしてソースコードはこちらです。
https://github.com/miyabi/dancing-ar

## 全身3Dスキャナー「3D GATEWAY」

去る9月25・26日に開催されたUnite Tokyo 2019のブース展示で、[Digital Artisan](https://www.digitalartisan.co.jp)さんが全身3Dスキャナー「[3D GATEWAY](https://prtimes.jp/main/html/rd/p/000000005.000040823.html)」を展示、3Dスキャン体験をおこなっていたので、スキャンしてもらってきました（3Dスキャン体験は他のイベントなどでも実施されているようです）。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">3D GATEWAYで3Dスキャナー体験ができます！スキャンデータは後日sketchfabに公開し、自由に3Dデータをダウンロードできるとのこと。 <a href="https://twitter.com/hashtag/UniteTokyo?src=hash&amp;ref_src=twsrc%5Etfw">#UniteTokyo</a> <a href="https://t.co/Ag8odd1sTY">pic.twitter.com/Ag8odd1sTY</a></p>&mdash; ユニティ・テクノロジーズ・ジャパン (@unity_japan) <a href="https://twitter.com/unity_japan/status/1176670576707956736?ref_src=twsrc%5Etfw">September 25, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

スキャン自体は一瞬（1/200秒）で終わり、約10分で3Dモデルデータを作成できるそうです。ただし当日はイベントで多数の方がスキャンされていたため、夕方スキャンして翌朝モデルデータが完成しました。モデルデータはSketchfab上で公開され、FBXかglTFでダウンロードが可能です。
https://sketchfab.com/3d-models/0926042-0f1953978d5a412bb5dace2e2d84feb5

モデルデータをFBXでダウンロードして手持ちの3Dツール、Cheetah 3Dで確認したところ、ポリゴン数は20万でした。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/63e963ab-70d7-6080-1f2c-dc347d717463.png" width="600" />

## mixamoでリギング

せっかくなので動かしてみたいと思い、Adobeの[mixamo](https://www.mixamo.com/)でリギングしてみました。リギングとは、3Dモデルをアニメーションで動かすための設定を施すことです。

元のFBXをそのままmixamoにアップロードしたらエラーになってしまいました。どうもポリゴン数が多すぎるようなので、減らしてみます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/df290900-01e7-7067-8269-484fe9eb7741.png" width="600" />
Cheetah 3DのTools→Polygon→Simplifyで試してみましたが微妙な感じだったので、一旦OBJに変換して[Meshmixer](http://www.meshmixer.com)を使うことにしました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/ed4f258a-75cd-9bc1-65e5-df189145b08d.png" width="600" />
Selectから全部選択してEdit→Reduce、デフォルトで50%になっているのでそのままAccept、再びOBJでエクスポートします。
これをマテリアル、テクスチャーと一式zipにしてmixamoにアップロードしてみました。今度はエラーにはならないものの、モデルが真っ黒になってしまいました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/d39c161f-7393-9110-efc1-2e21cddd491a.png" width="600" />
Cheetah 3DでOBJファイルを開いてみると余計なマテリアルがあったので、これを削除したあと再びzipにしてアップロードすると、今度はうまくいきました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/56184e29-986f-fd78-c789-bab6e414ea69.png" width="600" />
mixamoでは手首、肘など8箇所をマークするだけで、自動的にリギングしてくれます。とっても簡単！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/2be23ac1-721d-0c34-ea20-8e0975983194.png" width="600" />
リギングしたモデルには、2,000以上あるアニメーションを割り当ててその場でプレビューできます。これで色々試しているだけでもだいぶ楽しいです。
<a href="https://youtu.be/blNNbZl77d8"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/116d9020-ec4b-9854-8edc-01bd60f678de.png" width="520" /></a>

## ARKitでARコンテンツ化

さて、ここからが本編。アニメーション付きの3Dモデルデータが得られたので、[ARKit 3](https://developer.apple.com/augmented-reality/arkit/)を使って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で扱える形式に変換してくれるので、この形式でダウンロードしておきます。

XcodeのNewメニュー→Projectから「Augmented Reality App」を選択し、Content Technologyを「SceneKit」にします。
以下、コードはすべてViewController.swiftに実装しています。

### モデルの配置

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

```swift
var baseNode: SCNNode!

override func viewDidLoad() {
    // ...

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

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

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

    configuration.planeDetection = [.horizontal]

    // ...
}
```

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

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

```swift
@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を反映させるための環境光を追加します。

```swift
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を有効にします。

```swift
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では光源の方向を推定できないため、真上から光が当たるようにして、シーンに追加します。

```swift
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を空にしておくことで、透明で影だけ投影できる平面が作れます。

```swift
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](https://developer.apple.com/documentation/arkit/occluding_virtual_content_with_people?changes=late_8)

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

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

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

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

    // ...
}
```

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

### ARCoachingOverlayView

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

[ARCoachingOverlayView - ARKit | Apple Developer Documentation](https://developer.apple.com/documentation/arkit/arcoachingoverlayview)

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

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

```swift
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モデルを配置するとわらわらして楽しい感じになります。
<a href="https://youtu.be/8IVfsF_uhFI"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9836/992e1ec1-f631-d12e-1d71-aa289a62a077.png" width="520"></a>
スイッチの方は、People occlusionを有効にすると（ARWorldTrackingConfiguration.frameSemanticsに.personSegmentationWithDepthを設定すると）、なぜか平面に影が描画されないという現象があったので実装しました。
3Dモデル上には影が投影されているので、colorBufferWriteMaskあたりの問題なのかなあとは思っています。

こちらからは以上です。

## 参考記事

- [SceneKitで扱える3Dモデルのフォーマット／アニメーションつき3DモデルをSceneKitで使う](https://qiita.com/shu223/items/0161f82d000d5cdf009c)