はじめに
本記事では、Humanoid の Muscle 定義を用いて、Quest Touch Plus コントローラのタッチやトリガー押下で指が動かせるようにする方法について紹介します。
Disclaimer
筆者は28卒の絶賛学習/就活中専門学生であるため、
もっと良い入力取得方法や指の回転方法があるかもしれません。ご了承ください!
また、今回は XR Interaction Toolkit のみを前提としているため遠回りしていますが、SteamVR Plugin の利用でもっと簡単に実装ができると思います。
開発環境
- Unity 6000.59.f2
- XR Interaction Toolkit 3.0.10
実装方法
手を定義する
手を動かすために HandController を定義しておきます。
HandController.cs
using UnityEngine;
public class HandController : MonoBehaviour
{
[SerializeField]
private Animator avatarAnimator;
}
指が取れる角度を Muscle を用いて確認する
HumanPoseHandler を用いて Muscle に沿ったポージングをさせてみます。
HandController.cs
using UnityEngine;
[ExecuteInEditMode]
public class HandController : MonoBehaviour
{
// https://gist.github.com/neon-izm/0637dac7a29682de916cecc0e8b037b0
private const int FingerBeginIndex = 55; // Left Thumb 1 Stretched
private const int FingerEndIndex = 94; // Right Little 3 Stretched
[SerializeField]
private Animator avatarAnimator;
[SerializeField] [Range(-1, 1)]
private float weight;
private HumanPoseHandler _poseHandler;
private HumanPose _pose;
private void OnDisable()
{
_poseHandler?.Dispose();
_poseHandler = null;
}
private void Update()
{
if (_poseHandler == null)
{
if (avatarAnimator == null) return;
_poseHandler = new HumanPoseHandler(avatarAnimator.avatar, avatarAnimator.avatarRoot);
return;
}
_poseHandler.GetHumanPose(ref _pose);
for (var i = FingerBeginIndex; i <= FingerEndIndex; i++)
{
_pose.muscles[i] = weight;
}
_poseHandler.SetHumanPose(ref _pose);
}
}
指の回転を記録する
HumanPoseHandler を用いた方法だと、指以外のボーンも上書きして操作してしまう問題があります。
そこで、各指ボーンの通常時/閉じ時の回転を記録し、スクリプト上で個別に可動させることができる FingerController を作成します。
FingerController.cs
using UnityEngine;
[ExecuteInEditMode]
public class FingerController : MonoBehaviour
{
private Quaternion _defaultRotation;
private Quaternion _closedRotation;
private FingerController _child;
private void OnEnable()
{
Init();
}
private void OnDestroy()
{
transform.localRotation = _defaultRotation;
if (Application.IsPlaying(gameObject))
{
Destroy(_child);
}
else
{
DestroyImmediate(_child);
}
}
public void Init()
{
if (_child != null)
{
return;
}
if (transform.childCount == 0)
{
return;
}
var childTransform = transform.GetChild(0);
_child = childTransform.GetComponent<FingerController>();
if (_child == null)
{
_child = childTransform.gameObject.AddComponent<FingerController>();
}
_child.Init();
}
public void CalibrateDefault()
{
_defaultRotation = transform.localRotation;
if (_child != null)
{
_child.CalibrateDefault();
}
}
public void CalibrateClosed()
{
_closedRotation = transform.localRotation;
if (_child != null)
{
_child.CalibrateClosed();
}
}
public void ApplyRotation(float weight)
{
transform.localRotation = Quaternion.Slerp(_defaultRotation, _closedRotation, weight);
if (_child != null)
{
_child.ApplyRotation(weight);
}
}
}
この FingerController を扱うことができるように、HandController も書き換えます。
HandController.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class HandController : MonoBehaviour
{
// https://gist.github.com/neon-izm/0637dac7a29682de916cecc0e8b037b0
public enum FingerType
{
LeftThumb = 55,
LeftIndex = 59,
LeftMiddle = 63,
LeftRing = 67,
LeftLittle = 71,
RightThumb = 75,
RightIndex = 79,
RightMiddle = 83,
RightRing = 87,
RightLittle = 91,
}
private readonly Dictionary<FingerType, HumanBodyBones> _fingerToBonesMap = new Dictionary<FingerType, HumanBodyBones>
{
{ FingerType.LeftThumb, HumanBodyBones.LeftThumbProximal },
{ FingerType.LeftIndex, HumanBodyBones.LeftIndexProximal },
{ FingerType.LeftMiddle, HumanBodyBones.LeftMiddleProximal },
{ FingerType.LeftRing, HumanBodyBones.LeftRingProximal },
{ FingerType.LeftLittle, HumanBodyBones.LeftLittleProximal },
{ FingerType.RightThumb, HumanBodyBones.RightThumbProximal },
{ FingerType.RightIndex, HumanBodyBones.RightIndexProximal },
{ FingerType.RightMiddle, HumanBodyBones.RightMiddleProximal },
{ FingerType.RightRing, HumanBodyBones.RightRingProximal },
{ FingerType.RightLittle, HumanBodyBones.RightLittleProximal },
};
[SerializeField]
private Animator avatarAnimator;
[SerializeField] [Range(0, 1)]
private float weight;
private Dictionary<FingerType, FingerController> _fingers = new Dictionary<FingerType, FingerController>();
private void OnDisable()
{
foreach (var finger in _fingers)
{
if (Application.IsPlaying(gameObject))
{
Destroy(finger.Value);
}
else
{
DestroyImmediate(finger.Value);
}
}
_fingers.Clear();
_fingers = null;
}
private bool Init()
{
if (avatarAnimator == null) return false;
if (_fingers == null || _fingers.Count == 0)
{
_fingers = new Dictionary<FingerType, FingerController>();
foreach (var kvp in _fingerToBonesMap)
{
var boneTransform = avatarAnimator.GetBoneTransform(kvp.Value);
var fingerController = boneTransform.GetComponent<FingerController>();
if (fingerController == null) fingerController = boneTransform.gameObject.AddComponent<FingerController>();
_fingers[kvp.Key] = fingerController;
fingerController.Init();
}
using var poseHandler = new HumanPoseHandler(avatarAnimator.avatar, avatarAnimator.transform);
var defaultPose = new HumanPose();
poseHandler.GetHumanPose(ref defaultPose);
foreach (var finger in _fingers)
{
finger.Value.CalibrateDefault();
}
var closedPose = defaultPose;
foreach (var fingerType in Enum.GetValues(typeof(FingerType)))
{
for (var i = 0; i < 4; i++)
{
closedPose.muscles[(int)fingerType + i] = -1.0f;
}
}
poseHandler.SetHumanPose(ref closedPose);
foreach (var finger in _fingers)
{
finger.Value.CalibrateClosed();
}
poseHandler.SetHumanPose(ref defaultPose);
}
return true;
}
private void Update()
{
if (!Init())
{
return;
}
foreach (var kvp in _fingers)
{
kvp.Value.ApplyRotation(weight);
}
}
}
入力の取得
Input System を用いて、手ごとにコントローラの入力を取得するため、HandShapeProvider を作成します。
HandShapeProvider.cs
using System;
using UnityEngine;
using UnityEngine.InputSystem;
[Serializable]
public struct HandInput
{
[Range(0, 1)]
public float trigger;
[Range(0, 1)]
public float grip;
public bool touch;
}
public class HandShapeProvider : MonoBehaviour
{
[SerializeField]
private InputActionReference triggerAction;
[SerializeField]
private InputActionReference gripAction;
[SerializeField]
private InputActionReference touchAction;
[SerializeField]
private HandInput inputDebug;
private void Update()
{
GetHandShape(ref inputDebug);
}
public void GetHandShape(ref HandInput input)
{
if (Application.isPlaying)
{
input.trigger = triggerAction.action.ReadValue<float>();
input.grip = gripAction.action.ReadValue<float>();
input.touch = touchAction.action.IsPressed();
}
else
{
input = inputDebug;
}
}
}
Input Actions の設定はよしなにします。
HandShapeProvider から入力を受け取り、適用する
HandController から HandShapeProvider を見に行き、FingerController へ流す処理をします。
HandController.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class HandController : MonoBehaviour
{
// https://gist.github.com/neon-izm/0637dac7a29682de916cecc0e8b037b0
public enum FingerType
{
LeftThumb = 55,
LeftIndex = 59,
LeftMiddle = 63,
LeftRing = 67,
LeftLittle = 71,
RightThumb = 75,
RightIndex = 79,
RightMiddle = 83,
RightRing = 87,
RightLittle = 91,
}
private struct InputWeight
{
public float DefaultPose;
public float Trigger;
public float Grip;
public float Touch;
}
private static readonly Dictionary<FingerType, HumanBodyBones> FingerToBonesMap = new Dictionary<FingerType, HumanBodyBones>
{
{ FingerType.LeftThumb, HumanBodyBones.LeftThumbProximal },
{ FingerType.LeftIndex, HumanBodyBones.LeftIndexProximal },
{ FingerType.LeftMiddle, HumanBodyBones.LeftMiddleProximal },
{ FingerType.LeftRing, HumanBodyBones.LeftRingProximal },
{ FingerType.LeftLittle, HumanBodyBones.LeftLittleProximal },
{ FingerType.RightThumb, HumanBodyBones.RightThumbProximal },
{ FingerType.RightIndex, HumanBodyBones.RightIndexProximal },
{ FingerType.RightMiddle, HumanBodyBones.RightMiddleProximal },
{ FingerType.RightRing, HumanBodyBones.RightRingProximal },
{ FingerType.RightLittle, HumanBodyBones.RightLittleProximal },
};
private static readonly Dictionary<FingerType, InputWeight> InputWeights = new Dictionary<FingerType, InputWeight>
{
{ FingerType.LeftThumb, new InputWeight { DefaultPose = 0.4f, Trigger = 0.4f, Grip = -1.8f, Touch = 1.0f } },
{ FingerType.LeftIndex, new InputWeight { DefaultPose = 0.0f, Trigger = 1.0f, Grip = 0f, Touch = 0.2f } },
{ FingerType.LeftMiddle, new InputWeight { DefaultPose = 0.1f, Trigger = 0.6f, Grip = 1.0f, Touch = 0.2f } },
{ FingerType.LeftRing, new InputWeight { DefaultPose = 0.2f, Trigger = 0.4f, Grip = 1.0f, Touch = 0.2f } },
{ FingerType.LeftLittle, new InputWeight { DefaultPose = 0.3f, Trigger = 0.2f, Grip = 1.0f, Touch = 0.2f } },
{ FingerType.RightThumb, new InputWeight { DefaultPose = 0.4f, Trigger = 0.4f, Grip = -1.8f, Touch = 1.0f } },
{ FingerType.RightIndex, new InputWeight { DefaultPose = 0.0f, Trigger = 1.0f, Grip = 0f, Touch = 0.2f } },
{ FingerType.RightMiddle, new InputWeight { DefaultPose = 0.1f, Trigger = 0.6f, Grip = 1.0f, Touch = 0.2f } },
{ FingerType.RightRing, new InputWeight { DefaultPose = 0.2f, Trigger = 0.4f, Grip = 1.0f, Touch = 0.2f } },
{ FingerType.RightLittle, new InputWeight { DefaultPose = 0.3f, Trigger = 0.2f, Grip = 1.0f, Touch = 0.2f } },
};
[SerializeField]
private Animator avatarAnimator;
[SerializeField]
private HandShapeProvider leftHandShapeProvider;
[SerializeField]
private HandShapeProvider rightHandShapeProvider;
private HandInput _leftHandInput;
private HandInput _rightHandInput;
private Dictionary<FingerType, FingerController> _fingers = new Dictionary<FingerType, FingerController>();
private void OnDisable()
{
foreach (var finger in _fingers)
{
if (Application.IsPlaying(gameObject))
{
Destroy(finger.Value);
}
else
{
DestroyImmediate(finger.Value);
}
}
_fingers.Clear();
_fingers = null;
}
private bool Init()
{
if (avatarAnimator == null || leftHandShapeProvider == null || rightHandShapeProvider == null) return false;
if (_fingers == null || _fingers.Count == 0)
{
_fingers = new Dictionary<FingerType, FingerController>();
foreach (var kvp in FingerToBonesMap)
{
var boneTransform = avatarAnimator.GetBoneTransform(kvp.Value);
var fingerController = boneTransform.GetComponent<FingerController>();
if (fingerController == null) fingerController = boneTransform.gameObject.AddComponent<FingerController>();
_fingers[kvp.Key] = fingerController;
fingerController.Init();
}
using var poseHandler = new HumanPoseHandler(avatarAnimator.avatar, avatarAnimator.transform);
var defaultPose = new HumanPose();
poseHandler.GetHumanPose(ref defaultPose);
foreach (var finger in _fingers)
{
finger.Value.CalibrateDefault();
}
var closedPose = defaultPose;
foreach (var fingerType in Enum.GetValues(typeof(FingerType)))
{
for (var i = 0; i < 4; i++)
{
closedPose.muscles[(int)fingerType + i] = -1.0f;
}
}
poseHandler.SetHumanPose(ref closedPose);
foreach (var finger in _fingers)
{
finger.Value.CalibrateClosed();
}
poseHandler.SetHumanPose(ref defaultPose);
}
return true;
}
private void Update()
{
if (!Init())
{
return;
}
leftHandShapeProvider.GetHandShape(ref _leftHandInput);
rightHandShapeProvider.GetHandShape(ref _rightHandInput);
foreach (var kvp in _fingers)
{
var input = IsLeftHandFinger(kvp.Key) ? _leftHandInput : _rightHandInput;
var inputWeight = InputWeights[kvp.Key];
var weight = CalculateWeight(input, inputWeight);
kvp.Value.ApplyRotation(weight);
}
}
private static float CalculateWeight(HandInput input, InputWeight weight) => weight.DefaultPose + weight.Trigger * input.trigger + weight.Grip * input.grip + weight.Touch * (input.touch ? 1 : 0);
private static bool IsLeftHandFinger(FingerType type) => type < FingerType.RightThumb;
}
これでついに指をそれらしい形に可動させることができるようになりました!

おわり!
いかがでしたでしょうか? 😇
Photon Fusion 2 や FinalIK などを用いることで、簡単に VRSNS ライクな環境を整えることができるので、ぜひ試してみてください!
参考文献
https://gist.github.com/neon-izm/0637dac7a29682de916cecc0e8b037b0
