XR Interaction Toolkitを用いてコントローラを使わないタイプのVR移動システムを作った

# この記事について
学科の課題で2ヶ月程度かけて何か作ってくださいというのがあリまして，悩んだ結果VRを題材に決めました．発表の後レポートを書いたのですが，誰でも使えるような形のものも残しておこうと思いこの記事を書きました．冗長な良くない記事になってしまったかもしれません．すいません．記事の大まかな内容としては，UnityでXR Interaction Toolkitを用い移動システムを作成，実装するというものです．この移動システムを適用すればコントローラを使わずにVR空間を動き回る事ができます．詳細については追い追い書いていきます．
↓動作はこんな感じです（動画は3次元機動バージョン）<br>
<iframe width="560" height="315" src="https://www.youtube.com/embed/TGLNJ9hpiIQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
<br>
上動画のワールドはshogonirさんが製作したものをお借りしました．<br>
shogonirさんのtwitter：https://twitter.com/shogonir<br>
借りた先：https://github.com/shogonir/unity-oz<br>
<br>
移動方法だけ変更すれば良いので，大体どんなVRコンテンツにも導入できるものになっていると思います．

# 環境
VR機器：PICO 4
Unity：バージョン 2021.3.11f1
PICO Unity Integration SDK：バージョン2.1.1
XR Interaction Toolkit：バージョン2.2.0

それぞれのバージョンについては適当に新しそうなものを選びました．今回は深く考えず設定しても何とかなりましたが，オブジェクトの名前が違ったり，前バージョンの関数が使えなかったりして，そのあたりの辻褄を合わせるのは少し大変でした．そういう事態を避けるため，参考にするものとバージョンを合わせておいた方が安心ではあるかと思います．新しいバージョンを選ぶか古いバージョンを選ぶかは一長一短ある感じがしました．<br>

↓PICO Unity Integration SDKのパッケージ追加方法や簡単なセットアップについてはこちらの記事が参考になるかと思います．

https://qiita.com/afjk/items/6b587034c9ff3afb9e5c

↓公式ドキュメントにもほぼ同じことが書いてあるので英語が読めるならこちらを見ても良いかもしれません．

https://developer-global.pico-interactive.com/document/unity/quickstart-overview

↓Meta Questなど，PICO以外でXR Interaction Toolkitを使う場合はこちら．

https://framesynthesis.jp/tech/unity/xr/

これ以外にも，youtubeなどに解説動画があったりしました（英語のものが多かったです）．

>※読み飛ばすとこ
今回の開発に合わせてVR機器を初購入しました．最初はOculus Questを買おうと思っていたのですが，Meta Questに名前を変え値段が上がりまくっていたため，これは良くないとPICO 4の方を購入しました．QuestでもXR Interaction Toolkitを使うことはできますが，Questの方は今のところOculus Integrationを使う人の方が多い気がしています．Oculus Integrationの方が古株でドキュメントが充実しているからというのもあると思います．今回の開発は，XR Interaction Toolkit，PICO4共に日本語のドキュメントが少なく，UnityもC#も初めてなのにこれはもうダメかと思いました．英語のドキュメントなら沢山あったため，九死一生でしたけど．ただ，XR Interaction ToolkitはQuest，PICO以外にも色々なデバイスで使えるので，デバイス固有の機能が使いたいという訳でないなら汎用性は高いと思います．
XR Interaction Toolkitの使い方については，下のyoutubeチャンネルがかなり参考になりました．<br>
><iframe width="560" height="315" src="https://www.youtube.com/embed/videoseries?list=PLrk7hDwk64-a_gf7mBBduQb3PEBYnG4fU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

# 概要
今回作ったものにはImmersive Movement Systemと名付けました．英語であることに特に意味はありません．単にオシャレでそうしただけです．日本語だと没入移動みたいな感じです．頭を傾けた方向に向かってVR内で並進運動や回転運動を行えます．眼球の存在する頭を動かした方向へと動くため，視界と移動感との協調が取れやすくなっています．また，移動のためにコントローラを使用することもないため，両手が空いた状態でVR空間を動くことができるようになります．これらの性質により，従来のコントローラによる移動システムに比べたらかなり操作性が良く，酔いも少ないものになったと思います．定量的に酔いが少ないか，などの検証は全然してません．本当は体験してもらうのが一番ですが，難しいので，それ以外の方法で伝える工夫が必要なのもVR特有のものだと感じました．

# 動機と目的
>※読み飛ばすとこ2
PICO 4をウキウキで購入してセットアップを済ませると，有料のゲームがいくつか無料でプレイできるようになっていました．そのうち一つ，FPSハクスラでのチュートリアルにおいて移動方法を2つの内から一つ選べました．VRで最もよく用いられる2つの移動方法で，Continuous MovementとTeleportationです．Continuous Movementはコントローラのスティック操作，Teleportationはコントローラでワープ先を指定するものです．なんとなくContinuous Movementを選びました．手元のスティック操作一つで視界の全てが動くため，初めて動いた時は体がよろけてこれがベクションかと感動しました．最初のうちは異次元スライドムーヴｗとか言って楽しんでたのですが，操作に慣れないのもあって1時間くらい遊んだところでかなり体調が終了していました．

スティック操作とは違う，酔わない移動システムを作りたいというのが今回の目的です．VRでの移動には常に酔いがつきまとうため，自分では無く環境の方が動くようなゲームや，動かなくてもプレイできるものも多いです．酔いを軽減させるために，等速度運動にすること，移動中の視界を狭めることなどの対症療法はあります．ただこれだと酔いの根本的な解決にはなりません．そこで，自分が動こうと思った方へと動ける移動感と，視界変化の感覚とを合致させることでベクションを解消する必要があります．移動せんとした方向と，実際に自分が移動する方向とをいかに調和させるかというのが今回の課題でした．<br>

# 原理
HMDの変位を入力にして，VR内を動けるようにすることを一つの設計解として考えました．動こうとする意思を体の傾きとして表出させ，それを入力とすることにより直感的な移動入力が実現できます．マリオカートで体が傾くあの現象を利用するような感じです．大まかな設計としては，中心付近は不感帯として，前後方向へのHMDの変位があれば前後へ並進．左右方向への変位があれば左右へ回転または並進．3次元的に動く場合には，上下方向への変位に応じて上下へ並進といった運動ができるように作っていきました．前へ移動する時，左右への変位によりアバターが回転するものをセグウェイモード，並進するものをホバーモードと名付けました．
![何か，画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987176/de7c711c-35c9-b9c1-a951-c1b551e85af0.png)

# 設計
コメントで説明をつけた二次元機動のコードを下に貼り付けました．C#は初めてであっため，どこかおかしいこととかあるかもしれないです．これをAdd ComponentでXR Origin（XR Interaction Toolkitのバージョンによって名前が違います）に追加すれば，移動システムを実装できます．3次元機動の場合，上下移動についても前後移動と同様に書き足せば，上下方向へ動くことも可能になります．左コントローラのXボタンで移動on/off，Yボタンで移動モードが変化するようにしています．デッドゾーンの閾値や並進，回転速度などはエイヤで決めています．Inspectorから変更が可能ですが，VR内にスクロールバーなどのUIを設けて変更できるようにしてもいいかもしれません．
```cs
using UnityEngine;
using UnityEngine.XR;
using UnityEngine.XR.Interaction.Toolkit;
using Unity.XR.CoreUtils;
using System.Collections.Generic;

//二次元機動
public class ImmersiveMovement : MonoBehaviour
{
    //地面との当たり判定，落下を制御するための変数
    public float gravity = -9.81f;
    //落下スピード
    private float fallingSpeed;
    //地面レイヤーの設定
    //InspectorでgroundLayerに地面となるオブジェクトのレイヤーを選択
    public LayerMask groundLayer;

    //アバター（VR内のカメラ）の当たり判定など
    //CharacterControllerコンポーネントの取得用．また，これを使いアバターを動かせる．
    private CharacterController character;
    //視点より上に設ける当たり判定の大きさ（頭の大きさ）
    public float additionalHeight = 0.2f;

    //XROriginコンポーネントの取得用．アバターの位置，姿勢を用いるため
    private XROrigin xrOrigin;
    //デバイス関連
    private InputDevice leftController;

    //HMDの位置，姿勢を保持
    private Vector3 hmdPosition;
    private Quaternion hmdRotationQuaternion;

    //各種スピードなどの倍率
    //前後
    public float sagittalSpeed = 25.0f;
    //左右
    public float lateralSpeed = 10.0f;
    //回転
    public float rotationSpeed = 75.0f;

    //不感帯の範囲
    //前後
    public float sagittalDeadzone = 0.03f;
    //左右
    public float lateralDeadzone = 0.03f;

    //リセット時の位置合わせに用いるもの
    //キャリブレーションの座標
    private float offsetX = 0f;
    private float offsetZ = 0f;
    //現実でのHMDの回転量
    private float realRotationY;
    //アバターの向き
    private Quaternion headYaw;

    //移動システムon/off
    private bool preButtonStateX = false;
    private bool buttonStateY = false;
    public bool enableVehicle = false;
    //ホバー移動orセグウェイ移動
    public bool isHover = false;

    // Start is called before the first frame update
    void Start()
    {
        //各種コンポーネントを取得
        character = GetComponent<CharacterController>();
        xrOrigin = GetComponent<XROrigin>();
    }

    // Update is called once per frame
    void Update()
    {
        UpdateInputDevices();
        ChangeMovementMode();
        CapsuleFollowHeadset();

        if (enableVehicle)
        {
            MoveVehicle();
        }

        ApplyGravity();
    }

    //入力デバイスの更新
    private void UpdateInputDevices()
    {
        if (!leftController.isValid)
        {
            var leftHandDevices = new List<InputDevice>();
            InputDevices.GetDevicesAtXRNode(XRNode.LeftHand, leftHandDevices);
            if (leftHandDevices.Count > 0)
            {
                leftController = leftHandDevices[0];
            }
        }

        if (xrOrigin.Camera != null)
        {
            hmdPosition = xrOrigin.Camera.transform.localPosition;
            hmdRotationQuaternion = xrOrigin.Camera.transform.localRotation;
        }
    }

    //移動システムオンオフ切り替え，移動システムのモード切り替え
    private void ChangeMovementMode()
    {
        if (leftController.TryGetFeatureValue(CommonUsages.primaryButton, out bool buttonStateX))
        {
            if (!preButtonStateX && buttonStateX)
            {
                //on，off切り替え
                enableVehicle = !enableVehicle;
                ResetCalibration();
            }
            preButtonStateX = buttonStateX;
        }

        if (leftController.TryGetFeatureValue(CommonUsages.secondaryButton, out bool currentButtonStateY))
        {
            if (!buttonStateY && currentButtonStateY)
            {
                //モード切り替え
                isHover = !isHover;
            }
            buttonStateY = currentButtonStateY;
        }
    }

    //キャリブレーションのリセット
    private void ResetCalibration()
    {
        //左右方向の原点リセット用
        offsetX = hmdPosition.x;
        //前後方向の原点リセット用
        offsetZ = hmdPosition.z;
        //回転の原点リセット用
        //現実での頭の回転
        realRotationY = hmdRotationQuaternion.eulerAngles.y;
    }

    //移動処理
    private void MoveVehicle()
    {
        Vector3 hmdOffset = Quaternion.Euler(0, -realRotationY, 0) * (hmdPosition - new Vector3(offsetX, 0, offsetZ));
        Vector3 direction = Vector3.zero;
        float rotationYaw = 0f;

        //前後移動（並進）
        if (Mathf.Abs(hmdOffset.z) > sagittalDeadzone)
        {
            direction.z = Mathf.Sign(hmdOffset.z) * Mathf.Min(Mathf.Abs(hmdOffset.z) - sagittalDeadzone, sagittalSpeed) * sagittalSpeed;
        }

        //左右移動（前へ移動時は回転，後ろへ移動時は並進）
        if (Mathf.Abs(hmdOffset.x) > lateralDeadzone)
        {
            if (isHover || hmdOffset.z < -sagittalDeadzone * 2.0f)
            {
                direction.x = Mathf.Sign(hmdOffset.x) * Mathf.Min(Mathf.Abs(hmdOffset.x) - lateralDeadzone, lateralSpeed) * lateralSpeed;
            }
            else
            {
                rotationYaw = Mathf.Sign(hmdOffset.x) * Mathf.Min(Mathf.Abs(hmdOffset.x) - lateralDeadzone, rotationSpeed) * rotationSpeed;
            }
        }

        //VR内での頭の正面方向（カメラの向いている方向）を基点に進むよう変換
        headYaw = Quaternion.Euler(0, xrOrigin.Camera.transform.eulerAngles.y, 0);
        direction = headYaw * direction;

        //回転・並進
        transform.RotateAround(transform.position, Vector3.up, rotationYaw * Time.deltaTime);
        character.Move(direction * Time.deltaTime);
    }

    //カメラにアバター（カプセル型）を追従
    private void CapsuleFollowHeadset()
    {
        //アバターの高さ
        character.height = xrOrigin.CameraInOriginSpaceHeight + additionalHeight;
        //カメラ座標
        Vector3 capsuleCenter = transform.InverseTransformPoint(xrOrigin.Camera.transform.position);
        //アバターの中心座標
        character.center = new Vector3(capsuleCenter.x, character.height / 2 + character.skinWidth, capsuleCenter.z);
    }

    //重力の適用
    private void ApplyGravity()
    {
        //接地していない時は落下
        bool isGrounded = CheckIfGrounded();
        if (isGrounded)
            fallingSpeed = 0;
        else
            fallingSpeed += gravity * Time.deltaTime;
        character.Move(Vector3.up * fallingSpeed * Time.deltaTime);
    }

    //アバターが接地しているかを判定．
    bool CheckIfGrounded()
    {
        Vector3 rayStart = transform.TransformPoint(character.center);
        float rayLength = character.center.y + 0.01f;
        return Physics.SphereCast(rayStart, character.radius, Vector3.down, out RaycastHit hitInfo, rayLength, groundLayer);
    }
}
```

# 動作結果
offからonへの切替時にキャリブレーションとして現実の姿勢を真っ直ぐにする必要があります．それだけ気をつければ体を傾けた方へ直感的に動くことができると思います．off状態の時に自分の足で動き回ったあとも，on状態に戻せばキャリブレーションが自動で行われ移動システムを再開できるようになっています．上記のスクリプトには書いていませんが，VR内でもHMD変位入力の基準点がわかるよう，基準となる位置になにかオブジェクトが見えるようにしてもいいかもしれません．また，移動モードの状態などもディスプレイに表示しておくと親切だと思います．

↓下は二次元機動バージョンの方の移動デモ動画です（赤い線はコントローラから伸びてるだけなので気にしないでください）
<iframe width="560" height="315" src="https://www.youtube.com/embed/d0pyo8cZyAY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
<br>

今回のデモ動画は2つとも立って撮影しましたが，座っていても同じように動くことができます．座ってやった方が体が安定するためはじめは座るほうが安全かもしれません．<br>

## 最後に
かなり簡単な構造で実用に耐える程度のシステムをつくることができました．もし興味があったら是非使ってみてください．<br>
>※読み飛ばすとこ3
初めはWiiのバランスボードで重心を検出してVR空間を動けるようにしようと考えていました．調べてみると[それ](https://machinemaker.hatenablog.com/entry/2016/12/11/221720)自体はもう作っている人がいて，似たようなものを作るつもりでした．ただ，Wiiのバランスボードの入手手段が今やフリマしかないことや，有料アセットが必要になりそうなことなどの懸念がありました．また，直立が難しいこと，センサの精度，Wiiバランスボードの上から動けない，などの問題もありました．期間も限られていて，同じ要求機能を満たす設計解ならできるだけ簡単にしようと思い，HMDだけで完結するものを作りました．これ以外にも，PLATEAUの地形データで実際にある世界を飛び回ろうかとも思ったのですが，開発にMacを使っていたため軽量化をする手段が限られてしまい，断念しました．UnityのPreviewもMacだとできず，いちいちビルドして実行する必要があり，かなり時間を食ってしまいました．VR開発に限らず，まともなWindowsPCの一つや二つ持っておいた方がいい気がしました．