[iOS 11] ARKitでソファー設置からあの子の身長測定まで色々やってみた

1 はじめに

iOS 11では、iPhoneとiPadにAR(augmented reality)を簡単に作成できるARKitというフレームワークが導入されました。ARKitの大きな特徴は、以下の3つです。

  1. 位置(座標)を特定
  2. 平面検出
  3. 周辺光を把握

位置(座標)の特定は、画面上で指定した点が実世界のどこに該当するかを3次元ベクトルで取得できるという機能です。これを活用すると、画面をタップして地点を指定し、その距離を測定するメジャーのようなものが簡単に作成できます。

続いて平面検出が可能になると、机の上など、実世界の平面に仮想オブジェクトを正確に配置することが可能になります。

そして周辺光の状態を表示する仮想オブジェクトに適用することで、その見え方は、よりリアルになるでしょう。それでは、ARKitの各機能を順に使っていきます。

もくじ

2 オブジェクトの配置

(1) テンプレートのシーン

ARKitを利用したアプリを作成するには、プロジェクトの新規作成からAugmented Reality Appを選択します。 この時、Content Technologyに、SceneKitSpriteKit及び、Metalの3種類が選択可能ですが、ここでは、SceneKitを選択したものを紹介します。

001

テンプレートをそのままコンパイル・実行すると、SceneKitでお馴染みのジェット機がカメラ映像の中に表示されます。

002

テンプレートで生成されるアプリは、ARビューとなるscreenViewsceneに、シーンファイル(art.scnassets/ship.scn)から生成したシーンを適用しているだけです。

// シーンファイル(ship.scn)からSCNSceneを生成する
let scene = SCNScene(named: "art.scnassets/ship.scn")!

// ARSCNViewのsceneプロパティにセットする
sceneView.scene = scene

ここでシーンファイルを見てみると、shipと言うオブジェクト(飛行機)が1つ存在しています。これがシーンの中に配置されたノードとなります。

003

また、ノード(飛行機には、texture.pngという画像がテクスチャーとして利用されています。これは、ノードの表面(マテリアル)の表現となっています。

004

ノードは、位置(Position)、Euler(オイラー角)、Scale(縮尺)、といった要素も持っています。

007

これらが、ARKitで表示するオブジェクトの構造そのものになります。

(2) ノードの配置

簡単に図示すると次のようになります。

画面全体となるビューはARSCNViewです。その中に、シーンが存在していますが、表示できるのは1つのシーンです。 シーンの中には、ノードというオブジェクトが複数存在できます。そして、それぞれのノードは、その形状や、表面、位置などの情報を持っています。

005

上記の関係に従って、自前でオブジェクトを配置してみると、次のようなコードになります。

@IBOutlet var sceneView: ARSCNView! // 画面全体のビュー

override func viewDidLoad() {
    super.viewDidLoad()

    sceneView.scene = SCNScene() // ビューのsceneプロパティに空のシーンを生成する

    let node = SCNNode() // ノードを生成
    node.geometry = SCNBox(width: 0.2, height: 0.2, length: 0.2, chamferRadius: 0) // ノードの形状を一辺が20cmの立方体とする
    let material = SCNMaterial() // マテリアル(表面)を生成する
    material.diffuse.contents = UIImage(named: "brick") // 表面は、レンガ(テクスチャ)
    node.geometry?.materials = [material] // 表面の情報をノードに適用
    node.position = SCNVector3(0, 0, -0.5) // ノードの位置は、カメラを原点として左右:0m 上下:0m 奥に50cm

    sceneView.scene.rootNode.addChildNode(node) // 生成したノードをシーンに追加する
}

006


github [GitHub] https://github.com/furuya02/ARKitSample

なお、ARビューの中のサイズは、1.0を1mとして、現実のサイズとリンクしています。

ノードのオブジェクトについて、もう少し詳しく紹介していますので、ぜひ下記もご参照ください。
[iOS 11] はじめてのARKit #WWDC2017

3 距離の測定

(1) めそ子さんの身長

最初に距離を測定するサンプルアプリを作成してみたので、紹介させて下さい。

こちらは、Measureボタンで計測する始点と終点を指定すると、その距離が表示されるものです。これで、めそ子さんの身長が165cmであることが分かります。


github [GitHub] https://github.com/furuya02/ARKitMeasurementSample

こちらは、レーザーによる距離測定をイメージしたもので、レーザーが当たっている地点までの距離が表示されます。


github [GitHub] https://github.com/furuya02/ARKitLaserMeasurementSample

(2) hitTest

下の写真は、現実世界の位置を画面上でタッチしている様子ですが、ARKitでは、このように画面上でポイントから現実世界および、すでに追加されているノードを検索するメソッドがあります。

009 008

ARSCNViewhitTest(_:types:)は、カメラ画像内の現実世界のオブジェクト、又はARアンカーを検索するメソッドです。
https://developer.apple.com/documentation/arkit/arscnview/2875544-hittest

func hitTest(_ point: CGPoint, types: ARHitTestResult.ResultType) -> [ARHitTestResult]

下記のコードは、スマホ画面の中央に対応する実世界の座標を取得しているコードです。

types:featurePointが指定された場合、ノードを無視して現実世界の座標が取得できます。ジャイロの初期化などが完了していないと、取得に失敗することがありますので、isEmptyで取得できているかどうかを確認します。

func getCenter() -> SCNVector3? {
    // スマフォ画面の中央座標
    let touchLocation = sceneView.center 
    // hitTestによる判定
    let hitResults = sceneView.hitTest(touchLocation, types: [.featurePoint])
    // 結果取得に成功しているかどうか
    if !hitResults.isEmpty {
        if let hitTResult = hitResults.first {
            // 実世界の座標をSCNVector3で返す
            return SCNVector3(hitTResult.worldTransform.columns.3.x, hitTResult.worldTransform.columns.3.y, hitTResult.worldTransform.columns.3.z)
        }
    }
    return nil
}

取得できる現実世界の位置は、カメラを原点とし、スマホの向きとカメラ投影によって決定される方向に延びる線に沿った任意の点です。hitTestでは、その線に沿って交差するすべてのオブジェクトを返します。 データは、カメラからの距離が近い順にソートされた配列となっているため、最初のデータを利用しています。

hitTestの戻り値であるARHitTestResultには、distanceというプロパティがあり、カメラからの距離(メートル)が入っています。レーザー光線で距離を測定するサンプルは、この値をそのまま使っています。
https://developer.apple.com/documentation/arkit/arhittestresult

(3) 距離の計算

2地点の座標が分かれば、その間の距離を計算できます。次のコードは、2つのSCNVector3から、距離を求めているものです。 startPositionが始点、endPositionが終点です。

let position = SCNVector3Make(endPosition.x - startPosition.x, endPosition.y - startPosition.y, endPosition.z - startPosition.z)
let distance = sqrt(position.x * position.x + position.y * position.y + position.z * position.z)
print(String.init(format: "%.2fm", arguments: [distance]))

010
参考:二点間の距離を求める公式(2次元、3次元)より

4 平面の検出

(1) サイコロを振る

平面検出に関しても、サンプルを作成してみたので、紹介させてください。立方体ノードでサイコロを表現し、検出した平面に落としています。 サイコロは、引力で床に衝突するまで落下し、弾んで転がっています(やや不自然ですが…)。


github [GitHub] https://github.com/furuya02/ARKitDiceSample

(2) ARAnchor(ARPlaneAnchor)

ARAnchorとは、ARシーンにオブジェクトを配置するために使用する実世界の位置と方向を持ったオブジェクトです。そして、これを継承したARPlaneAnchorは、特に平面を表現するものです。

ARKitでは、ARシーンを初期化するARWorldTrackingConfigurationplaneDetectionPlaneDetection.horizontalがセットされると、平面を検出した時点で、自動的にARPlaneAnchorがシーンに追加されるようになります。

let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = .horizontal // <= 平面の検出を有効化する
sceneView.session.run(configuration)

(3) アンカーの検出

ARPlaneAnchorが検出され、自動的にシーンに追加される時、ARSCNViewDelegateのrenderer(_:didAdd:for:)が呼ばれます。

optional func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor)

最初に検出した時点では、その平面情報(位置や大きさなど)は、あまり正確ではありません。しかし、しばらくカメラを動かしていると、その情報が更新されてどんどん正確になっていきます。情報の正確さが向上するたびに、ARPlaneAnchorが更新され、同じくARSCNViewDelegateのデリゲートメソッドであるrenderer(_:didUpdate:for:)が呼ばれます。

optional func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor)

(4) 平面ノード

ARAnchorがシーンに追加されても、それが、他のノードと相互に影響し合うためには、その情報に基づいて、平面を表現するノードを追加する必要があります。

下記のクラスは、平面を表現するノードオブジェクトです。シーンに映る、実世界のオブジェクトと重なっても分かりやすいように色は半透明の白です。また、ARAnchorの情報で、その位置情報などを更新するメソッド update(anchor:)を備えています。

class PlaneNode: SCNNode {
    init(anchor: ARPlaneAnchor) {
        super.init()

        geometry = SCNPlane(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z))
        let planeMaterial = SCNMaterial()
        planeMaterial.diffuse.contents = UIColor.white.withAlphaComponent(0.5)
        geometry?.materials = [planeMaterial]
        SCNVector3Make(anchor.center.x, 0, anchor.center.z)
        transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0)
    }

    func update(anchor: ARPlaneAnchor) {
        (geometry as! SCNPlane).width = CGFloat(anchor.extent.x)
        (geometry as! SCNPlane).height = CGFloat(anchor.extent.z)
        position = SCNVector3Make(anchor.center.x, 0, anchor.center.z)
    }

    // ・・・略・・・
}

この平面ノードを、アンカーの検出で追加し、更新するコードは次のとおりです。

// MARK: - ARSCNViewDelegate
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    DispatchQueue.main.async {
        if let planeAnchor = anchor as? ARPlaneAnchor {
            // 平面ノードを追加する
            node.addChildNode(PlaneNode(anchor: planeAnchor) )
        }
    }
}

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
    DispatchQueue.main.async {
        if let planeAnchor = anchor as? ARPlaneAnchor, let planeNode = node.childNodes[0] as? PlaneNode {
            // 平面ノードの位置及び形状を修正する
            planeNode.update(anchor: planeAnchor)
        }
    }
}

(5) 衝突検知

SCNNodeには、physicsBody:SCNPhysicsBodyというプロパティがあり、これに物理的な形状の情報を設定すると、物理演算の対象になります。

下記のコードは、立方体ノードのphysicsBodyを、元々のgeometry(形状情報)と同じもので初期化しています。 type:dynamicにすることで、デフォルトで下方向に重力がかかり、シーンに追加した時点で、どんどん下に落ちていきます。

boxNode.physicsBody = SCNPhysicsBody(type: .dynamic, shape: SCNPhysicsShape(geometry: boxGeometry, options: [:]))
boxNode.physicsBody?.categoryBitMask = 1

落下してきたノードが平面で止まるようにするには、平面ノードの方にも物理的な形状の情報を追加する必要があります。 下記では、平面のノードに対して、立方体と同じように、geometry(形状情報)からphysicsBodyを初期化しています。なお、今度は重力で落下しないよう、type:static(固定)にしています。

planeNode.physicsBody = SCNPhysicsBody(type: .static, shape: SCNPhysicsShape(geometry: geometry!, options: nil))
planeNode.physicsBody?.categoryBitMask = 2

これで、サイコロを平面のやや上の座標でシーンに追加すると、平面に当たるまで落下し、バウンドして止まります。

5 3Dオブジェクトの変換

(1) ソファーを置いてみる

こちらのサンプルは、3Dモデルで作成されたソファーをリアルな部屋に置き、見栄えを確認するサンプルです。先に紹介した平面検出と衝突検知で、床の上に正確に配置できています。


github [GitHub] https://github.com/furuya02/ARKitFurnitureArrangementSample

ここでは、取得した3DモデルをARシーンの中に取り込む要領をご紹介します。

(2) 3Dモデルのインポート

今回使用させて頂いた3Dモデル(ソファー)は、Free3D(https://free3d.com/)のものです。

Free3Dには、たくさんの3Dモデルが公開されていますが、ジャンルやファイル形式が指定可能なので、検索が非常に簡単です。

012

Xcodeで、そのまま利用できるファイル形式は、Collada(拡張子 .dae)および、Windowfront(拡張子 .obj)の2種類です。

下記は、ダウンロードしたdaeファイルとテクスチャーをart.scnasetsの中にドロップした様子です。

014

そして、Edit > Convert to SceneKit scene file format (.scn) で .scn ファイルに変換することができます。

015

(3) 3Dモデルの調整

シーンの中は、たくさんのオブジェクトがありますが、ソファーを構成するもの(ライト以外)を、扱いやすいように1つのオブジェクトの階層下に移動しました。

017 018

オブジェクトの名前を、Sofaに変更しました。

019

ARシーンに追加した際のオブジェクトの(回転)状態は、カメラをFrontにすることで確認できます。変更したい場合は、この時点でEulerで調整が可能です。

020

また、アンカーの位置を確認すると、ちょうどソファーの中央で床に接する位置になっていることが分かります。ノードのpositionで、この位置を指定することになります。

最後に、Bounding Boxを見てみると、width876.333とかになっていますが、単位はmなので、 このままシーンに追加すると、横幅876mの巨大なソファーが出現することになってしまいます。この辺は、のちほどコードの方でスケール調整することにします。

021

(4) モデルの取り出しと挿入

下記のコードは、ソファーオブジェクトを含んだシーンから、ソファーオブジェクト(ノード)のみを取り出して、ARビューのシーンに追加しているコードです。

スケールを調整して、幅を1.5mにして表示しています。

// シーンからノードを取り出す
let sofaScene = SCNScene(named: "art.scnassets/Wooden Sofa.scn")!
let sofaNode = sofaScene.rootNode.childNode(withName: "Sofa", recursively: true)

// スケールの調整
let (min, max) = (sofaNode?.boundingBox)!
let w = CGFloat(max.x - min.x)
let magnification = 1.5 / w // 幅を1.5mにした場合の縮尺を計算
sofaNode?.scale = SCNVector3(magnification, magnification, magnification)

// 位置 カメラを原点として左右0 下に1m 奥に2mに設定
sofaNode?.position = SCNVector3(0, -1, -2)

//作成したノードを追加
sceneView.scene.rootNode.addChildNode(sofaNode!)

シーンに登場したソファーです。特に床に接するような調整を行っているわけではないので、空中に浮いた状態になってますが、ここで、「平面検出」や「衝突検知」を応用すれば、今回作成したソファー配置のサンプルとなります。

022

6 最後に

iOS 11からは、特別なハードウエアなど使わなくても、このような高精度ARを実現できるようになります。今後、色々な面白いアプリが出現する可能性を感じます。

今回は、ARKitの特徴の1つでもある「周辺光を把握」については、触れることができませんでした。次回は、そのあたりについても書いてみたいと考えています。

7 参考リンク


Introducing ARKit
Apple Developer > Documentation > ARKit
WWDC2017 Session 602 Introducing ARKit: Augmented Reality for iOS
ARKit by Example — Part 3: Adding geometry and physics fun
[iOS 11] はじめてのARKit #WWDC2017
[iOS 11][ARKit] 距離の計測について #WWDC2017
[iOS 11][ARKit] 平面の検出について #WWDC2017
[iOS 11][ARKit] 物理衝突を実装して、キューブを落として見る #WWDC2017
[iOS 11][ARKit] 空間に3Dテキストを表示してみる #WWDC2017