Zenn
🥽

Apple Vision ProとiOSで空間共有して三人称視点で見れるようにする

に公開

はじめに

昨年から今年にかけて自宅を建築しまして、その際にApple Vision Proで土地の上に間取図を表示するアプリを作りました。

https://youtu.be/XfxsXDLZ_zQ

この動画、何をやってるのかわかりずらいですよね。視点がすばやく動いて見ずらいし、全体を見渡すこともできません。Vision Proアプリを作って画面収録をしてこのように誰かにいてもらおうとしても、わかりづらいものになってしまいがちです。

そこで、このアプリを三人称視点で見れるようにしてみたのがこちら。

https://youtu.be/At728O4BaJ4

三人称視点で見れるようにし、視点を固定して、全体を見渡せるようにすることで、何をしているのかわかりやすくなりました。

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の申請が必要です。申請方法についてはここでは割愛します。詳細は以下のページをご覧ください。

https://developer.apple.com/documentation/visionOS/building-spatial-experiences-for-business-apps-with-enterprise-apis

以下では、エンタープライズ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コードを読む

ARSessionDelegatesession(_: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])
    }
}

ソースコード

https://github.com/rakusan/SpaceSharingExample

デモ動画

Vision ProとiPhoneを空間共有しiPhoneから三人称視点で見れるようにしたものを、Meetで配信している様子です。このように、Vision Proアプリの三人称視点を配信越しに見られるようにすることもできます。

https://youtu.be/2vpd73XCIc8

株式会社ソニックムーブ

Discussion

ログインするとコメントできます