「Grapnel Gun Playground」というVRChatのワールドを作った話
(追記)配布しました
はじめに
めちゃくちゃ久しぶりの投稿になります。 最近はVRChatでよく遊んでいるのですが、そこにワールドを1つアップロードしたのでそれの解説をします。
どのワールドか
「Grapnel Gun Playground」というワールドです。
グラップネルガン(フックショット)を使って飛び回って遊ぼうというワールドです。
「Grapnel Gun Playground」
— とりすーぷ (@toRisouP) 2020年5月6日
グラップネルガン(フックショット)で飛び回ったり、グライダーで滑空したりできるワールド作ったよ!
操作性はかなり拘ったので、酔わずにサクサク移動できるはず!https://t.co/IgEMlZRIDg#VRChat #MadeWithUdon #tori_world_grapnel pic.twitter.com/DaTughY6vK
完成までの作業時間としては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
から生成されるIL
をC#
から生成してしまうというヤバイツールです。
深く考えず、普段どおりのUnity C#を書けば、ほぼそのままUdon
上でスクリプトが動きます。
SerializeField
も使えます。ヤバイ。
つまりどういうことか
Unity
で動くC#
の上で動くVRChat
の上で動くUdon
の上で動くUdonSharp
を使って自由にスクリプトが書けるようになったということです。
素晴らしく便利なんですが、恐ろしくハードルは高いです。
これらのことをしっかり把握した上で書かないといけません。
特にUdon
では一部のUnity APIが使えないので、そうなったときに代替策を考えられるだけの引き出しの多さが必須になってきます。
普段からUnityでC#
書いている人なら問題ないでしょうが、UdonSharp
をきっかけにC#
を勉強し始めるのは茨の道でしょう。
グラップネルガンのこだわり解説
閑話休題。 グラップネルガンを作る上でどのような点を工夫したかを紹介します。
1.狙いやすさ
「狙いやすさ」とは、「狙った場所にちゃんとアンカーを撃てるか」です。 特に遠くの対象を狙うとなったときに、ちゃんと対象に銃口を向けないと狙えないのは結構なストレスです。 ということで簡易的な「エイムアシスト」的な挙動を追加しました。
といっても、やってることはすごく単純でRaycast
ではなくSphereCast
を使っているだけです。
SphereCast
は、「球体の当たり判定」を飛ばして対象にぶつかったかをチェックする機能です。
SphereCast
でかなり太めの判定を飛ばせば、対象が小さくても「だいたい狙えば当たる」という挙動を作ることができます。
つまり実質的なエイムアシスト。
RaycastとSphereCastの比較。奥がSphereCast。
— とりすーぷ (@toRisouP) 2020年5月8日
判定が太いので「だいたい向きがあってれば当たる」という挙動になる。実質的なエイムアシスト。 pic.twitter.com/WHj9FZKLTG
SphereCastだと「物体の縁(エッジ)」に吸い付くような挙動をするので、グラップネルガンに最適。 pic.twitter.com/a5rX2bMyCH
— とりすーぷ (@toRisouP) 2020年5月8日
また、小さいものを狙いやすくなるだけではなく、建物の縁(エッジ)部分も狙いやすくなります。 建物の上に登るためには縁を狙わないといけないので、このエイムアシストがかなり便利に使えます。
SphereCastによるエイムアシスト効果 pic.twitter.com/xOZDZ4w0fS
— とりすーぷ (@toRisouP) 2020年5月8日
2.登りやすさ
グラップネルガンで移動ですが、そのまま作ると「まっすぐアンカーに向かって直進して終わり」ということになります。 そうすると「建物の上に飛び上がって登る」ということができず、アンカーに到達した時点で落下してしまいます。
さすがにこれは面白くないので、「アンカーに到達すると前に少し飛び上がる」という挙動を入れています。
// グラップネルガンで移動中の処理の一部 if (targetDirection.magnitude < 1.0f) // 対象の1m以内に近づいたら { // 進行方向に上向きの補正を加えた上で、プレイヤーの速度に上書き _player.SetVelocity((startDirection.normalized + Vector3.up).normalized * 5.0f); return; }
アンカーに到達時、上向きの力をかけて建物に乗り上げやすくしている pic.twitter.com/TJkG9rU6Yu
— とりすーぷ (@toRisouP) 2020年5月8日
この動作は「バットマン・アーカム・シティ」の「グラップネルブースト」という仕組みをそのまま模倣しただけです。 (もともと「グライダー(後述)」とセットで遊ぶためにグラップネルガンを作っていたのでこの仕組を入れた)
3.酔いにくさ
個人差はありますが、VR酔いは「視界が突然回転する」「自分の意図した動きと違う動きをする」という場合に起きやすいです。
このグラップネルガンは基本的には「等速直線運動」となるように作ってあるのでこの酔いにくくなったいます。 ただ愚直に等速直線運動だと味気が無いので、アンカーを撃った最初の数秒間は加速フェーズにとして徐々に加速するようにしてます。
4.操作のしやすさ
「できるだけ直感的に操作したい」という欲求から、こだわって次のような挙動に調整しました。
トリガー引きっぱなしで自動的にアンカーを発射する
移動中に再度トリガーを引くと加速する
- 慣れてきたら高速移動したいよね
銃口を向けた方向にベクトル変更できる
- アンカーに向かって直線的に飛ぶだけだとつまらないので、銃の向きで若干の方向調整ができるようにしました
- 最終的にかならずアンカーに収束するような軌跡となります
移動中に銃口を反対に向けると移動キャンセル
- 間違えた場所にアンカーを撃った時に、キャンセルする仕組みが欲しかったのでいれました
- ワイヤーを切るイメージです
「Grapnel Gun Playground」のコツ
— とりすーぷ (@toRisouP) 2020年5月8日
トリガーを引いたままにすると建物の縁が狙いやすい
#VRChat #VRChat_world紹介 pic.twitter.com/4dtDDguYeI
「Grapnel Gun Playground」のコツ
— とりすーぷ (@toRisouP) 2020年5月8日
銃口の向きで移動方向が制御できて、反対方向に向けると移動キャンセルできる。
組み合わせると結構自由自在に動ける。
#VRChat #VRChat_world紹介 pic.twitter.com/lvrVwUWyAn
こういう細かい調整が、小気味よく使えるグラップネルガンになったのかなと。
グライダーの解説
「Grapnel Gun Playground」には実は「グライダー」という別のギミックが置いてあります。
「Grapnel Gun Playground」の小ネタ。
— とりすーぷ (@toRisouP) 2020年5月8日
どこかにグライダーが隠してあって、これを使うと自由自在に空を飛べるぞ!
#VRChat #VRChat_world紹介 pic.twitter.com/dqHW8LHUkh
空を滑空するためのギミックであり、持っている間は滑空することができます。 挙動としては次のように動きます。
- 落下すると加速する
- 上昇すると減速する
- 水平移動では速さは変化しない
- 落下の開始地点よりは上に登れない(滑空なので)
仕組み
グライダーの仕組みですが、実はかなりガバガバな計算で作られています。
この動きを「揚力」の式を使って実現しようとするとめちゃくちゃ大変です。 なので今回は「力学的エネルギー保存の法則」だけを使って実装しました。
(落下した分の位置エネルギー = 運動エネルギー)
この式を変形して、速度の項について解くとこうなります。
つまり開始地点より⊿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); } }
力学的エネルギー保存則の式だけを使ったグライダーくん pic.twitter.com/zPOnvBAlCb
— とりすーぷ (@toRisouP) 2020年5月8日
これくらい単純なスクリプトでグライダーっぽい挙動は作れてしまいます。
グラップネルガンと協調する
- グラップネルガンで移動中はグライダーを無効化する
- グラップネルガンの移動がキャンセルされたら落下判定を行う
こういう処理を追加して、グライダーとグラップネルガンを協調して利用できるようにしています。
「プレイヤー」をグラップネルガンのターゲットとする
グラップネルガン、他のプレイヤーをターゲットにできるようにしたら革命的に面白くなった。楽しい。#MadeWithUdon #VRChat pic.twitter.com/vQzRZSPZcb
— とりすーぷ (@toRisouP) 2020年5月3日
「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.transform
はnull
が返ってきてしまいます。
そのため相手のプレイヤーを直接追従させることはできず、代替策を考える必要があります。
代替策:プレイヤーを追従する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
に制限がある」などの制約があります。
これらの制約を回避するためにあらかじめ各プレイヤー分のオブジェクトを作成しておいて、起動時に登録するという手法をとっています。
(32個分用意されたCollider
たち。脳筋実装。)
また、これらCollider
のLayer
はプレイヤーと干渉させないために、VRChatで定義されている空いている適当なLayer
を使うことにしました。
今回はとりあえずStereoRight
というLayer
を使いましたが、特に問題もなく動作しました。
最後に
「Grapnel Gun Playground」は細かい操作感にこだわって作ったので、ぜひ一度遊んでみてほしいです。