【Unity(C#)】QuestのハンドトラッキングをARで表示する

## はじめに
タイトルの内容をイメージしやすいのが以下の動画です。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">久しぶりのwithARハッカソン (<a href="https://twitter.com/_withAR?ref_src=twsrc%5Etfw">@_withAR</a>) <br>参加中です👏<br><br>Questのハンドトラッキングを<br>ARで見れるようにしました👍<a href="https://twitter.com/hashtag/withAR%E3%83%8F%E3%83%83%E3%82%AB%E3%82%BD%E3%83%B3?src=hash&amp;ref_src=twsrc%5Etfw">#withARハッカソン</a> <a href="https://twitter.com/hashtag/NEUU?src=hash&amp;ref_src=twsrc%5Etfw">#NEUU</a> <a href="https://twitter.com/neuu_jp?ref_src=twsrc%5Etfw">@neuu_jp</a> <a href="https://t.co/xAu02hxtO3">pic.twitter.com/xAu02hxtO3</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中82/100 (@kento_xr) <a href="https://twitter.com/kento_xr/status/1601944116518178816?ref_src=twsrc%5Etfw">December 11, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


## プロジェクト構成
まず、プロジェクトをAR側とVR側でそれぞれ用意しました。
VR側にはハンドトラッキングが可能なように Oculus Integrationが動作するようにセットアップし、AR側ではARFoundationが動作するように諸々セットアップしています。

## バージョン情報
VR側
|  諸々名前 | バージョン |
|:-:|:-:|
| Unity  |  2020.3.4f1 |
| PUN2 |  2.41 |
| Oculus Integration  | 35.0  |
|  XR Plugin Management | 4.0.1  |

AR側
|  諸々名前 | バージョン |
|:-:|:-:|
| Unity  |  2020.3.4f1 |
| PUN2 |  2.41 |
|  ARFoundation |  4.1.7 |
|  ARCore XR Plugin | 4.1.7  |
| ARKit XR Plugin  | 4.1.7  |
|  XR Plugin Management | 4.0.1  |

## 仕組み
過去に書いた記事の要領でハンドトラッキングの同期処理を実装します。
【参考リンク】：[【Unity(C#),PUN2】OculusQuestのハンドトラッキング同期実装](https://qiita.com/OKsaiyowa/items/80fb857a285ee4aed95c)

上記記事はVRとVRでハンドトラッキングを共有しているサンプルですが、同期の仕組み自体大きく変わりません。

大きく異なる点として、AR側のプロジェクトにはOculus Integrationが含まれないことです。すなわち、前述の記事のようにOculus Integrationの機能を使ってAR側でハンドトラッキング用のメッシュを利用することはできないようになっています。

では、どうやってAR側でハンドトラッキングを同期しているかというと、以下リポジトリから手のメッシュを拝借してAR側のプロジェクトではこのメッシュを利用することで実現しています。

【参考リンク】：[SpeakGeek-Normcore-Quest-Hand-Tracking](https://github.com/dylanholshausen/SpeakGeek-Normcore-Quest-Hand-Tracking)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/19d4f005-92cf-2084-1cba-5fab62227400.png)

何度かOculus Integrationを含めてAR側プロジェクトのビルドを試みましたが、ビルドエラーを解消するために時間を要したり、ビルドがAR用として認識されなかったり、いろいろとうまくいかなかったので、このような構成となっています。

そして、AR側では他プレイヤーのアバターはVRとして出現するようにしています。
そのため、PUN2が生成するPlayerのPrefabも別々に作成しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/888758d6-f399-1082-f2f0-c56c80f07525.png)

ARとARも位置同期させたいなどの要望がある場合、`他プレイヤー = VR`という関係が成り立たなくなるのでまた別途工夫が必要です。

## コード

ひとまず、同期処理のコード全文です。

```CS
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Photon.Pun;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace ARVR_Demo
{
    /// <summary>
    /// VRのプレーヤーの位置同期
    /// </summary>
    public class VRDataSync : MonoBehaviourPunCallbacks, IPunObservable
    {
        [SerializeField] private GameObject _leftHandVisual;
        [SerializeField] private GameObject _rightHandVisual;
        [SerializeField] private GameObject _headVisual;

        private readonly List<Transform> _bonesL = new List<Transform>();
        private readonly List<Transform> _bonesR = new List<Transform>();
        private List<Transform> _listOfChildren = new List<Transform>();
        
#if VR
        private OVRSkeleton.SkeletonPoseData _dataL;
        private OVRSkeleton.SkeletonPoseData _dataR;

        private SkinnedMeshRenderer _skinMeshRendererL;
        private SkinnedMeshRenderer _skinMeshRendererR;
#endif
        
        private bool _isInitializedHandL;
        private bool _isInitializedHandR;
        
        private void Start()
        {
            var ct = this.GetCancellationTokenOnDestroy();
            ReadyVR(ct).Forget();
        }

        private async UniTask ReadyVR(CancellationToken ct)
        {
            if (!photonView.IsMine)
            {
                //部屋に入るまで待つ
                await UniTask.WaitUntil(() => PhotonNetwork.InRoom, cancellationToken: ct);
                
                //正しい順序で生成したボーンのリストを作成
                ReadyHand(_leftHandVisual, _bonesL, out _isInitializedHandL);
                ReadyHand(_rightHandVisual, _bonesR, out _isInitializedHandR);
                return;
            }
            
#if VR
            _skinMeshRendererL = _leftHandVisual.GetComponent<SkinnedMeshRenderer>();
            _skinMeshRendererR = _rightHandVisual.GetComponent<SkinnedMeshRenderer>();

            //部屋に入るまで待つ
            await UniTask.WaitUntil(() => PhotonNetwork.InRoom, cancellationToken: ct);

            //タグで検索
            var ovrSkeletonL = GameObject.FindGameObjectWithTag("HandL").GetComponent<OVRSkeleton>();
            var ovrSkeletonR = GameObject.FindGameObjectWithTag("HandR").GetComponent<OVRSkeleton>();

            //ボーン情報のデータプロバイダー
            var dataProviderL = ovrSkeletonL.GetComponent<OVRSkeleton.IOVRSkeletonDataProvider>();
            var dataProviderR = ovrSkeletonR.GetComponent<OVRSkeleton.IOVRSkeletonDataProvider>();
            
            //正しい順序で生成したボーンのリストを作成
            ReadyHand(_leftHandVisual, _bonesL, out _isInitializedHandL);
            ReadyHand(_rightHandVisual, _bonesR, out _isInitializedHandR);
            
            _skinMeshRendererL.enabled = true;
            _skinMeshRendererR.enabled = true;

            this.UpdateAsObservable()
                .Subscribe(_ =>
                {
                    if (_isInitializedHandL == false)
                    {
                        ReadyHand(_leftHandVisual, _bonesL, out _isInitializedHandL);
                    }

                    if (_isInitializedHandR == false)
                    {
                        ReadyHand(_rightHandVisual, _bonesR, out _isInitializedHandR);
                    }
                    
                    //頭
                    var cameraTransform = Camera.main.transform;

                    _headVisual.transform.localPosition = cameraTransform.localPosition;
                    _headVisual.transform.localRotation = cameraTransform.localRotation;

                    //ボーンの情報取得
                    _dataL = dataProviderL.GetSkeletonPoseData();
                    _dataR = dataProviderR.GetSkeletonPoseData();

                    //認識してないときは自分の手のみ非表示にする
                    var shouldRendererL = _dataL.IsDataValid && _dataL.IsDataHighConfidence;
                    var shouldRendererR = _dataR.IsDataValid && _dataR.IsDataHighConfidence;
                    _skinMeshRendererL.enabled = shouldRendererL;
                    _skinMeshRendererR.enabled = shouldRendererR;

                    //左手
                    if (_dataL.IsDataValid && _dataL.IsDataHighConfidence)
                    {
                        //ルートのローカルポジションを適用
                        _leftHandVisual.transform.localPosition = _dataL.RootPose.Position.FromFlippedZVector3f();
                        _leftHandVisual.transform.localRotation = _dataL.RootPose.Orientation.FromFlippedZQuatf();
                        _leftHandVisual.transform.localScale = new Vector3(_dataL.RootScale, _dataL.RootScale, _dataL.RootScale);

                        //ボーンのリストに受け取った値を反映
                        for (var i = 0; i < _dataL.BoneRotations.Length; ++i)
                        {
                            _bonesL[i].transform.localRotation = _dataL.BoneRotations[i].FromFlippedZQuatf();
                        }
                    }

                    //右手
                    if (_dataR.IsDataValid && _dataR.IsDataHighConfidence)
                    {
                        //ルートのローカルポジションを適用
                        _rightHandVisual.transform.localPosition = _dataR.RootPose.Position.FromFlippedZVector3f();
                        _rightHandVisual.transform.localRotation = _dataR.RootPose.Orientation.FromFlippedZQuatf();
                        _rightHandVisual.transform.localScale = new Vector3(_dataR.RootScale, _dataR.RootScale, _dataR.RootScale);

                        //ボーンのリストに受け取った値を反映
                        for (var i = 0; i < _dataR.BoneRotations.Length; ++i)
                        {
                            _bonesR[i].transform.localRotation = _dataR.BoneRotations[i].FromFlippedZQuatf();
                        }
                    }

                })
                .AddTo(this);

#endif
        }
        
        /// <summary>
        /// 手のボーンのリストを作成
        /// 後にOculusの持つボーン情報のリストと照らし合わせて値を更新するので順番に一工夫して作成
        /// </summary>
        /// <param name="hand">子にボーンを持っている手</param>
        /// <param name="bones">空のリスト</param>
        /// <param name="isInitialize">初期化完了フラグ</param>
        private void ReadyHand(GameObject hand, List<Transform> bones, out　bool isInitialize)
        {
            //'Bones'と名の付くオブジェクトからリストを作成する
            foreach (Transform child in hand.transform)
            {
                _listOfChildren = new List<Transform>();
                GetChildRecursive(child.transform);

                //まずは指先以外のリストを作成
                var fingerTips = new List<Transform>();
                foreach (var bone in _listOfChildren)
                {
                    if (bone.name.Contains("Tip"))
                    {
                        fingerTips.Add(bone);
                    }
                    else
                    {
                        bones.Add(bone);
                    }
                }

                //指先もリストに追加
                bones.AddRange(fingerTips);
            }

            //動的に生成されるメッシュをSkinnedMeshRendererに反映
            var skinMeshRenderer = hand.GetComponent<SkinnedMeshRenderer>();
            
            var bindPoses = new Matrix4x4[bones.Count];
            var localToWorldMatrix = transform.localToWorldMatrix;
            for (var i = 0; i < bones.Count; ++i)
            {
                bindPoses[i] = bones[i].worldToLocalMatrix * localToWorldMatrix;
            }

            //Mesh、SkinnedMeshRendererにボーンを反映
            skinMeshRenderer.bones = bones.ToArray();

            isInitialize = true;
        }

        /// <summary>
        /// 子のオブジェクトのTransformを再帰的に全て取得
        /// </summary>
        /// <param name="obj">子階層が欲しいオブジェクトのRoot</param>
        private void GetChildRecursive(Transform obj)
        {
            if (null == obj) return;

            foreach (Transform child in obj.transform)
            {
                if (null == child)
                    continue;

                if (child != obj)
                {
                    _listOfChildren.Add(child);
                }

                GetChildRecursive(child);
            }
        }

        private void Stream(PhotonStream stream)
        {
            //何かの初期化が終わってなかったらリターン
            if (_isInitializedHandL == false || _isInitializedHandR == false)
            {
                return;
            }
            
            //書き込み処理はVR側(自身のクライアントである場合)でのみ呼び出される
            if (stream.IsWriting)
            {
                //頭
                stream.SendNext(_headVisual.transform.localPosition);
                stream.SendNext(_headVisual.transform.localRotation);
                
                //左手
                stream.SendNext(_leftHandVisual.transform.localPosition);
                stream.SendNext(_leftHandVisual.transform.localRotation);

                foreach (var t in _bonesL)
                {
                    stream.SendNext(t.transform.localRotation);
                }

                //右手
                stream.SendNext(_rightHandVisual.transform.localPosition);
                stream.SendNext(_rightHandVisual.transform.localRotation);

                foreach (var t in _bonesR)
                {
                    stream.SendNext(t.transform.localRotation);
                }
            }
            else
            {
                //頭
                _headVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
                _headVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();
                
                //左手
                _leftHandVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
                _leftHandVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();

                //ボーンのリストに受け取った値を反映
                foreach (var t in _bonesL)
                {
                    t.transform.localRotation = (Quaternion) stream.ReceiveNext();
                }

                //右手
                //ルートのローカルポジションを適用
                _rightHandVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
                _rightHandVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();

                //ボーンのリストに受け取った値を反映
                foreach (var t in _bonesR)
                {
                    t.transform.localRotation = (Quaternion) stream.ReceiveNext();
                }
            }
        }

        /// <summary>
        /// Transformをやり取りする
        /// </summary>
        /// <param name="stream">値のやり取りを可能にするストリーム</param>
        /// <param name="info">タイムスタンプ等の細かい情報がやり取り可能</param>
        void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
        {
            Stream(stream);
        }
    }
}
```

実際、AR側では`OnPhotonSerializeView`の箇所くらいしかこのコードは仕事をしていません。以下に抜き出した箇所ですが、やっていることとしてはVR側が送ってきたデータを受け取ってハンドトラッキング用のオブジェクトに反映しているだけです。

```CS
private void Stream(PhotonStream stream)
{
    //何かの初期化が終わってなかったらリターン
    if (_isInitializedHandL == false || _isInitializedHandR == false)
    {
        return;
    }
    
    //書き込み処理はVR側(自身のクライアントである場合)でのみ呼び出される
    if (stream.IsWriting)
    {
        //頭
        stream.SendNext(_headVisual.transform.localPosition);
        stream.SendNext(_headVisual.transform.localRotation);
        
        //左手
        stream.SendNext(_leftHandVisual.transform.localPosition);
        stream.SendNext(_leftHandVisual.transform.localRotation);

        foreach (var t in _bonesL)
        {
            stream.SendNext(t.transform.localRotation);
        }

        //右手
        stream.SendNext(_rightHandVisual.transform.localPosition);
        stream.SendNext(_rightHandVisual.transform.localRotation);

        foreach (var t in _bonesR)
        {
            stream.SendNext(t.transform.localRotation);
        }
    }
    else
    {
        //頭
        _headVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
        _headVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();
        
        //左手
        _leftHandVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
        _leftHandVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();

        //ボーンのリストに受け取った値を反映
        foreach (var t in _bonesL)
        {
            t.transform.localRotation = (Quaternion) stream.ReceiveNext();
        }

        //右手
        //ルートのローカルポジションを適用
        _rightHandVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
        _rightHandVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();

        //ボーンのリストに受け取った値を反映
        foreach (var t in _bonesR)
        {
            t.transform.localRotation = (Quaternion) stream.ReceiveNext();
        }
    }
}

/// <summary>
/// Transformをやり取りする
/// </summary>
/// <param name="stream">値のやり取りを可能にするストリーム</param>
/// <param name="info">タイムスタンプ等の細かい情報がやり取り可能</param>
void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    Stream(stream);
}
```

VR側とAR側で同じコードを流用したかったのでだいぶ横着なコードになっていますが、ちゃんとやるならもっときれいにできるとは思います。

## 位置合わせ
ここまでの要素だけではAR空間とVR空間で位置を共有することができません。

実際、先ほどのサンプルでは人間の位置とARで表示したアバターの位置が、ある程度同じ位置にあるように見えましたが、これはVRヘッドセットとスマホの起動位置をおおよそ同じ位置/向きで固定しただけです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">久しぶりのwithARハッカソン (<a href="https://twitter.com/_withAR?ref_src=twsrc%5Etfw">@_withAR</a>) <br>参加中です👏<br><br>Questのハンドトラッキングを<br>ARで見れるようにしました👍<a href="https://twitter.com/hashtag/withAR%E3%83%8F%E3%83%83%E3%82%AB%E3%82%BD%E3%83%B3?src=hash&amp;ref_src=twsrc%5Etfw">#withARハッカソン</a> <a href="https://twitter.com/hashtag/NEUU?src=hash&amp;ref_src=twsrc%5Etfw">#NEUU</a> <a href="https://twitter.com/neuu_jp?ref_src=twsrc%5Etfw">@neuu_jp</a> <a href="https://t.co/xAu02hxtO3">pic.twitter.com/xAu02hxtO3</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中82/100 (@kento_xr) <a href="https://twitter.com/kento_xr/status/1601944116518178816?ref_src=twsrc%5Etfw">December 11, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


位置合わせに関しては平面検知機能を利用して、以下のような空間共有コンテンツを作成したので別途記事を書く予定です。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">・屋内規模のAR空間<br>・都市規模のAR空間<br>・VR空間<br>・現実空間<br><br>これら全ての前提条件が異なる空間同士のセッションシステムを構築しました！<br><br>家でもAR体験ができる<br>特定の場所でもできる<br>VR空間からの交流もできる<br><br>そんなわがまま詰め込みました😆<a href="https://twitter.com/hashtag/withAR%E3%83%8F%E3%83%83%E3%82%AB%E3%82%BD%E3%83%B3?src=hash&amp;ref_src=twsrc%5Etfw">#withARハッカソン</a> <a href="https://twitter.com/hashtag/NEUU?src=hash&amp;ref_src=twsrc%5Etfw">#NEUU</a><a href="https://twitter.com/hashtag/PLATEAU?src=hash&amp;ref_src=twsrc%5Etfw">#PLATEAU</a> <a href="https://t.co/uNCiJ5gdGO">pic.twitter.com/uNCiJ5gdGO</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中82/100 (@kento_xr) <a href="https://twitter.com/kento_xr/status/1604380515196600320?ref_src=twsrc%5Etfw">December 18, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

いろいろな方法がありますが、画像認識やGeoSpatialのようなVPSであれば以下記事のようなやり方の応用でVRとARの位置合わせが可能です。

【参考リンク】：[【Unity(C#)】ARFoundationのImageTrackingを使って三人称視点の実装](https://qiita.com/OKsaiyowa/items/597c5db17be40ab3b265)
【参考リンク】：[【Unity】GeoSpatialAPIの基礎理解～空間共有コンテンツ作成まで](https://qiita.com/OKsaiyowa/items/c2a2da503d71eb3af5bd)

---
2023/2/13 追記
位置合わせについて書きました。
【参考リンク】：[【Unity(C#)】VRとARでスケールの異なる空間同士を共有する方法](https://qiita.com/OKsaiyowa/items/d7ab06e5abef9f234b69)