アドカレ
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についてみていきます。
仕組み
以下にサンプルのPrefabがあるので見ていきます。
Assets/Samples/XR Interaction Toolkit/3.0.9/Hands Interaction Demo/HandsDemoSceneAssets/Prefabs/UI/ButtonHandMenu.prefab
挙動はHandMenuというコンポーネントが一元管理しています。

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

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

ジェスチャー認識と組み合わせる
HandMenuでは手のひらの向きを見てUIのアクティブ制御をしているため、手をグーにしようがチョキにしようが、UIが表示されるようになっています。
前回記事で紹介したジェスチャー機能と組み合わせて、手を開いているときのみ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を表示することができます。
Hand Interaction Profileが有効化されているとバグる
公式サンプルの実装に不具合らしき挙動があります。(※何度か試す中で、不安定な挙動がみられ、再現性は100%ではないかもしれません)
XRIはHand Interaction Profileを有効化して使う前提のようですが、有効化した状態では手に追従するUIが正しい位置に表示されません。
原因はTracked Pose Driverに設定されたInput Actionです。

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

検証を行った様子が以下です。まずはHand Interaction Profileの有効化時です。CylinderをTracked Pose Driverに追従させています。
次に、Hand Interaction Profileの無効化時です。正しく手のひら(Palm)の位置/回転に追従しているのが分かります。
さすがにHand Interaction Profileを無効化してXRIを使うわけにはいかないので、ThridのBindだけを登録したActionを新規で作成し、Tracked Pose Driverに割り当てるのが妥当な対応かと思います。(ただ、そうなるとHandMenuのコントローラー対応は諦めることになります。はやくなおりますように)




