土鍋で雑多煮

UnityでXR・ゲーム開発をしています。学んだことや備忘録、趣味の記録などを書いていきます。

XR Interaction toolkit の XR Hands で手のポーズを取得する

どうも、土鍋です。

昨日、XR Handsのサンプルを触ってある程度挙動が分かったので、コンテンツを作ろうと思ったのですが、Oculus Integrationと違ってデフォルトでいい感じに手のポーズ取得できるものが現状ないので、自分で書いてみました。

XR Handsのサンプル触ったりメリットを書いた昨日の記事はこちら

donabenabe.hatenablog.com

各関節のデータを取得する

XR HandsのHandVisualizerのサンプルコードを見てみるとある程度の使い方はわかると思いますが、拡張性皆無なので自分で書いていきます。

XR Handsの詳しい仕様が書いてあるスクリプトリファレンスはこちら
docs.unity3d.com

XRHandSubsystemの取得

ハンドトラッキングのデータにアクセスするにはXRHandSubsystemを使いますので、取得する必要があります。

subSystem = XRGeneralSettings.Instance?.Manager?.activeLoader?.GetLoadedSubsystem<XRHandSubsystem>();

で取得できます。

左手右手の取得

左右の手のデータを取ってきます。

// 左手
hand = subSystem.leftHand

// 右手
hand = subSystem.rightHand

で取得できます。

各関節のポーズデータの取得

片手のデータを取得したら、指の各関節のポーズを取得します。

hand.GetJoint(xrHandJointID).TryGetPose(out Pose pose);

これで各関節のPose(positionとrotation)を取得できます。

ここでXRHandJointIDというものが出てきましたが、これはどこの関節かを示すIDです。

XRHandJointID

これが最初ちょっとややこしくて把握するのに時間がかかりました。

namespace UnityEngine.XR.Hands
{
  public enum XRHandJointID
  {
    Invalid = 0,
    BeginMarker = 1,
    Wrist = 1,
    Palm = 2,
    ThumbMetacarpal = 3,
    ThumbProximal = 4,
    ThumbDistal = 5,
    ThumbTip = 6,
    IndexMetacarpal = 7,
    IndexProximal = 8,
    IndexIntermediate = 9,
    IndexDistal = 10, // 0x0000000A
    IndexTip = 11, // 0x0000000B
    MiddleMetacarpal = 12, // 0x0000000C
    MiddleProximal = 13, // 0x0000000D
    MiddleIntermediate = 14, // 0x0000000E
    MiddleDistal = 15, // 0x0000000F
    MiddleTip = 16, // 0x00000010
    RingMetacarpal = 17, // 0x00000011
    RingProximal = 18, // 0x00000012
    RingIntermediate = 19, // 0x00000013
    RingDistal = 20, // 0x00000014
    RingTip = 21, // 0x00000015
    LittleMetacarpal = 22, // 0x00000016
    LittleProximal = 23, // 0x00000017
    LittleIntermediate = 24, // 0x00000018
    LittleDistal = 25, // 0x00000019
    LittleTip = 26, // 0x0000001A
    EndMarker = 27, // 0x0000001B
  }
}

これだけ見てるとなんのこっちゃわからないのですが、 図にすると分かりやすいかなと思います。

Enumとintの変換

XRHandJointIDUtilityを使って変換しないと配列が一個ずれたりするので注意が必要です。(これに気がつかなくて結構時間使った…)

Enumからintへの変換

XRHandJointID.IndexTip.ToIndex()

intからEnumへの変換

XRHandJointIDUtility.FromIndex(jointID)

手のポーズを取得する

ここまでで各関節のデータを取得できたので、手のポーズを推定していきます。

各指の開き具合を取得する

このメソッドでは人差し指の開き具合を0から1の間で取得できます。実装的には単純で、人差し指の先と根本の距離で開き具合を計算してるだけです。(だいぶ無理やりですがとりあえず)
その他の指にも同じようなメソッドを作ってあげることで手のポーズをだいたい把握できます。

public float RatioIndex(bool isLeft)
{
    JointPoseData[] jointPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
    return Mathf.InverseLerp(0.04f, 0.13f, Vector3.Distance(
        jointPoseData[XRHandJointID.IndexMetacarpal.ToIndex()].Pose.position,
        jointPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.position));
}

※JointPoseDataは関節ごとのデータを保持してる自作のクラス。

Mathf.InverseLerpを使うことで指を閉じてる際と開いてる際の距離を0から1の間に丸めてます。

取得したデータを表示

0から1で指の開き具合を取得しているので、そのままSliderに流し込んであげれば、いい感じにデータを可視化できます。

完成形はこちら

まとめ

今後もっと使いやすいものが公式から出てくるかもしれませんが、とりあえずこれで手のポーズが取得できるようになりました。手の向きや加速度も取得できるのでもっと拡張すれば色々遊べそうですね。

以下に一部省略してますが今回書いたスクリプトを貼っておきます。 ちょっと雑な実装なのでお恥ずかしいですが、大まかな使い方は分かると思います。

今回書いたスクリプト

public class JointPoseData
{
    public Pose Pose;

    public XRHandJointID XRHandJointID => handJointID;

    private XRHandSubsystem m_Subsystem;
    private XRHand hand;
    private XRHandJointID handJointID;

    public JointPoseData(bool isLeft, int jointID)
    {
        handJointID = XRHandJointIDUtility.FromIndex(jointID);

        m_Subsystem = XRGeneralSettings.Instance?.Manager?.activeLoader?.GetLoadedSubsystem<XRHandSubsystem>();
        if (m_Subsystem != null)
        { 
            hand = isLeft ? m_Subsystem.leftHand : m_Subsystem.rightHand;
        }
    }

    public void UpdatePose()
    {
        hand.GetJoint(handJointID).TryGetPose(out Pose pose);
        Pose = pose;
    }
}
public class HandDataPresenter : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private GameObject ball;

    private JointPoseData[] _leftJointPoseData = new JointPoseData[XRHandJointID.EndMarker.ToIndex()];
    private JointPoseData[] _rightJointPoseData = new JointPoseData[XRHandJointID.EndMarker.ToIndex()];

    private List<GameObject> leftJointObj = new List<GameObject>();
    private List<GameObject> rightJointObj = new List<GameObject>();

    private float timeElapsed;

    private void Start()
    {
        for (int i = 0; i < XRHandJointID.EndMarker.ToIndex(); i++)
        {
            _leftJointPoseData[i] = new JointPoseData(true, i);
            GameObject leftobj = Instantiate(prefab);
            leftobj.GetComponent<HandDataView>().HandJointID = _leftJointPoseData[i].XRHandJointID;
            leftobj.name = "Left" + _leftJointPoseData[i].XRHandJointID;
            leftJointObj.Add(leftobj);

            _rightJointPoseData[i] = new JointPoseData(false, i);
            GameObject rightobj = Instantiate(prefab);
            rightobj.GetComponent<HandDataView>().HandJointID = _rightJointPoseData[i].XRHandJointID;
            rightobj.name = "Right" + _rightJointPoseData[i].XRHandJointID;
            rightJointObj.Add(rightobj);
        }
    }

    private void Update()
    {
        for (int i = 0; i < XRHandJointID.EndMarker.ToIndex(); i++)
        {
            _leftJointPoseData[i].UpdatePose();
            leftJointObj[i].transform.position = _leftJointPoseData[i].Pose.position;
            leftJointObj[i].transform.rotation = _leftJointPoseData[i].Pose.rotation;
            if (RatioIndex(true) > 0.95f && RatioMiddle(true) < 0.1f)
            {
                timeElapsed += Time.deltaTime;
                if (timeElapsed >= 30)
                {
                    Instantiate(ball, _leftJointPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.position,
                            _leftJointPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.rotation)
                        .GetComponent<Rigidbody>()
                        .AddRelativeForce(0, 0, 500);

                    timeElapsed = 0.0f;
                }
            }

            _rightJointPoseData[i].UpdatePose();
            rightJointObj[i].transform.position = _rightJointPoseData[i].Pose.position;
            rightJointObj[i].transform.rotation = _rightJointPoseData[i].Pose.rotation;
        }
    }

    public float RatioIndex(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.04f, 0.13f, Vector3.Distance(
            handPoseData[XRHandJointID.IndexMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.position));
    }

    public float RatioMiddle(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.04f, 0.14f, Vector3.Distance(
            handPoseData[XRHandJointID.MiddleMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.MiddleTip.ToIndex()].Pose.position));
    }

    public float RatioRing(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.035f, 0.13f, Vector3.Distance(
            handPoseData[XRHandJointID.RingMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.RingTip.ToIndex()].Pose.position));
    }

    public float RatioLittle(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.032f, 0.11f, Vector3.Distance(
            handPoseData[XRHandJointID.LittleMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.LittleTip.ToIndex()].Pose.position));
    }

    public float RatioThumb(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.075f, 0.083f, Vector3.Distance(
            handPoseData[XRHandJointID.ThumbMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.ThumbTip.ToIndex()].Pose.position));
    }
}