L is Bエンジニアブログ

ビジネス用メッセンジャーdirectのエンジニアによるブログ

LisBエンジニアブログ

ビジネスチャットdirectのエンジニアブログ

ARKit を使ってベアプログラミングを実践してみた

こんにちは、ソリューション開発部の湯川です。

本題に入る前にソリューション開発部って?と思われるかもしれませんので簡単に説明をすると、弊社が開発しているチャットツール「direct」と連携して企業様の抱える様々な問題を解決するためのツールの提案、開発を行う部署になります。

さて、とある案件で Web サービスの開発中にどうも意図した動作にならないことがありました。チームメンバーに相談しようと処理内容の説明していたところ、途中で問題点に気づいて「すいません、ダメな理由わかっちゃいました。」ってことがありました。
何かを人に説明して理解をさらに深めるという経験はみなさんもあるかと思います。 これはテディベア効果と呼ばれるもので、 プログラミング作法という書籍で「ベアプログラミング」として紹介されているようです。 本当にすごいくだらないミスが原因だったので、チームメンバーに申し訳ない。
詰まった時に一度説明するみたいなクッションを挟もうと思います。
どこかに聞き上手な暇を持て余す存在いないかな・・・

!!

AR で仮想空間に説明を聞いてくれる何かを出現させれば誰の迷惑にもならないのでは!?

ということで iOS の ARKit を使って以下の要件で作ってみました。

  • 平面上をタップしてその位置にキャラクターを出現させる。
  • 普段は何かアクションをしていて、声をかけるとアクションを停止してこちらを向く。
  • 一定時間声をかけないとアクションが再開される。

結果はこんな感じです。
ちょっとわかりにくいですが、普段はダンスして励ましてくれています。 f:id:shuhei-yukawa:20180711101043j:plain

声をかけるとダンスをやめて話を聞いてくれます。 真摯に話聞いてくれる良いやつです。 f:id:shuhei-yukawa:20180711101105j:plain

ARKit とは

ARKit は iOS 11 以降で使用できる AR 対応アプリを開発するためのフレームワークです。 iPhone や iPad などのカメラで現実空間を認識して、机の上にデジタルなモノやキャラクターを置いたり、現実の物の大きさや環境光を測定するなどできます。ARKit はカメラでキャプチャされたデータとデバイスモーションを統合的に管理し、それらの情報から現実空間との相対的な位置を計算して拡張現実空間を表現します。この拡張現実空間に SceneKit(3D) や SpriteKit(2D)を使用してオブジェクトを描画すると、現実空間に存在しないオブジェクトを存在しているように見せることができます。 詳しくはこちらをご覧ください。

ARKit - Apple Developer

実装

開発の手順は以下になります。

  1. モデルの準備
  2. プロジェクトの作成
  3. ARKit の初期化
  4. 平面ノードの設置
  5. キャラクターノードの配置
  6. マイク入力検知

モデルの準備

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 の設定

要件を満たすため各種 delegateself に割り当てます。

平面検出設定

平面を検出させるため 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)
        }
}

キャラクターノードを追加したところ、平面ノードをすり抜けて奈落の底に落ちていきました。 f:id:shuhei-yukawa:20180711101150j:plain

衝突検出のための設定

平面の上にキャラクターノードを立たせたいため、平面ノードとキャラクターノード間で反発する設定を行う必要があります。 平面ノードとキャラクターノードに物理特性を設定します。
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 の説明まで入れると長くなるので、詳細については割愛します。 以下の流れで初期化を行っており、マイク入力レベルを検知しています。

  1. AVAudioSession の初期化
    このアプリケーションがどういったカテゴリでオーディオを動作させるか定義します。
  2. AudioCompornent の初期化
    どの AudioCompornent を使用するか定義します。今回はマイク入力なので RemoteIO を使用します。
  3. データフォーマットの指定
    RemoteIO マイクバスから取り出すオーディオデータフォーマットを定義します。
  4. コールバックの設定
    マイクからの入力を得るため、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 で遊んでみようかなと思う方が一人でもいれば幸いです。

参考

以下の記事を参考にさせていただきました。

最後に

L is B では暇を持て余して僕の話を聞いてくれる人を・・・じゃなくて、 新たに開発チームに加わってくれるエンジニアを募集しています。 ご興味のあるかたは 採用情報 | 株式会社L is B(エルイズビー) からご応募いただければと思います。 オフィス見学も大歓迎です。

開発の拠点も、東京と徳島に加えて、新たに大阪に関西支社ができました。