Apple Vision ProとiOSで空間共有して三人称視点で見れるようにする
はじめに
昨年から今年にかけて自宅を建築しまして、その際にApple Vision Proで土地の上に間取図を表示するアプリを作りました。
この動画、何をやってるのかわかりずらいですよね。視点がすばやく動いて見ずらいし、全体を見渡すこともできません。Vision Proアプリを作って画面収録をしてこのように誰かにいてもらおうとしても、わかりづらいものになってしまいがちです。
そこで、このアプリを三人称視点で見れるようにしてみたのがこちら。
三人称視点で見れるようにし、視点を固定して、全体を見渡せるようにすることで、何をしているのかわかりやすくなりました。
Vision Proアプリを三人称視点で見れるように作る方法
Vision Proアプリを三人称視点で見れるように作るには、空間共有をする必要があります。空間共有とは、複数のARデバイスで同じコンテンツを表示し、またそのコンテンツの見え方が各デバイスの現実空間上の位置に応じたものとなる状態のことです。さらに、あるデバイスで生じたコンテンツの変化が他のデバイスにも伝わる必要もあります。
Vision ProとiOSデバイスが認識している座標系を一致させる
コンテンツの見え方が各デバイスの現実空間上の位置に応じたものとなるようにするためには、Vision ProとiOSデバイスが認識している座標系を一致させる必要があります。iOSのARKitにはこの機能が備わっているためiOSどうしでは簡単に実現できるのですが、Vision ProのARKitにはまだこの機能がありません、そのため現時点でVision Proどうし、またはVision ProとiOSデバイスで空間共有を行うためには、この機能を自前で用意する必要があります。
それにはいくつかの方法が考えられ、それぞれ次のようなメリット・デメリットがあります。
- QRコードを1つ使う
- QRコードを1つ読むだけで済むので、利用者の手間は少ない
- 誤差が大きいため狭い範囲での利用向き
- QRコードを2つ使う
- QRコードを2つ読む必要があるので、利用者の手間が多い
- QRコード1つの場合より誤差が小さい
- 空間内の特徴点等から位置を推定する
- 処理が複雑
- 利用者にQRコードを読ませる手順が不要
室内の1部屋で使用する場合はQRコード1つの方法でも良いと思います(QRコード1つで行う方法はここでは割愛します)。しかし、冒頭の動画の例のようにもっと広い空間で使用する場合はQRコード1つの方法では精度が十分ではありません。そこで、この記事ではQRコード2つを使う方法について説明します。
QRコードを2つ使う
- 前提
- デバイスには加速度センサーがある
- 2つのQRコードは同じ水平面上にある
デバイスの加速度センサーの値から上下方向がわかります(下図のベクトルU)。2つのQRコードが同じ水平面上にあれば、2つのQRコードを結んだベクトル(下図のベクトルQ)は上下方向のベクトルUと直行しています。ベクトルUとQを2軸とし、3軸目をUとQの外積とすることで空間上の3軸が定まります。この3軸を使用することで、各デバイスが認識している座標系が一致します。
Vision Proでの実装
Vision ProでQRコードを読むには、エンタープライズAPIの申請が必要です。申請方法についてはここでは割愛します。詳細は以下のページをご覧ください。
以下では、エンタープライズAPIの利用ができる状態になっているものとします。
QRコードを読む
Vision ProでQRコードを読むには、BarcodeDetectionProvider
を使います。
private let arkitSession = ARKitSession()
@State private var qrScanningTask: Task<Void, Never>?
private func startQRScanning() {
qrScanningTask = Task {
await arkitSession.queryAuthorization(for: [.worldSensing])
let barcodeDetection = BarcodeDetectionProvider(symbologies: [.qr])
do {
try await arkitSession.run([barcodeDetection])
} catch {
return
}
for await anchorUpdate in barcodeDetection.anchorUpdates {
let anchor = anchorUpdate.anchor
guard let name = anchor.payloadString else { continue }
switch anchorUpdate.event {
case .added, .updated:
anchor.originFromAnchorTransform // QRコードの位置と向き
anchor.extent // QRコードの大きさ
anchor.payloadString // QRコードに格納されている文字列
case .removed:
// removed
}
}
}
}
private func endQRScanning() {
qrScanningTask?.cancel()
}
QRコードの位置
anchor.originFromAnchorTransform
から次のようにして取得します。
let transform = anchor.originFromAnchorTransform
let c3 = transform.columns.3
let position = simd_float3(c3.x, c3.y, c3.z)
QRコードの位置の送信
QRコードを2つ読んで、その位置をiOSデバイス側に送信する必要があります。送信方法は任意の手段で構いません。アプリの内容に応じて適宜選んでください。
iOSでの実装
QRコードを読む
ARSessionDelegate
の session(_:didUpdate:)
で得たフレームに CIDetector
を使い、QRコードの認識をします。
class QRScanner : NSObject, ARSessionDelegate {
private var taskRunning = false
func session(_ session: ARSession, didUpdate frame: ARFrame) {
if taskRunning { return }
taskRunning = true
Task {
defer { taskRunning = false }
let ciimg = CIImage(cvImageBuffer: frame.capturedImage)
let iw = ciimg.extent.size.width
let ih = ciimg.extent.size.height
if let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil) {
for feature in detector.features(in: ciimg) {
if let qrFeature = feature as? CIQRCodeFeature, let messageString = qrFeature.messageString {
let topLeft = raycast(point: qrFeature.topLeft, iw: iw, ih: ih, session: session, frame: frame)
let topRight = raycast(point: qrFeature.topRight, iw: iw, ih: ih, session: session, frame: frame)
let bottomLeft = raycast(point: qrFeature.bottomLeft, iw: iw, ih: ih, session: session, frame: frame)
let bottomRight = raycast(point: qrFeature.bottomRight, iw: iw, ih: ih, session: session, frame: frame)
let matrix = transformMatrix(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
let extent = simd_float3(length(topLeft - topRight), 0, length(topLeft - bottomLeft))
matrix // QRコードの位置と向き
extent // QRコードの大きさ
messageString // QRコードに格納されている文字
}
}
}
}
}
private func raycast(point: CGPoint, iw: Double, ih: Double, session: ARSession, frame: ARFrame) -> simd_float3 {
let x = point.x / iw
let y = (ih - point.y) / ih
let query = frame.raycastQuery(from: CGPoint(x: x, y: y), allowing: .existingPlaneGeometry, alignment: .horizontal)
if let result = session.raycast(query).first {
let c3 = result.worldTransform.columns.3
return simd_float3(c3.x, c3.y, c3.z)
}
return .zero
}
private func transformMatrix(
topLeft: simd_float3,
topRight: simd_float3,
bottomLeft: simd_float3,
bottomRight: simd_float3
) -> simd_float4x4 {
var matrix = simd_float4x4(simd_quatf(from: simd_float3(1, 0, 0), to: normalize(topLeft - topRight)))
let center = (topLeft + topRight + bottomLeft + bottomRight) / 4
matrix.columns.3.x = center.x
matrix.columns.3.y = center.y
matrix.columns.3.z = center.z
return matrix
}
}
CIDetector
で検出したQRコードの四隅を取得し、その4点に向けてARKitの水平面へレイキャストすることで、空間上におけるQRコードの四隅の座標が得られます。その座標からQRコードのトランスフォーム行列を計算しています。
座標系を一致させる
iOS上で読んだQRコードの位置とVision Pro上で読んで送られてきたQRコードの位置を使い、iOS上での座標系をVision Pro上の座標系に一致させる処理は以下のようになります。
private var entity: Entity?
private func updateSpaceSharing(_ spaceSharingData: SpaceSharingData) {
// iOS上で読んだQRコードの位置
guard let myQR1 = qrPositions.first else { return }
guard let myQR2 = qrPositions.last else { return }
// Vision Pro上で読んで送られてきたQRコードの位置
guard let ssQR1 = spaceSharingData.qrPositions.first(where: { $0.name == myQR1.name }) else { return }
guard let ssQR2 = spaceSharingData.qrPositions.first(where: { $0.name == myQR2.name }) else { return }
guard let entity, let parent = entity.parent else { return }
// entityは、表示するコンテンツ。
// その位置や向きがVision Pro側で変化した際はiOS側にも反映させる。
// 親に対するトランスフォームなのでそのまま反映させるだけ。
entity.setTransformMatrix(spaceSharingData.entityTransform, relativeTo: parent)
// iOS上で読んだ2つのQRコードを結んだベクトル
let myQRVec = myQR1.position - myQR2.position
// Vision Pro上で読んだ2つのQRコードを結んだベクトル
let ssQRVec = ssQR1.position - ssQR2.position
// それらのベクトルは同じものなので、
// QRコードからそれぞれ算出した数値上の方向の違い
// =それぞれの座標系の方向の違いとなる。
let orientation = simd_quatf(from: simd_float3(ssQRVec.x, 0, ssQRVec.z), to: simd_float3(myQRVec.x, 0, myQRVec.z))
parent.setOrientation(orientation, relativeTo: nil)
// 同様に、座標系の位置の違い(myQR1とssQR1の違い)も算出し、一致させる。
parent.setPosition(myQR1.position - normalize(simd_act(orientation, ssQR1.position)) * length(ssQR1.position), relativeTo: nil)
}
struct NamedPosition: Codable {
let name: String
let position: simd_float3
}
struct SpaceSharingData: Codable {
let qrPositions: [NamedPosition]
let entityTransformColumns: [simd_float4]
var entityTransform: simd_float4x4 {
simd_float4x4(entityTransformColumns[0], entityTransformColumns[1], entityTransformColumns[2], entityTransformColumns[3])
}
}
ソースコード
デモ動画
Vision ProとiPhoneを空間共有しiPhoneから三人称視点で見れるようにしたものを、Meetで配信している様子です。このように、Vision Proアプリの三人称視点を配信越しに見られるようにすることもできます。
Discussion