VR空間で球面上を自由に歩き回りたい（Unity）

#はじめに
（この記事は[UT-virtual Advent Calendar 2021](https://qiita.com/advent-calendar/2021/ut-virtual)に参加しています。）

昨年末から人生初のゲーム制作を始め、「オープンワールド化された地球の球面上を歩き回る」ゲームを友人と開発していました。

その際に球面上を自由に歩き回る仕組みの実装で行き詰まり、日本語でわかりやすく説明した記事も見つからなかったので実際に書いたコードをまとめておきます。

Unity初心者&Qiita初記事なので色々変な箇所があるかと思いますが悪しからず。

###使用環境

- Oculus Quest/Quest2
- Unity 2020.3.7f1
- Oculus Integration 25.0
- Oculus XR Plugin 1.9.1
- XR Plugin Management 4.0.6

#実装に必要な要素
大きく分けると以下の2つになります。

- 重力（球面上のどこでも中心方向に力が働き、常に地面と垂直に立っていられるようにするもの）
- 球面上での滑らかな移動

###①重力
重力を発生させるもの（地球など）にGravityAttractorを、重力の影響を受けるもの（プレイヤー、建造物など）にGravityBodyをアタッチします。

```C#:GravityAttractor
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GravityAttractor : MonoBehaviour
{
    //プレイヤーにかかる重力
    float playergravity = -10f;
    //プレイヤー以外にかかる重力
    float gravity = -10f;

    public void Attract(Transform body, GameObject gameObject)
    {
        Vector3 targetDir = (body.position - transform.position).normalized;
        Vector3 bodyUp = body.up;

        body.rotation = Quaternion.FromToRotation(bodyUp, targetDir) * body.rotation;
        if(gameObject.tag == "Player")
        {
            gameObject.GetComponent<Rigidbody>().AddForce(targetDir * playergravity);
        }
        else
        {
            gameObject.GetComponent<Rigidbody>().AddForce(targetDir * gravity);
        }
    }
}
```

上の例ではプレイヤーとそれ以外の物体で働く重力を変えられるようになっています。

```C#:GravityBody
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent (typeof (Rigidbody))]
public class GravityBody : MonoBehaviour
{
    GravityAttractor planet;

    void Awake()
    {
        planet = GameObject.FindGameObjectWithTag("Planet").GetComponent<GravityAttractor>();
        GetComponent<Rigidbody>().useGravity = false; //Rigidbody内にあるGravityは使わないので無効にする
        GetComponent<Rigidbody>().constraints = RigidbodyConstraints.FreezeRotation;
    }
    void FixedUpdate() 
    {
        planet.Attract(this.gameObject.transform, this.gameObject);
    }
}
```

###②球面上での滑らかな移動
プレイヤーの移動を制御するスクリプトを作り、以下のコードを加えてプレイヤーにアタッチします。

```C#:PlayerController
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour {
 
    Rigidbody rigid;
 
    float cameraSensitivityX; //カメラの回転速度
    float walkSpeed; //歩行速度
    private Vector3 moveAmount;
    private Vector3 smoothMoveVelocity;
 
    void Start () {
        rigid = this.GetComponent<Rigidbody>();
        cameraSensitivityX = 150f;
        walkSpeed = 3f;
    }
 
    void Update () {
        //左スティックでカメラの操作
        this.transform.Rotate(Vector3.up * OVRInput.Get(OVRInput.RawAxis2D.LThumbstick).x * Time.deltaTime * cameraSensitivityX);

        //右スティックで移動
        Vector3 moveDir = new Vector3(OVRInput.Get(OVRInput.RawAxis2D.RThumbstick).x, 0, OVRInput.Get(OVRInput.RawAxis2D.RThumbstick).y).normalized;
        Vector3 targetMoveAmount = moveDir * walkSpeed;
        moveAmount = Vector3.SmoothDamp(moveAmount, targetMoveAmount, ref smoothMoveVelocity, .15f);
    }
 
    void FixedUpdate() {
        GetComponent<Rigidbody>().MovePosition(GetComponent<Rigidbody>().position + transform.TransformDirection(moveAmount) * Time.fixedDeltaTime);
    }
}
```

###カメラの改善
上のコードではUnityシーン上のプレイヤー（GameObject）の向きと現実のHMDの向きが同期していません。よってこのままだと、左スティックを使わずに現実の自分が体の向きを変えた際に、右スティックの入力方向とプレイヤーの動く向きが一致しなくなります。体を反転させて進行方向を180度変えたらスティックを前に倒してもプレイヤーは後ろに移動してしまう、といった問題が発生してしまうわけです。

これでは体験者にとって非常に不便なので、HMDの向きを取得してGameObjectのRotationに反映させるようにします。

```C#:PlayerController
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour {
 
    Rigidbody rigid;
 
    float cameraSensitivityX; //カメラの回転速度
    float walkSpeed; //歩行速度
    private Vector3 moveAmount;
    private Vector3 smoothMoveVelocity;
 
    void Start () {
        rigid = this.GetComponent<Rigidbody>();
        cameraSensitivityX = 150f;
        walkSpeed = 3f;
    }
 
    void Update () {
        //左スティックでカメラの操作
        this.transform.Rotate(Vector3.up * OVRInput.Get(OVRInput.RawAxis2D.LThumbstick).x * Time.deltaTime * cameraSensitivityX);

        //HMDのY軸の角度を取得
            InputTracking.GetNodeStates(nodeStates);

            foreach(var node in nodeStates)
            {
                if(node.nodeType == XRNode.Head)
                {
                    headState = node;
                    break;
                }
            }
            headState.TryGetRotation(out headRotation);

            Vector3 changeRotation = new Vector3(0, headRotation.eulerAngles.y, 0);

        //右スティックで移動
            Vector3 moveDir = new Vector3(OVRInput.Get(OVRInput.RawAxis2D.RThumbstick).x, 0, OVRInput.Get(OVRInput.RawAxis2D.RThumbstick).y).normalized;
            Vector3 targetMoveAmount =  Quaternion.Euler(changeRotation) * moveDir * walkSpeed;
            moveAmount = Vector3.SmoothDamp(moveAmount, targetMoveAmount, ref smoothMoveVelocity, .15f);
}
```
これで現実の自分がいくら回転しても、体が向いている方向にスティックを倒せばまっすぐ進むことができるようになります。

#おわりに
Unityで作られる3Dゲームの多くは平地が舞台なので、球面上の移動に実装に関しては先行事例が非常に少なく苦労しました。おまけに球面となると数学の力も必要になるのでまあ大変です。
それでも一度完成させてしまえば、「球面上を自由に歩き回れる」という要素それだけでVR体験として楽しめるものになりました。
球面にどうやってオブジェクトを配置するのかなど難しい点は他にも色々あるのですが、球面オープンワールドは普通のVRゲームでは味わえない面白さが詰まっていそうです。

#参考文献
コードはここから引用したものを一部改変しています。

https://www.youtube.com/watch?v=TicipSVT-T8