こんにちは!Life is Tech !でiPhoneメンターしているとうようです!
この記事はLife is Tech ! メンター Advent Calendar 2017の12日目の記事です。今回紹介する内容のARKitの部分を抽出してとARKit Advent Calendar 2017の12日目の記事も用意しているのでそちらもよければご覧ください。
ちなみにメンターの有志でやっているLife is Tech !メンターのアドベントカレンダーはAdventarにもカレンダーを作成していますのであわせてチェックしてみてください。
さぁ怒涛のiPhoneメンター三連チャン最終日となった今日ですが、未来のクリスマス風アプリを製作した上での技術的な点を語って締めたいと思います。初心者へのHow Toというよりは中級者へのTips的な面が強く結構難易度高めですが楽しんでくれたらうれしいです!!!
※この記事では製作の過程を大雑把に追っていきながら、その中でさまざまなTopicを紹介していければと思っています。細かく目次をつけておきますので、もし長いなと思ったら適宜飛ばしながら読んでもらえればと思います。
きっかけ
今回の記事のアプリは、「せっかくだしクリスマスっぽいなにかを作りたいな」という思いから生まれました。
そこでメンターの勉強会でもハンズオンを行ったARKitを使ってなにか面白いアプリがつくれたらと思い考え始めました。
作るもの
そんなわけで今回作るアプリはその名もXmas Present Goです。(著作権バリバリ引っかかりそうな危ういアプリなのでリリースはしない予定です笑)
具体的には以下のようなフローで遊べるアプリを目指します。
- ユーザーは任意の場所になんらかのプレゼントを置くことができる
- その後他のユーザーが同じ場所にいくとそこにプレゼントの箱が置いてある
- プレゼントをタップするとそのプレゼントを獲得できる
シンプルながらARKitの応用例としていい感じです。
技術・サービスの選択
次にこのアプリを作るために用いる技術やサービスについて考え、2つのものを採用することに決めました。
まずひとつは他の人とプレゼントを共有するという機能を実現するためのデータベースの選択です。今回はリリースの予定が無かったこともあり、これには定番のFirebaseを利用することに決めました。
もうひとつはARKitでプレゼントを置くとなった時にいかにそこにクリスマス感、そして飽きさせない多様性を演出するかということでした。せっかく現実世界でのゲームになるので3Dを使いたいのですが、これを実現できるモデリング力は残念ながら持ち合わせていません。そのため無料で3Dモデルを提供してくれるサービスを探す必要がありました。
Polyの登場
そんな時登場したのがPolyというサービスです。Googleが提供している3Dモデルの共有サイトなのですが、このサービスがARKitに対応した素材を提供するというニュースをたまたま目にしました。
これからのARKit開発においても重要な立ち位置を占めて来そうな予感もあって、僕はこのサイトからいくつかCCライセンスのクリスマスプレゼントっぽいモデルデータをダウンロードし利用させていただくことにしました。
難航する製作
ここからは製作途中に困ったことを述べつつ、様々なトピックに注目していきたいと思います。
ARKitの限界
機能としてはシンプルなアプリだったのですが、基本コンセプトに実は大きな壁がありました。それは
ARKitの取得する座標はあくまでARAnchorやカメラからの相対座標にすぎない
というものです。つまりある端末でプレゼントを置いてもそれを他の端末で同じ場所に出現させるというのは単純に実装するだけでは出来ませんでした。
ですがこれは全くの実現不可能ということではありません。
例えばWorld Brushというアプリは似たようなアイデアで「世界中の人がARで自由に落書きをしてそれを共有できる」というものです。これを知っていたのでやってみようの精神で調査をはじめました。
似たサンプルアプリを探す
手始めに見つけたのがこのナビゲーションをARで実現するチュートリアルシリーズでした。
- ARKit and CoreLocation: Part One
- ARKit and CoreLocation: Part Two
- ARKit and CoreLocation: Part Three
現状まだ途中なのですが、完成版コードもあがっており比較的丁寧に解説してくれています。(※英語です)
たとえばCoreLocationのextensionとして以下のようにある座標からある座標への方位角などが求められます。
let metersPerRadianLat: Double = 6373000.0
let metersPerRadianLon: Double = 5602900.0
extension CLLocationCoordinate2D {
/// Calcurate bearing toward other coordinate
func bearing(to coordinate: CLLocationCoordinate2D) -> Double {
let a = sin(coordinate.longitude.radian - longitude.radian) * cos(coordinate.latitude.radian)
let b = cos(latitude.radian) * sin(coordinate.latitude.radian) - sin(latitude.radian) * cos(coordinate.latitude.radian) * cos(coordinate.longitude.radian - longitude.radian)
return atan2(a, b)
}
/// Calcurate direction toward other coordinate
func direction(to coordinate: CLLocationCoordinate2D) -> CLLocationDirection {
return self.bearing(to: coordinate).degree
}
/// Calcurate coordinate with bearing and distance
func coordinate(with bearing: Double, and distance: Double) -> CLLocationCoordinate2D {
let distRadiansLat = distance / metersPerRadianLat
let distRadiansLon = distance / metersPerRadianLon
let lat1 = self.latitude.radian
let lon1 = self.longitude.radian
let lat2 = asin(sin(lat1) * cos(distRadiansLat) + cos(lat1) * sin(distRadiansLat) * cos(bearing))
let lon2 = lon1 + atan2(sin(bearing) * sin(distRadiansLon) * cos(lat1), cos(distRadiansLon) - sin(lat1) * sin(lat2))
return CLLocationCoordinate2D(latitude: lat2.degree, longitude: lon2.degree)
}
}
extension Double {
/// Convert degree value to radian value
var radian: Double {
return self * .pi / 180.0
}
/// Convert radian value to degree value
var degree: Double {
return self * 180.0 / .pi
}
}
これはデモのREADMEにあるGIFからもわかるように地図上の一定距離離れた地点にモデルを置くという処理に対して非常に有効な手段となります。細かい解説は元記事に任せますが、たとえば自分の現在位置と置きたい場所の座標を用いてどの位置にモデルを出現させるかということを決める行列を求めるメソッド群が以下のように書けます。
static func rotateAroundY(with matrix: matrix_float4x4, for degrees: Float) -> matrix_float4x4 {
var matrix: matrix_float4x4 = matrix
matrix.columns.0.x = cos(degrees)
matrix.columns.0.z = -sin(degrees)
matrix.columns.2.x = sin(degrees)
matrix.columns.2.z = cos(degrees)
return matrix.inverse
}
static func translationMatrix(with matrix: matrix_float4x4, for translation: vector_float4) -> matrix_float4x4 {
var matrix = matrix
matrix.columns.3 = translation
return matrix
}
/// This method calcurate distance and bearing between one and the other location
static func transformMatrix(for matrix: simd_float4x4, originLocation: CLLocation, location: CLLocation) -> simd_float4x4 {
let distance = Float(location.distance(from: originLocation))
let bearing = GLKMathDegreesToRadians(Float(originLocation.coordinate.direction(to: location.coordinate)))
let position = vector_float4(0.0, 0.0, -distance, 0.0)
let translationMatrix = MatrixHelper.translationMatrix(with: matrix_identity_float4x4, for: position)
let rotationMatrix = MatrixHelper.rotateAroundY(with: matrix_identity_float4x4, for: bearing)
let transformMatrix = simd_mul(rotationMatrix, translationMatrix)
return simd_mul(matrix, transformMatrix)
}
このまま使っても良かったのですが、今回はナビゲーションよりさらに高い精度を求められるアプリというのもあり、記事のように地球を完全な球体と見た近似は粗い可能性がありました。そのためもっと精密に求められるアルゴリズムを探しました。
Vincentyの公式
そこで見つけたのがこのページで使われていた距離と方位角から緯度経度を算出するという公式です。Vincentyの公式というもので、最初はWikipediaを見ながらやっていたのですが、数式から落とし込む時にミスが多かったので最終的にページのJavaScriptをSwiftに変換する方針で次のように実装しました。
/// Radius at equator [m]
private let a: Double = 6378137.0
/// Flattening of the ellipsoid
private let f: Double = 1 / 298.257223563
/// Radius at the poles [m]
private let b: Double = 6356752.314245
/// Reduced latitude
private func u(of latitude: Double) -> Double {
return atan((1 - f) * tan(latitude))
}
// MARK: - Internal
func calcurateDistanceAndAzimuths(at location1: CLLocationCoordinate2D, and location2: CLLocationCoordinate2D) -> (s: Double, a1: Double, a2: Double) {
let lat1 = location1.latitude.radian
let lat2 = location2.latitude.radian
let lon1 = location1.longitude.radian
let lon2 = location2.longitude.radian
let omega = lon2 - lon1
let tanU1 = (1 - f) * tan(lat1)
let cosU1 = 1 / sqrt(1 + pow(tanU1, 2.0))
let sinU1 = tanU1 * cosU1
let tanU2 = (1 - f) * tan(lat2)
let cosU2 = 1 / sqrt(1 + pow(tanU2, 2.0))
let sinU2 = tanU2 * cosU2
var lambda = omega
var lastLambda = omega - 100
var cos2alpha: Double = 0.0
var sinSigma: Double = 0.0
var cosSigma: Double = 0.0
var cos2sm: Double = 0.0
var sigma: Double = 0.0
var sinLambda: Double = 0.0
var cosLambda: Double = 0.0
while abs(lastLambda - lambda) > pow(10, -12.0) {
sinLambda = sin(lambda)
cosLambda = cos(lambda)
let sin2sigma = pow(cosU2 * sinLambda, 2.0) + pow(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda, 2.0)
sinSigma = sqrt(sin2sigma)
cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda
sigma = atan2(sinSigma, cosSigma)
let sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma
cos2alpha = 1 - pow(sinAlpha, 2.0)
if cos2alpha == 0 {
cos2sm = 0
} else {
cos2sm = cosSigma - 2 * sinU1 * sinU2 / cos2alpha
}
let C = f / 16.0 * cos2alpha * (4 + f * (4 - 3 * cos2alpha))
lastLambda = lambda
lambda = omega + (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cos2sm + C * cosSigma * (2 * pow(cos2sm, 2.0) - 1)))
}
let u2 = cos2alpha * (pow(a, 2.0) - pow(b, 2.0)) / pow(b, 2.0)
let A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
let B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
let dSigma = B * sinSigma * (cos2sm + B / 4 * (cosSigma * (2 * pow(cos2sm, 2.0) - 1) - B / 6 * cos2sm * (4 * pow(sinSigma, 2.0) - 3) * (4 * pow(cos2sm, 2.0) - 3)))
// Result
let s = b * A * (sigma - dSigma)
let a1 = atan2(cosU2 * sinLambda, cosU1 * sinU2 - sinU1 * cosU2 * cosLambda)
let a2 = atan2(cosU1 * sinLambda, cosU1 * sinU2 * cosLambda - sinU1 * cosU2)
return (s: s, a1: a1.degree, a2: a2.degree)
}
func calcurateNextPointLocation(from location: CLLocationCoordinate2D, s: Double, a1: Double) -> (location: CLLocationCoordinate2D, a2: Double) {
let latRad = location.latitude.radian
let lonRad = location.longitude.radian
let a1Rad = a1.radian
let u1 = u(of: latRad)
let sigma1 = atan2(tan(u1), cos(a1Rad))
let sinalp = cos(u1) * sin(a1Rad)
let cos2alp = 1 - pow(sinalp, 2.0)
let u22 = cos2alp * (pow(a, 2.0) - pow(b, 2.0)) / pow(b, 2.0)
let A = 1 + u22 / 16384 * (4096 + u22 * (u22 * (320 - 175 * u22) - 768))
let B = u22 / 1024 * (256 + u22 * (u22 * (74 - 47 * u22) - 128))
var sigma = s / b / A
var lastSigma = sigma - 100
var dm2: Double = 0.0
while abs(lastSigma - sigma) > pow(10, -9.0) {
lastSigma = sigma
dm2 = 2 * sigma1 + sigma
let x = cos(sigma) * (2 * pow(cos(dm2), 2.0) - 1) - B / 6 * cos(dm2) * (4 * pow(sin(dm2), 2.0) - 3) * (4 * pow(cos(dm2), 2.0) - 3)
let dsigma = B * sin(sigma) * (cos(dm2) + B / 4 * x)
sigma = s / b / A + dsigma
}
let x = sin(u1) * cos(sigma) + cos(u1) * sin(sigma) * cos(a1Rad)
let y = (1 - f) * sqrt(pow(sinalp, 2.0) + pow(sin(u1) * sin(sigma) - cos(u1) * cos(sigma) * cos(a1Rad), 2.0))
let lambda = atan2(sin(sigma) * sin(a1Rad), cos(u1) * cos(sigma) - cos(u1) * sin(sigma) * cos(a1Rad))
let C = f / 16 * cos2alp * (4 + f * (4 - 3 * cos2alp))
let z = cos(dm2) + C * cos(sigma) * (2 * pow(cos(dm2), 2.0) - 1)
let dL = lambda - (1 - C) * f * sinalp * (sigma + C * sin(sigma) * z)
// Result
let latitude = atan2(x, y)
let longitude = lonRad + dL
let a2 = atan2(sinalp, cos(u1) * cos(sigma) * cos(a1) - sin(u1) * sin(sigma))
return (location: CLLocationCoordinate2D(latitude: latitude.degree, longitude: longitude.degree), a2: a2.degree)
}
論文が30年ほど前に出されていたアルゴリズムで、これなら緯度経度から距離と方位角を測る演算も距離と方位角から緯度経度を導く演算もサポートしていてしかもかなりの精度が見込まれます。
せっかくどちらも実装したので同じ入力に対してどのくらい変わるのか確認してみたところ、Vincentyの公式では小数以下第六位ぐらいまであうところが先程の方法だと整数部分しかあっていないことがわかりました。これはかなりの差ですね。探してよかった。。。
これが実装できたことにより以下のようなフローでモデルの位置と座標を対応付けることにしました。
- モデルを置いた際は、座標を取得し置いた場所までの方位角と距離を出しておく
- 次にそれを置いた場所の緯度経度に変換する。ここまでできたらプレゼントの種類、緯度経度、ユーザーの端末のUUIDをセットにしてFirebaseにアップ
- データベースが更新されたら全てのデータをダウンロードする。そして新しいデータがあった場合、緯度経度と端末の位置からどの場所に置けばいいかを決定する。他人のプレゼントは置く際にプレゼントボックスにしておく
- モードを用意しGetモードではタップしたプレゼントボックスを中身のモデルに置き換える。また置くモードでは指定したモデルをタップした場所に置く。
高さは今回は割愛させてもらい進めていくことにしました。
方位角などの処理
方位角をカメラの向きをz軸とした座標系で計算するのはいささか骨が折れます。
そこで今回はy軸を重力の向き、x軸を東西、z軸を南北に固定したいと思います。
これにはARSessionのconfigurationのうちworldAlignment
という項目をいじります。
.camera
.gravity
.gravityAndHeading
の3つの中から選ぶことができるのですが、このうち.gravityAndHeading
がまさに求めていたものだったので採用しました。
具体的にどう設定すればいいかというと、SceneKitであればviewWillAppear(_ animated: Bool)
内で
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Create a session configuration
let configuration = ARWorldTrackingConfiguration()
// z: North and South, x: East and West, y: parallel to gravity
configuration.worldAlignment = .gravityAndHeading
// Run the view's session
sceneView.session.run(configuration)
}
というように書いてあげます。これで特別意識しなくとも座標と緯度経度の変換が簡単な計算と先程のメソッド達で計算できるようになりました。
というわけでMatrixHelper
の実装を変更します。
/// This method calcurate distance and bearing between one and the other location
static func transformMatrix(for matrix: simd_float4x4, originLocation: CLLocation, location: CLLocation) -> simd_float4x4 {
let (s: distance, a1: bearing, a2: a2) = VincentyHelper.shared.calcurateDistanceAndAzimuths(at: originLocation.coordinate, and: location.coordinate)
let position = vector_float4(0.0, 0.0, Float(-distance), 0.0)
let translationMatrix = MatrixHelper.translationMatrix(with: matrix_identity_float4x4, for: position)
let rotationMatrix = MatrixHelper.rotateAroundY(with: matrix_identity_float4x4, for: Float(bearing.radian))
let transformMatrix = simd_mul(rotationMatrix, translationMatrix)
return simd_mul(matrix, transformMatrix)
}
/// This method calcurate ar location to real world location
static func transformLocation(for matrix: simd_float4x4, originLocation: CLLocation, location: SCNVector3) -> CLLocation {
let x2 = pow(location.x, 2.0)
let y2 = pow(location.y, 2.0)
let z2 = pow(location.z, 2.0)
// Bearing and distance in AR World
let distance = sqrt(x2 + y2 + z2)
let a1 = atan2(Double(location.x), Double(location.z)).degree
let (location: location, a2: _) = VincentyHelper.shared.calcurateNextPointLocation(from: originLocation.coordinate, s: Double(distance), a1: a1)
return CLLocation(latitude: location.latitude, longitude: location.longitude)
}
これで緯度経度と距離方位角を交互に変換する準備が整いました。
現在地取得は楽をしたい
さてここまで来たら次は現在地の取得です。現在地を取得するラッパーライブラリとしてLocationManagerなどがあると思いますが、これが最新のXcodeではうまく動かなかったのでかわりにこんなシングルトンをつくりました。(コードが長くなるのでGitHubの該当箇所へのリンクだけにし、記事としてコードは割愛します)
(これを使って得られた知見なのですが、シングルトンの初期化は一番最初に使った時に起きるんですね。バッドノウハウ的な雰囲気も漂ってたりしなかったりしますが、とりあえず出来ているのでこれを使っていきます。)
準備が整ったのでひとまずデータがちゃんととれるのか確認してみましょう。
以下のようにとりあえず画面の触ったところにプレゼントを置いて、その場所の座標を計算してみます。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touchLocation = touches.first?.location(in: sceneView) {
guard let firstHit = sceneView.hitTest(touchLocation, types: .featurePoint).first else {
return
}
let hitTransform = SCNMatrix4(firstHit.worldTransform)
let hitPosition = SCNVector3Make(hitTransform.m41, hitTransform.m42, hitTransform.m43)
let newNode = ARManager.shared.generateModel(.present)
newNode.position = hitPosition
sceneView.scene.rootNode.addChildNode(newNode)
print(MatrixHelper.transformLocation(for: matrix_identity_float4x4, originLocation: ARCLManager.shared.location, location: hitPosition).coordinate)
print(ARCLManager.shared.location.altitude + Double(hitPosition.z))
}
}
ここは何も工夫する必要はないですね。実際に動かしてみた所しっかりとそれらしき座標を出力することができました。
最後の壁:タップしたプレゼントを検知する
ここが最後の壁となりました。詳しくは別記事に書きますが、主な問題として
- ARSCNViewのhitTestはノードの情報を返してくれない
- 置いた後、そのままだと誰が置いたプレゼントなのか情報が持てなくなる
これをどうにかこうにか解決しました。
Firestoreとの通信・仕上げ
さてここまででARKitまわりの作業が終わったので最後に通信部分を作っていきます。
(※今回はきちんと利用できていませんが、実際にはLocalCollection.swiftというファイルにあるクラスを利用するとキャッシュが利用できるようになるようです。)
まずは保存するための構造体の定義です。DocumentSerializable
というプロトコル(このプロトコルじゃなくても中にあるinitが使えれば十分便利)とEquatable
プロトコル(後で既に置かれているオブジェクトかどうかをこのデータで判断したいため)を適用しています。
struct ObjectData {
var latitude: Double
var longitude: Double
var object: Int
var userID: String
var dictionary: [String: Any] {
return [
"latitude": latitude,
"longitude": longitude,
"objectID": object,
"userID": userID
]
}
}
extension ObjectData: DocumentSerializable {
init?(dictionary: [String : Any]) {
guard let latitude = dictionary["latitude"] as? Double,
let longitude = dictionary["longitude"] as? Double,
let objectRaw = dictionary["objectID"] as? Int,
let userId = dictionary["userID"] as? String else {
return nil
}
self.init(latitude: latitude, longitude: longitude, object: objectRaw, userID: userId)
}
}
extension ObjectData: Equatable {
static func ==(lhs: ObjectData, rhs: ObjectData) -> Bool {
return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude &&
lhs.object == rhs.object && lhs.userID == rhs.userID
}
}
続いてこの構造体を実際に書き込んだり読み込んだりするためのメソッドを書いていきます。
長くなるのでコード自体はこちらを参照してください。
送信には将来的に汎用性の高いbatch
を使いました。これを使うと同時に複数のdocumentに書き込むことができます。またquery
を自動で監視する実装もしっかりと行っています。
またデータが更新された時のためにデリゲートを用意しました。データが追加されるとobserveQuery
で行われているaddSnapshotListener
によってデータが全て読み込まれ、その中で最新のデータをデリゲートメソッドに渡すことで画面に反映するようにするわけです。
大規模になってくるとほりー(@HALU5071)が9日目に紹介してくれていたCloud Functionsなどを利用して近くのデータ何件かだけ取ってくるといった処理が必要になりそうですが、今回は一旦この単純な方法で実装しました。
オブジェクトの更新処理自体は次のようになります。
// MARK: - Firestore Delegate
extension MainViewController: FirestoreHelperDelegate {
func updateObjects(_ objects: [ObjectData]) {
for object in FirestoreHelper.shared.objects {
guard !self.addedObjects.contains(object) else {
continue
}
let location = CLLocation(latitude: object.latitude, longitude: object.longitude)
let isMe = FirestoreHelper.shared.userId == object.userID
let (dist, _, _) = VincentyHelper.shared.calcurateDistanceAndAzimuths(at: ARCLManager.shared.coordinate, and: location.coordinate)
if abs(dist) < 100 {
let newNode = ARManager.shared.generateModel(isMe ? ARManager.Model(rawValue: object.object)! : .present)
newNode.objectData = object
let newPosition = SCNMatrix4(MatrixHelper.transformMatrix(for: matrix_identity_float4x4, originLocation: ARCLManager.shared.location, location: location))
newNode.position = SCNVector3Make(newPosition.m41, newPosition.m42, newPosition.m43)
sceneView.scene.rootNode.addChildNode(newNode)
addedObjects.append(object)
}
}
}
}
先程Equatable
を用意していたので既にあるものは飛ばすという処理がcontains
を使ってとてもシンプルに書けました。
一つ躓いたのはposition
の設定です。最初transform
に直接MatrixHelperの結果を渡していたのですが、transform
は都度上書きされてしまうので上のようにきちんと別に適用させるべきならば行列の一部をとってくる方針にするのが都合がいいです。
まとめ
以上で全ての工程が終わりました。実際の動作の様子を載せたかったのですが、iPhoneが二台以上必要なのと適切なロケーションなどが確保できなかったので一旦見送らせてください...汗
// TODO: ここにデモ動画を入れたい
スクショだとこんな感じです(壁の向こうらへんにあるせいで遠くてプレゼントがタッチできないの図)
残っている課題として
- 高さが変わってしまう
- GPSの誤差で時々少し離れた場所に表示されてしまう(これでもいいアルゴリズムを使っているので限界なのかもしれません...)
- 一度獲得したデータの状態が保存できておらずアプリを閉じるとまたプレゼントの状態に戻る
などがありますが、目指していたメイン機能は実装できたので今回は良しとします。
全体としてのコードはこちらにあります。MITライセンスとしているのでぜひクローンして問題点を解決してみたりプルリクおくってみたりしてください(設計が汚くなってしまってる感があるのはお許しを。。。)
さて明日はWebSメンターのたいが(@shichisan)がgulp bower skrollr.jsについて書いてくれるそうです!
ついに折り返し地点に来ましたがまだまだ続くLife is Tech ! Mentor Advent Calendar 2017、ぜひ最後まで楽しんでください👍👍
追記 (2017/12/12 16:01)
せっかくなのでDeploygateのリンクを貼っておきます
多分適当なタイミングで打ち切るのと、Deploygateの使い方をマスターできるまで配布ができない気がするので、興味がある方のみぜひ入れてみてください。