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?

【MetaQuest】XR Hands経由でボーン情報を扱う

0
Last updated at Posted at 2025-12-05

アドカレ

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

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

XR Hands

XR Handsは、OpenXRのHand Tracking Extensionが提供する手や指の関節データを、Unityが統一されたAPIとして扱えるように抽象化した仕組みです。これにより、OpenXR互換デバイスにて、統一された方法でハンドトラッキングのボーン情報を扱えます。

デモ

GIFのようなお絵描きデモを作成していきます。

2025AdventCalendar8.gif

導入

PackageManager経由でXR Handsを導入します。
image.png

ついでにHandVisualizerというサンプルも取得しておきます。
image.png

次に、設定からHand Tracking Subsystemを有効化します。
image.png

また、OVRManagerのHandTrackingSupportもHandを利用可能な設定に変更します。
image.png

実装

まず、XRHandSubsystemを扱いやすくするためのユーティリティークラスを作成します。

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;
    }
}

ポイントとして、XR HandsのBone座標は “ユーザーローカル空間(XROrigin配下)” で扱うため、CameraOffsetを基準Transformとして姿勢を変換しています。

このユーティリティークラスを使って、ピンチ処理を作成します。親指と人差し指の距離でピンチを判定するロジックです。

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 _wasPinching;

    /// <summary>
    /// ピンチの状態を取得。
    /// </summary>
    public static PinchState CheckPinch()
    {
        var isPinching = IsPinchDetected();

        if (!_wasPinching && isPinching)
        {
            _wasPinching = true;
            return PinchState.Started;
        }

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

        if (_wasPinching && !isPinching)
        {
            _wasPinching = false;
            return PinchState.Ended;
        }

        return PinchState.None;
    }

    private static bool IsPinchDetected()
    {
        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;
        if (!xrHandSubsystemUtility.IsRightHandTracked)
        {
            return false;
        }

        var isAbleToGetThumbTip = xrHandSubsystemUtility.TryGetJointPose(
            Handedness.Right,
            XRHandJointID.ThumbTip,
            out Pose thumbTipPose);

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

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

        return false;
    }
}

最後に、独自のピンチ認識を用いたお絵描き処理です。

using UnityEngine;
using UnityEngine.XR.Hands;

/// <summary>
/// ピンチ操作でTrailRendererを使ったお絵描きを行うクラス。
/// </summary>
public class PinchDrawing : MonoBehaviour
{
    [SerializeField] private GameObject _trailPrefab;

    private GameObject _currentTrailObject;

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

        switch (pinchState)
        {
            case PinchState.Started:
                StartDrawing();
                break;
            case PinchState.Holding:
                UpdateDrawing();
                break;
            case PinchState.Ended:
                EndDrawing();
                break;
        }
    }

    private void StartDrawing()
    {
        _currentTrailObject = Instantiate(_trailPrefab);
        UpdateTrailPosition();
    }

    private void UpdateDrawing()
    {
        if (!_currentTrailObject) return;
        UpdateTrailPosition();
    }

    private void EndDrawing()
    {
        _currentTrailObject = null;
    }

    private void UpdateTrailPosition()
    {
        if (!_currentTrailObject) return;

        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;

        if (xrHandSubsystemUtility.TryGetJointPose(
                Handedness.Right,
                XRHandJointID.IndexTip,
                out Pose indexTipPose))
        {
            _currentTrailObject.transform.position = indexTipPose.position;
        }
    }
}

これにて完成です。ボーンの位置で直接判定しているので、シミュレーター上でも動作します。

2025AdventCalendar6.gif

ここまでの実装はMetaのAPIに依存しないため、OpenXR互換デバイスであればコードを変更することなく動作させることができます。(※OpenXR互換デバイス側で実装ミスがないという前提はあります)

Boneを表示する

このままだと、UnityEditorのシミュレーター上では手がどこにあるのかがわかりません。

そこで、デバッグ用の関節表示用クラスを作成します。(一部UniTaskを使用しています)

using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.XR.Hands;

/// <summary>
/// デバッグ用の手のジョイント表示を管理するクラス。Editor専用。
/// </summary>
public class HandJointDrawer : MonoBehaviour
{
    [SerializeField] private GameObject _debugDrawPrefab;

    private HandDebugObjects _leftHandDebugObjects;
    private HandDebugObjects _rightHandDebugObjects;

    private async UniTask Start()
    {
        if (!Application.isEditor) return;

        var ct = this.GetCancellationTokenOnDestroy();
        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;
        await UniTask.WaitUntil(() => xrHandSubsystemUtility.IsSubsystemRunning, cancellationToken: ct);

        var userLocalSpace = xrHandSubsystemUtility.UserLocalSpace;
        _leftHandDebugObjects ??= new HandDebugObjects(Handedness.Left, userLocalSpace, _debugDrawPrefab);
        _rightHandDebugObjects ??= new HandDebugObjects(Handedness.Right, userLocalSpace, _debugDrawPrefab);

        UpdateJointVisibility(false, Handedness.Left);
        UpdateJointVisibility(false, Handedness.Right);

        SubscribeHandSubsystem();
    }

    private void OnDestroy()
    {
        _leftHandDebugObjects?.OnDestroy();
        _rightHandDebugObjects?.OnDestroy();
        UnsubscribeHandSubsystem();
    }

    private void SubscribeHandSubsystem()
    {
        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;
        if (!xrHandSubsystemUtility.IsSubsystemRunning) return;

        xrHandSubsystemUtility.Subsystem.trackingAcquired += OnTrackingAcquired;
        xrHandSubsystemUtility.Subsystem.trackingLost += OnTrackingLost;
        xrHandSubsystemUtility.Subsystem.updatedHands += OnUpdatedHands;
    }

    private void UnsubscribeHandSubsystem()
    {
        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;
        if (!xrHandSubsystemUtility.IsSubsystemRunning) return;

        xrHandSubsystemUtility.Subsystem.trackingAcquired -= OnTrackingAcquired;
        xrHandSubsystemUtility.Subsystem.trackingLost -= OnTrackingLost;
        xrHandSubsystemUtility.Subsystem.updatedHands -= OnUpdatedHands;
    }

    private void UpdateJointVisibility(bool isTracked, Handedness handedness)
    {
        var shouldDisplayJoints = isTracked;
        var handDebugObjects = handedness == Handedness.Left ? _leftHandDebugObjects : _rightHandDebugObjects;
        handDebugObjects?.ToggleDebugDrawJoints(shouldDisplayJoints);
    }

    private void OnTrackingAcquired(XRHand hand)
    {
        UpdateJointVisibility(true, hand.handedness);
    }

    private void OnTrackingLost(XRHand hand)
    {
        UpdateJointVisibility(false, hand.handedness);
    }

    private void OnUpdatedHands(
        XRHandSubsystem subsystem,
        XRHandSubsystem.UpdateSuccessFlags updateSuccessFlags,
        XRHandSubsystem.UpdateType updateType)
    {
        if (updateType == XRHandSubsystem.UpdateType.Dynamic) return;

        var leftHandTracked = subsystem.leftHand.isTracked;
        var rightHandTracked = subsystem.rightHand.isTracked;

        UpdateJointVisibility(leftHandTracked, Handedness.Left);
        UpdateJointVisibility(rightHandTracked, Handedness.Right);

        UpdateDebugJoints(subsystem, updateSuccessFlags, leftHandTracked, rightHandTracked);
    }

    private void UpdateDebugJoints(
        XRHandSubsystem subsystem,
        XRHandSubsystem.UpdateSuccessFlags updateSuccessFlags,
        bool leftHandTracked,
        bool rightHandTracked)
    {
        if (leftHandTracked)
        {
            _leftHandDebugObjects?.UpdateJoints(
                subsystem.leftHand,
                (updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.LeftHandJoints) != 0,
                true);
        }

        if (rightHandTracked)
        {
            _rightHandDebugObjects?.UpdateJoints(
                subsystem.rightHand,
                (updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.RightHandJoints) != 0,
                true);
        }
    }

    private class HandDebugObjects
    {
        private readonly GameObject _drawJointsParent;
        private readonly GameObject[] _drawJoints = new GameObject[XRHandJointID.EndMarker.ToIndex()];
        private readonly LineRenderer[] _lines = new LineRenderer[XRHandJointID.EndMarker.ToIndex()];
        private static readonly Vector3[] LinePointsReuse = new Vector3[2];
        private const float LineWidth = 0.005f;

        public HandDebugObjects(Handedness handedness, Transform parent, GameObject debugDrawPrefab)
        {
            _drawJointsParent = new GameObject
            {
                transform =
                {
                    parent = parent,
                    localPosition = Vector3.zero,
                    localRotation = Quaternion.identity
                },
                name = handedness + "HandDebugDrawJoints"
            };

            for (var jointId = XRHandJointID.BeginMarker; jointId < XRHandJointID.EndMarker; ++jointId)
            {
                var jointIndex = jointId.ToIndex();
                _drawJoints[jointIndex] = Instantiate(debugDrawPrefab);
                _drawJoints[jointIndex].transform.parent = _drawJointsParent.transform;
                _drawJoints[jointIndex].name = jointId.ToString();

                _lines[jointIndex] = _drawJoints[jointIndex].GetComponent<LineRenderer>();
                if (_lines[jointIndex])
                {
                    _lines[jointIndex].startWidth = _lines[jointIndex].endWidth = LineWidth;
                    LinePointsReuse[0] = LinePointsReuse[1] = Vector3.zero;
                    _lines[jointIndex].SetPositions(LinePointsReuse);
                }
            }
        }

        public void OnDestroy()
        {
            for (var jointIndex = 0; jointIndex < _drawJoints.Length; ++jointIndex)
            {
                if (!_drawJoints[jointIndex]) continue;

                Destroy(_drawJoints[jointIndex]);
                _drawJoints[jointIndex] = null;
            }

            if (_drawJointsParent)
            {
                Destroy(_drawJointsParent);
            }
        }

        public void ToggleDebugDrawJoints(bool debugDrawJoints)
        {
            for (var jointIndex = 0; jointIndex < _drawJoints.Length; ++jointIndex)
            {
                if (_drawJoints[jointIndex])
                {
                    ToggleRenderers<MeshRenderer>(debugDrawJoints, _drawJoints[jointIndex].transform);
                    if (_lines[jointIndex])
                    {
                        _lines[jointIndex].enabled = debugDrawJoints;
                    }
                }
            }

            if (_lines.Length > 0 && _lines[0])
            {
                _lines[0].enabled = false;
            }
        }

        public void UpdateJoints(XRHand hand, bool areJointsTracked, bool debugDrawJoints)
        {
            if (!areJointsTracked) return;

            var wristPose = Pose.identity;
            var parentIndex = XRHandJointID.Wrist.ToIndex();
            UpdateJoint(debugDrawJoints, hand.GetJoint(XRHandJointID.Wrist), ref wristPose, ref parentIndex);
            UpdateJoint(debugDrawJoints, hand.GetJoint(XRHandJointID.Palm), ref wristPose, ref parentIndex, false);

            for (var fingerIndex = (int)XRHandFingerID.Thumb; fingerIndex <= (int)XRHandFingerID.Little; ++fingerIndex)
            {
                var parentPose = wristPose;
                var fingerId = (XRHandFingerID)fingerIndex;
                parentIndex = XRHandJointID.Wrist.ToIndex();

                var jointIndexBack = fingerId.GetBackJointID().ToIndex();
                for (var jointIndex = fingerId.GetFrontJointID().ToIndex(); jointIndex <= jointIndexBack; ++jointIndex)
                {
                    UpdateJoint(
                        debugDrawJoints,
                        hand.GetJoint(XRHandJointIDUtility.FromIndex(jointIndex)),
                        ref parentPose,
                        ref parentIndex);
                }
            }
        }

        private void UpdateJoint(
            bool debugDrawJoints,
            XRHandJoint joint,
            ref Pose parentPose,
            ref int parentIndex,
            bool cacheParentPose = true)
        {
            if (joint.id == XRHandJointID.Invalid) return;

            var jointIndex = joint.id.ToIndex();

            if (!joint.TryGetPose(out var pose)) return;

            if (_drawJoints[jointIndex])
            {
                _drawJoints[jointIndex].transform.localPosition = pose.position;
                _drawJoints[jointIndex].transform.localRotation = pose.rotation;

                if (debugDrawJoints && joint.id != XRHandJointID.Wrist && _lines[jointIndex] != null)
                {
                    LinePointsReuse[0] = _drawJoints[parentIndex].transform.position;
                    LinePointsReuse[1] = _drawJoints[jointIndex].transform.position;
                    _lines[jointIndex].SetPositions(LinePointsReuse);
                }
            }

            if (cacheParentPose)
            {
                parentPose = pose;
                parentIndex = jointIndex;
            }
        }

        private static void ToggleRenderers<TRenderer>(bool toggle, Transform rendererTransform)
            where TRenderer : Renderer
        {
            if (rendererTransform.TryGetComponent<TRenderer>(out var renderer))
                renderer.enabled = toggle;

            for (var childIndex = 0; childIndex < rendererTransform.childCount; ++childIndex)
                ToggleRenderers<TRenderer>(toggle, rendererTransform.GetChild(childIndex));
        }
    }
}

上記コードは以下のサンプルコードを参考に実装しています。
Assets/Samples/XR Hands/1.7.1/HandVisualizer/Scripts/HandVisualizer.cs

以下パスにジョイント用のPrefabも用意されています。
Assets/Samples/XR Hands/1.7.1/HandVisualizer/Prefabs/Joint.prefab

実行するとこのような感じで手の位置が可視化されます。
2025AdventCalendar7.gif

XRCommonHandGestures

ここまで、ピンチ処理を自前実装してきましたが、XRHandSubSystem内にあらかじめXRCommonHandGesturesというクラスが実装されています。

このクラスを使えば各プラットフォームがネイティブで定義した共通ジェスチャー処理の色々を簡単に取得できます。

注意点として、XRCommonHandGesturesはXR Device Simulator上では動作しません。

XRHandSubsystemに実装されているので、XRHandSubsystemUtilityに呼び出し処理を追加します。

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;
    }
    
+   /// <summary>
+   /// CommonGesturesからピンチ箇所の姿勢をCameraOffset基準で取得する。
+   /// </summary>
+   public bool TryGetPinchPoseFromCommonGesture(Handedness handedness, out Pose pose)
+   {
+       var commonGestures = handedness == Handedness.Left
+           ? Subsystem.leftHandCommonGestures
+           : Subsystem.rightHandCommonGestures;
+        
+       var success = commonGestures.TryGetPinchPose(out var localPose);
+       pose = success ? localPose.GetTransformedBy(_cameraOffset.transform) : Pose.identity;
+       return success;
+   }
}

次に、PinchGestureの代わりに、CommonGestureを利用する形式のクラスを作成します。TryGetPinchValueでピンチジェスチャーの度合を0~1で取得できます。

/// <summary>
/// XRCommonHandGesturesを使用したピンチ認識を行うクラス。
/// </summary>
public static class CustomCommonGesturesPinch
{
    private const float PinchThreshold = 0.8f;
    private const float ReleaseThreshold = 0.3f;
    private static bool _wasPinching;

    /// <summary>
    /// ピンチの状態を取得。
    /// </summary>
    public static PinchState CheckPinch()
    {
        var isPinching = IsPinchDetected();

        if (!_wasPinching && isPinching)
        {
            _wasPinching = true;
            return PinchState.Started;
        }

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

        if (_wasPinching && !isPinching)
        {
            _wasPinching = false;
            return PinchState.Ended;
        }

        return PinchState.None;
    }

    private static bool IsPinchDetected()
    {
        var commonHandGestures = XRHandSubsystemUtility.Instance.Subsystem.rightHandCommonGestures;
        if (!commonHandGestures.TryGetPinchValue(out var pinchValue)) return false;
        
        // 押下時は高い閾値、離す時は低い閾値。
        if (_wasPinching)
        {
            return pinchValue > ReleaseThreshold;
        }
        
        return pinchValue >= PinchThreshold;
    }
}

最後に修正した処理をお絵描き処理で呼び出します。

using UnityEngine;
using UnityEngine.XR.Hands;

/// <summary>
/// ピンチ操作でTrailRendererを使ったお絵描きを行うクラス。
/// </summary>
public class PinchDrawing : MonoBehaviour
{
    [SerializeField] private GameObject _trailPrefab;

    private GameObject _currentTrailObject;

    private void Update()
    {
-       var pinchState = CustomPinch.CheckPinch();
+       var pinchState = CustomCommonGesturesPinch.CheckPinch();
        
        switch (pinchState)
        {
            case PinchState.Started:
                StartDrawing();
                break;
            case PinchState.Holding:
                UpdateDrawing();
                break;
            case PinchState.Ended:
                EndDrawing();
                break;
        }
    }

    private void StartDrawing()
    {
        _currentTrailObject = Instantiate(_trailPrefab);
        UpdateTrailPosition();
    }

    private void UpdateDrawing()
    {
        if (!_currentTrailObject) return;
        UpdateTrailPosition();
    }

    private void EndDrawing()
    {
        _currentTrailObject = null;
    }

    private void UpdateTrailPosition()
    {
        if (!_currentTrailObject) return;

        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;

-       if (xrHandSubsystemUtility.TryGetJointPose(
-               Handedness.Right,
-               XRHandJointID.IndexTip,
-               out Pose indexTipPose))
-       {
-           _currentTrailObject.transform.position = indexTipPose.position;
-       }

+       if (xrHandSubsystemUtility.TryGetPinchPoseFromCommonGesture(Handedness.Right, out var pose))
+       {
+           _currentTrailObject.transform.position = pose.position;
+       }
    }
}

今回はピンチジェスチャーですが、他にもよく使うジェスチャーが用意されており、大変便利でした。

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?