こんにちは、ソリューション開発部の湯川です。
本題に入る前にソリューション開発部って?と思われるかもしれませんので簡単に説明をすると、弊社が開発しているチャットツール「direct」と連携して企業様の抱える様々な問題を解決するためのツールの提案、開発を行う部署になります。
さて、とある案件で Web サービスの開発中にどうも意図した動作にならないことがありました。チームメンバーに相談しようと処理内容の説明していたところ、途中で問題点に気づいて「すいません、ダメな理由わかっちゃいました。」ってことがありました。
何かを人に説明して理解をさらに深めるという経験はみなさんもあるかと思います。 これはテディベア効果と呼ばれるもので、 プログラミング作法という書籍で「ベアプログラミング」として紹介されているようです。 本当にすごいくだらないミスが原因だったので、チームメンバーに申し訳ない。
詰まった時に一度説明するみたいなクッションを挟もうと思います。
どこかに聞き上手な暇を持て余す存在いないかな・・・
!!
AR で仮想空間に説明を聞いてくれる何かを出現させれば誰の迷惑にもならないのでは!?
====
ということで iOS の ARKit
を使って以下の要件で作ってみました。
- 平面上をタップしてその位置にキャラクターを出現させる。
- 普段は何かアクションをしていて、声をかけるとアクションを停止してこちらを向く。
- 一定時間声をかけないとアクションが再開される。
結果はこんな感じです。
ちょっとわかりにくいですが、普段はダンスして励ましてくれています。
声をかけるとダンスをやめて話を聞いてくれます。 真摯に話聞いてくれる良いやつです。
ARKit とは
ARKit
は iOS 11 以降で使用できる AR 対応アプリを開発するためのフレームワークです。 iPhone や iPad などのカメラで現実空間を認識して、机の上にデジタルなモノやキャラクターを置いたり、現実の物の大きさや環境光を測定するなどできます。ARKit
はカメラでキャプチャされたデータとデバイスモーションを統合的に管理し、それらの情報から現実空間との相対的な位置を計算して拡張現実空間を表現します。この拡張現実空間に SceneKit
(3D) や SpriteKit
(2D)を使用してオブジェクトを描画すると、現実空間に存在しないオブジェクトを存在しているように見せることができます。 詳しくはこちらをご覧ください。
実装
開発の手順は以下になります。
- モデルの準備
- プロジェクトの作成
- ARKit の初期化
- 平面ノードの設置
- キャラクターノードの配置
- マイク入力検知
モデルの準備
3D モデルにアニメーションをつけたものを無料でダウンロードできるMixamo を使いました。 このサイトでは 3D のオブジェクトに関節位置などを定義するだけで用意されている豊富なアニメーションパターンを適用してダウンロードすることができます。 3D オブジェクトの用意がなくてもいくつかキャラクターのモデルが用意されているのでそれを使うこともできます。 今回は上記サイトで用意されているキャラクターのモデルにダンスアニメーションを適用したものと話を聞いているアニメーションを適用したものを使います。 (本当はシモンくんを登場させたかったけど 3D モデルの作成がうまく作成できず断念・・・。有志のモデラーさんよろしくお願いします。)
プロジェクトの作成
Xcode で新規プロジェクトを作成し、iOS
タブの Augmented Reality App
を選択します。 このプロジェクトを元に変更を加えていきます。
設定
声をかけたことを検知するためのマイク入力と、ARKit
を利用するためのカメラ入力を有効にする必要があります。 以下のキーを Info.plist
に設定します。
Cocoa Keys
- NSCameraUsageDescription
- NSMicrophoneUsageDescription
ARKit の初期化
ARKit
を扱う上で重要なオブジェクトとして ARSession
があります。 これは AR シーン(拡張現実空間)と現実空間の関連付けの処理を管理するオブジェクトで、AR 対応アプリを作成する上で必須となります。 ARSession
を自前で定義して使用すると、カスタムカメラビューに AR を実現するなどができます。 今回は特に複雑なことはしないので ARSession
を内部的に持っている ARSCNView
を使用します。
delegate の設定
要件を満たすため各種 delegate
を self
に割り当てます。
- ARAnchor 検出検知に使用
ARSCNViewDelegate – ARKit | Apple Developer Documentation - SceneKit ノードの衝突検知に使用
SCNPhysicsContactDelegate – SceneKit | Apple Developer Documentation
平面検出設定
平面を検出させるため ARWorldTrackingConfiguration
インスタンスを生成して、平面の検出を有効化するように設定します。 以下の設定で ARSession
をスタートさせます。
let configuration = ARWorldTrackingConfiguration() override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // 平面検出の有効化 self.configuration.planeDetection = .horizontal self.sceneView.session.run(self.configuration) }
平面ノードの配置
平面が検出されると ARKit
のセッションが自動的に ARAnchor
をシーンに追加します。 ARAnchor
は AR シーンにオブジェクトを配置するために必要な現実世界の位置と方向を持っています。 平面検出時に追加されるのは ARAnchor
を継承した ARPlaneAnchor
になります。 自動的にアンカーが追加された際、ARSCNViewDelegate
のデリゲートメソッドが呼ばれます。
ARSCNViewDelegate – ARKit | Apple Developer Documentation
optional func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor)
平面が検出されたことをユーザーに知らせるため視覚化します。 AR シーンで検出された ARPlaneAnchor
に合わせ、平面ノード用に SCNNode
インスタンスを生成し、表示します。
init(anchor: ARPlaneAnchor) { super.init() // 平面の検出時に呼ばれて検出された平面の大きさで SCNPlane を作成 self.geometry = SCNPlane(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z)) SCNVector3Make(anchor.center.x, 0, anchor.center.z) self.transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0) // 物理特性の設定 self.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: self.geometry!, options: nil)) self.setPhysicsBody() self.display() } private func display() { let planeMaterial = SCNMaterial() planeMaterial.diffuse.contents = UIColor(red: 0.0, green: 0.1, blue: 0.0, alpha: 0.3) self.geometry?.materials = [planeMaterial] }
平面の検出は繰り返し行われます。 新しい平面を検出すると以下の delegate
メソッドが呼ばれます。 ここで平面ノードのサイズを変更する処理を行って、キャラクターノードを配置できる範囲を広げることができます。
optional func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor)
キャラクターノードの配置
タップした位置に平面が存在する場合、そこにキャラクターノードを配置するようにします。 ARシーンに配置可能な位置を得るため hitTest
メソッドを使用して ARAnchor
を取得します。
hitTest(_:types:) – ARSCNView | Apple Developer Documentation
@objc func tapped(sender: UITapGestureRecognizer) { // すでに追加済みであれば無視 if self.characterNode != nil { return } // タップされた位置を取得する let tapLocation = sender.location(in: self.sceneView) // タップされた位置のARアンカーを探す let hitTest = self.sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent) if !hitTest.isEmpty { // タップした箇所が取得できていればitemを追加 self.addItem(hitTestResult: hitTest.first!) } } private func addItem(hitTestResult: ARHitTestResult) { self.characterNode = CharacterNode(hitTestResult: hitTestResult) self.sceneView.scene.rootNode.addChildNode(self.characterNode!) }
キャラクターノードを追加します。追加時は 制止状態にしておきます。 hitTest
メソッドで検出された位置より若干 Y 軸方向に値をプラスして、キャラクターノードを配置します。 平面ノードに降り立たせるため、自由落下するように物理特性を設定しておきます。
init(hitTestResult: ARHitTestResult) { // 初期化時のステータスは Stop self.status = .Stop super.init() // アセットより、シーンを作成 self.setNode(fileName: STOP_NODE) // サイズ調整 self.scale = SCNVector3(0.0005, 0.0005, 0.0005) // 位置決定 self.position = SCNVector3(hitTestResult.worldTransform.columns.3.x, hitTestResult.worldTransform.columns.3.y + 0.3, hitTestResult.worldTransform.columns.3.z) // 物理特性追加 self.addPhysics() } private func setNode(fileName: String) { // アセットより、シーンを作成 let scene = SCNScene(named: fileName)! for childNode in scene.rootNode.childNodes { self.addChildNode(childNode) } }
キャラクターノードを追加したところ、平面ノードをすり抜けて奈落の底に落ちていきました。
衝突検出のための設定
平面の上にキャラクターノードを立たせたいため、平面ノードとキャラクターノード間で反発する設定を行う必要があります。 平面ノードとキャラクターノードに物理特性を設定します。
SCNPhysicsBody – SceneKit | Apple Developer Documentation
平面ノード
private func setPhysicsBody() { self.physicsBody?.categoryBitMask = 2 // 衝突 self.physicsBody?.collisionBitMask = 1 self.physicsBody?.contactTestBitMask = 1 // 摩擦 self.physicsBody?.friction = 1 // 弾性 self.physicsBody?.restitution = 0 }
キャラクターノード
どうもキャラクターのノードに直接設定するとうまく衝突してくれなかったので矩形のノードを定義してそれに対して物理設定を追加しています。
private func addPhysics() { // 物理特性追加(node で追加するとうまく平面で止まってくれず・・・ひとまずキューブで対応) let cube = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0) self.physicsBody = SCNPhysicsBody(type: .dynamic, shape: SCNPhysicsShape(geometry: cube, options: nil)) self.physicsBody?.categoryBitMask = 1 self.physicsBody?.restitution = 0 // 空気抵抗(ゆっくり落としたいので 1) self.physicsBody?.damping = 1 self.physicsBody?.angularDamping = 1 self.physicsBody?.friction = 1 }
衝突の検知には BitMask を使用します。 各 BitMask の意味は以下のとおりです。
- categoryBitMask
この物体の定義 - collisionBitMask
この物体と衝突したときに通過せず反発する物体の定義 - contactTestBitMask
この物体と衝突した時に通知が発信される物体の定義
今回、categoryBitMask
を 平面ノードが 2、キャラクターノードが 1 で設定しています。
平面ノードに collisionBitMask
をキャラクターノードの categoryBitMask
を設定し、反発するようにしています。
衝突検出
ノード間の衝突が検出された際に以下の delegate メソッドが呼ばれます。ここでキャラクターをストップ状態からダンスしている動作に変更します。
SCNPhysicsContactDelegate – SceneKit | Apple Developer Documentation
optional func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact)
マイク入力検知
マイクの入力には CoreAudio
を使用します。 CoreAudio
の説明まで入れると長くなるので、詳細については割愛します。 以下の流れで初期化を行っており、マイク入力レベルを検知しています。
- AVAudioSession の初期化
このアプリケーションがどういったカテゴリでオーディオを動作させるか定義します。 - AudioCompornent の初期化
どのAudioCompornent
を使用するか定義します。今回はマイク入力なのでRemoteIO
を使用します。 - データフォーマットの指定
RemoteIO
マイクバスから取り出すオーディオデータフォーマットを定義します。 - コールバックの設定
マイクからの入力を得るため、AudioUnit
にコールバック関数と登録します。
以下コールバック関数が自動で呼ばれるので、ここで入力レベルを測定しておきます。
let recordingCallback: AURenderCallback = {(inRefCon, ioActionFlags, inTimeStamp, inBusNumber, frameCount, ioData ) -> OSStatus in let audioObject = unsafeBitCast(inRefCon, to: MicInput.self) if let au = audioObject.audioUnit { // マイクから取得したデータを取り出す AudioUnitRender(audioObject.audioUnit!, ioActionFlags, inTimeStamp, inBusNumber, frameCount, &audioObject.audioBufferList!) } let inputDataPtr = UnsafeMutableAudioBufferListPointer(&audioObject.audioBufferList!) let mBuffers: AudioBuffer = inputDataPtr[0] guard let bufferPointer = UnsafeMutableRawPointer(mBuffers.mData) else { return -1 } let dataArray = bufferPointer.assumingMemoryBound(to: Float.self) // マイクから取得したデータからレベルを計算する var sum:Float = 0.0 if frameCount > 0 { for i in 0 ..< Int(frameCount) { sum += (dataArray[i]*dataArray[i]) } audioObject.level = sqrt(sum / Float(frameCount)) } return 0 }
マイクの入力レベルを 1 秒ごとに確認します。 マイクの入力が一定以上あったときにキャラクターノードを制止状態に変更します。 また、制止状態時は常にカメラの方向を見ているようにします。
private func setUpMic() { self.micInput.setUpAudio() Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(ViewController.timerUpdate), userInfo: nil, repeats: true) } @objc func timerUpdate() { guard let char = self.characterNode else { return } // マイクの入力が一定以上ならストップに if self.micInput.level > 0.01 && char.collision { char.stop() self.stopCount = 0 } // ストップの場合は常にカメラに向ける if char.status == Status.Stop && char.collision { self.stopCount += 1 // カメラに向ける char.headForCamera(sceneView: self.sceneView) } if self.stopCount > 5 { char.dance() } }
カメラノードを取得し、カメラに向くよう Yaw 方向に回転させます。 pointOfView – SCNSceneRenderer | Apple Developer Documentation
func headForCamera(sceneView: SCNView) { // カメラ方向に向けるアニメーション if let camera = sceneView.pointOfView { // Y 軸のみ回す let action = SCNAction.rotateTo(x: 0, y: CGFloat(camera.eulerAngles.y), z: 0, duration: 1) self.runAction(action) } }
実践
実践してみました。 カメラを通して PC の画面を見ることになりますが、かなり見にくくて辛いです。 あと職場で作成したアプリに話かけるのはかなり勇気がいります。
今回作ったアプリでは僕の冷めた部分が邪魔して本気で語りかけづらく、ベアプログラミングの効果を上げることは期待できそうにありませんでした。 ただもっと作り込んでキャラクターの表現を豊かにすることで余計な先入観を取り除くこともできるかもしれないなと思いました。
うちの長女はこのアプリに向かって「胡瓜いるか?」と語りかけ、おままごとに強制参加させてました。 僕の心が偏見にまみれていない純粋無垢なものなら 3D モデル君と友達になって大きな効果を上げることができたのかもしれません。
まとめ
個人的に久しぶりに Swift を触って楽しかったです。 普段の仕事では JavaScriptまたはTypeScript を書くことがほとんどで Swift を忘れちゃいそうになるのですが、たまにこうしてリハビリするのもいいなと思いました。 iOS アプリエンジニアの方にソースコードを見られると怒られちゃいそうですが、公開しておきます。
GitHub – shuheyheyhey/ARBear
実装のほとんどは SceneKit
での描画で、ARSCNView
を使うと AR アプリを作成するのは結構簡単なんだということがわかりました。 3D オブジェクトの描画やアニメーションでつまることが結構多く、ARKit
のお勉強よりも SceneKit
のお勉強の時間が多かった気がします。 本当は平面ノード上を自由に駆け回らせてやりたかったのですが、うまく座標の計算ができずタイムアップとなりました。 今回はアレな実装ですが、夢が広がるフレームワークで本格的に何か楽しい AR アプリを作ってみたいなという気になりました。 この記事を見て ARKit
で遊んでみようかなと思う方が一人でもいれば幸いです。
参考
以下の記事を参考にさせていただきました。
- ARKitができる事、できない事
- Animating a 3D model in AR with ARKit and Mixamo – Pusher Blog
- iOS で SceneKit を試す(Swift 3) その8 – SCNAction でアニメーション設定 – Apple Engine
- ARKitのサンプルコード集「ARKit-Sampler」
最後に
L is B では暇を持て余して僕の話を聞いてくれる人を・・・じゃなくて、 新たに開発チームに加わってくれるエンジニアを募集しています。
ご興味のあるかたは採用情報 | 株式会社L is B(エルイズビー)からご応募いただければと思います。 オフィス見学も大歓迎です。
開発の拠点も、東京と徳島に加えて、新たに大阪に関西支社ができました。