アドカレ
KENTOのひとりアドカレ16日目の記事です。
https://qiita.com/advent-calendar/2025/kento
環境情報
| ツール/SDK | バージョン |
|---|---|
| Unity | 6000.0.62f1 |
| Meta XR Core SDK | 81.0.0 |
| Open XR Plugin | 1.16.0 |
| XR Interaction Toolkit | 3.0.9 |
| XR Hands | 1.7.1 |
事前準備は以下の通りです。
デモ
何の設定もしない場合、コントローラー操作からハンドトラッキング操作へと切り替える際、コントローラーから手を離して手を数秒見つめ続ける必要があります。以下のような挙動です。
加えて、強制的に切り替えられるので、左右で別々の役割を持てません。

シームレスに切り替える設定(マルチモーダル設定)を行えば、数秒待たずしてすぐに切り替わります。

設定方法
まずはOVR Managerの設定値を変更していきます。大前提として、HandTrackingSupportでControllers And Handsのサポートを有効化しておきます。
次に、SimultaneousHandsAndControllersEnabledというパラメータにチェックを入れます。
SimultaneousHandsAndControllersEnabledをオンにすると、OVR Manager内のLaunch simulataneous hands and controllers modeが表示されます。これをオンにします。SimultaneousHandsAndControllersEnabledがオフのとき、このパラメータは変更できないようになっています。

Launch simulataneous hands and controllers modeのTipsにProjectSettingsで有効化しないと動きません、的なことが書かれていますが、その設定箇所が見当たりませんでした。

どうやってこの解決策にたどり着いたのか忘れましたが、以下をAndroidManifestに書き込めば有効化されました。
<meta-data android:name="com.oculus.handtracking.simultaneous_controllers" android:value="true" />
以下のようなドキュメントもありますが、古くてあてにならずです。
参考リンク:Use Simultaneous Hands and Controllers (Multimodal)
XRIはマルチモーダルを想定していない
ここまでの実装に誤りはないはずですが、残念なことにXRI(XR Origin)を使う場合はマルチモーダルを想定したような挙動にはなりません。
一度ハンドトラッキングが有効化されれば、コントローラーを再表示することはできません。これはコントローラーとハンドトラッキングの切り替え機能を持つXRInputModalityManagerの影響です。
前提として、コントローラーとハンドトラッキングを両方使うとき、InteractionProfileをそれぞれ1つずつ用意することになると思います。今回のケースでは、Hand Interaction Profileを無効化すると正常に動きます。
何が起きているかAIに助力してもらいながらコードを追いましたが、どうやらXRInputModalityManagerのコードの中で、"Hand Interaction Profileによるハンドトラッキング判定"がコントローラーを持っている手自体に反応してしまい、コントローラーより優先的に利用デバイスとして判定されるようでした。
なので、"Hand Interaction Profileによるハンドトラッキング判定"を無視する仕組みが必要です。ハンドトラッキング判定自体はXR HandのXRHandSubSystemで行えばよいので、以下のように修正可能です。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Utilities;
using UnityEngine.InputSystem.XR;
using UnityEngine.XR;
using UnityEngine.XR.Hands;
using InputDevice = UnityEngine.InputSystem.InputDevice;
using InputSystemCommonUsages = UnityEngine.InputSystem.CommonUsages;
/// <summary>
/// XRInputModalityManagerのカスタム版。
/// SimultaneousHandsAndControllersEnabled環境で、コントローラーが有効なときは
/// ハンドトラッキングをオフにする動作をサポートする。
/// </summary>
public class CustomInputModalityManager : MonoBehaviour
{
private enum InputMode
{
None,
TrackedHand,
MotionController,
}
[SerializeField] private GameObject _leftHand;
[SerializeField] private GameObject _rightHand;
[SerializeField] private GameObject _leftController;
[SerializeField] private GameObject _rightController;
[SerializeField] private UnityEvent _trackedHandModeStarted;
[SerializeField] private UnityEvent _trackedHandModeEnded;
[SerializeField] private UnityEvent _motionControllerModeStarted;
[SerializeField] private UnityEvent _motionControllerModeEnded;
private XRHandSubsystem _handSubsystem;
private InputMode _leftInputMode;
private InputMode _rightInputMode;
private readonly List<XRHandSubsystem> _handSubsystemsReuse = new();
private void OnEnable()
{
TryGetHandSubsystem(out _handSubsystem);
if (_handSubsystem != null)
{
_handSubsystem.trackingAcquired += OnHandTrackingAcquired;
_handSubsystem.trackingLost += OnHandTrackingLost;
}
InputSystem.onDeviceChange += OnDeviceChange;
UpdateLeftMode();
UpdateRightMode();
}
private void OnDisable()
{
if (_handSubsystem != null)
{
_handSubsystem.trackingAcquired -= OnHandTrackingAcquired;
_handSubsystem.trackingLost -= OnHandTrackingLost;
}
InputSystem.onDeviceChange -= OnDeviceChange;
}
private void Update()
{
// HandSubsystemの再取得
if (_handSubsystem is not { running: true })
{
if (TryGetHandSubsystem(out var runningHandSubsystem))
{
if (runningHandSubsystem != _handSubsystem)
{
if (_handSubsystem != null)
{
_handSubsystem.trackingAcquired -= OnHandTrackingAcquired;
_handSubsystem.trackingLost -= OnHandTrackingLost;
}
_handSubsystem = runningHandSubsystem;
if (_handSubsystem != null)
{
_handSubsystem.trackingAcquired += OnHandTrackingAcquired;
_handSubsystem.trackingLost += OnHandTrackingLost;
}
}
}
}
// 毎フレーム、コントローラーのトラッキング状態をチェックしてモードを更新
UpdateLeftMode();
UpdateRightMode();
}
private bool TryGetHandSubsystem(out XRHandSubsystem handSubsystem)
{
SubsystemManager.GetSubsystems(_handSubsystemsReuse);
foreach (var subsystem in _handSubsystemsReuse)
{
if (subsystem.running)
{
handSubsystem = subsystem;
return true;
}
}
handSubsystem = null;
return false;
}
private void UpdateLeftMode()
{
if (HasTrackedController(InputSystemCommonUsages.LeftHand))
{
SetLeftMode(InputMode.MotionController);
}
else if (GetLeftHandIsTracked())
{
SetLeftMode(InputMode.TrackedHand);
}
else
{
SetLeftMode(InputMode.None);
}
}
private void UpdateRightMode()
{
if (HasTrackedController(InputSystemCommonUsages.RightHand))
{
SetRightMode(InputMode.MotionController);
}
else if (GetRightHandIsTracked())
{
SetRightMode(InputMode.TrackedHand);
}
else
{
SetRightMode(InputMode.None);
}
}
private void SetLeftMode(InputMode inputMode)
{
SafeSetActive(_leftHand, inputMode == InputMode.TrackedHand);
SafeSetActive(_leftController, inputMode == InputMode.MotionController);
var oldMode = _leftInputMode;
_leftInputMode = inputMode;
OnModeChanged(oldMode, inputMode, _rightInputMode);
}
private void SetRightMode(InputMode inputMode)
{
SafeSetActive(_rightHand, inputMode == InputMode.TrackedHand);
SafeSetActive(_rightController, inputMode == InputMode.MotionController);
var oldMode = _rightInputMode;
_rightInputMode = inputMode;
OnModeChanged(oldMode, inputMode, _leftInputMode);
}
private void OnModeChanged(InputMode oldInputMode, InputMode newInputMode, InputMode otherHandInputMode)
{
if (oldInputMode == newInputMode) return;
if (otherHandInputMode != InputMode.TrackedHand && oldInputMode == InputMode.TrackedHand)
{
_trackedHandModeEnded?.Invoke();
}
else if (otherHandInputMode != InputMode.MotionController && oldInputMode == InputMode.MotionController)
{
_motionControllerModeEnded?.Invoke();
}
if (otherHandInputMode != InputMode.TrackedHand && newInputMode == InputMode.TrackedHand)
{
_trackedHandModeStarted?.Invoke();
}
else if (otherHandInputMode != InputMode.MotionController && newInputMode == InputMode.MotionController)
{
_motionControllerModeStarted?.Invoke();
}
}
private static void SafeSetActive(GameObject gameObject, bool active)
{
if (gameObject && gameObject.activeSelf != active)
{
gameObject.SetActive(active);
}
}
private bool GetLeftHandIsTracked() => _handSubsystem is { leftHand: { isTracked: true } };
private bool GetRightHandIsTracked() => _handSubsystem is { rightHand: { isTracked: true } };
/// <summary>
/// 指定したUsageのコントローラーがトラッキングされているかどうかを確認する。
/// </summary>
private static bool HasTrackedController(InternedString usage)
{
var devices = InputSystem.devices;
foreach (var t in devices)
{
if (t is not XRController xrController) continue;
+ // Hand Interaction Profileが手をInputSystem.deviceとして返すので除外する。
+ if (IsHandInteractionController(xrController)) continue;
if (!xrController.usages.Contains(usage)) continue;
if (IsTracked(xrController)) return true;
}
return false;
}
+ /// <summary>
+ /// Hand Interaction Profileによって判定されたデバイスかどうかを判定する。
+ /// </summary>
+ private static bool IsHandInteractionController(XRController device)
+ {
+ var deviceType = device.GetType();
+ var typeName = deviceType.Name;
+ return typeName.Contains("HandInteraction");
+ }
/// <summary>
/// デバイスがトラッキングされているかどうかを確認する。
/// </summary>
private static bool IsTracked(TrackedDevice device)
{
if (device.isTracked.isPressed) return true;
var trackingState = (InputTrackingState)device.trackingState.value;
const InputTrackingState positionAndRotation = InputTrackingState.Position | InputTrackingState.Rotation;
return (trackingState & positionAndRotation) == positionAndRotation;
}
private void OnDeviceChange(InputDevice device, InputDeviceChange change)
{
if (device is not XRController controllerDevice) return;
if (IsHandInteractionController(controllerDevice)) return;
switch (change)
{
case InputDeviceChange.Added:
case InputDeviceChange.Reconnected:
case InputDeviceChange.Enabled:
case InputDeviceChange.UsageChanged:
if (!device.added) return;
var usages = device.usages;
if (usages.Contains(InputSystemCommonUsages.LeftHand))
{
UpdateLeftMode();
}
else if (usages.Contains(InputSystemCommonUsages.RightHand))
{
UpdateRightMode();
}
break;
case InputDeviceChange.Removed:
case InputDeviceChange.Disconnected:
case InputDeviceChange.Disabled:
var deviceUsages = device.usages;
if (deviceUsages.Contains(InputSystemCommonUsages.LeftHand))
{
UpdateLeftMode();
}
else if (deviceUsages.Contains(InputSystemCommonUsages.RightHand))
{
UpdateRightMode();
}
break;
}
}
private void OnHandTrackingAcquired(XRHand hand)
{
switch (hand.handedness)
{
case Handedness.Left:
if (!HasTrackedController(InputSystemCommonUsages.LeftHand))
{
SetLeftMode(InputMode.TrackedHand);
}
break;
case Handedness.Right:
if (!HasTrackedController(InputSystemCommonUsages.RightHand))
{
SetRightMode(InputMode.TrackedHand);
}
break;
}
}
private void OnHandTrackingLost(XRHand hand)
{
switch (hand.handedness)
{
case Handedness.Left:
if (_leftInputMode == InputMode.TrackedHand)
{
UpdateLeftMode();
}
break;
case Handedness.Right:
if (_rightInputMode == InputMode.TrackedHand)
{
UpdateRightMode();
}
break;
}
}
}
ハイライトした箇所がポイントで、Hand Interaction Profileによるデバイス判定を取得することで、コントローラーデバイスの認識を上書きしてしまわないような対応を入れています。
この対応により、Hand Interaction Profileを有効化した状態でも、コントローラーとハンドトラッキングをシームレスに切り替えることが可能となります。
ただ、若干無理やり実装なことに変わりはないです。はやくなおりますように





