はじめに

Apple Vision ProでImmersalによる位置合わせを利用するためのSwiftパッケージを作成しました。
本記事では、 Swift/visionOS環境でImmersalを扱うために、本パッケージを実装する中で得られた知見や遭遇した問題の回避策を紹介します。
なお、私自身はUnity開発をメインにしており、SwiftはvisionOS開発のために比較的新しく触り始めた言語です。より良い実装方法もありそうですが、これからvisionOSでImmersalを使ってみたい方の参考になれば幸いです。

本記事で扱わないこと

目次

Immersalについて

Immersalは高精度な位置合わせを提供するクラウドベースの3Dマッピングプラットフォームです。事前に作成したマップに対して、カメラ画像から6DOFの位置・姿勢を推定できます。

開発環境はUnity向けのSDKが中心で、visionOSには未対応です。iOS向けのネイティブサンプルコードは公開されているため、これを参考にvisionOS用パッケージの開発を始めました。

作ったもの

visionOS対応のImmersalパッケージをSwift Package Managerで公開しました。

主な機能:

  • ImmersalローカライゼーションのvisionOS対応
    • オンデバイス/REST APIによるLocalizer
  • RealityKitとの座標変換
  • Reality Composer Proを使ったシーン作成

GitHub:
https://github.com/gaprot/ImmersalKit
README_JP.md

開発環境

  • visionOS 2.0+
  • Swift 5.8+
  • Xcode 16.0+

実装方針

以下のアプローチで実装しています:

  • visionOSのカメラフレームから画像とカメラパラメータを取得
  • Immersalのローカライゼーション処理でマップとの位置合わせを実行
    • iOSサンプルのPosePluginヘッダーとUnity SDKに含まれる静的ライブラリを利用し、 Swiftから呼び出し
  • 結果をRealityKit座標系に変換して利用

実装時の課題

1. Swift Package Manager(SPM)での静的ライブラリ(.a)の扱い

SPMで静的ライブラリを扱う際によく使われる方法にxcframeworkがあるようですが、 今回利用したいImmersalの静的ライブラリは再配布が禁止となっています。
xcframeworkだと再配布が必要になるため、直接利用することはできません。
ImmersalのiOSサンプルのPosePluginNativeTesterでは、 静的ライブラリのファイル自体はリポジトリに含めずに、ユーザーが手動でSDKから取得してプロジェクトに追加する形になっています。

この方式を参考に、今回作成したSPMでも同様の仕組みをとりました:

// Package.swift
.systemLibrary(
    name: "PosePlugin",
    pkgConfig: nil,
    providers: nil
)
// Sources/PosePlugin/module.modulemap
module PosePlugin {
    header "PosePlugin.h"
    link "PosePlugin"
    export *
}
// Core.swift - 静的ライブラリのWrapper
public enum Core {
    public static func localizeImage(
        mapIds: [Int],
        width: Int32,
        height: Int32,
        intrinsics: UnsafePointer<Float>,
        pixels: UnsafeMutableRawPointer,
        channels: Int32,
        solverType: Int32,
        cameraRotation: inout simd_quatf
    ) -> LocalizeInfo {
        // PosePlugin C APIの呼び出し
    }
}

ユーザー側での対応(公式サンプルと同様の手法):

  • ユーザーが手動でImmersal SDKから.aファイルをプロジェクトに追加
  • XCodeのBuild SettingsでOther Linker Flags-lc++を追加
  • ビルド時に自動的にリンクされる

2. visionOSでの座標変換

visionOSでは、CameraFrameProviderからカメラ画像と外部パラメータ(extrinsics)を、WorldTrackingProviderからデバイスのワールド座標でのトラッキング情報を取得できます。
外部パラメータは、カメラとデバイス間の相対的な位置・回転を表す4×4変換行列です。 これはカメラからデバイスアンカーへの変換となっているようです。
これらのデータを使用して、Immersalのマップデータを現実空間に位置合わせするために段階的な変換を行っています。

座標変換の実装

1. ローカライゼーション結果の処理 (ImmersalSession.swift)
// カメラ→デバイス変換(CameraFrameProviderから取得)
let cam2Device = extrinsics

// デバイス→ワールド変換(WorldTrackingProviderから取得)
let device2World = trackingData.device2World

// カメラ→ワールド変換を計算
let cam2World = device2World * cam2Device
2. マップ位置の更新 (SceneUpdater.swift)
// Immersalマップ → カメラ変換 (Immersalマップから見たカメラの姿勢を行列に)
let mapLocalMatrix = simd_float4x4(
    position: localizeInfo.position,
    rotation: localizeInfo.rotation
)

// 逆変換で、 カメラ → Immersalマップ変換
let mapLocalMatrixInv = mapLocalMatrix.inverse

// Immersalマップ座標系からワールド座標系への変換を計算してEntityを更新
let finalTransform = cam2World * mapLocalMatrixInv * entity.transformMatrix(relativeTo: nil)
entity.transform.matrix = finalTransform
entity.position.y -= 0.25  // 高さ調整

注意事項: 現在の実装では座標変換後になぜか高さが約25cmずれてしまっており、 根本的な原因を特定できていないため、ハードコーディングで調整しています。

3. Reality Composer Pro(RCP)との連携

本パッケージ内で実装したComponentをRCPから利用したかったのですが、依存関係の制約により以下の構成では実現できませんでした:

App
├── ImmersalKit (ImmersalMapComponent定義)
└── RealityKitContent (RCPで作成)
    └── ImmersalKit内のコンポーネントを参照できない

RealityKitContentからSPMパッケージへの依存は設定しても、SPM内で定義したComponentはRCPから参照できないようです。
そのため、 本パッケージ内にテンプレートとして用意したImmersalMapComponentファイルをアプリ側プロジェクトの所定のディレクトリに手動で複製してもらう回避策を取っています。

このComponentの主な用途は、 RCPで作成するシーンで扱うエンティティとImmersal上のMapIDを紐づけることです。


// RealityKitContent内でのComponent定義(アプリから参照可能)
// RealityKitContent/Sources/RealityKitContent/ImmersalMapComponent.swift
public struct ImmersalMapComponent: Component, Codable {
    public var mapId: Int = -1

    public init(mapId: Int = -1) {
        self.mapId = mapId
    }
}

// 使用例(Demo/UI/ImmersalSpaceView.swift より)
if let scene = try? await Entity(named: "Scene", in: realityKitContentBundle) {
    content.add(scene)
    scene.forEachDescendant(withComponent: RealityKitContent.ImmersalMapComponent.self) { entity, component in
        let result = immersalKit.mapManager.registerMap(mapEntity: entity, mapId: component.mapId)
        print("\(result)")
    }
}

現状はこの方式をとっていますが、 AppleがRCPでのSPM内のComponent参照をサポートした場合には、移行を検討したいと考えています。

基本的な使い方

マップリソースの準備

Immersal Developer Portal からマップファイル(とテクスチャ付きメッシュを使うならGLBファイル)をダウンロードしておきます。

マップファイルは以下の形式でアプリバンドルに含める必要があります:

  • ファイル名形式: {mapId}-{任意の名前}.bytes
  • 例: 127558-RoomL.bytes

Reality Composer Pro(RCP)でのシーン作成

RCPでImmersalMapComponentを使用するには、以下の手順でファイルをコピーする必要があります:

  1. 必要なファイルをRealityKitContentプロジェクトにコピーしてコメントアウトを解除
    • Sources/ImmersalKit/RealityKitContentTemplates/ImmersalMapComponent.swiftYOUR_APP/Packages/RealityKitContent/Sources/RealityKitContent/
    • Sources/ImmersalKit/RealityKitContentTemplates/Entity+Extensions.swiftYOUR_APP/Packages/RealityKitContent/Sources/RealityKitContent/
  2. Reality Composer Proでシーンを作成
    • RCPでシーンを開く
    • マップを配置したいEntityを選択
    • InspectorでAdd Component → ImmersalMapComponentを追加
    • Map IDフィールドにマップIDを入力(例:127558)
  3. マップコンテンツの配置
    • 位置合わせ後、自動的にマップの原点に合わせて表示される
    • 位置合わせして使用したい自分のコンテンツ(3Dモデルなど)をImmersalMapComponentを持つEntityの子として配置しておけば、親子関係による相対位置が保持され、正しい位置に配置される
    • 注意: ImmersalKitは位置合わせを行いますが、表示/非表示の制御はアプリ側の責務です。Demoアプリでは、位置合わせが成功するまでマップを非表示にし、成功後にentity.isEnabled = trueで表示する実装をしています

GLBファイルの変換と使用

Immersal Developer Portalからダウンロードしたマップのテクスチャ付きメッシュ(GLBファイル)を使用して、位置合わせ結果を可視化することができます。

手順:

  1. GLBファイルのダウンロード
    • Immersal Developer Portalからマップファイル(.bytes)と一緒にGLBファイルをダウンロード
    • GLBファイルは位置合わせ結果の可視化のためのテクスチャ付きメッシュ
  2. Reality Converterで変換
    • RCPはGLB形式を直接扱えないため、Reality Converterを使用
    • GLBファイルをUSDZ形式に変換
  3. Reality Composer Proへインポートと配置
    • 変換したUSDZファイルをRCPプロジェクトにインポート
    • ImmersalMapComponentを持つEntityの子として配置
    • 位置合わせ時に自動的に正しい位置に表示され、マップの可視化として機能

実装例

https://github.com/gaprot/ImmersalKit のDemoから抜粋


// 1. ImmersalKitの初期化
let immersalKit = ImmersalKit(
    localizerType: .posePlugin,
    arSessionManager: ARSessionManager()
)

// 初期化時にImmersalMapComponentを登録
init() {
    RealityKitContent.ImmersalMapComponent.registerComponent()
}

// 2. RealityKitシーンからマップを探索・登録
if let scene = try? await Entity(named: "Scene", in: realityKitContentBundle) {
    content.add(scene)

    // ImmersalMapComponentを持つEntityを探索・登録
    scene.forEachDescendant(withComponent: RealityKitContent.ImmersalMapComponent.self) { entity, component in
        let result = immersalKit.mapManager.registerMap(mapEntity: entity, mapId: component.mapId)
        print("Map \(component.mapId) registration: \(result)")
    }
}

// 3. 必要なマップのロード(PosePluginの場合のみ)
// ユーザーがUIで選択したマップをロード
func loadSelectedMaps(selectedMapIds: Set<Int>) {
    guard !selectedMapIds.isEmpty else { return }

    // REST APIの場合はマップファイルのロードは不要
    if immersalKit.localizerType == .restApi {
        print("REST API: Ready with \(selectedMapIds.count) map(s) selected")
        return
    }

    // PosePluginの場合は実際のマップファイル(.bytes)をロード
    var successCount = 0
    var failureCount = 0

    for mapId in selectedMapIds {
        let result = immersalKit.mapManager.loadMap(mapId: mapId)
        switch result {
        case .success:
            successCount += 1
            print("Map \(mapId) loaded successfully")
        case .failure(let error):
            failureCount += 1
            print("Failed to load map \(mapId): \(error)")
        }
    }

    if failureCount == 0 {
        print("Loaded all \(successCount) selected maps")
    } else {
        print("Loaded \(successCount) maps (\(failureCount) failed)")
    }
}

// 4. ローカライゼーション開始
try await immersalKit.startLocalizing()

// 5. 結果の監視
for await event in immersalKit.localizationEvents() {
    switch event {
    case .result(let result):
        print("Map \(result.mapId) - 位置: \(result.position)")
        print("信頼度: \(result.confidence)")
        
        // 位置合わせ成功時にマップを表示
        if let entry = immersalKit.mapManager.mapEntries[result.mapId] {
            await MainActor.run {
                entry.sceneParent?.isEnabled = true
            }
        }
        
    case .failed(let error):
        print("エラー: \(error)")
    case .started:
        print("ローカライゼーション開始")
    case .stopped:
        print("ローカライゼーション停止")
    }
}

まとめ

visionOS向けのImmersalパッケージ実装では、SPMでの静的ライブラリ扱いや座標変換の調整、RCPとの連携など、いくつかの技術的課題に遭遇しました。ワークアラウンド的な対応もあり、 まだ改善の余地はありますが、 visionOSにおける位置合わせの選択肢の1つとして同じような実装を検討している方の参考になれば幸いです。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる