【ARKit】3Dアニメーションの切り替え

# はじめに
状況に応じてアニメーションを切り替えたかったので、そのメモ。

# 実行環境
- Xcode 11.2.1
- [MagicaVoxel](https://ephtracy.github.io) 0.99.4
- [Mixamo](https://www.mixamo.com)

# やりたいこと
- iPhoneのミュージックなど、他のアプリでの音楽が再生／停止の検知。
- 再生中はキャラクタが踊り、停止すると踊りを止めるようにアニメーションを切り替える。
（昔あった玩具のフラワーロック的なイメージ）

[Mixamo](https://www.mixamo.com)で作るアニメーションは、1つの3Dモデルに1つのアニメーションを割り当てるので、座ってる状態（sitting.scn）と踊っている状態（swing.scn）の二つのシーンからnodeを取りだして、差し替えることでアニメーションを切り替えることにしました。

【座ってる状態（sitting.scn）】
![スクリーンショット 2020-02-25 0.46.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106624/8f931f6b-924d-05d6-9c36-c19282ae52a9.png)

【踊っている状態（swing.scn）】
![スクリーンショット 2020-02-25 0.46.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106624/dd891e0b-4bfe-8d35-ae68-a809b1b97bf1.png)


# 他のアプリでのAVAudioSessionの状態を検知する
他のアプリでの音楽が再生／停止されたかは、[AVAudioSession.silenceSecondaryAudioHintNotification](https://developer.apple.com/documentation/avfoundation/avaudiosession/1616622-silencesecondaryaudiohintnotific)で 検知できるようなので、通知されたときに呼び出される関数（handleSecondaryAudio）をNotificationCenterに登録します。

```Swift:silenceSecondaryAudioHintNotificationの登録処理
    func setupNotifications() {
        // AVAudioSession：初期化
        try! AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
        try! AVAudioSession.sharedInstance().setActive(true)
        
        // 他アプリでのサウンド再生／停止を監視
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(self.handleSecondaryAudio), name: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil)
    }
```

setupNotificationsは、viewDidLoadなどで呼び出してておきます。また、[AVAudioSession.silenceSecondaryAudioHintNotification](https://developer.apple.com/documentation/avfoundation/avaudiosession/1616622-silencesecondaryaudiohintnotific) は、再生／停止が変化したの通知のため、初回のみ [AVAudioSession.sharedInstance().isOtherAudioPlaying](https://developer.apple.com/documentation/avfoundation/avaudiosession/1616610-isotheraudioplaying) でアプリ起動時の再生／停止状態を判断して初期化します。

```Swift:再生／停止検知の初期化
    let val_idle:String? = "sitting"
    let val_swing:String? = "swing"
    
    var selectedItem: String?

    override func viewDidLoad() {
        super.viewDidLoad()

        //（中略）
        
        setupNotifications()
        
        if AVAudioSession.sharedInstance().isOtherAudioPlaying {
            // 再生中!!
            print("---再生中")
            selectedItem = val_swing
        } else {
            print("---停止中")
            selectedItem = val_idle
        }
    }

```

# 他のアプリでの再生／停止検知時の処理
notification.userInfoの AVAudioSession.SilenceSecondaryAudioHintType を取りだして、その値で再生（.begin）、停止（.end）を判断します。

そして切り替えるシーン名を変更して（selectedItem）、キャラクタのnodeを差し替える処理（changeAllNodes()）を呼び出します。

```Swift:再生／停止検知時の処理
    ///　他アプリでのサウンド再生／停止を監視
    @objc func handleSecondaryAudio(notification: Notification) {
        // Determine hint type
        print("---check audio")
        
        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
            let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
                return
        }
        
        if type == .begin {
            // Other app audio started playing - mute secondary audio.
            print("---audio:begin")
            
            // キャラクタのnodeを差し替える
            selectedItem = val_swing
            changeAllNodes()
        } else if type == .end {
                // Other app audio started playing - mute secondary audio.
            print("---audio:stop")
            
            // キャラクタのnodeを差し替える
            selectedItem = val_idle
            changeAllNodes()
        } else {
            // Other app audio stopped playing - restart secondary audio.
        }
    }
 ```   

# キャラクタのnodeを差し替える
self.mainSceneView から1つずつノードを取りだして、現在表示されているノード（prevItem）を新しいノード（selectedItem）に差し替えます。

```Swift:キャラクタのnodeを差し替える
    // キャラクタのnodeを差し替える
    func changeAllNodes() -> Void {
        var prevItem:String?
        
        if (selectedItem == val_swing){
            prevItem = val_idle
        } else {
            prevItem = val_swing
        }
        
        // 追加されている全ての子ノードを1つずつ取り出す
        for Node : AnyObject in self.mainSceneView.scene.rootNode.childNodes{
            if (Node as! SCNNode).name == nil  {
                print("name is nil")
            } else {
                if Node.name == prevItem{
                    
                    if let selectedItem = self.selectedItem {

                        // .scnファイルから新しい3Dモデルのノードを作成
                        let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
                        let newNode = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

                        // 3Dモデルの配置（現在と同じ位置に）
                        newNode.position = Node.position
                        
                        // 3Dモデルのサイズを変更（現在と同じサイズに）
                        newNode.scale = Node.scale
                        
                        // 3Dモデルに名前をつける（削除用）
                        newNode.name = selectedItem
                        
                        // 新しい3Dモデルのノードに差し替える
                        self.mainSceneView.scene.rootNode.replaceChildNode(Node as! SCNNode, with: newNode)

//                        // シーンに追加
//                        self.mainSceneView.scene.rootNode.addChildNode(newNode)
//
//                        // 古い3Dモデルを削除
//                        Node.removeFromParentNode();
                }
            }

        }
            
    }
```
※最初は、新しいノードを追加＞古いノードを削除としていましたが、[replaceChildNode(_:with:)](https://developer.apple.com/documentation/scenekit/scnnode/1408002-replacechildnode)で差し替えできるとがわかったので、書き換えました。


# 完成
できました！

↓音楽停止中は座り込み...
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106624/e73651e5-89ea-3e64-5500-d3c40beef081.png" width=50% align="center">

↓音楽再生中は踊る！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106624/93f67b9b-777e-b9fd-e580-db9f18cd2023.png" width=50%>


音楽の再生／停止をコントロールセンターで行うと、切り替え時にタイムラグがあるので、リモコン付きのイヤフォンで再生／停止を行うと挙動がわかりやすいです。

最後にスクリプト全体を載せておきます。

```Swift:ViewController.swift
import UIKit
import ARKit
import AVFoundation

class ViewController: UIViewController{

    @IBOutlet weak var mainSceneView: ARSCNView!

    let configuration = ARWorldTrackingConfiguration()
    
//    let val_idle:String? = "idle"
    let val_idle:String? = "sitting"
    let val_swing:String? = "swing"
    
    var selectedItem: String?

    override func viewDidLoad() {
        super.viewDidLoad()

        initialize()
        registerGestureRecognizers()
        
        setupNotifications()
        
        if AVAudioSession.sharedInstance().isOtherAudioPlaying {
            // 再生中!!
            print("---再生中")
            selectedItem = val_swing
        } else {
            print("---停止中")
            selectedItem = val_idle
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    func setupNotifications() {
        // AVAudioSession：初期化
        try! AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
        try! AVAudioSession.sharedInstance().setActive(true)
        
        // 他アプリでのサウンド再生／停止を監視
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(self.handleSecondaryAudio), name: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil)
        
        
    }
    /// ARSCNiew初期化設定
    func initialize (){

        self.mainSceneView.debugOptions = [ARSCNDebugOptions.showWorldOrigin, ARSCNDebugOptions.showFeaturePoints]
        self.configuration.planeDetection = .horizontal
        self.mainSceneView.session.run(configuration)
        self.mainSceneView.autoenablesDefaultLighting = true
    }

    ///　メインのビューのタップを検知するように設定する
    func registerGestureRecognizers() {

        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped))
        self.mainSceneView.addGestureRecognizer(tapGestureRecognizer)
        
        // ロングプレスイベントハンドラの登録
        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPressView))
        self.mainSceneView.addGestureRecognizer(longPressGesture)
    }

    @objc func tapped(sender: UITapGestureRecognizer) {
        // タップされた位置を取得する
        let sceneView = sender.view as! ARSCNView
        let tapLocation = sender.location(in: sceneView)

        // タップされた位置のARアンカーを探す
        let hitTest = sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent)
        if !hitTest.isEmpty {
            // タップした箇所が取得できていればitemを追加
            self.addItem(hitTestResult: hitTest.first!)
        }
    }
    
    // 長押しでキャラクタを削除する
    @objc func longPressView(sender: UILongPressGestureRecognizer) {
        print("----長押し！")
        
        if sender.state == .began {
            let location = sender.location(in: self.mainSceneView)
            let hitTest  = self.mainSceneView.hitTest(location)
            
            print("---hitTest:%d",hitTest)
            
            if let result = hitTest.first  {
                
                if ((result.node.name) != nil){
                    print("--.name:%d",result.node.name!)
                }
                if ((result.node.parent!.name) != nil){
                    print("--parent.name:%d",result.node.parent!.name!)
                }

                // 3Dアニメーションモデルは、複数パーツで構成されるため、親ノードの名前で判定・削除する
                if result.node.parent!.name == selectedItem
                {
                    result.node.parent!.removeFromParentNode();
                }
            }
        }
    }

    /// アイテム配置メソッド
    func addItem(hitTestResult: ARHitTestResult) {
        if let selectedItem = self.selectedItem {

            // .scnファイルから新しい3Dモデルのノードを作成
            let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
            let node = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

            // 現実世界の座標を取得
            let transform = hitTestResult.worldTransform
            let thirdColumn = transform.columns.3

            // 3Dモデルの配置
            node.position = SCNVector3(thirdColumn.x, thirdColumn.y, thirdColumn.z)
            
            // 3Dモデルのサイズを変更
            node.scale = SCNVector3(0.05, 0.05, 0.05)
            
            // 3Dモデルに名前をつける
            node.name = selectedItem
            
            // シーンに追加
            self.mainSceneView.scene.rootNode.addChildNode(node)
        }
    }

    ///　他アプリでのサウンド再生／停止を監視
    @objc func handleSecondaryAudio(notification: Notification) {
        // Determine hint type
        print("---check audio")
        
        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
            let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
                return
        }
        
        if type == .begin {
            // Other app audio started playing - mute secondary audio.
            print("---audio:begin")
            
            // キャラクタのnodeを差し替える
            selectedItem = val_swing
            changeAllNodes()
        } else if type == .end {
                // Other app audio started playing - mute secondary audio.
            print("---audio:stop")
            
            // キャラクタのnodeを差し替える
            selectedItem = val_idle
            changeAllNodes()
        } else {
            // Other app audio stopped playing - restart secondary audio.
        }
    }
    
    // キャラクタのnodeを差し替える
    func changeAllNodes() -> Void {
        var prevItem:String?
        
        if (selectedItem == val_swing){
            prevItem = val_idle
        } else {
            prevItem = val_swing
        }
        
        // 追加されている全ての子ノードを1つずつ取り出す
        for Node : AnyObject in self.mainSceneView.scene.rootNode.childNodes{
            if (Node as! SCNNode).name == nil  {
                print("name is nil")
            } else {
                if Node.name == prevItem{
                    
                    if let selectedItem = self.selectedItem {

                        // .scnファイルから新しい3Dモデルのノードを作成
                        let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
                        let newNode = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

                        // 3Dモデルの配置（現在と同じ位置に）
                        newNode.position = Node.position
                        
                        // 3Dモデルのサイズを変更（現在と同じサイズに）
                        newNode.scale = Node.scale
                        
                        // 3Dモデルに名前をつける（削除用）
                        newNode.name = selectedItem
                        
                        // 新しい3Dモデルのノードに差し替える
                        self.mainSceneView.scene.rootNode.replaceChildNode(Node as! SCNNode, with: newNode)
//                        // シーンに追加
//                        self.mainSceneView.scene.rootNode.addChildNode(newNode)
//
//                        // 古い3Dモデルを削除
//                        Node.removeFromParentNode();
                }
            }

        }
            
    }
}

}
```

# まとめ
Mixamoで作るモデルが、1つの3Dモデルに1つのアニメーションなので、このような方法を取りましたが、別の3Dアプリなどで複数のアニメーションを1つのオブジェクトに内包させることができたりするんですかね？（Unityのようにアニメーション設定そのものを別ファイルとして、モデルに適用するような）

もし他にいい方法があるようなら、教えてください。

以下の記事が参考になりました。ありがとうございます。

- [Activating an Audio Session](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/ConfiguringanAudioSession/ConfiguringanAudioSession.html)
- [【Swift x iOSアプリ開発】アプリ起動中に他のアプリの音楽をバックグラウンド再生できるようにする](https://amazarashi.me/swift-ios-background-music/)
- [010 親Nodeの中の特定のnameのNodeを全て得る](https://sites.google.com/a/gclue.jp/swift-docs/ni-yinki100-ios/spritekit/qinnodeno-zhongno-te-dingnonamenonodewo-quante-deru)