1
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のサンプルから理解する"手に追従するUI"

1
Last updated at Posted at 2025-12-20

アドカレ

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

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

デモ

以下のような手に追従して操作可能なUIについてみていきます。

2025AdventCalendar22.gif

仕組み

以下にサンプルのPrefabがあるので見ていきます。
Assets/Samples/XR Interaction Toolkit/3.0.9/Hands Interaction Demo/HandsDemoSceneAssets/Prefabs/UI/ButtonHandMenu.prefab

挙動はHandMenuというコンポーネントが一元管理しています。
image.png

手のひらの向きに合わせたアクティブ制御のためにPalmAnchorがアタッチされており、TrackedPoseDriverで手に追従するように設定されています。
image.png

FollowPresetにターゲットとなるUIの細かい表示位置や設定を逃がすような設計です。
image.png

ジェスチャー認識と組み合わせる

HandMenuでは手のひらの向きを見てUIのアクティブ制御をしているため、手をグーにしようがチョキにしようが、UIが表示されるようになっています。

2025AdventCalendar23.gif

前回記事で紹介したジェスチャー機能と組み合わせて、手を開いているときのみUIが表示されるように改修していきます。

参考リンク:【Meta Quest】XR Handsのジェスチャー機能でカスタムしたジェスチャーを利用する

700行以上ある結構大きなクラスなので、まずは既存コードからUIのアクティブ制御を分離していきます。

まずはUIのアクティブ制御を利用する側のコードです。

using Unity.XR.CoreUtils;
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit.Utilities;
using UnityEngine.XR.Interaction.Toolkit.Utilities.Tweenables.Primitives;
using UnityEngine.XR.Interaction.Toolkit.Utilities.Tweenables.SmartTweenableVariables;

public class CustomHandUI : MonoBehaviour
{
    [SerializeField] private GameObject _targetUI;
    [SerializeField] private ActivateHandUI _activator;
    [SerializeField] private CustomHandUIHandedness _menuHandedness = CustomHandUIHandedness.Either;
    [SerializeField] private float _minFollowDistance = 0.005f;
    [SerializeField] private float _maxFollowDistance = 0.03f;
    [SerializeField] private float _minToMaxDelaySeconds = 1f;

#pragma warning disable CS0618
    private readonly SmartFollowVector3TweenableVariable _handAnchorSmartFollow = new ();
    private readonly QuaternionTweenableVariable _rotTweenFollow = new ();
#pragma warning restore CS0618

    private Transform _lastValidCameraTransform;
    private Transform _lastValidPalmAnchor;
    private Transform _lastValidPalmAnchorOffset;
    private float _lastValidTrackingTime;
    private bool _isVisible;
    private bool _lastIsRightHand;
    
    /// <summary>
    /// どちらの手にメニューを表示するかの設定。
    /// </summary>
    public enum CustomHandUIHandedness
    {
        None,
        Left,
        Right,
        Either,
    }

    private void Awake()
    {
        _handAnchorSmartFollow.minDistanceAllowed = _minFollowDistance;
        _handAnchorSmartFollow.maxDistanceAllowed = _maxFollowDistance;
        _handAnchorSmartFollow.minToMaxDelaySeconds = _minToMaxDelaySeconds;
    }

    private void Start()
    {
        _targetUI.SetActive(false);
    }

    private void OnEnable()
    {
        var target = _targetUI.transform;
        _handAnchorSmartFollow.Value = target.position;
        _handAnchorSmartFollow.Subscribe(pos => target.position = pos);

        _rotTweenFollow.Value = target.rotation;
        _rotTweenFollow.Subscribe(rot => target.rotation = rot);

        Hide();
    }

    private void OnDestroy()
    {
        _handAnchorSmartFollow.Dispose();
    }

    private void OnValidate()
    {
        _handAnchorSmartFollow.minDistanceAllowed = _minFollowDistance;
        _handAnchorSmartFollow.maxDistanceAllowed = _maxFollowDistance;
        _handAnchorSmartFollow.minToMaxDelaySeconds = _minToMaxDelaySeconds;
    }

    private void Show()
    {
        if (_isVisible) return;

        _isVisible = true;
        _targetUI.SetActive(true);
    }

    private void Hide()
    {
        if (!_isVisible) return;

        _isVisible = false;
        _targetUI.SetActive(false);
    }

    private void LateUpdate()
    {
        var currentPreset = _activator.CurrentPreset;
        var showMenu = _activator.TryGetTrackedAnchors(
            _menuHandedness,
            out var isRightHand,
            out var cameraTransform,
            out var palmAnchor,
            out var palmAnchorOffset);

        if (showMenu)
        {
            _lastValidCameraTransform = cameraTransform;
            _lastValidPalmAnchor = palmAnchor;
            _lastValidPalmAnchorOffset = palmAnchorOffset;
            _lastValidTrackingTime = Time.unscaledTime;
            _lastIsRightHand = isRightHand;
            Show();
        }
        else
        {
            var timeSinceLastValidTracking = Time.unscaledTime - _lastValidTrackingTime;
            if (timeSinceLastValidTracking > currentPreset.hideDelaySeconds)
            {
                Hide();
            }

            if (!_lastValidCameraTransform || !_lastValidPalmAnchor || !_lastValidPalmAnchorOffset) return;
        }

        if (!_isVisible) return;

        var gazeToObject = (_lastValidPalmAnchorOffset.position - _lastValidCameraTransform.position).normalized;
        var palmAnchorOffsetPose = _lastValidPalmAnchorOffset.GetWorldPose();
        var targetPos = palmAnchorOffsetPose.position;
        var targetRot = palmAnchorOffsetPose.rotation;

        var referenceAxis = currentPreset.GetReferenceAxisForTrackingAnchor(_lastValidPalmAnchor, _lastIsRightHand);
        var objectToGaze = -gazeToObject;

        if (currentPreset.snapToGaze && Vector3.Dot(referenceAxis, objectToGaze) > currentPreset.snapToGazeDotThreshold)
        {
            var referenceUpDirection = Vector3.up;
            BurstMathUtility.OrthogonalLookRotation(gazeToObject, referenceUpDirection, out targetRot);
        }

        _handAnchorSmartFollow.target = targetPos;
        _rotTweenFollow.target = targetRot;

        if (currentPreset.allowSmoothing)
        {
            _handAnchorSmartFollow.HandleSmartTween(Time.deltaTime, currentPreset.followLowerSmoothingValue,
                currentPreset.followUpperSmoothingValue);
            _rotTweenFollow.HandleTween(Time.deltaTime * currentPreset.followLowerSmoothingValue);
        }
        else
        {
            _handAnchorSmartFollow.HandleTween(1f);
            _rotTweenFollow.HandleTween(1f);
        }
    }
}

次に、アクティブ制御側のコードです。XRHandShapeによるジェスチャー認識を加えています。

using UnityEngine;
using UnityEngine.XR.Hands;
using UnityEngine.XR.Hands.Gestures;
using UnityEngine.XR.Interaction.Toolkit;
using UnityEngine.XR.Interaction.Toolkit.Interactors;
using UnityEngine.XR.Interaction.Toolkit.UI.BodyUI;

public class ActivateHandUI : MonoBehaviour
{
    private enum UpDirection
    {
        WorldUp,
        TransformUp,
        CameraUp,
    }

    [SerializeField] private UpDirection _upDirection = UpDirection.TransformUp;
    [SerializeField] private XRHandShape _handShape;
    [SerializeField] private Transform _leftPalmAnchor;
    [SerializeField] private Transform _rightPalmAnchor;
    [SerializeField] private FollowPresetDatumProperty _followPreset;
    [SerializeField] private XRInteractionManager _interactionManager;

    public FollowPreset CurrentPreset => _followPreset.Value;

    private Transform _cameraTransform;
    private Transform _leftOffsetRoot;
    private Transform _rightOffsetRoot;
    private CustomHandUI.CustomHandUIHandedness _lastHandThatMetRequirements = CustomHandUI.CustomHandUIHandedness.Left;

    private void Awake()
    {
        _rightOffsetRoot = new GameObject("Right Offset Root").transform;
        _rightOffsetRoot.transform.SetParent(_rightPalmAnchor);

        _leftOffsetRoot = new GameObject("Left Offset Root").transform;
        _leftOffsetRoot.transform.SetParent(_leftPalmAnchor);

        _followPreset.Value?.ApplyPreset(_leftOffsetRoot, _rightOffsetRoot);
    }

    public bool TryGetTrackedAnchors(
        CustomHandUI.CustomHandUIHandedness desiredHandedness,
        out bool isRightHand,
        out Transform cameraTransform,
        out Transform palmAnchor,
        out Transform palmAnchorOffset)
    {
        palmAnchor = null;
        palmAnchorOffset = null;
        isRightHand = false;

        if (!TryGetCamera(out cameraTransform) ||
            desiredHandedness == CustomHandUI.CustomHandUIHandedness.None) return false;

        var isLeftSelecting = _interactionManager.IsHandSelecting(InteractorHandedness.Left);
        var isRightSelecting = _interactionManager.IsHandSelecting(InteractorHandedness.Right);

        var currentPreset = _followPreset.Value;
        var leftMeetsRequirements = !isLeftSelecting
                                    && PalmMeetsRequirements(cameraTransform, _leftPalmAnchor, false, currentPreset)
                                    && IsCorrectGesture(Handedness.Left);
        var rightMeetsRequirements = !isRightSelecting
                                     && PalmMeetsRequirements(cameraTransform, _rightPalmAnchor, true, currentPreset)
                                     && IsCorrectGesture(Handedness.Right);

        if (!leftMeetsRequirements && !rightMeetsRequirements) return false;

        if (desiredHandedness == CustomHandUI.CustomHandUIHandedness.Either)
        {
            // 両手が条件を満たしている場合は、最後に条件を満たした手を優先
            if (leftMeetsRequirements && rightMeetsRequirements)
            {
                var handToTry = _lastHandThatMetRequirements == CustomHandUI.CustomHandUIHandedness.Right
                    ? CustomHandUI.CustomHandUIHandedness.Right
                    : CustomHandUI.CustomHandUIHandedness.Left;
                GetTransformAnchorsForHandedness(handToTry, out palmAnchor, out palmAnchorOffset);
                isRightHand = handToTry == CustomHandUI.CustomHandUIHandedness.Right;
                return true;
            }

            if (leftMeetsRequirements)
            {
                GetTransformAnchorsForHandedness(CustomHandUI.CustomHandUIHandedness.Left, out palmAnchor, out palmAnchorOffset);
                _lastHandThatMetRequirements = CustomHandUI.CustomHandUIHandedness.Left;
                isRightHand = false;
                return true;
            }

            GetTransformAnchorsForHandedness(CustomHandUI.CustomHandUIHandedness.Right, out palmAnchor, out palmAnchorOffset);
            _lastHandThatMetRequirements = CustomHandUI.CustomHandUIHandedness.Right;
            isRightHand = true;
            return true;
        }

        if (desiredHandedness == CustomHandUI.CustomHandUIHandedness.Left)
        {
            if (leftMeetsRequirements)
            {
                GetTransformAnchorsForHandedness(CustomHandUI.CustomHandUIHandedness.Left, out palmAnchor, out palmAnchorOffset);
                _lastHandThatMetRequirements = CustomHandUI.CustomHandUIHandedness.Left;
                isRightHand = false;
                return true;
            }
            return false;
        }

        if (desiredHandedness == CustomHandUI.CustomHandUIHandedness.Right)
        {
            if (rightMeetsRequirements)
            {
                GetTransformAnchorsForHandedness(CustomHandUI.CustomHandUIHandedness.Right, out palmAnchor, out palmAnchorOffset);
                _lastHandThatMetRequirements = CustomHandUI.CustomHandUIHandedness.Right;
                isRightHand = true;
                return true;
            }
        }

        return false;
    }
    
    /// <summary>
    /// 指定した手のジェスチャーをチェックしてメニューを表示すべきかどうかを返す。
    /// </summary>
    private bool IsCorrectGesture(Handedness handedness)
    {
        var xrHandSubsystemUtility = XRHandSubsystemUtility.Instance;
        return xrHandSubsystemUtility.CheckHandShape(_handShape, handedness);
    }

    private void GetTransformAnchorsForHandedness(
        CustomHandUI.CustomHandUIHandedness handedness,
        out Transform palmAnchor,
        out Transform palmAnchorOffset)
    {
        if (handedness == CustomHandUI.CustomHandUIHandedness.Left)
        {
            palmAnchor = _leftPalmAnchor;
            palmAnchorOffset = _leftOffsetRoot;
        }
        else if (handedness == CustomHandUI.CustomHandUIHandedness.Right)
        {
            palmAnchor = _rightPalmAnchor;
            palmAnchorOffset = _rightOffsetRoot;
        }
        else
        {
            palmAnchor = null;
            palmAnchorOffset = null;
        }
    }

    private Vector3 GetReferenceUpDirection(Transform cameraTransform)
    {
        switch (_upDirection)
        {
            case UpDirection.WorldUp:
                return Vector3.up;
            case UpDirection.TransformUp:
                return transform.up;
            case UpDirection.CameraUp:
                return cameraTransform.up;
            default:
                return transform.up;
        }
    }

    private bool PalmMeetsRequirements(
        Transform cameraTransform, 
        Transform palmAnchor, 
        bool isRightHand,
        in FollowPreset currentPreset)
    {
        if (currentPreset == null) return false;

        var palmAnchorUp = currentPreset.GetReferenceAxisForTrackingAnchor(palmAnchor, isRightHand);
        var referenceUpDirection = GetReferenceUpDirection(cameraTransform);

        var meetsPalmFacingUserThreshold = !currentPreset.requirePalmFacingUser ||
                                           Vector3.Dot(palmAnchorUp, -cameraTransform.forward) >
                                           currentPreset.palmFacingUserDotThreshold;
        var meetsPalmFacingUpThreshold = !currentPreset.requirePalmFacingUp ||
                                         Vector3.Dot(palmAnchorUp, referenceUpDirection) >
                                         currentPreset.palmFacingUpDotThreshold;

        return meetsPalmFacingUserThreshold && meetsPalmFacingUpThreshold;
    }

    private bool TryGetCamera(out Transform cameraTransform)
    {
        if (!_cameraTransform)
        {
            var mainCamera = Camera.main;
            if (!mainCamera)
            {
                cameraTransform = null;
                return false;
            }

            _cameraTransform = mainCamera.transform;
        }

        cameraTransform = _cameraTransform;
        return true;
    }
}

これらを適当にアタッチすれば対象の手の形のときにのみUIを表示することができます。

2025AdventCalendar26.gif

Hand Interaction Profileが有効化されているとバグる

公式サンプルの実装に不具合らしき挙動があります。(※何度か試す中で、不安定な挙動がみられ、再現性は100%ではないかもしれません)

XRIはHand Interaction Profileを有効化して使う前提のようですが、有効化した状態では手に追従するUIが正しい位置に表示されません。

原因はTracked Pose Driverに設定されたInput Actionです。
image.png

Hand Interaction Profileの有効時にFirst/SecondのActionが優先的に利用されるようになっており、これが誤った位置/回転を返すようです。
image.png

検証を行った様子が以下です。まずはHand Interaction Profileの有効化時です。CylinderをTracked Pose Driverに追従させています。

2025AdventCalendar25.gif

次に、Hand Interaction Profileの無効化時です。正しく手のひら(Palm)の位置/回転に追従しているのが分かります。

2025AdventCalendar24.gif

さすがにHand Interaction Profileを無効化してXRIを使うわけにはいかないので、ThridのBindだけを登録したActionを新規で作成し、Tracked Pose Driverに割り当てるのが妥当な対応かと思います。(ただ、そうなるとHandMenuのコントローラー対応は諦めることになります。はやくなおりますように)

1
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
1
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?