この記事はMagic Leap Advent Calendar 2020 23日目の記事です。
はじめに
MagicLeapにはハンドトラッキングがあり現在ベータ版で設定からハンドポインターがあります
MagicLeapの設定 > System > Inputs ( 左側のリスト )
を開きEnable Gesture( Beta )を有効にするとホーム等でハンドポインターが利用できます。
但し、今現在 MagicLeap Tool-Kit 等にはまだその手のコンポーネントは提供されていないため自作のアプリ等でハンドポインターを利用したい場合は自作する必要があります ( MixedRealityToolKitは試していないので今回は紹介を省きます )
やっぱね、コントローラを使わずに手で入力するのってなんかイイんですよ...
失敗作
MagicLeap Tool-KitのサンプルにControlPointerがあるのでそれをそのままHandTrackingにあてがえばいけるんじゃね?という安直な発想で試してみた結果ハンドトラッキングのノイズでポインターが暴れてしまってうまくいかなかった。
MagicLeap-Tools > Examples > ControlPointer
にサンプルシーンがあります
MagicLeapの手からいい感じにカーソル出したいけどなんか惜しいんだよなぁこれ pic.twitter.com/PH4X0ea2os
— 松本隆介 (@matsumotokaka11) September 24, 2020
結構いい感じに行けたけどまだ頭揺らすとかなりぶれるんだよね、どうにかいい感じにできないかな pic.twitter.com/cBIKSGeV9a
— 松本隆介 (@matsumotokaka11) September 24, 2020
なにを作るの?
今回作成するのはハンドポインターを表示し、こちらの全集中・Magic Leapの呼吸 肆ノ型 手入力 "かすたむ じぇすちゃ"で作成したプロジェクトを流用してRayCastがヒットしたところにオブジェクトを配置するサンプルを作るところまでです。
機能としてはまだ足りないところがありますがひとまず今回はここまでを作成します。
なお今回作成するサンプルはすべてZeroIterationでのテストまでを行っています、実機上でテストする際はCertifiedファイルの作成などを別途行う必要があります。
開発環境
- Lumin SDK : 0.24.1
- MagicLeap Unity Package : 0.24.1 ( 少し古いけど Lumin SDKと合わせました、 0.24.2はUnity2020以降で利用できます )
- MagicLeap Tool-Kit : MagicLeapToolKitのリポジトリ最新版
- UniRx : 7.1.0
- UniTask : 2.0.26
シーンの構成
今回のシーンの構成はこのようになってます、新たにHandPointerシーンを作成しポインターのヒットをとるBoxとポインタで選択した座標に配置するTargetオブジェクトを配置しています。
CameraRigはカスタムジェスチャで作成したものを流用します
スクリプト
今回作成したスクリプトは以下の通り
スクリプト名 | 解説 |
---|---|
HandPointer.cs | HandPointerの本体 |
HandPointerSelect.cs | HandPointerで選択した対象をカプセル化したクラス |
HandPointerCursor.cs | ポインタのカーソル |
IHandPointer.cs | HandPointerへのインターフェース |
IHandPointerCursor.cs | HandPointerCursorへのインターフェース |
HandPointerTest.cs | テストスクリプト、ポインタで選択した際のイベント処理を登録とかする |
メイン所のスクリプトをここに記述します、詳細はリポジトリに置いてあるので参考になればと
**HandPointer.cs**
using MagicLeapTools;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.XR.MagicLeap;
namespace AdventCalendar.HandPointer
{
/// <summary>
/// ハンドトラッキングでのポインター.
/// こいつだけで両手分の処理を行う.
/// </summary>
public class HandPointer : MonoBehaviour, IHandPointer
{
#region --- class SelectEvent ---
/// <summary>
/// ハンドポインタで選択したイベント.
/// </summary>
private class SelectEvent : UnityEvent<HandPointerSelect> { }
#endregion --- class SelectEvent ---
#region --- class PointerPosition ---
/// <summary>
/// ポインタのカーソル座標.
/// </summary>
private class PointerPosition
{
public Vector3 Target { get; private set; } = Vector3.zero;
public Vector3 LastTarget { get; private set; } = Vector3.zero;
public Vector3 Start { get; private set; } = Vector3.zero;
public Vector3 LastStart { get; private set; } = Vector3.zero;
public void SetTarget(
Vector3 position)
{
LastTarget = Target;
Target = position;
}
public void SetStartPosition(
Vector3 position)
{
LastStart = Start;
Start = Vector3.Lerp(LastStart, position, 0.5f);
}
}
#endregion --- class PointerPosition ---
// Pointerのステート.
public enum HandPointerState
{
None,
NoSelected,
Selected,
}
[SerializeField] private Transform mainCamera;
[SerializeField] private float speed = 1f;
[SerializeField] private GameObject cursorPrefab; // ポインターの先端に配置するカーソルのプレハブ,設定されていなければ利用しない.
[SerializeField] private float eyeTrackingRatio = 0.3f;
public float PointerRayDistance { get; set; } = 2f;
public MLHandTracking.HandKeyPose SelectKeyPose { get; set; } = MLHandTracking.HandKeyPose.Pinch;
public MLHandTracking.HandKeyPose RayDrawKeyPose { get; set; } = MLHandTracking.HandKeyPose.OpenHand;
public HandPointerState LeftHandState { get; private set; } = HandPointerState.None;
public HandPointerState RightHandState { get; private set; } = HandPointerState.None;
private SelectEvent onSelect = new SelectEvent();
private SelectEvent onSelectContinue = new SelectEvent();
private PointerPosition leftPointerPosition;
private PointerPosition rightPointerPosition;
private IHandPointerCursor leftCursor;
private IHandPointerCursor rightCursor;
// TODO : デバッグ用パラメータ.
// 肩幅、お好みのサイズに調整.
[SerializeField] private float shoulderWidth = 0.2f;
// 疑似的に決定した左右の型の位置.
private Vector3 debugRightShoulderPosition;
private Vector3 debugLeftShoulderPosition;
// =========================
/// <summary>
/// Eyeトラッキングが有効か否か.
/// </summary>
private bool IsEyeTrackingValid => MLEyes.IsStarted && MLEyes.CalibrationStatus == MLEyes.Calibration.Good;
/// <summary>
/// 描画しているか否か.
/// </summary>
public bool IsShow { get; private set; } = false;
private void Start()
{
if (HandInput.Ready)
{
HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
}
else
{
HandInput.OnReady += () =>
{
HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
};
}
MLEyes.Start();
leftCursor = new HandPointerCursor(CreateLineRenderer("LeftLineRenderer"), CreateCursor("LeftHandCursor"));
rightCursor = new HandPointerCursor(CreateLineRenderer("RightLineRenderer"), CreateCursor("RightHandCursor"));
leftPointerPosition = new PointerPosition();
rightPointerPosition = new PointerPosition();
}
private void Update()
{
UpdateHandRay();
if (LeftHandState == HandPointerState.Selected)
{
var result = GetSelect(MLHandTracking.HandType.Left);
if (result.Item1)
onSelectContinue?.Invoke(result.Item2);
}
if (RightHandState == HandPointerState.Selected)
{
var result = GetSelect(MLHandTracking.HandType.Right);
if (result.Item1)
onSelectContinue?.Invoke(result.Item2);
}
}
/// <summary>
/// HandPointerのカーソル生成.
/// </summary>
private GameObject CreateCursor(
string name)
{
if (cursorPrefab == null) return null;
GameObject cursor = Instantiate(cursorPrefab, transform);
cursor.name = name;
return cursor;
}
private void UpdateHandRay()
{
if (!HandInput.Ready || !IsShow)
{
LeftHandState = RightHandState = HandPointerState.None;
leftCursor.Hide();
rightCursor.Hide();
return;
}
LeftHandState = HandInput.Left.Visible ? LeftHandState: HandPointerState.None;
RightHandState = HandInput.Right.Visible ? RightHandState: HandPointerState.None;
leftCursor.Show();
rightCursor.Show();
// Rayのスタート位置計算.
leftPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Left));
rightPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Right));
// ポインターの更新.
leftPointerPosition.SetTarget(Vector3.Lerp(leftPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Left), Time.deltaTime * speed));
leftCursor.Update(LeftHandState, leftPointerPosition.Start, leftPointerPosition.Target);
rightPointerPosition.SetTarget(Vector3.Lerp(rightPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Right), Time.deltaTime * speed));
rightCursor.Update(RightHandState, rightPointerPosition.Start, rightPointerPosition.Target);
}
private Vector3 GetCurrentTargetPosition(
MLHandTracking.HandType type)
{
Vector3 tempTargetDir = Vector3.zero;
(bool isValid, Vector3 dir) eyeTrackingDir = GetEyeTrackingNormalizedDir();
if (eyeTrackingDir.isValid)
tempTargetDir = eyeTrackingDir.dir;
Vector3 start = type == MLHandTracking.HandType.Left ? leftPointerPosition.Start : rightPointerPosition.Start;
Vector3 shoulderToHandDir = (start - GetShoulderPosition(type)).normalized;
Vector3 dir = tempTargetDir == Vector3.zero ? shoulderToHandDir : Vector3.Lerp(shoulderToHandDir, tempTargetDir, eyeTrackingRatio).normalized;
return start + dir * PointerRayDistance;
}
/// <summary>
/// RaycastHitしたターゲットを返す、ヒットしない場合は Item2 はnullになる.
/// </summary>
/// <param name="ray"></param>
/// <param name="maxDistance"></param>
/// <returns></returns>
private (bool, HandPointerSelect) GetRayCastHitTarget(
Ray ray,
float maxDistance)
{
RaycastHit hit;
return Physics.Raycast(ray, out hit, maxDistance) ? (true, new HandPointerSelect(hit)) : (false, null);
}
/// <summary>
/// 選択したターゲットを取得する,選択できていない場合は Item2 はnullになる.
/// </summary>
/// <param name="type"></param>
/// <returns></returns>
private (bool, HandPointerSelect) GetSelect(
MLHandTracking.HandType type)
{
Vector3 start = type == MLHandTracking.HandType.Left ? leftPointerPosition.Start : rightPointerPosition.Start;
Vector3 target = type == MLHandTracking.HandType.Left ? leftPointerPosition.Target : rightPointerPosition.Target;
return GetRayCastHitTarget(new Ray(start, target - start), PointerRayDistance);
}
/// <summary>
/// ハンドジェスチャの変更イベント取得.
/// </summary>
/// <param name="hand"></param>
/// <param name="pose"></param>
private void OnHandGesturePoseChange(
ManagedHand hand,
MLHandTracking.HandKeyPose pose)
{
switch (hand.Hand.Type)
{
case MLHandTracking.HandType.Left:
LeftHandState = pose == SelectKeyPose ? HandPointerState.Selected : HandPointerState.NoSelected;
if (LeftHandState == HandPointerState.Selected)
{
(bool, HandPointerSelect) result = GetSelect(MLHandTracking.HandType.Left);
if (result.Item1)
onSelect?.Invoke(result.Item2);
}
break;
case MLHandTracking.HandType.Right:
RightHandState = pose == SelectKeyPose ? HandPointerState.Selected : HandPointerState.NoSelected;
if (RightHandState == HandPointerState.Selected)
{
(bool, HandPointerSelect) result = GetSelect(MLHandTracking.HandType.Right);
if (result.Item1)
onSelect?.Invoke(result.Item2);
}
break;
}
}
/// <summary>
/// LineRendererを作成.
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
private LineRenderer CreateLineRenderer(
string name)
{
var ret = Instantiate(new GameObject(name), transform).AddComponent<LineRenderer>();
ret.startWidth = 0.01f;
ret.endWidth = 0.01f;
ret.enabled = false;
return ret;
}
/// <summary>
/// 親指の根元と人差し指の根元の中間をスタートポイントとする.
/// </summary>
/// <param name="hand"></param>
/// <returns></returns>
private Vector3 GetRayStartPosition(ManagedHand hand)
=> Vector3.Lerp(hand.Skeleton.Thumb.Knuckle.positionFiltered, hand.Skeleton.Index.Knuckle.positionFiltered, 0.5f);
//=> hand.Skeleton.HandCenter.positionFiltered;
/// <summary>
/// Eyeトラッキングの方向を取得.
/// </summary>
/// <returns></returns>
private (bool isValid, Vector3 normalizedDir) GetEyeTrackingNormalizedDir()
{
if (!IsEyeTrackingValid) return (false, Vector3.zero);
bool isBlink = MLEyes.LeftEye.IsBlinking || MLEyes.RightEye.IsBlinking;
if (isBlink) return (false, Vector3.zero);
// Eyeトラッキングが有効ならEyeトラッキングの向きで補正する.
float leftConfidence = MLEyes.LeftEye.CenterConfidence * -0.5f;
float rightConfidence = MLEyes.RightEye.CenterConfidence * 0.5f;
float eyeRatio = 0.5f + (leftConfidence + rightConfidence);
return (true, Vector3.Lerp(MLEyes.LeftEye.ForwardGaze, MLEyes.RightEye.ForwardGaze, eyeRatio).normalized);
}
/// <summary>
/// 頭の位置から推定した肩の座標を取得.
/// </summary>
/// <param name="type"></param>
/// <returns></returns>
private Vector3 GetShoulderPosition(
MLHandTracking.HandType type)
{
Vector3 headPosition = mainCamera.position;
Vector3 shoulderPosition = headPosition + (mainCamera.right * (type == MLHandTracking.HandType.Left ? -shoulderWidth : shoulderWidth)) + (-mainCamera.up * 0.15f);
if (type == MLHandTracking.HandType.Left)
debugLeftShoulderPosition = shoulderPosition;
else
debugRightShoulderPosition = shoulderPosition;
return shoulderPosition;
}
private void OnDrawGizmos()
{
// 推定の肩の位置を表示.
Gizmos.color = Color.blue;
Gizmos.DrawWireSphere(debugLeftShoulderPosition, 0.1f);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(debugRightShoulderPosition, 0.1f);
}
/// <summary>
/// 選択のイベントハンドラを登録.
/// </summary>
/// <param name="callback"></param>
public void RegisterOnSelectHandler(
UnityAction<HandPointerSelect> callback)
{
if (onSelect == null)
onSelect = new SelectEvent();
onSelect.AddListener(callback);
Debug.Log($"Count : {onSelect.GetPersistentEventCount()}");
}
/// <summary>
/// 長選択のイベントハンドラを登録.
/// </summary>
/// <param name="callback"></param>
public void RegisterOnSelectContinueHandler(
UnityAction<HandPointerSelect> callback)
{
if (onSelectContinue == null)
onSelectContinue = new SelectEvent();
onSelectContinue.AddListener(callback);
}
/// <summary>
/// HandPointerを有効化.
/// </summary>
public void Show() => IsShow = true;
/// <summary>
/// HandPointerを無効化.
/// </summary>
public void Hide() => IsShow = false;
}
}
ハンドポインタのメイン所の処理、このスクリプト一つで両手分処理してしまいます。
初期化処理
先ずはStart()で初期化を行います、ジェスチャを利用するためHandInputの初期化確認を行い、初期化されていなければ初期化完了時イベントに処理を登録します、この辺はUniTaskで待機したりするのもよいかもしれません。
登録している処理は左右の手からのジェスチャ変更イベントのリスナーです。
if (HandInput.Ready)
{
HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
}
else
{
HandInput.OnReady += () =>
{
HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
};
}
今回は僕の個人的な好みでEyeトラッキングも利用するので MLEyes.Start()
でEyeトラッキングを起動します。
MLEyes.Start();
そして左右のカーソルの座標更新及び描画を担当するクラスを生成します。
leftCursor = new HandPointerCursor(CreateLineRenderer("LeftLineRenderer"), CreateCursor("LeftHandCursor"));
rightCursor = new HandPointerCursor(CreateLineRenderer("RightLineRenderer"), CreateCursor("RightHandCursor"));
leftPointerPosition = new PointerPosition();
rightPointerPosition = new PointerPosition();
ハンドポインタの更新処理
メインの更新処理( UpdateHandRay() )
あなたは魔法使いになれる。そう、Magic Leapならねでも紹介されていたLeapMotionのブログを参考にしておおよその肩の位置からポインタのスタート位置までのベクトルを計算してそのベクトル方向にスタート位置からRayを飛ばしています。
Rayのスタート位置はよくあるハンドトラッキングのスタート位置の掌ではなく人差し指の根元と親指の根元の中間に設定しています
Rayのスタート位置を親指の根元と人差し指の根元の中間にした場合
掌の中心にRayのスタート位置を設定するとHandMeshなどを利用した時に選択したいターゲットに手がかなりの割合でかぶって選択しづらくなってしまいます、そのため親指の根元と人差し指の根元に設定しました。
さらにこの二点の場合はジェスチャをした際の関節の動きにそこまで大きく左右されないためオブジェクトを選択する際のジェスチャ時にポインタが大きくぶれることも無くなります。
// Rayのスタート位置計算.
leftPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Left));
rightPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Right));
Rayのスタート位置を取得する関数
/// <summary>
/// 親指の根元と人差し指の根元の中間をスタートポイントとする.
/// </summary>
/// <param name="hand"></param>
/// <returns></returns>
private Vector3 GetRayStartPosition(ManagedHand hand)
=> Vector3.Lerp(hand.Skeleton.Thumb.Knuckle.positionFiltered, hand.Skeleton.Index.Knuckle.positionFiltered, 0.5f);
//=> hand.Skeleton.HandCenter.positionFiltered;
ポインタの更新処理
ポインタのRayのベクトルは肩から手までのベクトルにEyeトラッキングのベクトルをちょっと掛け合わせています( Eyeトラッキングを掛け合わせた事に深い理由はありません、個人的な好みです )
何かを選択する際は大抵目はその方向を見るため 肩 -> 手 のベクトルのみよりもより自然な位置にポインタが向くのでは?と思い入れてみました。
結果としては思ったほど良い体験にはなりませんでした、いざ実装してみると選択するものを最初は見るのですが無意識のうちにポインタの先端に視線が動き( その際選択したい座標からポインタが遠ざかる挙動になる )それを修正するために意識的にポインタから視線を逸らし選択したい座標に目を向けるため地味に疲れました。
// ポインターの更新.
leftPointerPosition.SetTarget(Vector3.Lerp(leftPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Left), Time.deltaTime * speed));
leftCursor.Update(LeftHandState, leftPointerPosition.Start, leftPointerPosition.Target);
rightPointerPosition.SetTarget(Vector3.Lerp(rightPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Right), Time.deltaTime * speed));
rightCursor.Update(RightHandState, rightPointerPosition.Start, rightPointerPosition.Target);
}
ポインタの先端位置を求める処理
private Vector3 GetCurrentTargetPosition(
MLHandTracking.HandType type)
{
Vector3 tempTargetDir = Vector3.zero;
(bool isValid, Vector3 dir) eyeTrackingDir = GetEyeTrackingNormalizedDir();
if (eyeTrackingDir.isValid)
tempTargetDir = eyeTrackingDir.dir;
Vector3 start = type == MLHandTracking.HandType.Left ? leftPointerPosition.Start : rightPointerPosition.Start;
Vector3 shoulderToHandDir = (start - GetShoulderPosition(type)).normalized;
Vector3 dir = tempTargetDir == Vector3.zero ? shoulderToHandDir : Vector3.Lerp(shoulderToHandDir, tempTargetDir, eyeTrackingRatio).normalized;
return start + dir * PointerRayDistance;
}
メイン所としてはこんな感じです、これのさらにプロトタイプの場合は 肩 -> 手 のベクトルではなく 頭の向きベクトルとEyeトラッキングのベクトルを掛け合わせたものでしたがそちらよりは良い結果となったと思います。
ちなみに↓こちらがそのプロトタイプの動画です。
MagicLeapのHandPointer( 仮称 )のRayの飛ばしなかなかいい感じになってきたと思う
— 松本隆介 (@matsumotokaka11) October 5, 2020
Eyeトラッキングが有効なときはそっちで補正かけてトラッキングが取れない場合は単純にMainCameraの向きで補正かけてる pic.twitter.com/5GUHjW0Fkj
**HandPointerCursor.cs**
using UnityEngine;
namespace AdventCalendar.HandPointer
{
/// <summary>
/// HandPointerのカーソル.
/// </summary>
public class HandPointerCursor : IHandPointerCursor
{
private LineRenderer lineRenderer;
private GameObject cursor = null;
private bool IsValid => lineRenderer != null || cursor != null;
public HandPointerCursor(
LineRenderer _lineRenderer,
GameObject _cursor)
{
lineRenderer = _lineRenderer;
cursor = _cursor;
lineRenderer.material = cursor.GetComponent<MeshRenderer>().material;
}
public void Update(
HandPointer.HandPointerState state,
Vector3 startPosition,
Vector3 endPosition)
{
if (!IsValid) return;
if (state == HandPointer.HandPointerState.None)
{
Hide();
return;
}
Show();
RaycastHit hit;
var ray = new Ray(startPosition, endPosition - startPosition);
if (Physics.Raycast(ray, out hit, Vector3.Distance(startPosition, endPosition)))
endPosition = hit.point;
lineRenderer.SetPositions(new []{startPosition, endPosition});
cursor.SetActive(true);
cursor.transform.SetPositionAndRotation(lineRenderer.GetPosition(lineRenderer.positionCount - 1), cursor.transform.rotation);
}
public void Hide()
{
if (!IsValid) return;
lineRenderer.enabled = false;
cursor.SetActive(false);
}
public void Show()
{
if (!IsValid) return;
lineRenderer.enabled = true;
cursor.SetActive(true);
}
}
}
ハンドポインタのカーソル処理、ポインタ先端のカーソルとRayの線の描画を担当。
こちらは特に特別な処理をしてるわけではなく単純に開始位置から終了位置までのLineRendererの描画とカーソルオブジェクトの移動、HandPointerのステートによっては表示非表示の切り替えを行ってます
後はCameraRigのHandControllerにHandPointerをアタッチし、パラメータの調節を行うと完成です。
**HandPointerTest.cs**
using UnityEngine;
namespace AdventCalendar.HandPointer
{
/// <summary>
/// テスト用のスクリプト.
/// </summary>
public class HandPointerTest : MonoBehaviour
{
[SerializeField] private HandPointer pointer;
[SerializeField] private Transform targetObj;
private void Start()
{
if (pointer != null)
{
pointer.RegisterOnSelectHandler(OnSelectHandler);
pointer.RegisterOnSelectContinueHandler(OnSelectContinueHandler);
}
pointer.Show();
}
private void OnSelectHandler(
HandPointerSelect target)
{
Debug.Log($"target : {target.Object.name}");
targetObj.position = target.Position;
}
private void OnSelectContinueHandler(
HandPointerSelect target)
{
Debug.Log($"target : {target.Object.name}");
targetObj.position = target.Position;
}
}
}
あとはテスト用スクリプトでポインタの選択イベントの購読用リスナーを登録するだけで動きます、現在はセレクト、セレクト中の二つのイベントがあり呼ばれた際にTargetのオブジェクトをその位置に配置しています。
出来たやつ
アドカレ用動画
— 松本隆介 (@matsumotokaka11) December 22, 2020
部屋がきったねぇのは許して...#MagicLeap #MagicLeapJapan pic.twitter.com/qavCzEjtSw
あとがき
初のアドカレ投稿での拙い文章を読んでいただきありがとうございます。
今はまだ簡単な選択くらいしかできないのでこれからさらに機能追加していこうかなと思ってます、その時はまた別の記事でアップグレード版を紹介する記事になるかも。
次世代機のMagicLeapのハンドトラッキングの精度がさらに向上してたらハッピーだなぁ