はじめに
この記事では、visionOS 26とLogitech MUSE(空間スタイラス)を使って、Mixed Reality空間で実際に振り回せる魔法の杖アプリを実装する過程で得た知見を共有します。
特に、日本語の情報が少ないMUSE(GCStylus)のSpatial Trackingに焦点を当て、実装の具体例とハマりどころを解説します。
In action
目次
- Logitech MUSEとは
- MUSE Spatial Trackingの概要
- 実装:デバイス検出と接続
- 実装:Spatial Anchoring
- 実装:ハプティックフィードバック
- 実装:位置ベースのモーション検出
- 実装:ボタン入力の処理
- MUSEを再起動しなければならない場面
- まとめ
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 -
クラス:
GCStylus(GCDeviceを継承) -
トラッキングポイント:
"aim"または"tip"ロケーション -
入力:
GCStylusInput経由でボタン入力を取得
MUSE Spatial Trackingの概要
MUSE Spatial Trackingは、GameControllerフレームワークのGCStylusと、ARKitフレームワークのAnchoringComponent.AccessoryAnchoringSourceを組み合わせて実現します。
基本的な流れ
-
デバイス検出:
NotificationCenterでMUSEの接続を監視 -
Anchoring Source作成:
AccessoryAnchoringSourceでMUSEの位置追跡を開始 -
AnchorEntity生成:
AnchorEntityをMUSEの位置に追従させる - 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: 下側のサイドボタン
重要なポイント
- @MainActorで実行: UI更新はメインスレッドで行う
- weak self: メモリリークを防ぐ
-
pressedフラグ:
trueで押下、falseで離す - ハプティック: ボタン押下時に即座にフィードバック
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を使った実装のポイントをまとめます:
実装のポイント
-
デバイス検出:
GCControllerDidConnectとGCStylusDidConnectの両方を監視 -
Spatial Anchoring:
AccessoryAnchoringSourceとAnchorEntityで位置追跡 -
SpatialTrackingSession:
.taskでSpatialTrackingSessionを開始 - Haptics: CoreHapticsで強度と鋭さを調整した複数パターンを用意
- Motion Detection: 位置の変化から速度を計算してハプティックをトリガー
-
Button Input:
GCStylusInputでボタン入力を処理
ハマりどころ
- SpatialTrackingSessionの開始忘れ: Anchorが更新されない
-
trackingMode:
.predictedを使わないと遅延が大きい - MUSE再起動の頻度: visionOS再起動やアプリクラッシュ後は必須
- ボタン入力の@MainActor: メインスレッドで実行しないとクラッシュ
- Haptic Engineの再利用: 一度作成したEngineとPlayerは使い回す
参考リンク
- Apple Developer Documentation - GameController
- Apple Developer Documentation - ARKit
- Apple Developer Documentation - CoreHaptics
- Logitech MUSE公式サイト
ソースコード
オープンソースで公開しています。遊んでみてください!
魔法の杖 MagicWands @Github
最後まで読んでいただき、ありがとうございました!
MUSEとvisionOSでの開発で困ったことがあれば、コメント欄で共有していただけると嬉しいです。