UnityとQuest2でVR空間を移動する手振り歩行の実装

# モチベーション
VRアプリを開発する際、バーチャル空間をどのように移動するかは悩みどころの一つだと思います。既存のアプリで多い実装は、瞬時に指定位置に移動する「テレポーテーション」や、従来の2Dゲームを踏襲した「ジョイスティック移動」、それに加えて移動の際に視野を絞って視界の変化を減らす対策などが採られることが多いと思います。VR空間を移動する際にVR酔いが起きてしまうのは、現実世界での体の動きとVR空間での視界の動きに乖離が生まれることが主な理由で、これを防ぐためにはなるべく大きいフィジカルな動作を採用した方が良さそうです。そこで今回は、前腕（ぜんわん）を上下に運動させる通称「手振り歩行」を実装してみました。

# 目的
Unity と Oculus Quest 2 を用いて、VR空間で「手振り歩行」を実装すること。

# 実行環境
- Unity 2019.4.14f1
- Oculus Integration for Unity - v25
- Mac OSX 11.2.3

# 実装
前提として、[Oculus Integration](https://developer.oculus.com/downloads/package/unity-integration/) をインストールします。パッケージに含まれる"PlayerController.cs"の処理に倣いつつ、同クラスに登場する"PreCharacterMove()"を新しく定義する形で処理を記述しました。

```C#
using System;
using System.Collections;
using UnityEngine;

public class PlayerMotion : MonoBehaviour
{
    [SerializeField] private GameObject OVRPlayerControllerGameObject = null;
    [SerializeField] private Transform LeftHandAnchorTransform = null;
    [SerializeField] private Transform RightHandAnchorTransform = null;
    private OVRPlayerController OVRPlayerControllerComponent;

    // identical to fields of OVRPlayerController class
    private CharacterController Controller;
    private Vector3 MoveThrottle = Vector3.zero;
    private float MoveScale = 1.0f;
    private float MoveScaleMultiplier = 1.0f;
    private float SimulationRate = 60f;
    private float FallSpeed = 0.0f;
    private float Acceleration;
    private float Damping;
    private float GravityModifier;
    private float JumpForce;

    // original fields for this script
    private Vector3 touchVelocityL;
    private Vector3 touchVelocityR;
    private Vector3 touchAccelerationL;
    private Vector3 touchAccelerationR;
    private bool motionInertia = false;
    private float motionInertiaDuration = 1.0f;

    const float WALK_THRESHOLD = 0.8f;
    const float RUN_THRESHOLD = 1.3f;
    const float JUMP_THRESHOLD = 1.5f;


    private void Awake()
    {
        Controller
            = OVRPlayerControllerGameObject.GetComponent<CharacterController>();
        OVRPlayerControllerComponent
            = Controller.GetComponent<OVRPlayerController>();
    }


    private void Start()
    {
        // store public fields of OVRPlayerController-class to local private fileds
        Acceleration = OVRPlayerControllerComponent.Acceleration;
        Damping = OVRPlayerControllerComponent.Damping;
        GravityModifier = OVRPlayerControllerComponent.GravityModifier;
        JumpForce = OVRPlayerControllerComponent.JumpForce;

        // pre-setting for overriding character-control
        OVRPlayerControllerComponent.PreCharacterMove
            += () => CharacterMoveByHandShake();
        OVRPlayerControllerComponent.EnableLinearMovement = false;

        // necessary for initial grounded-evaluation
        Controller.Move(Vector3.zero * Time.deltaTime);
    }


    private void Update() { }


    private void CharacterMoveByHandShake()
    {
        HandShakeControler();
        UpdateController();

        // display for development purpose
        Debug.Log("L-touch velocity: " + touchVelocityL);
        Debug.Log("R-touch velocity: " + touchVelocityR);
    }


    private void HandShakeControler()
    {
        touchVelocityL
            = OVRInput.GetLocalControllerVelocity(OVRInput.Controller.LTouch);
        touchVelocityR
            = OVRInput.GetLocalControllerVelocity(OVRInput.Controller.RTouch);
        touchAccelerationL
            = OVRInput.GetLocalControllerAcceleration(OVRInput.Controller.LTouch);
        touchAccelerationR
            = OVRInput.GetLocalControllerAcceleration(OVRInput.Controller.RTouch);

        if (!IsGrounded()) MoveScale = 0.0f;
        else MoveScale = 1.0f;

        MoveScale *= SimulationRate * Time.deltaTime;

        float moveInfluence
            = Acceleration * 0.1f * MoveScale * MoveScaleMultiplier;

        Transform activeHand;
        Vector3 handShakeVel;
        Vector3 handShakeAcc;

        if (Math.Abs(touchVelocityL.y) > Math.Abs(touchVelocityR.y))
        {
            activeHand = LeftHandAnchorTransform;
            handShakeVel = touchVelocityL;
            handShakeAcc = touchAccelerationL;
        }
        else
        {
            activeHand = RightHandAnchorTransform;
            handShakeVel = touchVelocityR;
            handShakeAcc = touchAccelerationR;
        }

        Quaternion ort = activeHand.rotation;
        Vector3 ortEuler = ort.eulerAngles;
        ortEuler.z = ortEuler.x = 0f;
        ort = Quaternion.Euler(ortEuler);

        MoveThrottle += CalculateMoveEffect(moveInfluence, ort, handShakeVel, handShakeAcc);
    }


    private Vector3 CalculateMoveEffect(float moveInfluence,
        Quaternion ort, Vector3 handShakeVel, Vector3 handShakeAcc)
    {
        Vector3 tmpMoveThrottle = Vector3.zero;

        bool isWalk = DetectHandShakeWalk(Math.Abs(handShakeVel.y)) || motionInertia;
        if (isWalk)
        {
            if (!motionInertia)
                SetMotionInertia();
            tmpMoveThrottle += ort
                * (OVRPlayerControllerGameObject.transform.lossyScale.z
                * moveInfluence * Vector3.forward) * 0.2f;

            bool isRun = DetectHandShakeRun(Math.Abs(handShakeVel.y));
            if (isRun)
                tmpMoveThrottle *= 2.0f;
        }

        bool isJump = DetectHandShakeJump();
        if (isJump)
            tmpMoveThrottle += new Vector3(0.0f, JumpForce, 0.0f);

        return tmpMoveThrottle;
    }


    IEnumerator SetMotionInertia()
    {
        motionInertia = true;
        yield return new WaitForSecondsRealtime(motionInertiaDuration);
        motionInertia = false;
    }


    private bool DetectHandShakeWalk(float speed)
    {
        if (!IsGrounded()) return false;
        if (speed > WALK_THRESHOLD) return true;
        return false;
    }


    private bool DetectHandShakeRun(float speed)
    {
        if (!IsGrounded()) return false;
        if (speed > RUN_THRESHOLD) return true;
        return false;
    }


    private bool DetectHandShakeJump()
    {
        if (!IsGrounded())
            return false;
        if (touchVelocityL.y > JUMP_THRESHOLD && touchVelocityR.y > JUMP_THRESHOLD)
            return true;
        return false;
    }


    private bool IsGrounded()
    {
        if (Controller.isGrounded) return true;

        var pos = OVRPlayerControllerGameObject.transform.position;
        var ray = new Ray(pos + Vector3.up * 0.1f, Vector3.down);
        var tolerance = 0.3f;
        return Physics.Raycast(ray, tolerance);
    }


    private void UpdateController()
    {
        Vector3 moveDirection = Vector3.zero;

        float motorDamp = 1.0f + (Damping * SimulationRate * Time.deltaTime);

        MoveThrottle.x /= motorDamp;
        MoveThrottle.y = (MoveThrottle.y > 0.0f) ?
            (MoveThrottle.y / motorDamp) : MoveThrottle.y;
        MoveThrottle.z /= motorDamp;

        moveDirection += MoveThrottle * SimulationRate * Time.deltaTime;

        // calculate gravity influence
        if (Controller.isGrounded && FallSpeed <= 0)
            FallSpeed = Physics.gravity.y * (GravityModifier * 0.002f);
        else
            FallSpeed += Physics.gravity.y
                * (GravityModifier * 0.002f) * SimulationRate * Time.deltaTime;

        moveDirection.y += FallSpeed * SimulationRate * Time.deltaTime;

        if (Controller.isGrounded && MoveThrottle.y
            <= OVRPlayerControllerGameObject.transform.lossyScale.y * 0.001f)
        {
            // offset correction for uneven ground
            float bumpUpOffset
                = Mathf.Max(
                    Controller.stepOffset,
                    new Vector3(moveDirection.x, 0, moveDirection.z).magnitude);
            moveDirection -= bumpUpOffset * Vector3.up;
        }

        Vector3 predictedXZ
            = Vector3.Scale(
                Controller.transform.localPosition + moveDirection,
                new Vector3(1, 0, 1));

        // update character position
        Controller.Move(moveDirection);

        Vector3 actualXZ
            = Vector3.Scale(
                Controller.transform.localPosition,
                new Vector3(1, 0, 1));

        if (predictedXZ != actualXZ)
            MoveThrottle += (actualXZ - predictedXZ)
                / (SimulationRate * Time.deltaTime);
    }


}
```
上のコードをスクリプトに貼り付け、シーン中のGameObjectにアタッチすると動作しました。ベストな実装かはわかりませんが、意図した動作は実現できました。
実装のポイントは、「HMDの正面の方向に移動」ではなく、「速度の大きい方のコントローラーの向いている方向」へ移動する点です。この点は他の「VR, arm swing, walk in place (WIP)」などで検索して出てくる実装と異なる点かな、と思っています。両手を同時に振り上げると「ジャンプ！」をする処理なども入れています。

# 結果
実行した際のキャプチャを撮影しました：
![handshakeWalk.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/223271/5739f9ef-1961-da41-8975-ea3a1dd34771.gif)
インスペクタからAccelerationやDampなどの値を調整することで、それなりにヌルヌルと動くようになりました。Oculus Integration のデフォルトではGravityの値もかなり大きめに設定されているので、この値も0.1程度に下げた方が落下した際に酔いを感じにくくなります。

# 終わりに
実装当初は「コントローラーの速度情報を一次配列に格納して、腕振りの波形データとの類似度を計算して…」などと考えていましたが、フレーム毎の速度情報のみでも、きちんと動きたい方向に動けていることは確認できました。自分しか試していない（N=1）ので実際はわかりませんが、主観的にはそれなりにVR酔いを抑えられていそうです。（私の知る限りでは）Oculus Store などに並ぶアプリでこの移動手法が使われている例を見たことがありませんが、こういうゲームがあってもいいような気がしています。そのうち自分で作ろう！