iOS
Unity
Swift
VR
ARKit
ARKitDay 10

[ARKit] Appleによるヒットテストの自前実装コードを読む

WWDC17でARKitが発表されてすぐの頃から公開されていた「PlacingObjects」というサンプルがありました。

placingobjectss.jpg

現実空間に仮想の家具を設置する、完成度の高いサンプルです。ビルドしてみた方や、これをベースにアプリを実装してみた方は多いのではないでしょうか。

私もARKitのとっかかりとしてこのサンプルのコードを読むところから始めたのですが、このサンプルはシンプルではなく(自分比)、ARKitを学ぶ最初の一歩目としては難しいと感じるポイントがいくつかありました。

しかし、最初の一歩目に読むには不向きかと個人的には思うものの1、ARKitの基本をある程度理解してから見ると、ARKitのAPIをただたたくだけではない、「その次」の実装方法を示してくれている好サンプルです。

本記事では、そのサンプル内の実装から、SceneKitやARKitにあらかじめ用意されているヒットテストのメソッドを「用いない」、自前ヒットテスト実装をピックアップし、見ていきたいと思います。

ちなみに「PlacingObjects」というサンプルはもう配布されてないのですが、Handling 3D Interaction and UI Controls in Augmented Realityというサンプルが同様の機能を持っています。実装の方向性も基本的には同じなので、PlacingObjectsのリファクタリング版といえるでしょう。本記事ではこちらをベースに書いていきます。

ARKitが備えているヒットテストメソッドとの違い

メソッド定義

件の実装は、VirtualObjectARViewに実装されている、hitTestWithFeatures(_:coneOpeningAngleInDegrees:minDistance:maxDistance:maxResults:)というメソッドです。

func hitTestWithFeatures(_ point: CGPoint, coneOpeningAngleInDegrees: Float, minDistance: Float = 0, maxDistance: Float = Float.greatestFiniteMagnitude, maxResults: Int = 1) -> [FeatureHitTestResult]

その名の通り、特徴点群とのヒットテストを行うメソッドです。

特徴点群を渡す引数はありませんが、メソッド内でARFramerawFeaturePointsプロパティにアクセスして直接ARPointCloudオブジェクトを取得しています。

guard let features = session.currentFrame?.rawFeaturePoints, let ray = hitTestRayFromScreenPosition(point) else {
    return []
}

疑問

さて、ここでARKitのAPIをひと通り習得した方は疑問に思われるかもしれません。

ARSCNViewhitTest(_:types:)メソッドを持っていて、第2引数に指定できる「ヒットテスト結果のタイプ」にfeaturePointを指定すると、特徴点へのヒットテストが行えるのです。

let results = sceneView.hitTest(pos, types: [.featurePoint])

なぜ、Appleの公式サンプルでは、特徴点群に対するヒットテストを行う際にこのメソッドを用いず2VirtualObjectARViewにわざわざ自前実装しているのでしょうか??

考察

ARKit組み込みヒットテストメソッドを利用しない理由として考えられるのは、

  1. ARSCNViewを使用していない
  2. ヒットテスト手法が違う
  3. より柔軟に使うため

あたりですが、1に関しては当該サンプルではARSCNViewを用いている 3 のであてはまりません。2に関しては一般のアプリ開発者には実装内容がわからないので判断しようがありません4。自前実装版の引数では細かくヒットテスト条件を指定できるようになっているので、3はあるかもしれません・・・が、結局これもARKit組み込み版の中身がわからないので何ともいえないところです。

というわけで、Appleがなぜサンプルでわざわざカスタム実装しているのか、既存のものとどう違うのか、どう使い分けるのか、というところは実際のところわかりません

が、少なくとも、この実装から「ヒットテストとはこう実装するんだ」ということがわかり、参考にはなります。ある点からある点へ向かう単位ベクトルの求め方、スクリーンの任意の位置からのレイの求め方etc...

以下でその実装内容を見ていきます。

スクリーン座標からの「レイ」を求める

先ほど挙げたコードの再掲になりますが、本ヒットテスト実装では、その処理のはじめに、ARFrameから特徴点群を取得すると同時に、スクリーンの指定位置からのRayを計算しています。

guard let features = session.currentFrame?.rawFeaturePoints, let ray = hitTestRayFromScreenPosition(point) else {
    return []
}

その実装はこのようになっています。

let ray = hitTestRayFromScreenPosition(point)
func hitTestRayFromScreenPosition(_ point: CGPoint) -> HitTestRay? {
    guard let frame = session.currentFrame else { return nil }

    let cameraPos = frame.camera.transform.translation // (1)

    // Note: z: 1.0 will unproject() the screen position to the far clipping plane.
    let positionVec = float3(x: Float(point.x), y: Float(point.y), z: 1.0) // (2)
    let screenPosOnFarClippingPlane = unprojectPoint(positionVec) // (3)

    let rayDirection = simd_normalize(screenPosOnFarClippingPlane - cameraPos) // (4)
    return HitTestRay(origin: cameraPos, direction: rayDirection) // (5)
}
  1. カメラの位置を取得
  2. スクリーン上の座標を3次元座標にする(zを1.0としているので、遠方のクリッピング平面上にあることになる)
  3. 2をunproject(2のワールド座標が得られる)
  4. 1から3に向かうベクトルを求め、正規化する(simd_normalizeを利用)
  5. 4で得られた単位ベクトルを「方向」とし、1を「起点」とするHitTestRay型として初期化する
struct HitTestRay {
    var origin: float3
    var direction: float3

    // 以下の実装は略
}

各特徴点との当たり判定

特徴点との当たり判定が、平面との当たり判定と大きく違う点は、特徴「点」は大きさを持たないという点です。普通に「レイと交差するか」で判定を行った場合、0.0000000001でもずれていれば交差していないことになってしまいます。

なので、その「当たったと判定する範囲」を設けるために、コーン(円錐)型でその範囲を指定できるようにconeOpeningAngleInDegreeという引数が用意してあったわけです。

func hitTestWithFeatures(_ point: CGPoint, coneOpeningAngleInDegrees: Float, minDistance: Float = 0, maxDistance: Float = Float.greatestFiniteMagnitude, maxResults: Int = 1) -> [FeatureHitTestResult]

閾値の計算

サンプルでは、このconeOpeningAngleInDegreeから、次のように「特徴点と原点のなすベクトル」と「レイ」とがなす角度の閾値を求めています。

let maxAngleInDegrees = min(coneOpeningAngleInDegrees, 360) / 2  // (1)
let maxAngle = (maxAngleInDegrees / 180) * .pi                   // (2)

(1)で360°を超える角度を丸めてコーンの半分の角度を求め、(2)でラジアンにしています。

各特徴点に対する判定

ARPointCloudpointsからflatMapで各特徴点を取り出し、次のように判定処理を行っています。

// 特徴点とレイの原点とがなすベクトル・・・(1)
let originToFeature = featurePosition - ray.origin

// (1)とレイのクロス積を計算
let crossProduct = simd_cross(originToFeature, ray.direction)
// 外積の長さ
let featureDistanceFromResult = simd_length(crossProduct)

let hitTestResult = ray.origin + (ray.direction * simd_dot(ray.direction, originToFeature))
let hitTestResultDistance = simd_length(hitTestResult - ray.origin)

if hitTestResultDistance < minDistance || hitTestResultDistance > maxDistance {
    // Skip this feature - it is too close or too far away.
    return nil
}

// (1)を単位ベクトル化・・・(2)
let originToFeatureNormalized = simd_normalize(originToFeature)
// (2)とレイのなす角度を計算・・・(3)
let angleBetweenRayAndFeature = acos(simd_dot(ray.direction, originToFeatureNormalized))

// (3)と前項で求めた閾値を比較
if angleBetweenRayAndFeature > maxAngle {
    // Skip this feature - is is outside of the hit test cone.
    return nil
}

このコードを理解する上でのポイントは"cross product"は「クロス積」つまりいわゆる「外積」であるという点です。

外積代数入門  クロス積 ウェッジ積 テンソル

外積の大きさ(長さ)は両ベクトルによりつくられる平行四辺形の面積になり、ray.directionは単位ベクトルなので、平行四辺形の面積=底辺×高さの小学校か中学校で習った公式から、featureDistanceFromResultは「特徴点からのレイに対する法線の長さ」ということになります。法線とレイの交わる点をヒットした点として、特徴点はヒットした点から実際どれぐらい離れてたの?ということを示す値です。結果に格納されます。

また、simd_dotはベクトルの内積を計算する関数です。このへんどういう計算なのか解読できてないのでまたわかったら更新します。

最後に、前項で求めた閾値を用いて、「特徴点と原点のなすベクトル」と「レイのベクトル」のなす角度(3)に対して判定を行っています。

ARKitの参考書籍

つい先日発売された「iOS 11 Programming」という技術書にて、ARKitの章の執筆を担当させていただきました。

本記事に出てくるARKitやSceneKitのヒットテストメソッド、ARPointCloud等々についても解説しています。本記事の解説は雑ですが、書籍の解説はもっと丁寧です(後述するサンプルでご確認ください)。

一般販売はされてなくて、PEAKSというサイトでのみご購入いただけます。

iOS 11 Programming

iOS 11 Programming

  • 著者:堤 修一,吉田 悠一,池田 翔,坂田 晃一,加藤 尋樹,川邉 雄介,岸川克己,所 友太,永野 哲久,加藤 寛人,
  • 発行日:2017年11月16日
  • 対応フォーマット:製本版,PDF
  • PEAKSで購入する

こんな感じの章立てになってまして、

  • 第1章 iOS 11 概要
  • 第2章 ARKit
  • 第3章 Core ML
  • 第4章 Swift 4の新機能とアップデート
  • 第5章 Xcode 9 の新機能
  • 第6章 Drag and Drop
  • 第7章 FilesとDocument Based Application
  • 第8章 レイアウト関連の新機能及び変更点
  • 第9章 Core NFC
  • 第10章 PDF Kit
  • 第11章 SiriKit
  • 第12章 HomeKit入門とiOS 11のアップデート
  • 第13章 Metal
  • 第14章 Audio関連アップデート

執筆を担当したARKitの章は、3行で書けるサンプルからスタートして、平面を検出する方法、その平面に仮想オブジェクトを設置する方法、そしてその仮想オブジェクトとインタラクションできるようにする方法…と、読み進めるにつれて「作りながら」引き出しが増えていくよう構成しています。

  • 第2章 ARKit
    • 2.1 はじめに
    • 2.2 ARKit入門その1 - 最小実装で体験してみる
    • 2.3 ARKit入門その2 - 水平面を検出する
    • 2.4 ARKit入門その3 - 検出した水平面に仮想オブジェクトを置く
    • 2.5 ARKit開発に必須の機能
    • 2.6 特徴点(Feature Points)を利用する
    • 2.7 AR空間におけるインタラクションを実現する
    • 2.8 アプリケーション実装例1: 現実空間の長さを測る
    • 2.9 アプリケーション実装例2: 空中に絵や文字を描く
    • 2.10 アプリケーション実装例3: Core ML + Vision + ARKit
    • 2.11 Metal + ARKit

ARKitの章だけでも30ページ以上あります。最終的にはARKitを用いた巻尺(メジャー)や、空間に絵や文字を描くといったアプリケーションの実装ができるようになります。

版元のPEAKSのサイトでサンプルPDFも読めるので、気になった方はぜひお試しください。


  1. 手前味噌になりますが、最初の一歩目に読むコードとしては、3行で書けるコードから順番にARKitの機能を見ていけるARKit-Samplerがオススメです。 

  2. existingPlaneUsingExtentへのヒットテスト、またSceneKitのヒットテストメソッドは当該サンプル内で用いられている。 

  3. まさにそのカスタム実装を持っているVirtualObjectARViewARSCNViewを継承している。 

  4. リファレンスでも、ヒットテストの詳しいアルゴリズムについては書かれていません。