【Unity(C#)】DMM VR ConnectとImmersalを組み合わせてVR空間で操作したアバターをAR空間に表示する

##はじめに
VR空間で操作したアバターをAR空間に表示するという一見単純な実装ですが、
意外と考慮しないといけない点が多く、大変だったのでメモします。

私のやろうとしていることとほぼ同じことを行い、
ソースコードまで公開されている先駆者の方がいらっしゃいました。
実装する上で大いに助けになりました。ありがとうございます。

【参考リンク】：[AR_VR_Viewer](https://github.com/TORISOUP/AR_VR_Viewer)
【参考リンク】：[ARでVRアバターを表示するシステムを構築しよう](https://www.slideshare.net/torisoup/arvr-148151769)

まずは、今回私が作成を試みた経緯からその実装概要までを説明します。

## 経緯

まずはなぜ、VR空間で操作したアバターをAR空間に表示する必要があったのかについてです。

[PLATEAU](https://www.mlit.go.jp/plateau/)という国土交通省が行っている、日本の都市を3Dデータ化するプロジェクトが存在しており、
東京23区を皮切りに、オープンデータとして公開されています。

そのPLATEAUが[ハッカソン](https://asciistartup.doorkeeper.jp/events/122248)を開催し、私もそのハッカソンに参加しました。
そこで試したかった内容を図示したものが下記画像です。

![ARLiveアイデア図解.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/1a9a051c-5456-1c72-66ac-131ad01704a3.png)

簡単に言うと**PLATEAUとARを組み合わせたライブシステム**の構築です。
VR空間でアバターを操作し、AR側でリアルタイムに巨大なアバターが動くという仕組みです。
PLATEAUはオクルージョン用のオブジェクトとして使用します。

既にPLATEAUを使って任意の場所に存在感のある巨大なARオブジェクトを表示する事例は存在しています。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">【AR開発サポートツール「CFA」の近況】<br>CFAの広域対応＆オクルージョン性能UPを見越して、ImmersalとPLATEAUとの連携を研究中です。<br>ビル群でユニティちゃんを表示するとこんな感じに！<a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://twitter.com/hashtag/UPFT_CFA?src=hash&amp;ref_src=twsrc%5Etfw">#UPFT_CFA</a> <a href="https://twitter.com/hashtag/Immersal?src=hash&amp;ref_src=twsrc%5Etfw">#Immersal</a> <a href="https://twitter.com/hashtag/PLATEAU?src=hash&amp;ref_src=twsrc%5Etfw">#PLATEAU</a> <a href="https://twitter.com/hashtag/%E3%83%97%E3%83%A9%E3%83%88%E3%83%BC?src=hash&amp;ref_src=twsrc%5Etfw">#プラトー</a> <a href="https://twitter.com/hashtag/VPS?src=hash&amp;ref_src=twsrc%5Etfw">#VPS</a> <a href="https://twitter.com/hashtag/ARCloud?src=hash&amp;ref_src=twsrc%5Etfw">#ARCloud</a> <a href="https://twitter.com/hashtag/unitychan?src=hash&amp;ref_src=twsrc%5Etfw">#unitychan</a> <a href="https://twitter.com/hashtag/%E3%83%A6%E3%83%8B%E3%83%86%E3%82%A3%E3%81%A1%E3%82%83%E3%82%93?src=hash&amp;ref_src=twsrc%5Etfw">#ユニティちゃん</a> <a href="https://t.co/nhe9oeepPp">pic.twitter.com/nhe9oeepPp</a></p>&mdash; アップフロンティア株式会社 (@Up_frontier) <a href="https://twitter.com/Up_frontier/status/1392426970110054406?ref_src=twsrc%5Etfw">May 12, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

【参考リンク】：[Immersal×PLATEAUで巨大AR](https://gaprot.jp/2021/05/12/immersal-plateau/)

ハッカソンにおいては上記の例に一工夫加えた、リアルタイムにアバターを操作する仕組みをアイデアとして持ち込みました。
アイデアソンであったため、実装する必要はありませんでしたが、
技術的に可能であるかどうかを提示した方がアイデアの質が高まると考え、爆速で実装しました。

現実空間で試すところまでは2日間という短い時間では検証できませんでしたが、
VR→ARという仕組み自体はお見せできるレベルまで実装が間に合いました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">アイデアソンでしたが、VRの動きをARで表示するところまでやりましたｗ<br><br>DMMVRConnectが神過ぎて<br>なんとか間に合いました😆<br><br> <a href="https://twitter.com/hashtag/DVRSDK?src=hash&amp;ref_src=twsrc%5Etfw">#DVRSDK</a> <a href="https://twitter.com/hashtag/DMMVRConnect?src=hash&amp;ref_src=twsrc%5Etfw">#DMMVRConnect</a> <a href="https://t.co/Jb4yyxYyiR">pic.twitter.com/Jb4yyxYyiR</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中36/100 (@okprogramming) <a href="https://twitter.com/okprogramming/status/1409120194400702478?ref_src=twsrc%5Etfw">June 27, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

そしてチームのみなさんとデモとして動画にしたものが下記です。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">PLATEAU Business Challenge 2021に参加し、<br>審査員奨励賞を受賞しました！！<br><br>PLATEAUを利用した、今までに無いリッチなARライブ配信の収益モデルを提案しました！👏<a href="https://twitter.com/hashtag/PLATEAU?src=hash&amp;ref_src=twsrc%5Etfw">#PLATEAU</a> <a href="https://t.co/A5TQD2mjqk">pic.twitter.com/A5TQD2mjqk</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中36/100 (@okprogramming) <a href="https://twitter.com/okprogramming/status/1409118833760030721?ref_src=twsrc%5Etfw">June 27, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

Youtubeライブのようなものをイメージして収益モデルまで提示し、
審査員奨励賞を頂きました!(チームのみなさんありがとうございます！)

【当日の発表資料】：https://www.slideshare.net/ssuserb5ac78/ar-249505224

## 実装概要
ここまではプレゼンに必要な機能だけを実装してきました。
ハッカソンが終わって、実際にPLATEAUを現実空間に対して位置合わせする方法を考えました。

まず、作りたいものの要件を簡単に列挙したものが下記です。

**・VRはデスクトップ、ARはモバイル想定
・VR空間で操作したアバターをAR空間でリアルタイムに表示する
・AR側は相手のアバターだけ見えれば良い(自分のアバターはいらない)
・AR側は任意の場所をカメラで読み取ると相手のアバターが表示される
・VR側は自分のアバターだけ見えれば良い(今後要検討)
・VR側はフルトラ(6点)でアバターを操作する**

上記を実装するために使用するサービスとしては下記です。
**・DMM VR Connect
・Immersal**

それぞれを使用した背景を説明します。

---

### DMM VR Connect
今回の実装における面倒なことのほとんどを[DMM VR Connect](https://connect.vrlab.dmm.com/)というサービスに依存させてもらっています。

>DMM VR Connect SDKはアバター読み出し機能とストリーミング機能を内包したUnity Packageです。
SDKの利用を希望される方はデベロッパー登録を行ってください。
【引用元】：https://connect.vrlab.dmm.com/

本当に便利で助かりました。
今回の実装において特に便利だった点は下記です。

**①アバターの取り回しをよしなにやってくれる
②トラッキングの面倒なことを全部やってくれる(これ本当に最強)**

---

####①アバターの取り回しをよしなにやってくれる

アバターを動的にロードする仕組みを用意するのは面倒です。
他プレイヤーのアバターをそれぞれが同期するとなるとさらにややこしくなります。

DMM VR ConnectではあらかじめユーザーがVRMをWeb上のマイページにてアップロードします。
その事前準備により、ユーザーIDさえ同期すればお互いのメインアバターをロードしてくるという仕組みが簡単に作成できます。
下記参考に実装しました。
【参考リンク】：[UnityでマルチプレイヤーVRチャットアプリが作れるDMM VR Connect #5](https://inside.dmm.com/entry/2021/05/14/DMMVRConnect05)

下記がユーザーのIDに応じてアバターをロードする関数です。

クラスの一部を抜き出しているので少々わかりにくいですが、
`GetUserInformation`が自身のユーザー情報を取得する関数で、
`LoadCurrentAvatarModelByUserId`がユーザーIDに応じたアバターをロードする関数です。

```c#

//ユーザーID取得
private async Task GetUserInformation()
{
    //_currentUserはCurrentUserModelクラス
    _currentUser = await Authentication.Instance.Okami.GetCurrentUserAsync();
    if (_currentUser != null)
    {
        var imageBinary = await Authentication.Instance.Okami.GetUserThumbnailAsync(_currentUser);
        if (imageBinary != null)
        {
            var texture = new Texture2D(1, 1);
            texture.LoadImage(imageBinary);
            _thumnbnailTexture = texture;
        }

        _userNameText = _currentUser.name;
    }
}

//ユーザーIDからアバター取得
public async void LoadCurrentAvatarModelByUserId(string userId, AvatarModelEventHandler avatarModelLoadCompleted)
{
    try
    {
        AvatarModel avatarModel;
        if (_currentUser.id == userId)
        {
            avatarModel = _currentUser.current_avatar;
        }
        else
        {
            var userModel = await Authentication.Instance.Okami.GetUserAsync(userId);
            avatarModel = userModel.current_avatar;
        }

        VRMLoader vrmLoader = new VRMLoader();
        GameObject avatarModelObject =
            await Authentication.Instance.Okami.LoadAvatarVRMAsync(avatarModel,
                vrmLoader.LoadVRMModelFromConnect) as GameObject;

        avatarModelLoadCompleted?.Invoke(true, userId, vrmLoader, avatarModelObject);
    }
    catch (ApiRequestException ex)
    {
        SetLog(_apiRequestErrorMessages[ex.ErrorType]);
        avatarModelLoadCompleted?.Invoke(false, userId, null, null);
    }
}

```

ただし、記事内にもある通り、"ぶっこ抜き" の対策は何かしら必要となります。

---

####②トラッキングの面倒なことを全部やってくれる

下記スライドにもある通り、キャリブレーションにまつわる面倒なことを全て担ってくれます。
【参考リンク】：[【令和最新版】意のままに身体を動かすキャリブレーション](https://speakerdeck.com/sh_akira/ling-he-zui-xin-ban-yi-falsemamanishen-ti-wodong-kasukiyariburesiyon)

VR側はこれでほとんどの実装が解決します。
個人的に驚いたことは、6点までのトラッキングはトラッカーの割り当てなどする必要すらなく、
いい感じに設定してくれることです。

DVRSDK内のSteamVRTracker.csを覗くと、
mDeviceToAbsoluteTracking.GetPosition()でトラッカーの座標を取得し、
xyで順番にソートして割り当てていました。(実際はもっと複雑)

何はともあれ、**トラッカー3個に電源を入れるだけで動くのは神**です。

---

###Immersal
AR側での位置合わせにはImmersalを利用しています。

>Immresalは[VPS](https://xr-hub.com/archives/22580)と呼ばれる技術に基づいたARクラウドサービスです。
【引用元】：[Immersal×PLATEAUで巨大AR](https://gaprot.jp/2021/05/12/immersal-plateau/)

Immersalでビル群の特徴点を内包したマップデータを作成し、
PLATEAUのモデルデータと組み合わせて位置合わせを行うことで、より詳細なAR表現が可能です。

今回は検証として、机の上に小さいアバターを表示することに試みました。
位置合わせの実装は下記です。
**①Hierarchy上でARSpace配下に"アバターの表示位置"として扱うGameObjectを配置(下記画像参照)
②ターゲットの子階層に"VRM"と"Photonで生成するアバター(同期用オブジェクト)"を移動させる
③"VRM"と"Photonで生成するアバター"それぞれのローカル座標を0にする**

![Immersalデスク配置.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/109a14c7-0a51-3227-6e01-5c5d0b5cb922.png)

"アバターの表示位置"として扱うGameObjectの子階層に
"VRM"と"Photonで生成するアバター"を配置することで
スケールの変更時に親オブジェクトの大きさを変えるだけで破綻なく動いてくれるので楽です。

コードのイメージは下記のような感じです。

```C#
using DVRSDK;
using DVRSDK.Avatar;
using Photon.Pun;
using UnityEngine;

/// <summary>
/// アバターの配置設定
/// Photonで生成されるアバター(同期用オブジェクト)にアタッチ
/// </summary>
public class ArrangeAvatar : MonoBehaviourPun
{
    [SerializeField, Range(0f, 1f)] private float stageSize = 0.1f;

    private CustomDMMVRConnectUI _dmmvrConnectUI;
    private Transform _spawn;

    void Start()
    {
        if (photonView.IsMine) return;
        
        _spawn = GameObject.FindGameObjectWithTag("Spawn").transform;
        _dmmvrConnectUI = FindObjectOfType<CustomDMMVRConnectUI>();
        _dmmvrConnectUI.AvatarModelLoadCompleted += OnAvatarLoaded;
    }

    private void OnDestroy()
    {
        if (photonView.IsMine) return;

        _dmmvrConnectUI.AvatarModelLoadCompleted -= OnAvatarLoaded;
    }

    /// <summary>
    /// ロード完了した時に実行する処理
    /// </summary>
    /// <param name="isSuccess">成功したかどうか</param>
    /// <param name="userId">ユーザーID</param>
    /// <param name="vrmLoader">ローダー</param>
    /// <param name="avatarModelObject">アバターモデル</param>
    private void OnAvatarLoaded(bool isSuccess, string userId, VRMLoader vrmLoader, GameObject avatarModelObject)
    {
        _spawn.localScale = Vector3.one;
        
        transform.SetParent(_spawn);
        transform.localPosition = Vector3.zero;
        transform.localRotation = Quaternion.identity;
        
        avatarModelObject.transform.SetParent(_spawn);
        avatarModelObject.transform.localPosition = Vector3.zero;
        avatarModelObject.transform.localRotation = Quaternion.identity;
        
        //サイズ
        _spawn.localScale = new Vector3(stageSize,stageSize,stageSize);
    }
}
```

---

ImmersalのようなVPSはARコンテンツ作成において非常に強力ですが、運用上では注意が必要です。

下記事例をもとに説明します。

<iframe width="560" height="560" src="https://www.youtube.com/embed/IRYFKTsVBTk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

上記動画のアプリは[Sturfee](https://sturfee.com/)というVPSが使用されているそうです。
Sturfeeにおいても特徴点の照合による位置合わせが行われます。
その前提のもと、下記画像のようにビュースポットというものが定義され、
端末をかざす方向とARが表示されるエリアがあらかじめ定めらています。

![VPS運用.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/25a0c8f2-6307-d766-97db-2febe0f7204f.png)
【引用元】：[街の奥行きを認識するARアプリ「XR CHANNEL」と「DinoScience 恐竜科学博」のコラボーレーションにより、横浜・みなとみらいに巨大な恐竜が出現](https://prtimes.jp/main/html/rd/p/000000037.000045734.html)

**特定の場所に紐づいて出現するARは、ユーザーの行動を正しく誘導する必要があります。**
あらかじめ「この場所とこの向きで体験してください」と示すことについて
UIUXの設計の観点で疑問を持つ方がいらっしゃるかもしれません。
しかし、**現時点のVPSの技術を効果的に利用する上では有効な手法の一つ**だと私は思います。

加えて、アプリの説明には下記文言があります。

>・日が昇っている時間にプレイしてください（アプリ特性上、夜間は動作しません）
【引用元】：[XR CHANNEL -3DマップAR-](https://play.google.com/store/apps/details?id=net.sovec)

ARにおけるカメラからの映像というのは、自己位置推定に使用されることに加えて、
VPSの特徴点の照合にも使用されます。

そのため、**夜間の動作が不安定になる**ことは想像に容易いかと思います。

少し脱線しましたが、本格的な運用まで考えた際には可能なことと不可能なことの理解が必要です。

## バージョン情報
今回はVR側とAR側でプロジェクトを分けて作成しました。
バージョンが異なるとエラーが出る組み合わせがあるので注意です。

VR側

| 名称 | バージョン |
|:-:|:-:|
| Unity|  2019.4.26f1 |
| ImmersalSDK  |  1.13.0 |
|  AR Foundation | 4.0.10  |
| ARCore XR Plugin  | 4.0.10  |
| ARKit XR Plugin  |  4.0.10 |
|  Multiplayer HLAPI |  1.0.8 |
| UniTask  | 2.2.4  |
| UniRx  |  7.1.0 |
| DMM VR Connect SDK  | 1.7.0  |
| UniVRM  | 0.66.0  |
| Photon2| 2.32  |
| Pun2Task| 1.0.2  |
| Final IK|  2.0 |

AR側

| 名称 | バージョン |
|:-:|:-:|
| Unity|  2019.4.26f1 |
| UniTask  | 2.2.4  |
| UniRx  |  7.1.0 |
| DMM VR Connect SDK  | 1.7.0  |
| UniVRM  | 0.66.0  |
| Photon2| 2.32  |
| Pun2Task| 1.0.2  |
| SteamVR Unity Plugin| 2.6.1  |
| Final IK| 2.0  |

##VR→ARの同期
VRで動かしたアバターの同期ですが、下記画像と同様の手法を用いて実装しています。

![VRAR同期SS.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/9f2645af-5129-558d-9a99-625d5774d135.png)
【引用元】：[ARでVRアバターを表示するシステムを構築しよう](https://www.slideshare.net/torisoup/arvr-148151769)


フルトラ(6点トラッキング)の分だけ同期用のオブジェクトを用意して同期させます。
![DVRConnect10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/e74c6137-eec6-7363-8fcd-c66d8db07845.gif)

VR側のコードは下記です。[参考リンク](https://github.com/TORISOUP/AR_VR_Viewer/blob/master/Assets/VrmArPlayer/Scripts/Scene/Main/Common/AvaterScynchronizeDispatcher.cs)のほぼそのままです。

```C#:同期用のオブジェクトとBoneを紐づける。Photonで生成したアバターにアタッチ。

    [SerializeField] private Transform head;
    [SerializeField] private Transform leftHand;
    [SerializeField] private Transform leftFoot;
    [SerializeField] private Transform rightHand;
    [SerializeField] private Transform rightFoot;
    [SerializeField] private Transform pelvis;

    /// <summary>
    /// "アバターの同期させたいBone(Transform)"を同期用オブジェクトに設定する
    /// </summary>
    /// <param name="model">モデルデータ</param>
    private void SetAvatarTransformToSyncObject(GameObject model)
    {
        var animator = model.GetComponent<Animator>();

        head.GetComponent<SyncObjectTransformUpdater>()
            .SetSyncObjectTransform(animator.GetBoneTransform(HumanBodyBones.Head));
        leftHand.GetComponent<SyncObjectTransformUpdater>()
            .SetSyncObjectTransform(animator.GetBoneTransform(HumanBodyBones.LeftHand));
        leftFoot.GetComponent<SyncObjectTransformUpdater>()
            .SetSyncObjectTransform(animator.GetBoneTransform(HumanBodyBones.LeftFoot));
        rightHand.GetComponent<SyncObjectTransformUpdater>()
            .SetSyncObjectTransform(animator.GetBoneTransform(HumanBodyBones.RightHand));
        rightFoot.GetComponent<SyncObjectTransformUpdater>()
            .SetSyncObjectTransform(animator.GetBoneTransform(HumanBodyBones.RightFoot));
        pelvis.GetComponent<SyncObjectTransformUpdater>()
            .SetSyncObjectTransform(animator.GetBoneTransform(HumanBodyBones.Spine));
    }
```

もう一つ同期用オブジェクトのTransformを更新するScriptを作成します。

```C#
using UniRx;
using UniRx.Triggers;
using UnityEngine;

/// <summary>
/// 同期するオブジェクトのTransformを更新する
/// 同期用オブジェクトにアタッチ
/// </summary>
public class SyncObjectTransformUpdater : MonoBehaviour
{
    private Transform _syncObjectTransform;

    /// <summary>
    /// 自身(同期オブジェクト)に追従したいターゲットをセット
    /// </summary>
    /// <param name="target">追従したいターゲットのTransform</param>
    public void SetSyncObjectTransform(Transform target)
    {
        _syncObjectTransform = target;
    }
    
    void Start()
    {
        this.UpdateAsObservable()
            .Subscribe(_ =>
            {
                if (_syncObjectTransform == null) return;
                transform.SetPositionAndRotation(_syncObjectTransform.position, _syncObjectTransform.rotation);
            });
    }
}
```


あとはPhotonViewコンポーネントを同期用オブジェクトにアタッチすれば完成です。
あとはAR側で相手の同期用オブジェクトの位置をアバターの操作に利用します。

ここまでは先駆者の方の資料のおかげもあり、スムーズに実装できたのですが、
VR側の動きをAR側で正しく再現することにかなりの時間を使いました。

単純に頭、右腕、左腕、腰、右足、左足の6か所を同期した場合下記GIFのようになります。
HumanoidのBoneの一部だけを無理やり動かしているからです。
![DVRConnect11.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/82f18c7f-f484-e6a6-031f-76ceefc177a9.gif)

VR側はIKで動きを補正しているため、AR側で何もしないとこのような動きになってしまいます。
(上記GIFはVRの操作ではなくデバッグ用のアニメーションを適用しています。)

全てのボーンを通信同期しても再現することは可能なようですが、
今回はせっかくなのでIKによる動きの再現で通信データを軽減した実装を頑張ってみました。
やりすぎると沼にハマりそうなので下記をクリアした段階でヨシとしました。
・ちゃんと足が上がっている
・関節が破綻してない

最終的に設定したAR側のIKの内容は下記です。
DVRSDK内のFinalIKCalibrator.csを参考にしています。

```C#
    /// <summary>
    /// FinalIKCalibrator.csのおまじない
    /// </summary>
    private void FixLegDirection(GameObject targetHumanoidModel)
    {
        var avatarForward = targetHumanoidModel.transform.forward;
        var animator = targetHumanoidModel.GetComponent<Animator>();

        var leftUpperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
        var leftLowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
        var leftFoot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
        var leftFootDefaultRotation = leftFoot.rotation;
        var leftFootTargetPosition = new Vector3(leftFoot.position.x, leftFoot.position.y, leftFoot.position.z);
        LookAtBones(leftFootTargetPosition + avatarForward * 0.01f, leftUpperLeg, leftLowerLeg);
        LookAtBones(leftFootTargetPosition, leftLowerLeg, leftFoot);
        leftFoot.rotation = leftFootDefaultRotation;

        var rightUpperLeg = animator.GetBoneTransform(HumanBodyBones.RightUpperLeg);
        var rightLowerLeg = animator.GetBoneTransform(HumanBodyBones.RightLowerLeg);
        var rightFoot = animator.GetBoneTransform(HumanBodyBones.RightFoot);
        var rightFootDefaultRotation = rightFoot.rotation;
        var rightFootTargetPosition = new Vector3(rightFoot.position.x, rightFoot.position.y, rightFoot.position.z);
        LookAtBones(rightFootTargetPosition + avatarForward * 0.01f, rightUpperLeg, rightLowerLeg);
        LookAtBones(rightFootTargetPosition, rightLowerLeg, rightFoot);
        rightFoot.rotation = rightFootDefaultRotation;
    }

    /// <summary>
    /// FinalIKCalibrator.csのおまじない
    /// </summary>
    private void LookAtBones(Vector3 lookTargetPosition, params Transform[] bones)
    {
        for (int i = 0; i < bones.Length - 1; i++)
        {
            bones[i].rotation = Quaternion.FromToRotation((bones[i].position - bones[i + 1].position).normalized,
                (bones[i].position - lookTargetPosition).normalized) * bones[i].rotation;
        }
    }
    
    /// <summary>
    /// 任意のTransformの値を持つGameObjectを生成する
    /// 親の原点に配置
    /// </summary>
    /// <param name="objectName">ゲームオブジェクト名</param>
    /// <param name="parent">親</param>
    /// <returns>Transform</returns>
    private Transform CreateTransform(string objectName,  Transform parent)
    {
        var newGameObject = new GameObject(objectName);
        var t = newGameObject.transform;
        if (parent != null) t.SetParent(parent);
        t.localPosition = Vector3.zero;
        t.localRotation = Quaternion.identity;
        return t;
    }

    /// <summary>
    /// 任意のTransformの値を持つGameObjectを生成する
    /// 指定の座標、回転角度に配置
    /// </summary>
    /// <param name="objectName">ゲームオブジェクト名</param>
    /// <param name="parent">親</param>
    /// <param name="targetPosition">指定の座標</param>
    /// <param name="targetRotation">指定の回転角度</param>
    /// <returns>Transform</returns>
    private Transform CreateTransform(string objectName,Transform parent,Vector3? targetPosition,Quaternion? targetRotation)
    {
        var newGameObject = new GameObject(objectName);
        var t = newGameObject.transform;
        if (parent != null) t.SetParent(parent);
        if(targetPosition!= null) t.localPosition = targetPosition.Value;
        if(targetRotation!= null) t.localRotation = targetRotation.Value;
        return t;
    }

    /// <summary>
    /// IKを設定する
    /// FinalIKCalibrator.csを参考に適用
    /// </summary>
    /// <param name="model">モデルデータ</param>
    private void SetIK(GameObject model)
    {
        //VRIKアタッチ前にボーンの曲げ方向を補正して関節が正しい方向に曲がるようにする
        FixLegDirection(model);

        var vrik = model.AddComponent<VRIK>();

        //初期処理
        vrik.AutoDetectReferences();
        vrik.solver.FixTransforms();
        vrik.solver.IKPositionWeight = 0f;
        vrik.solver.leftArm.stretchCurve = new AnimationCurve();
        vrik.solver.rightArm.stretchCurve = new AnimationCurve();
        vrik.UpdateSolverExternal();
        
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        // 頭のIKを設定
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        
        var headOffset = CreateTransform("HeadIKTarget", head);
        var headOffsetYPosition = headOffset.localPosition;
        headOffset.localPosition = new Vector3(headOffsetYPosition.x,0.4f,-0.57f);
        vrik.solver.spine.headTarget = headOffset;

        //頭のトラッキングの補正度合いを変更
        vrik.solver.spine.minHeadHeight = 0f;
        vrik.solver.spine.neckStiffness = 0.1f;
        vrik.solver.spine.headClampWeight = 0;
        vrik.solver.spine.maxRootAngle = 20;
        
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        // 手のIKを設定
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        
        var leftHandPosFixedValue =  new Vector3(-0.04f,0.04f,-0.15f);
        var leftHandOffset = CreateTransform("LeftHandIKTarget", leftHand,leftHandPosFixedValue,Quaternion.identity);
        vrik.solver.leftArm.target = leftHandOffset;
        vrik.solver.leftArm.positionWeight = 1.0f;
        vrik.solver.leftArm.rotationWeight = 1.0f;

        var rightHandPosFixedValue =  new Vector3(0.04f,0.04f,-0.15f);
        var rightHandOffset = CreateTransform("LeftHandIKTarget", rightHand,rightHandPosFixedValue,Quaternion.identity);
        vrik.solver.rightArm.target = rightHandOffset;
        vrik.solver.rightArm.positionWeight = 1.0f;
        vrik.solver.rightArm.rotationWeight = 1.0f;
        
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        // 腰のIKを設定
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        
        var pelvisOffset = CreateTransform("PelvisIKTarget", pelvis);
        var pelvisOffsetYPosition = pelvisOffset.position;
        pelvisOffset.position = new Vector3(pelvisOffsetYPosition.x,pelvisOffsetYPosition.y - 0.1f,pelvisOffsetYPosition.z -0.1f);
        vrik.solver.spine.pelvisTarget = pelvisOffset;
        vrik.solver.spine.pelvisPositionWeight = 1.0f;
        vrik.solver.spine.pelvisRotationWeight = 1.0f;

        vrik.solver.plantFeet = true;
        vrik.solver.spine.neckStiffness = 0f;
        vrik.solver.spine.maxRootAngle = 180f;

        //腰のトラッキングを調整
        vrik.solver.spine.maintainPelvisPosition = 0; //アバターによって腰がグリングリンするのが直るらしい

        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        // 足のIKを設定
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        
        var leftFootOffset = CreateTransform("LeftFootIKTarget", leftFoot);
        vrik.solver.leftLeg.target = leftFootOffset;
        vrik.solver.leftLeg.positionWeight = 1.0f;
        vrik.solver.leftLeg.rotationWeight = 1.0f;

        var rightFootOffset = CreateTransform("RightFootIKTarget", rightFoot);
        vrik.solver.rightLeg.target = rightFootOffset;
        vrik.solver.rightLeg.positionWeight = 1.0f;
        vrik.solver.rightLeg.rotationWeight = 1.0f;

        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        // 膝を曲げる方向のターゲットを設定
        //＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝＝
        
        var bendGoalL = CreateTransform("LeftFootBendGoal", leftFoot);
        bendGoalL.transform.position = vrik.references.leftFoot.position + model.transform.forward + model.transform.up;
        vrik.solver.leftLeg.bendGoal = bendGoalL.transform;
        vrik.solver.leftLeg.bendGoalWeight = 0.7f;

        var bendGoalR = CreateTransform("RightFootBendGoal", rightFoot);
        bendGoalR.transform.position = vrik.references.rightFoot.position + model.transform.forward + model.transform.up;
        vrik.solver.rightLeg.bendGoal = bendGoalR.transform;
        vrik.solver.rightLeg.bendGoalWeight = 0.7f;

        //自動の歩行はさせない
        vrik.solver.locomotion.weight = 0f;
        //下記コンポーネントが膝の向きが壊れることを防ぐらしい
        vrik.references.root.gameObject.AddComponent<VRIKRootController>();

        //Originalのコンポーネントで補正しているらしい
        var wristRotationFix = model.AddComponent<WristRotationFix>();
        wristRotationFix.SetVRIK(vrik);

        //IKを適用
        vrik.solver.IKPositionWeight = 1.0f;
        vrik.UpdateSolverExternal();
    }
}
```

同期用オブジェクトの子に"IKのターゲットとなるオブジェクト"をイイ感じの位置に配置しています。
このIKの設定を用いればVRCのようにVR⇔VRのアバター同期実装も可能になるかと思います。
(VRCがどういう実装なのか全く詳しくないので違ったらごめんなさい)

実際に同期させたものが下記です。まあ許容範囲内でしょう。
![DVRConnect9.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/1c834d28-2391-46b0-3da3-244b3e1731da.gif)


## デモ
ここまでの内容を実装して動かしてみたデモが下記です。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">IK調整しました！<br><br>これ以上パラメータの調整するのは辛すぎるので一旦IK周りは忘れますｗ<a href="https://twitter.com/hashtag/DVRSDK?src=hash&amp;ref_src=twsrc%5Etfw">#DVRSDK</a> <a href="https://twitter.com/hashtag/DMMVRConnect?src=hash&amp;ref_src=twsrc%5Etfw">#DMMVRConnect</a> <a href="https://twitter.com/hashtag/Immersal?src=hash&amp;ref_src=twsrc%5Etfw">#Immersal</a> <a href="https://t.co/o4LzoPADzV">pic.twitter.com/o4LzoPADzV</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中36/100 (@okprogramming) <a href="https://twitter.com/okprogramming/status/1412028269587681283?ref_src=twsrc%5Etfw">July 5, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

黄色い点がImmersalで取得した点群です。
空間の特徴点を認識して机の上にアバターを表示しています。

##おわりに
DVRSDKはかなり便利であることが今回開発してよくわかりました。

VR⇔ARの同期についてですが、よくよく考えたらVR側からAR側に何かアクションを起こしたい際に、
相手の位置が見えないと不便なのでその辺りの実装なども必要になってきそうです。

あとは実際にPLATEAUと組み合わせてどのような見た目になるのか動かしてみるのが楽しみです。

(開発者コミュニティで丁寧な回答くださった開発者の方ありがとうございます！)