1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

visionOS 26とLogitech MUSEで作る魔法の杖

1
Last updated at Posted at 2026-04-10

はじめに

この記事では、visionOS 26とLogitech MUSE(空間スタイラス)を使って、Mixed Reality空間で実際に振り回せる魔法の杖アプリを実装する過程で得た知見を共有します。

特に、日本語の情報が少ないMUSE(GCStylus)のSpatial Trackingに焦点を当て、実装の具体例とハマりどころを解説します。

In action

目次

  1. Logitech MUSEとは
  2. MUSE Spatial Trackingの概要
  3. 実装:デバイス検出と接続
  4. 実装:Spatial Anchoring
  5. 実装:ハプティックフィードバック
  6. 実装:位置ベースのモーション検出
  7. 実装:ボタン入力の処理
  8. MUSEを再起動しなければならない場面
  9. まとめ

Logitech MUSEとは

Logitech MUSE(ミューズ)は、Apple Vision Pro向けに設計された空間スタイラスです。visionOS 26以降で正式サポートされ、Mixed Reality空間での3D描画やジェスチャー操作を可能にします。

MUSEの特徴

  • 6DOF(6自由度)トラッキング: 3軸の位置(X, Y, Z)と3軸の回転(pitch, yaw, roll)を追跡
  • ハプティックフィードバック: CoreHapticsを使った触覚フィードバック
  • 物理ボタン: Primary(上側)とSecondary(下側)の2つのサイドボタン
  • バッテリー駆動: USB-C充電、約8時間の連続使用
  • 寸法: 全長約16cm(0.16m)

技術仕様

  • プロダクトカテゴリ: GCProductCategorySpatialStylus
  • クラス: GCStylusGCDeviceを継承)
  • トラッキングポイント: "aim"または"tip"ロケーション
  • 入力: GCStylusInput経由でボタン入力を取得

MUSE Spatial Trackingの概要

MUSE Spatial Trackingは、GameControllerフレームワークのGCStylusと、ARKitフレームワークのAnchoringComponent.AccessoryAnchoringSourceを組み合わせて実現します。

基本的な流れ

  1. デバイス検出: NotificationCenterでMUSEの接続を監視
  2. Anchoring Source作成: AccessoryAnchoringSourceでMUSEの位置追跡を開始
  3. AnchorEntity生成: AnchorEntityをMUSEの位置に追従させる
  4. Entity追加: AnchorEntityの子としてRealityKit Entityを追加

必要なフレームワーク

import GameController  // GCStylus, GCDeviceHaptics
import ARKit           // AnchoringComponent, SpatialTrackingSession
import RealityKit      // AnchorEntity, ModelEntity

必要なEntitlements

Info.plistまたは.entitlementsに以下を追加:

<key>NSAccessorySetupKitSupport</key>
<true/>

実装:デバイス検出と接続

1. 通知の設定

MUSEの接続・切断を検出するため、4つの通知を監視します:

func setupNotifications() {
    // Controller接続(MUSEはControllerとしても認識される)
    NotificationCenter.default.addObserver(
        forName: NSNotification.Name.GCControllerDidConnect,
        object: nil,
        queue: .main
    ) { [weak self] notification in
        guard let controller = notification.object as? GCController else { return }
        Task { @MainActor in
            try? await self?.handleDeviceConnection(device: controller)
        }
    }

    // Stylus接続(MUSEの正式な接続通知)
    NotificationCenter.default.addObserver(
        forName: NSNotification.Name.GCStylusDidConnect,
        object: nil,
        queue: .main
    ) { [weak self] notification in
        guard let stylus = notification.object as? GCStylus else { return }
        Task { @MainActor in
            try? await self?.handleDeviceConnection(device: stylus)
        }
    }

    // Controller切断
    NotificationCenter.default.addObserver(
        forName: NSNotification.Name.GCControllerDidDisconnect,
        object: nil,
        queue: .main
    ) { [weak self] _ in
        Task { @MainActor in
            self?.handleDeviceDisconnection()
        }
    }

    // Stylus切断
    NotificationCenter.default.addObserver(
        forName: NSNotification.Name.GCStylusDidDisconnect,
        object: nil,
        queue: .main
    ) { [weak self] _ in
        Task { @MainActor in
            self?.handleDeviceDisconnection()
        }
    }
}

2. 既存デバイスのスキャン

アプリ起動時、すでに接続されているMUSEを検出します:

func setupExistingDevices() async {
    let controllers = GCController.controllers()
    let styluses = GCStylus.styli

    print("🔍 Scanning for existing MUSE devices...")

    // Check existing controllers
    for controller in controllers {
        if controller.productCategory == GCProductCategorySpatialController {
            try? await setupSpatialAccessory(device: controller)
            return  // Only setup one device
        }
    }

    // Check existing styluses
    for stylus in styluses {
        if stylus.productCategory == GCProductCategorySpatialStylus {
            try? await setupSpatialAccessory(device: stylus)
            return  // Only setup one device
        }
    }

    print("ℹ️ No MUSE devices found at startup")
}

3. デバイス接続ハンドラ

デバイスタイプを判定して、適切なセットアップを行います:

private func handleDeviceConnection(device: GCDevice) async throws {
    // Spatial StylusまたはSpatial Controllerのみ処理
    guard device.productCategory == GCProductCategorySpatialController ||
          device.productCategory == GCProductCategorySpatialStylus else {
        return
    }

    // Haptics取得(デバイスタイプに応じて)
    if let controller = device as? GCController, let haptics = controller.haptics {
        hapticsModel.setupHaptics(haptics: haptics)
    } else if let stylus = device as? GCStylus, let haptics = stylus.haptics {
        hapticsModel.setupHaptics(haptics: haptics)
    }

    try await setupSpatialAccessory(device: device)
}

実装:Spatial Anchoring

MUSEの物理位置をRealityKitのEntityに反映させるため、AnchorEntityを使用します。

1. AccessoryAnchoringSourceの作成

func setupSpatialAccessory(device: GCDevice) async throws {
    // Create anchoring source from MUSE device
    let source = try await AnchoringComponent.AccessoryAnchoringSource(device: device)

    // Get location (priority: "aim" > "tip")
    // "aim"はMUSEの向きを含む位置、"tip"は先端位置
    guard let location = source.locationName(named: "aim") ??
                         source.locationName(named: "tip") else {
        print("⚠️ No suitable location found for MUSE device")
        return
    }

    print("✅ Using location: \(location)")
}

2. AnchorEntityの生成

// Create anchor entity with predicted tracking
let anchorEntity = AnchorEntity(
    .accessory(from: source, location: location),
    trackingMode: .predicted,      // 予測トラッキングで遅延を最小化
    physicsSimulation: .none       // 物理シミュレーションは不要
)
anchorEntity.name = "WandAnchor"

重要なポイント:

  • trackingMode: .predicted: 予測アルゴリズムで遅延を減らし、スムーズな追従を実現
  • physicsSimulation: .none: Anchorは物理演算の対象外(子Entityで物理を使う場合は子側で設定)

3. 子Entityの追加

AnchorEntityの子として、実際に表示するModelEntityを追加します:

// 魔法の杖の円柱メッシュ
let wandMesh = MeshResource.generateCylinder(height: 1.0, radius: 0.005)

// UnlitMaterialで発光効果
var wandMaterial = UnlitMaterial()
wandMaterial.color = .init(tint: .white)
let wandModel = ModelEntity(mesh: wandMesh, materials: [wandMaterial])

// 円柱のデフォルトはY軸方向→Z軸(前方)に回転
wandModel.transform.rotation = simd_quatf(angle: .pi / 2, axis: [1, 0, 0])
wandModel.position = [0, 0, -0.5]  // MUSEの先端から50cm前方
wandModel.name = "WandModel"

anchorEntity.addChild(wandModel)

4. SpatialTrackingSessionの開始

ImmersiveViewでSpatialTrackingSessionを開始します:

.task {
    // Start Spatial Tracking Session for accessory tracking
    let configuration = SpatialTrackingSession.Configuration(tracking: [.accessory])
    let session = SpatialTrackingSession()
    await session.run(configuration)
}

注意点:

  • .taskモディファイアで実行(ビューのライフサイクルに連動)
  • tracking: [.accessory]でアクセサリトラッキングを有効化
  • この処理を忘れるとAnchorEntityが更新されない

実装:ハプティックフィードバック

MUSEのハプティックフィードバックは、CoreHapticsを使って実装します。

1. Haptic Engineの初期化

@MainActor
@Observable
final class HapticsModel {
    var hapticEngine: CHHapticEngine? = nil
    var lightSwingPattern: CHHapticPattern? = nil
    var lightSwingPlayer: CHHapticPatternPlayer? = nil
    var impactPattern: CHHapticPattern? = nil
    var impactPlayer: CHHapticPatternPlayer? = nil

    func setupHaptics(haptics: GCDeviceHaptics) {
        // Initialize haptic engine from MUSE
        hapticEngine = haptics.createEngine(withLocality: .default)

        do {
            try hapticEngine?.start()
            print("✅ Haptic engine started")
        } catch {
            print("⚠️ Failed to start haptic engine: \(error)")
            return
        }
    }
}

2. Haptic Patternの作成

異なる強度のハプティックパターンを2種類作成します:

// Light swing pattern (wand motion feedback)
lightSwingPattern = try CHHapticPattern(events: [
    CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3),  // 軽め
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)   // 柔らかめ
        ],
        relativeTime: 0.0
    )
], parameters: [])

// Impact pattern (collision feedback)
impactPattern = try CHHapticPattern(events: [
    CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),  // 最大
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)   // 鋭い
        ],
        relativeTime: 0.0
    )
], parameters: [])

パラメータの意味:

  • hapticIntensity: 振動の強さ(0.0〜1.0)
  • hapticSharpness: 振動の鋭さ(0.0〜1.0)
    • 低い値 = 柔らかい振動(ブーン)
    • 高い値 = 鋭い振動(カツッ)

3. Pattern Playerの生成

// Create players from patterns
lightSwingPlayer = try hapticEngine?.makePlayer(with: lightSwingPattern)
impactPlayer = try hapticEngine?.makePlayer(with: impactPattern)

4. Hapticのトリガー

func triggerLightSwing() {
    guard let player = lightSwingPlayer else { return }
    try? player.start(atTime: CHHapticTimeImmediate)
}

func triggerImpact() {
    guard let player = impactPlayer else { return }
    try? player.start(atTime: CHHapticTimeImmediate)
}

実装:位置ベースのモーション検出

MUSEを振ったときにハプティックフィードバックを発生させるため、位置の変化から速度を計算します。

実装コード

private var lastPosition: SIMD3<Float>? = nil
private var lastUpdateTime: TimeInterval = 0
private let velocityThreshold: Float = 0.8  // m/s threshold

func checkMotionForHaptics() {
    guard let anchor = wandAnchorEntity else { return }

    let currentTime = CACurrentMediaTime()
    let currentPosition = anchor.position(relativeTo: nil)

    // Calculate velocity if we have a previous position
    if let lastPos = lastPosition {
        let deltaTime = Float(currentTime - lastUpdateTime)

        // Skip if deltaTime is too small
        guard deltaTime > 0.001 else { return }

        // Calculate velocity vector
        let deltaPosition = currentPosition - lastPos
        let velocity = deltaPosition / deltaTime

        // Calculate velocity magnitude
        let velocityMagnitude = length(velocity)

        // Trigger haptics if velocity exceeds threshold
        if velocityMagnitude > velocityThreshold {
            print("⚡️ Motion detected - velocity: \(velocityMagnitude) m/s")
            hapticsModel?.triggerLightSwing()
        }
    }

    // Update last position and time
    lastPosition = currentPosition
    lastUpdateTime = currentTime
}

更新ループ

60FPSで位置を監視します:

.task {
    while true {
        wandModel.update()  // checkMotionForHaptics()を内部で呼ぶ
        try? await Task.sleep(for: .seconds(0.016))  // ~60fps
    }
}

閾値の調整

velocityThresholdの値を調整することで、感度を変更できます:

  • 0.5 m/s: 非常に敏感(手を少し動かしただけで反応)
  • 0.8 m/s: 中程度(実装値)
  • 1.5 m/s: 鈍感(大きく振らないと反応しない)

実装:ボタン入力の処理

MUSEには2つのサイドボタンがあり、それぞれ異なる機能を割り当てられます。

Stylusボタンの処理

func setupStylusInputs(stylus: GCStylus) {
    guard let input = stylus.input else { return }

    // Primary button (upper side button)
    input.buttons[.stylusPrimaryButton]?.pressedInput.pressedDidChangeHandler =
        { [weak self] _, _, pressed in
            Task { @MainActor in
                if pressed {
                    self?.cycleWandColor()
                }
            }
        }

    // Secondary button (lower side button)
    input.buttons[.stylusSecondaryButton]?.pressedInput.pressedDidChangeHandler =
        { [weak self] _, _, pressed in
            Task { @MainActor in
                if pressed {
                    self?.hapticsModel.triggerImpact()
                    self?.toggleWandExtension()
                }
            }
        }
}

ボタンの種類

  • .stylusPrimaryButton: 上側のサイドボタン
  • .stylusSecondaryButton: 下側のサイドボタン

重要なポイント

  1. @MainActorで実行: UI更新はメインスレッドで行う
  2. weak self: メモリリークを防ぐ
  3. pressedフラグ: trueで押下、falseで離す
  4. ハプティック: ボタン押下時に即座にフィードバック

MUSEを再起動しなければならない場面

実装中に遭遇した、MUSEの再起動(電源OFF→ON)が必要な場面をまとめます。

1. visionOSの再起動後

症状: visionOSを再起動すると、MUSEが接続されているように見えるが、Spatial Trackingが機能しない

解決策: MUSEの電源を一度OFFにして、再度ONにする

原因: visionOS側のトラッキングセッションがリセットされても、MUSE側の接続状態が保持されているため、再ハンドシェイクが必要

2. アプリのクラッシュ後

症状: アプリがクラッシュした後、MUSEが接続されているがAnchorEntityが更新されない

解決策: MUSEの電源を一度OFFにして、再度ONにする

原因: クラッシュ時にSpatialTrackingSessionが正常に終了せず、デバイス側の状態が不整合になる

3. 長時間のスリープ後

症状: Vision Proをスリープ状態から復帰させた後、MUSEの位置がずれる、または更新されない

解決策: MUSEの電源を一度OFFにして、再度ONにする

原因: スリープ中にトラッキングが一時停止し、復帰時に座標系がリセットされる可能性がある

4. 複数回の接続・切断を繰り返した後

症状: アプリの開発中、Xcodeから何度もビルド&実行を繰り返すと、MUSEが認識されなくなる

解決策: MUSEの電源を一度OFFにして、再度ONにする

原因: 短時間に接続・切断を繰り返すことで、Bluetooth接続が不安定になる

デバッグのコツ

MUSEの接続状態をログで確認すると、問題の切り分けが容易になります:

func setupSpatialAccessory(device: GCDevice) async throws {
    print("📱 Device: \(device.vendorName ?? "Unknown") - \(device.productCategory)")

    let source = try await AnchoringComponent.AccessoryAnchoringSource(device: device)

    if let aim = source.locationName(named: "aim") {
        print("✅ Location 'aim' available")
    }
    if let tip = source.locationName(named: "tip") {
        print("✅ Location 'tip' available")
    }

    // ...
}

正常な接続時のログ:

📱 Device: Logitech - GCProductCategorySpatialStylus
✅ Location 'aim' available
✅ Location 'tip' available
✅ MUSE device connected and wand setup complete

異常な接続時のログ:

📱 Device: Logitech - GCProductCategorySpatialStylus
⚠️ No suitable location found for MUSE device

この場合、MUSEを再起動する必要があります。

まとめ

visionOS 26とLogitech MUSEを使った実装のポイントをまとめます:

実装のポイント

  1. デバイス検出: GCControllerDidConnectGCStylusDidConnectの両方を監視
  2. Spatial Anchoring: AccessoryAnchoringSourceAnchorEntityで位置追跡
  3. SpatialTrackingSession: .taskSpatialTrackingSessionを開始
  4. Haptics: CoreHapticsで強度と鋭さを調整した複数パターンを用意
  5. Motion Detection: 位置の変化から速度を計算してハプティックをトリガー
  6. Button Input: GCStylusInputでボタン入力を処理

ハマりどころ

  1. SpatialTrackingSessionの開始忘れ: Anchorが更新されない
  2. trackingMode: .predictedを使わないと遅延が大きい
  3. MUSE再起動の頻度: visionOS再起動やアプリクラッシュ後は必須
  4. ボタン入力の@MainActor: メインスレッドで実行しないとクラッシュ
  5. Haptic Engineの再利用: 一度作成したEngineとPlayerは使い回す

参考リンク

ソースコード

オープンソースで公開しています。遊んでみてください!
魔法の杖 MagicWands @Github


最後まで読んでいただき、ありがとうございました!
MUSEとvisionOSでの開発で困ったことがあれば、コメント欄で共有していただけると嬉しいです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?