「Grapnel Gun Playground」というVRChatのワールドを作った話

(追記)配布しました

torisoup.booth.pm

はじめに

めちゃくちゃ久しぶりの投稿になります。 最近はVRChatでよく遊んでいるのですが、そこにワールドを1つアップロードしたのでそれの解説をします。

どのワールドか

「Grapnel Gun Playground」というワールドです。 f:id:torisoup:20200508205601p:plain

グラップネルガン(フックショット)を使って飛び回って遊ぼうというワールドです。

完成までの作業時間としては30時間弱くらいです。

元ネタ

ワイヤーアクション、グラップネルガン、フックショット、立体機動装 置、人によって呼び方がバラバラです。 自分は「グラップネルガン」と呼んでいますが、これは元ネタとしてバットマンを使っているからです。

自分は「バットマンアーカムシリーズ」が好きなので、そのグラップネルガンの挙動に似せて作っています。 特に、「バットマンアーカム・シティ」以降の作品においては「グラップネル・ブースト」というシステムが追加されています。 (グラップネルガンで移動中に加速して、そのまま建物を飛び越して飛び出すことができる)


Batman Arkham City: How to use Grapnel Boost (PC tutorial)

このシステムが好きなので、これも再現しようと取り込んでみました。

こだわり

次の点にかなりこだわって作ってあります。

  • 操作性
  • 汎用性

「操作性」は、とにかく気持ちよく操作したいという欲求から来ています。 巷にグラップネルガンを使ったVRコンテンツはたくさんあるのですが、酔いやすかったり、思ったとおりに動けなかったりして結構なストレスが溜まります。 この点はかなり調整して作ったため、ぜひ触ってみて欲しいです。

「汎用性」は、グラップネルガンをワールドに依存させないということです。 後からグラップネルガンのアセット一式(スクリプト込み)で配布できるようにしたかったからです。 実際、グラップネルガンは疎結合に作ってあるのでそのままワールドに配置すればどんな場所でも動くようになっています。

仕組み

次のものを使って作られています。

  • VRCSDK3
  • Udon
  • UdonSharp

VRChatのワールド作成の仕組み

VRChatでは、ユーザが作成したコンテンツ(アバターやワールド)はすべてAsset Bundleの形式でアップロードされることになります。 つまりAsset Bundleで使えるものがVRChat上で使えるものということになります。

Udon

Udonとは、VRChatが開発している、VRChat用のプログラミング言語です。 ノードベースでビジュアルスクリプティングすることができ、作成したノードをコンパイルしてUdon VM上で実行するという仕組みになっています。

Asset Bundleは仕組み上、C#スクリプトを含めることができません。 そのためUnity標準のスクリプト機構を使わずに、独自のプログラム実行環境をVRChat側で用意したということになります。

UdonSharp

UdonSharpは、このUdonから生成されるILC#から生成してしまうというヤバイツールです。 深く考えず、普段どおりのUnity C#を書けば、ほぼそのままUdon上でスクリプトが動きます。 SerializeFieldも使えます。ヤバイ。

つまりどういうことか

Unityで動くC#の上で動くVRChatの上で動くUdonの上で動くUdonSharpを使って自由にスクリプトが書けるようになったということです。

素晴らしく便利なんですが、恐ろしくハードルは高いです。

これらのことをしっかり把握した上で書かないといけません。 特にUdonでは一部のUnity APIが使えないので、そうなったときに代替策を考えられるだけの引き出しの多さが必須になってきます。

普段からUnityでC#書いている人なら問題ないでしょうが、UdonSharpをきっかけにC#を勉強し始めるのは茨の道でしょう。

グラップネルガンのこだわり解説

閑話休題。 グラップネルガンを作る上でどのような点を工夫したかを紹介します。

1.狙いやすさ

「狙いやすさ」とは、「狙った場所にちゃんとアンカーを撃てるか」です。 特に遠くの対象を狙うとなったときに、ちゃんと対象に銃口を向けないと狙えないのは結構なストレスです。 ということで簡易的な「エイムアシスト」的な挙動を追加しました。

といっても、やってることはすごく単純でRaycastではなくSphereCastを使っているだけです。 SphereCastは、「球体の当たり判定」を飛ばして対象にぶつかったかをチェックする機能です。

SphereCastでかなり太めの判定を飛ばせば、対象が小さくても「だいたい狙えば当たる」という挙動を作ることができます。 つまり実質的なエイムアシスト。

また、小さいものを狙いやすくなるだけではなく、建物の縁(エッジ)部分も狙いやすくなります。 建物の上に登るためには縁を狙わないといけないので、このエイムアシストがかなり便利に使えます。

2.登りやすさ

グラップネルガンで移動ですが、そのまま作ると「まっすぐアンカーに向かって直進して終わり」ということになります。 そうすると「建物の上に飛び上がって登る」ということができず、アンカーに到達した時点で落下してしまいます。

さすがにこれは面白くないので、「アンカーに到達すると前に少し飛び上がる」という挙動を入れています。

// グラップネルガンで移動中の処理の一部
if (targetDirection.magnitude < 1.0f) // 対象の1m以内に近づいたら
{
    // 進行方向に上向きの補正を加えた上で、プレイヤーの速度に上書き
    _player.SetVelocity((startDirection.normalized + Vector3.up).normalized * 5.0f);
    return;
}

この動作は「バットマンアーカム・シティ」の「グラップネルブースト」という仕組みをそのまま模倣しただけです。 (もともと「グライダー(後述)」とセットで遊ぶためにグラップネルガンを作っていたのでこの仕組を入れた)

3.酔いにくさ

個人差はありますが、VR酔いは「視界が突然回転する」「自分の意図した動きと違う動きをする」という場合に起きやすいです。

このグラップネルガンは基本的には「等速直線運動」となるように作ってあるのでこの酔いにくくなったいます。 ただ愚直に等速直線運動だと味気が無いので、アンカーを撃った最初の数秒間は加速フェーズにとして徐々に加速するようにしてます。

4.操作のしやすさ

「できるだけ直感的に操作したい」という欲求から、こだわって次のような挙動に調整しました。

  • トリガー引きっぱなしで自動的にアンカーを発射する

    • 「狙いをつけてからトリガー引く」ではなく、「トリガーを引いたまま対象に銃口を雑に向ければ勝手に発射してくれる」という動きにしたかったため
    • 建物の縁を狙う時は、先にトリガーを引きっぱなしにしてから銃口を合わせると簡単に狙えます
  • 移動中に再度トリガーを引くと加速する

    • 慣れてきたら高速移動したいよね
  • 銃口を向けた方向にベクトル変更できる

    • アンカーに向かって直線的に飛ぶだけだとつまらないので、銃の向きで若干の方向調整ができるようにしました
    • 最終的にかならずアンカーに収束するような軌跡となります
  • 移動中に銃口を反対に向けると移動キャンセル

    • 間違えた場所にアンカーを撃った時に、キャンセルする仕組みが欲しかったのでいれました
    • ワイヤーを切るイメージです

こういう細かい調整が、小気味よく使えるグラップネルガンになったのかなと。

グライダーの解説

「Grapnel Gun Playground」には実は「グライダー」という別のギミックが置いてあります。

空を滑空するためのギミックであり、持っている間は滑空することができます。 挙動としては次のように動きます。

  • 落下すると加速する
  • 上昇すると減速する
  • 水平移動では速さは変化しない
  • 落下の開始地点よりは上に登れない(滑空なので)

仕組み

グライダーの仕組みですが、実はかなりガバガバな計算で作られています。

この動きを「揚力」の式を使って実現しようとするとめちゃくちゃ大変です。 なので今回は「力学的エネルギー保存の法則」だけを使って実装しました。

\displaystyle{
mg\Delta h = \frac{1}{2}mv^2
}

(落下した分の位置エネルギー = 運動エネルギー)

この式を変形して、速度の項について解くとこうなります。

\displaystyle{
v = \sqrt{2g\Delta h}
}

つまり開始地点より⊿h[m]落下したときの速度がこの式より導出可能になります。 この式をそのまま用いてプレイヤーの速度を上書きすることで、グライダーによる滑空っぽい挙動を実現しています。

using UnityEngine;

// すごく単純なグライダーのサンプル実装
public class Glider : MonoBehaviour
{
    private Rigidbody rigidBody;
    private float glideStartedY;

    void Start()
    {
        rigidBody = GetComponent<Rigidbody>();
        glideStartedY = transform.position.y;
    }

    private void Update()
    {
        if (Input.GetKey(KeyCode.W))
        {
            transform.rotation =
                Quaternion.AngleAxis(60.0f * Time.deltaTime, transform.right)
                * transform.rotation;
        }
        else if (Input.GetKey(KeyCode.S))
        {
            transform.rotation =
                Quaternion.AngleAxis(-60.0f * Time.deltaTime, transform.right)
                * transform.rotation;
        }
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        var deltaY = glideStartedY - transform.position.y;

        // 力学的エネルギー保存則より、落下分を移動速度に変換
        rigidBody.velocity =
            transform.forward.normalized
            * Mathf.Sqrt(2 * Physics.gravity.magnitude * deltaY);
    }
}

これくらい単純なスクリプトでグライダーっぽい挙動は作れてしまいます。

グラップネルガンと協調する

  • グラップネルガンで移動中はグライダーを無効化する
  • グラップネルガンの移動がキャンセルされたら落下判定を行う

こういう処理を追加して、グライダーとグラップネルガンを協調して利用できるようにしています。

「プレイヤー」をグラップネルガンのターゲットとする

「Grapnel Gun Playground」では、他のプレイヤーに対してアンカーを撃ってグラップネルガンで移動することができます。 単純そうに見えて、ちょっと面倒くさいことをしています。

アンカーの追従

撃ち込んだアンカーを移動する対象にくっつけて追従させる方法です。

一番簡単なのは、アンカーとなるGameObjectを用意してそれを追尾対象のTransformの子に入れてしまう方法です。 実装としては一番簡単ですが、対象がDestroyされた時にアンカーも一緒にDestroyされてしまうという欠点があります。 このような挙動は予期せぬバグを生み出しかねないので、別方法を使います。

それは、愚直に「ベクトル演算で相手に追従する」というやり方です。 やり方としてはそんな難しくなく、アンカーと相手との相対位置を維持するようなスクリプトを書くだけです。

次のスクリプトはその例です。

using UnityEngine;

public class AttachSample : MonoBehaviour
{
    /// <summary>
    /// 追従する対象
    /// </summary>
    private Transform _target;

    /// <summary>
    /// 相対位置
    /// </summary>
    private Vector3 _deltaPosition;

    /// <summary>
    /// 相対角度
    /// </summary>
    private Quaternion _deltaRotation;

    private void Update()
    {
        if (_target != null)
        {
            // 相対ベクトルにぶつかったときの角度の逆を反映してから、
            // 現在の角度を反映させる
            transform.position = _target.position + _target.rotation * _deltaRotation * _deltaPosition;
        }
    }

    /// <summary>
    /// 衝突したらくっつく
    /// </summary>
    /// <param name="other"></param>
    private void OnCollisionEnter(Collision other)
    {
        _target = other.transform;

        // 衝突したワールド座標
        var cp = other.contacts[0];

        // 対象の中心部から衝突地点への相対ベクトル
        _deltaPosition = cp.point - _target.position;

        // 対象の現在角度の「逆」
        _deltaRotation = Quaternion.Inverse(_target.rotation);
    }
}

PlayerのTransform取れないじゃん問題

これはUdonの仕様なのですが、プレイヤーのGameObjectおよびTransformは取得ができません。 Physics.SphereCast等でプレイヤーとの衝突自体は取得できるのですが、RaycastHit.transformnullが返ってきてしまいます。 そのため相手のプレイヤーを直接追従させることはできず、代替策を考える必要があります。

代替策:プレイヤーを追従するColliderを別に用意する

かなり脳筋な解決策なのですが、「プレイヤーを追従するColliderを作ってそっちのTransformに追従させる」という手法をとりました。

using UdonSharp;
using VRC.SDKBase;

namespace GrapnelGunPlayground
{
    // Playerを追従するコライダーのマネージャ
    public class PlayerColliderManager : UdonSharpBehaviour
    {
        // プレイヤーを追従するコライダーたち
        private PlayerFollowCollider[] _colliders;
        private int _current = 0;


        public void Register(PlayerFollowCollider c)
        {
            // 遅延初期化で32個分用意
            if (_colliders == null) _colliders = new PlayerFollowCollider[32];
            _colliders[_current++] = c;
        }

        public override void OnPlayerJoined(VRCPlayerApi player)
        {
            if (_colliders == null) _colliders = new PlayerFollowCollider[32];

            if (player.isLocal) return;

            // 他のプレイヤーがワールドに参加したら
            // 未使用のPlayerFollowColliderを割り当てる
            foreach (var c in _colliders)
            {
                if (c == null) continue;
                if (c.IsUsing()) continue;
                c.Set(player);
                break;
            }
        }
    }
}
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;

namespace GrapnelGunPlayground
{
    // プレイヤーを追従するスクリプト
    public class PlayerFollowCollider : UdonSharpBehaviour
    {
        private VRCPlayerApi _vrcPlayerApi;
        private Transform _anchor;
        [SerializeField] private Collider _collider;
        [SerializeField] private PlayerColliderManager _manager;
        private int myId;


        public bool IsUsing()
        {
            return _vrcPlayerApi != null;
        }

        private void Start()
        {
            // 初期化時にManagerに自身を登録する
            _manager.Register(this);
            transform.position = -Vector3.up * 100;
            _collider.enabled = false;
        }

        public void Set(VRCPlayerApi player)
        {
            _vrcPlayerApi = player;
            myId = player.playerId;
        }

        private void Update()
        {
            if (_vrcPlayerApi != null && Networking.LocalPlayer != null)
            {
                var myPosition = _vrcPlayerApi.GetPosition();
                transform.position = myPosition;

                var d = Vector3.Distance(Networking.LocalPlayer.GetPosition(), myPosition);
                if (d < 3.0f)
                {
                    _collider.enabled = false;
                }
                else
                {
                    _collider.enabled = true;
                }
            }
        }

        public override void OnPlayerLeft(VRCPlayerApi player)
        {
            if (_vrcPlayerApi == null) return;
            if (player.playerId == myId)
            {
                _vrcPlayerApi = null;
                transform.position = Vector3.up * -100;
                _collider.enabled = false;
                myId = -1;
            }
        }
    }
}

UdonSharpは「List<T>が使えない」「配列の初期化は1回しかできない」「GetComponentに制限がある」などの制約があります。 これらの制約を回避するためにあらかじめ各プレイヤー分のオブジェクトを作成しておいて、起動時に登録するという手法をとっています。

f:id:torisoup:20200508204656p:plain
あらかじめシーンにおかれたCollider
(32個分用意されたColliderたち。脳筋実装。)

また、これらColliderLayerはプレイヤーと干渉させないために、VRChatで定義されている空いている適当なLayerを使うことにしました。 今回はとりあえずStereoRightというLayerを使いましたが、特に問題もなく動作しました。

f:id:torisoup:20200508204708p:plain
Playerと不干渉な未使用っぽいレイヤーを使う
f:id:torisoup:20200508204706p:plain
Collider側のレイヤをStereoRightに
f:id:torisoup:20200508204659p:plain
グラップネルガン側のLayerMask

最後に

「Grapnel Gun Playground」は細かい操作感にこだわって作ったので、ぜひ一度遊んでみてほしいです。