0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Meta Quest】XR Interaction Toolkitのサンプルにおいて、XR Device Simulator利用時にハンドトラッキングのつかむ機能が動かない(近距離編)

0
Last updated at Posted at 2025-12-10

アドカレ

KENTOのひとりアドカレ10日目の記事です。
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

事前準備は以下の通りです。

シミュレーター利用時のハンドトラッキングのつかむ動作

GIFのようにピンチしても反応しません。

2025AdventCalendar10.gif

問題はNear-Far InteractorのSelect Inputにあります。
image.png

対象のInputActionが割り当てられているコードはSelect InputというGameObjectにアタッチされています。
image.png

InputActionにはちゃんとOpenXRのHand Interactionが割り当たっているように見えますが、これらのActionはシミュレーター利用時には呼び出されません。
image.png

シミュレーター利用時は登録済みの手の形となるようにBoneを動かしているだけで、XRIのHand Interactionをとのつながりを持たないようです。

コントローラーのアクションにはシミュレーターのGripがBindされているので、シミュレーター上でも正常に動きます。
image.png

解決策

簡単な解決策として、SelectのInputActionにシミュレーター実行時の対象となるキー入力を割り当てることです。pinchはMのキーボードに割り当たっているので、そのままMのキー入力のActionを追加します。
image.png

ただ、このやり方だと以下GIFのように無関係のジェスチャー中や別の操作時にもつかむ機能が反応してしまいます。
2025AdventCalendar11.gif

そこで別の方法として、Near-Far InteractorのSelect Inputを拡張する方法があります。

サンプルのSelect InputにはReleaseThresholdButtonReaderが登録してあります。以下がそのコード全文です。IXRInputButtonReaderで抽象化されていてややこしいですが、InputAcionから渡ってきた値をNear-Far Interactorへ渡すだけのシンプルなコードです。

using UnityEngine.XR.Interaction.Toolkit.Inputs.Readers;

namespace UnityEngine.XR.Interaction.Toolkit.Samples.Hands
{
    /// <summary>
    /// An input button reader based on another <see cref="XRInputButtonReader"/> and holds it true until falling below a lower release threshold.
    /// Useful with hand interaction because the bool select value can bounce when the hand is near the tight internal threshold,
    /// so using this will keep the pinch true until moving the fingers much further away than the pinch activation threshold.
    /// </summary>
    [DefaultExecutionOrder(XRInteractionUpdateOrder.k_XRInputDeviceButtonReader)]
    public class ReleaseThresholdButtonReader : MonoBehaviour, IXRInputButtonReader
    {
        [SerializeField]
        [Tooltip("The source input that this component reads to create a processed button value.")]
        XRInputButtonReader m_ValueInput = new XRInputButtonReader("Value");

        /// <summary>
        /// The source input that this component reads to create a processed button value.
        /// </summary>
        public XRInputButtonReader valueInput
        {
            get => m_ValueInput;
            set => XRInputReaderUtility.SetInputProperty(ref m_ValueInput, value, this);
        }

        [SerializeField]
        [Tooltip("The threshold value to use to determine when the button is pressed. Considered pressed equal to or greater than this value.")]
        [Range(0f, 1f)]
        float m_PressThreshold = 0.8f;

        /// <summary>
        /// The threshold value to use to determine when the button is pressed. Considered pressed equal to or greater than this value.
        /// </summary>
        /// <remarks>
        /// This reader will also be considered performed if the source input is performed.
        /// </remarks>
        public float pressThreshold
        {
            get => m_PressThreshold;
            set => m_PressThreshold = value;
        }

        [SerializeField]
        [Tooltip("The threshold value to use to determine when the button is released when it was previously pressed. Keeps being pressed until falls back to a value of or below this value.")]
        [Range(0f, 1f)]
        float m_ReleaseThreshold = 0.25f;

        /// <summary>
        /// The threshold value to use to determine when the button is released when it was previously pressed.
        /// Keeps being pressed until falls back to a value of or below this value.
        /// </summary>
        /// <remarks>
        /// This reader will still be considered performed if the source input is still performed
        /// when this threshold is reached.
        /// </remarks>
        public float releaseThreshold
        {
            get => m_ReleaseThreshold;
            set => m_ReleaseThreshold = value;
        }

        bool m_IsPerformed;
        bool m_WasPerformedThisFrame;
        bool m_WasCompletedThisFrame;

        /// <summary>
        /// See <see cref="MonoBehaviour"/>.
        /// </summary>
        void OnEnable()
        {
            m_ValueInput?.EnableDirectActionIfModeUsed();
        }

        /// <summary>
        /// See <see cref="MonoBehaviour"/>.
        /// </summary>
        void OnDisable()
        {
            m_ValueInput?.DisableDirectActionIfModeUsed();
        }

        /// <summary>
        /// See <see cref="MonoBehaviour"/>.
        /// </summary>
        void Update()
        {
            // Go true when either the press threshold is reached or the bool is already performed.
            // Only drop back to false when the release threshold is reached and the bool is no longer performed.
            var prevPerformed = m_IsPerformed;
            var pressAmount = m_ValueInput.ReadValue();

            bool newValue;
            if (prevPerformed)
                newValue = pressAmount > m_ReleaseThreshold || m_ValueInput.ReadIsPerformed();
            else
                newValue = pressAmount >= m_PressThreshold || m_ValueInput.ReadIsPerformed();

            m_IsPerformed = newValue;
            m_WasPerformedThisFrame = !prevPerformed && m_IsPerformed;
            m_WasCompletedThisFrame = prevPerformed && !m_IsPerformed;
        }

        /// <inheritdoc />
        public bool ReadIsPerformed()
        {
            return m_IsPerformed;
        }

        /// <inheritdoc />
        public bool ReadWasPerformedThisFrame()
        {
            return m_WasPerformedThisFrame;
        }

        /// <inheritdoc />
        public bool ReadWasCompletedThisFrame()
        {
            return m_WasCompletedThisFrame;
        }

        /// <inheritdoc />
        public float ReadValue()
        {
            return m_ValueInput.ReadValue();
        }

        /// <inheritdoc />
        public bool TryReadValue(out float value)
        {
            return m_ValueInput.TryReadValue(out value);
        }
    }
}

上記をベースに独自処理を用いて拡張していきます。まずは以前書いた、XR Handsを使ったピンチ処理を利用するのでおさらいです。

Todo ここに記事リンク

適当なGameObjectにアタッチ
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.XR.Hands;

/// <summary>
/// XRHandSubsystemを扱いやすくするためのユーティリティクラス。
/// </summary>
public class XRHandSubsystemUtility : MonoBehaviour
{
    [SerializeField] private Transform _cameraOffset;

    public static XRHandSubsystemUtility Instance { get; private set; }
    public XRHandSubsystem Subsystem => GetOrUpdateSubsystem();

    public bool IsLeftHandTracked => Subsystem?.leftHand.isTracked ?? false;
    public bool IsRightHandTracked => Subsystem?.rightHand.isTracked ?? false;
    public bool IsSubsystemRunning => Subsystem?.running ?? false;
    public Transform UserLocalSpace => _cameraOffset;

    private XRHandSubsystem _subsystem;
    private readonly List<XRHandSubsystem> _subsystemsReuse = new();

    private void Awake()
    {
        Instance = this;
    }

    private XRHandSubsystem GetOrUpdateSubsystem()
    {
        if (_subsystem is { running: true }) return _subsystem;

        SubsystemManager.GetSubsystems(_subsystemsReuse);
        foreach (var handSubsystem in _subsystemsReuse.Where(handSubsystem => handSubsystem.running))
        {
            _subsystem = handSubsystem;
            return _subsystem;
        }

        _subsystem = null;
        return null;
    }

    private XRHandJoint GetJoint(Handedness handedness, XRHandJointID jointId)
    {
        var hand = handedness == Handedness.Left ? Subsystem.leftHand : Subsystem.rightHand;
        return hand.GetJoint(jointId);
    }

    /// <summary>
    /// ジョイントの姿勢をCameraOffset基準で取得する。
    /// </summary>
    public bool TryGetJointPose(Handedness handedness, XRHandJointID jointId, out Pose pose)
    {
        pose = Pose.identity;
        var joint = GetJoint(handedness, jointId);
        if (!joint.TryGetPose(out var localPose)) return false;

        pose = localPose.GetTransformedBy(_cameraOffset.transform);
        return true;
    }
}

CustomPinchは両手対応版に修正しています。

using UnityEngine;
using UnityEngine.XR.Hands;

/// <summary>
/// ピンチの状態。
/// </summary>
public enum PinchState
{
    None,
    Started,
    Holding,
    Ended
}

/// <summary>
/// 独自のピンチ認識を行うクラス。
/// </summary>
public static class CustomPinch
{
    private const float PinchThreshold = 0.02f;
    private static bool _wasPinchingLeft;
    private static bool _wasPinchingRight;

    /// <summary>
    /// ピンチの状態を取得。
    /// </summary>
    public static PinchState CheckPinch(Handedness handedness)
    {
        var wasPinching = handedness == Handedness.Left ? _wasPinchingLeft : _wasPinchingRight;
        var isPinching = IsPinchDetected(handedness);

        if (!wasPinching && isPinching)
        {
            SetWasPinching(handedness, true);
            return PinchState.Started;
        }

        if (wasPinching && isPinching)
        {
            return PinchState.Holding;
        }

        if (wasPinching && !isPinching)
        {
            SetWasPinching(handedness, false);
            return PinchState.Ended;
        }

        return PinchState.None;
    }

    private static void SetWasPinching(Handedness handedness, bool value)
    {
        if (handedness == Handedness.Left)
        {
            _wasPinchingLeft = value;
        }
        else
        {
            _wasPinchingRight = value;
        }
    }

    private static bool IsPinchDetected(Handedness handedness)
    {
        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;
        var isHandTracked = handedness == Handedness.Left
            ? xrHandSubsystemUtility.IsLeftHandTracked
            : xrHandSubsystemUtility.IsRightHandTracked;

        if (!isHandTracked) return false;
        
        var isAbleToGetThumbTip = xrHandSubsystemUtility.TryGetJointPose(
            handedness,
            XRHandJointID.ThumbTip,
            out Pose thumbTipPose);

        var isAbleToGetIndexTip = xrHandSubsystemUtility.TryGetJointPose(
            handedness,
            XRHandJointID.IndexTip,
            out Pose indexTipPose);

        if (isAbleToGetThumbTip && isAbleToGetIndexTip)
        {
            var dist = Vector3.Distance(thumbTipPose.position, indexTipPose.position);
            return dist < PinchThreshold;
        }

        return false;
    }
}

上記ピンチのコードを利用したReaderクラスを作成します。

using UnityEngine;
using UnityEngine.XR.Hands;
using UnityEngine.XR.Interaction.Toolkit;
using UnityEngine.XR.Interaction.Toolkit.Inputs.Readers;

[DefaultExecutionOrder(XRInteractionUpdateOrder.k_XRInputDeviceButtonReader)]
public class PinchGestureReader : MonoBehaviour, IXRInputButtonReader
{
    [SerializeField] private Handedness _hand = Handedness.Right;
    
    private bool _isPerformed;
    private bool _wasPerformedThisFrame;
    private bool _wasCompletedThisFrame;

    private void Update()
    {
        var pinchState = CustomPinch.CheckPinch(_hand);

        _wasPerformedThisFrame = pinchState == PinchState.Started;
        _isPerformed = pinchState == PinchState.Holding || pinchState == PinchState.Started;
        _wasCompletedThisFrame = pinchState == PinchState.Ended;
    }

    public bool ReadIsPerformed()
    {
        return _isPerformed;
    }

    public bool ReadWasPerformedThisFrame()
    {
        return _wasPerformedThisFrame;
    }

    public bool ReadWasCompletedThisFrame()
    {
        return _wasCompletedThisFrame;
    }

    public float ReadValue()
    {
        return _isPerformed ? 1f : 0f;
    }

    public bool TryReadValue(out float value)
    {
        value = _isPerformed ? 1f : 0f;
        return _isPerformed;
    }
}

このReaderを登録すれば、シミュレーター上でもピンチが動作します。

image.png

2025AdventCalendar12.gif

Editor上でのみ動作させたい

ここまでの処理により、シミュレーター上でピンチを動かすことができました。しかし、実機ではHand Interaction Profileに依存した処理を扱いたい場合にこのままでは困ります。

その対策として、動作環境に応じて処理を使い分けるReaderを作成していきます。IXRInputButtonReaderで抽象化されていることを活かします。

using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit.Inputs.Readers;
using UnityEngine.XR.Interaction.Toolkit.Samples.Hands;
using UnityEngine.XR.Interaction.Toolkit;

[DefaultExecutionOrder(XRInteractionUpdateOrder.k_XRInputDeviceButtonReader)]
public class GrabReader : MonoBehaviour, IXRInputButtonReader
{
    [SerializeField] private PinchGestureReader _pinchReader;
    [SerializeField] private ReleaseThresholdButtonReader _thresholdReader;
    
    private IXRInputButtonReader _activeReader;
    
    private void Awake()
    {
        ConfigureInputReader();
    }
    
    private void ConfigureInputReader()
    {
        if (Application.isEditor)
        {
            _pinchReader.enabled = true;
            _thresholdReader.enabled = false;
            _activeReader = _pinchReader;
        }
        else
        {
            _pinchReader.enabled = false;
            _thresholdReader.enabled = true;
            _activeReader = _thresholdReader;
        }
    }
    
    public bool ReadIsPerformed()
    {
        return _activeReader?.ReadIsPerformed() ?? false;
    }
    
    public bool ReadWasPerformedThisFrame()
    {
        return _activeReader?.ReadWasPerformedThisFrame() ?? false;
    }
    
    public bool ReadWasCompletedThisFrame()
    {
        return _activeReader?.ReadWasCompletedThisFrame() ?? false;
    }
    
    public float ReadValue()
    {
        return _activeReader?.ReadValue() ?? 0f;
    }
    
    public bool TryReadValue(out float value)
    {
        if (_activeReader != null)
        {
            return _activeReader.TryReadValue(out value);
        }
        
        value = 0f;
        return false;
    }
}

使い方ですが、まず、Select InputにGrabReaderをアタッチし、PinchGestureReaderと既存のThresholdReaderを用意して登録します。
image.png

image.png

image.png

最後に、Select Inputに新しいReader(GrabReader)を登録して完了です。
image.png

Editor上ではPinchGestureReader、実機ではThresholdReaderを利用する仕組みができました。

おわりに

「お前のそのシミュレーターへの情熱はなんなんだよ」みたいなツッコミがきそうな内容ですが、出先にMacBookで"簡単な内容なのにシミュレートできない影響で進められないことがかなりストレスになった"という背景があります。

あとは、仕組みを知っておきたいという単純な知的好奇心です。深くまで仕組みが理解できてよかったです。

遠距離編に続きます。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?