アドカレ
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 |
事前準備は以下の通りです。
- 【Meta Quest】しばらく触ってないうちにMeta XR SDKがOpenXRベースになっていたので改めて整理
- 【Meta Quest】XR Interaction Toolkitを導入する
- 【Meta Quest】パススルー設定方法
XR Hands
XR Handsは、OpenXRのHand Tracking Extensionが提供する手や指の関節データを、Unityが統一されたAPIとして扱えるように抽象化した仕組みです。これにより、OpenXR互換デバイスにて、統一された方法でハンドトラッキングのボーン情報を扱えます。
デモ
GIFのようなお絵描きデモを作成していきます。
導入
PackageManager経由でXR Handsを導入します。

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

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

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

実装
まず、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;
}
}
}
これにて完成です。ボーンの位置で直接判定しているので、シミュレーター上でも動作します。
ここまでの実装は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
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;
+ }
}
}
今回はピンチジェスチャーですが、他にもよく使うジェスチャーが用意されており、大変便利でした。


