はじめに
状況に応じてアニメーションを切り替えたかったので、そのメモ。
実行環境
- Xcode 11.2.1
- MagicaVoxel 0.99.4
- Mixamo
やりたいこと
- iPhoneのミュージックなど、他のアプリでの音楽が再生/停止の検知。
- 再生中はキャラクタが踊り、停止すると踊りを止めるようにアニメーションを切り替える。
(昔あった玩具のフラワーロック的なイメージ)
Mixamoで作るアニメーションは、1つの3Dモデルに1つのアニメーションを割り当てるので、座ってる状態(sitting.scn)と踊っている状態(swing.scn)の二つのシーンからnodeを取りだして、差し替えることでアニメーションを切り替えることにしました。
他のアプリでのAVAudioSessionの状態を検知する
他のアプリでの音楽が再生/停止されたかは、AVAudioSession.silenceSecondaryAudioHintNotificationで 検知できるようなので、通知されたときに呼び出される関数(handleSecondaryAudio)をNotificationCenterに登録します。
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 は、再生/停止が変化したの通知のため、初回のみ AVAudioSession.sharedInstance().isOtherAudioPlaying でアプリ起動時の再生/停止状態を判断して初期化します。
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())を呼び出します。
/// 他アプリでのサウンド再生/停止を監視
@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)に差し替えます。
// キャラクタの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:)で差し替えできるとがわかったので、書き換えました。
完成
できました!
音楽の再生/停止をコントロールセンターで行うと、切り替え時にタイムラグがあるので、リモコン付きのイヤフォンで再生/停止を行うと挙動がわかりやすいです。
最後にスクリプト全体を載せておきます。
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のようにアニメーション設定そのものを別ファイルとして、モデルに適用するような)
もし他にいい方法があるようなら、教えてください。
以下の記事が参考になりました。ありがとうございます。