hyoromoのブログ

最近はVRSNS向けに作ったものについて書いています

はじめてのVRChat向けゲームワールド「Maze In Cube」作成するにあたって生まれたTips

www.youtube.com
完成したゲームワールドは迷路とパズルを組み合わせた内容です。

本エントリーは気を付けた点とTipsに関して書きます。
ちなみに「Maze In Cube」が初作成ワールドとなります。なので初心者レベルの内容が多く、既にワールド作成した事がある人にとっては退屈な内容かもしれない事にご注意ください。

本文中に多数Twitterからの引用があります。その時点の状態なためリリース済みの内容と異なります。

リリース時点の開発環境

  • Unity2018.4.20f1
  • VRCSDK3-WORLD-2021.07.12.18.53

2021/07時点の環境となるため、これから書いている内容は最新バージョンでは状況が異なっている可能性があります。
情報は鵜呑みにせず、面倒でも一度検証された方が良いです。

説明対象のワールド

VRChatのワールドリンクは以下になります。ワールドを少し遊んでみてからの方が理解しやすい点があるため、1度プレイされるてから読まれる事をオススメします。
https://vrchat.com/home/world/wrld_3a278573-e742-4ac8-85ec-2713ddd131d0

事前に知りたかった点

VRChatのワールド作成前に知っておきたかった事についてを書きます。

VRChatワールドで出来ること/出来ないことを知る方法

Unityで作成するので結構出来る範囲広いのかな?と勘違いして企画を立てると実現せず終わります。
かくいう私も企画当初はアバター状態で壁や天井を歩いて移動できる企画を立て、見えないVRCStationを使ったりして何とか実現できないか試し続けていました。結果は駄目で、乗り物に乗るよう企画変更しています。
なので、まずは公式ドキュメントを一読してみると良いです。
docs.vrchat.com

でもドキュメントに詳しく載って無かったり、よく分からない事が多々あります。それを調べるために次は公式のサンプルプロジェクトを触ります。
World SDKをUnityへImportし、以下のSceneを開いてください。

Assets/VRChat Examples/UdonExampleScene.unity

こちらでLocal Buildして公式が提供する一通りの機能が試せます。更に試したい事があればこのサンプルプロジェクトを改造して試すと手っ取り早くて良いです。

3rd Party製の便利なプラグイン
  • C#ライクに書ける Udon Sharp
  • UnityEditor上で擬似実行できる CyanEmu
    VRChatクライアント上での動作と差異があるため、軽く確認したい時に使用

以下で公開されている MUST HAVES は多くの方が使っているプラグインのようなので、上記以外に便利なプラグインを探してみてください。

気を付けた点/Tips

半年間、試行錯誤した時に気を付けた点/Tipsを挙げます。

同期する/しないを決める

同期すればするほど他プレイヤーと観ている景色が同じになります。が、同期すればするほど当然通信負荷が増します。
なので、同期するもの/しないものを選別する必要があります。

これは「プレイヤーの行動がその場の状態を変化させる」のであれば同期は必須。させないのであればどちらでも構わない。という方針を立てて作りました。
「ペン」は描く事によって「線」が表示されるため、「ペン」と「線」は同期すべき。「(言語/3D酔い対策の)個別設定ウィンドウ」は設定しているPlayerにしか関係無いため同期しない。

重要な処理を担う人を決める

ワールドのMaster、もしくは各ObjectのOwner。どちらかに重要な処理を任せる事になります。
重要な処理とはObject移動、全Playerに影響するワールドの設定とかです。
Master/Ownerどちらに処理を任せるかの判断は、ワールド全体に関わることはMaster、対象Objectにのみ関わることはOwner...とすると考えやすいです。

Master/Owner権限は他へ譲渡出来ます。特にObject毎に付与されているOwnerは移動に必須権限であるため、移動直前に必ず移動者へOwner権限を付与するようにしてください。

Networking.SetOwner(Networking.LocalPlayer, gameObject); // Local PlayerをGameObjectのOwnerにする

後述しますがMaster/Ownerはいつインスタンスから退室してもおかしくないです。そのため、Master/Ownerしか持っていない情報が無いよう後述する変数同期を必ず行ってください。

変数同期

変数定義する時に以下のように UdonSynced を付ける事で同期する変数扱いになります。

[UdonSynced(UdonSyncMode.None)]
private string _seatedPlayerName = "";

同期タイミングは大きく分けて2種類

  • Continuous
    自動同期。勝手に同期してくれます
  • Manual
    手動同期。RequestSerialization() を同期したいPlayer側が呼び出すことで指定した変数を同期

一見Continuousの方が良さそうに見えますが、こちらを使用すると変数同期されるとコールされる以下がコールされ続けてしまいます。Manualだと同期されたタイミングでのみ呼ばれる事になります。

public override void OnDeserialization() {} 

OnDeserializationは同期変数のどれか1つ更新されるだけで呼ばれるため、個別に処理したい使い勝手が悪い事があります。
そんな時は最近は同期した変数毎にgetter/setterを書けるようになったので以下のように書くといいです。
github.com

Objectの座標/回転角の同期

同期したいObjectへVRCObjectSyncコンポーネントをアタッチするだけ!
気を付ける事は同じObjectにアタッチしたUdonSharpBehaviourをManual同期出来ない点。もしManual同期したい場合は別Objectに予め分けておく事。

同期処理

インスタンスに居る全員またはOwnerを対象に、メソッドをコールさせる事が出来ます。
以下の例はインスタンスに居る全員を対象に SuncStartDelete メソッドをコールさせるコードです。

    SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(SuncStartDelete));
}

public void SuncStartDelete() { /* インスタンスに居る全員が呼ばれる削除処理を開始 */ }

対象はAllもしくはOwnerの2択のみ。Allにした場合、実行する自身も対象に含まれることに注意してください。

遅延実行

以下の2種類あります。

  • SendCustomEventDelayedFrames
    指定フレーム後にメソッド呼び出し
  • SendCustomEventDelayedSeconds
    指定秒数後にメソッド呼び出し
    SendCustomEventDelayedFrames(nameof(Respawn), 1);
    SendCustomEventDelayedSeconds(nameof(StartJump), 0.3f);
}

public void Respawn() {}
public void StartJump() {}

同フレームで処理させたくない事は結構あるので使い勝手良いです。
例えばGameObjectをActiveにした直後に対象GameObjectがActiveで無いと実行出来ない処理を遅延実行させる...とか。

インスタンスから退室

どんな状態からでもインスタンスから退室出来ます。たとえ手に何か持っていたり、乗り物に乗っていたとしても...
退室されると手放/下車した時の処理が実行されないため*1、代わりに誰かが実行する必要があります。

誰かが退出するとインスタンスに居る全員 OnPlayerLeft が呼ばれます。
もし退室者が "手に持っていた/乗っていた" 物がある場合、こちらで処置します。
以下は実際に使ったコードを少し改変した内容になります。Interactで乗り物に乗車した時に名前を取得しておき、ワールドから退出者が該当人物であったら下車処理をMasterに代行して貰っています。

[UdonSynced(UdonSyncMode.None)]
private string _seatedPlayerName = "";

public override void Interact() {
    _seatedPlayerName = Networking.LocalPlayer.displayName;
    RequestSerialization(); // Manual同期の場合は実施
}

public override void OnPlayerLeft(VRCPlayerApi player) {
    if (Networking.IsMaster) {
        if (player.displayName == _seatedPlayerName) {
            OnStationExit();
        }
    }
}

private void OnStationExit() { /* 下車させて乗り物を元の位置へ戻す処理 */}

以下の場合に注意ください。

  • VRCPickup
  • VRCStation
  • Colliderによるエリア検知
    PlayerがColliderに入った/出た時に呼ばれる OnPlayerTriggerEnter/OnPlayerTriggerExit で他プレイヤーにも影響出る処理が無いかを確認してください。
    Collider内に居る時に退室した場合は OnPlayerTriggerExit が呼ばれず退室済みとなります。もしここを同期しているようならバグの原因となります*2
インスタンスへ遅れて入室

インスタンスがどんな状態であっても入室上限未満なら入室できます。
なので、入室済みPlayerによってワールド干渉された内容は遅れて入室したPlayerにも同じように見える必要があります。

退室時のように入室時に呼ばれるメソッドが存在ます OnPlayerJoined です。じゃあ変数同期してこれ使えばいいじゃん!ってなりそうですが、そうでもありません。
Playerが入室して最初に呼ばれるのが OnPlayerJoined か OnDeserialization かは決まっていません。なので、OnPlayerJoined が呼ばれたからと言って変数が同期済みとは限らないという訳です。

以下の使い分けをしていました。注意点は OnDeserialization が入室時のみ呼ばれるメソッドという訳ではないため、フラグ管理等を行う必要があるかもしれません。

public override void OnPlayerJoined(VRCPlayerApi player) { /* Master側が自身のローカル変数を元に同期処理を実行 */ }
public override void OnDeserialization() { /* 同期された変数を元に遅れて入室したPlayerが実行 */ }
視線誘導

Playerがゲームルールを把握し、快適にプレイして貰えるよう視線誘導には特に気を付けました。
これは実際にMazeInCubeワールドへ入ると分かりやすいと思います。

  1. 入室すると目の前にナビゲーターが居るだけの状態から開始される
    他に目の行き場がない(一応上にロゴはあれど)ため、Playerが次に取る行動を迷わせません
  2. チュートリアル中は行き先を制限
    次へ移動する先は壁で塞ぎ、移動可能になったら壁を消して視界を広げるようにしています。「マルチプレイだから人の集まるところへ行きたくなる」行動をチュートリアル終わるまでは抑止しています
  3. チュートリアル終了地点とステージ起動地点を同じにする
    次に取ってほしい行動を1歩も動かず示すようにしています
  4. ステージクリア後のリザルト画面
    ステージをクリアすると、ブロック消去時に生成された写真がリザルト表示位置まで移動します。あと紙吹雪を舞わせたりし、Playerが何処を見ればいいか迷いにくいようにしました
  5. リザルトの先にホーム
    リザルト表示の奥にホームへ戻れる道を用意しています。こうする事で言語で伝えずとも次へ進むべき道が分かる...ハズです

他にも色々気を付けた箇所がありますが、やはり実際に見に行って貰った方が把握しやすいので見てみてください。

乗り物の乗り心地

乗り物なのでどう頑張っても酔うのですが、それでもいくぶんか軽減する事は出来ます。
回転する時に視界を制限したり、移動中に眼前に中心点を表示*3させています。

なお、視界制限に関しては公式からAPI提供があると2021/04に発表がありました。今だとそれを待った方が良いかもしれません。

ペンは自分だけのもの

VRCPickupは持っている状態で他人から奪えなくする設定がありますが、ペンは書き終えたら手放すものです。なのですが手放した瞬間に盗んでいくPlayerも居て困る事があります。
なので、本ワールドでのペンは「入室時にペンのOwner割当を行い、他の人にはPickup出来なくする」としています。

入室上限数だけペンを用意しておき、入室したらOwner未割り当てのペンに対してOwner割当。以降、ペンを使う時は割り当て済みのみ呼び出して使用して貰うようにしています。

マルチ言語対応

以下の「ローカライズ対応」で書いた内容とほぼ同じ対応をしています。
hyoromo.hatenablog.com

複数言語対応した時に同じ意味の文章が多言語分だけ並んでいると文字圧を感じてしまいます。
ユーザーに視線を向けて欲しい箇所を減らすand視線を迷わせないために表示言語の1本化に努めました。

Treasure Hunt IslandValkyrie Defenseだとボード上で言語選択でき、切り替える事でボード上の言語が切り替わるタイプでした。こちらの切り替え方法のほうが実装しやすく、Playerもウッカリ選択ミスしないから良い作りのように感じます。

乗り物の自動高さ調整

VRCStationでは座る位置を PlayerEnterLocation で指定できます。ですが、アバターの等身によって乗り物に埋もれたり浮いたりしてしまいます。
なので、座った時に呼ばれる OnStationEntered でEnterの高さ調整をしてあげる必要があります。
ただこれに関しては自分の中で納得行く答えとコードを持っておらず、一応行ってはいますが結構適当な高さになってしまっています。
というのもアバター種類ありすぎて何かに寄せると何かが駄目になる...というパターンが多く。ある程度自動で調整し、あとはユーザーに手動調整して貰うのが正解なのかもしれません。

ちなみに今行っている自動調整は Networking.LocalPlayer.GetBonePosition で各Boneを取得して調整しています。

地面に対して垂直でない乗り物


VRプレイ時のみ発生する問題として、PlayerEnterLocation が地面に対して垂直でない所へ乗ろうとすると視点が狂います。
回避方法としては、乗った後に PlayerEnterLocation を回転させれば解決します。

[SerializeField]
private Transform _seatEnterTram; // 椅子の座る場所

private void Start() {
    _seatEnterTram.rotation = Quaternion.identity; // 椅子がどんな向きで配置されていたとしても回転0とする
}

// 座った時に呼び出される
private void OnStationEntered() {
    if ( /* 座った人かを判定する処理 */ ) {
        _seatEnterTram.localRotation = Quaternion.identity; // もしlocal rotationが0でない場合、StartでMasterとかに同期変数へ格納。後からjoinした人も問題無いよう対応が必要
    }
}

// 席から離れる時に呼び出される
private void OnStationExited() {
    if ( /* 座った人かを判定する処理 */ ) {
        _seatEnterTram.rotation = Quaternion.identity;
    }
}

誰も座っていない時は座席の回転角を0にし、座った時に初期値に戻します。
OnStationEntered/OnStationExitedは座る/降りる時に全Playerで呼び出しが行われるため、ちゃんと座っている本人か判定してください。

乗り物から降りる先にColliderがあると吹き飛ぶ

乗り物から降りた先 PlayerExitLocation が壁などのColliderと接触状態にある時に降りるとぶっ飛ばされます。


解決方法は Networking.LocalPlayer.TeleportTo でのテレポート降りです。PositionをPlayerExitLocation、RotationをNetworking.LocalPlayer.GetRotation()とする事で降りる向きはそのままに吹き飛ばされずに下車出来ます。

タイム計測

ステージ開始からクリアまでの時間を計測しています。
以下のように開始時に StartStageTimer() を呼び、クリア時に FinishStageTimer() を呼びます。Masterにのみ処理させ、_startTime /_syncTotalTime ともに同期変数としています。startも同期しているのはプレイ中にMasterが退室しても問題無いようにするためです。

[UdonSynced(UdonSyncMode.None)]
private int _startTime = 0;

[UdonSynced(UdonSyncMode.None)]
private float _syncTotalTime = 0;

private void StartStageTimer() {
    if (Networking.IsMaster) {
        _startTime = Networking.GetServerTimeInMilliseconds();
    }
}

private void FinishStageTimer() {
    if (Networking.IsMaster) {
        var endTime = Networking.GetServerTimeInMilliseconds();
        _syncTotalTime = Mathf.Abs(endTime - _startTime) * 0.001f;
    }
}

ただ Networking.GetServerTimeInMilliseconds() で計測している方法が合っているか自信がありません。
符号がおかしい時がある?ような気がするようなしないような...もし間違った使い方をしているなら連絡して貰えると助かります。

VR/非VRモードでのButton操作

ButtonはUI Layerにしてはいけない
Desktopでは操作出来ますが、VRで操作出来なくなります。それとCanvasにはVRCUiShapeコンポーネントを追加します。

VRモードでHandがCollider内に居るとButton操作出来ない

床にCollider敷いてエリア内に入ったら何かする。ってよくやる手かと思いますが、そのColliderの高さが高すぎる場合に小さいアバターだとColliderに埋もれてButton操作出来なくなります。適切な高さにするよう調整が必要です。

プレイヤーの眼前にUIを表示

ホワイトアウトの例が上図となります。ポイントは以下。

ここは非VRVRで見え方に差が出てしまいます。非VRは適当に設定しても期待通り表示されるのですが、VRの場合は上記を守らないと見えない状態となります。

サウンドの設定

BGMは特別な演出が無ければデフォルトの2D*4で構いませんが、VRにおいてSE/ジングルは鳴らす位置と減衰距離が重要になります。
Spatial Blendを3Dにし、鳴らしたい場所に配置。聞こえる距離や減衰カーブを調整する必要があります。

Desktopでばかりテストしていると気にならずスルーしちゃうかもですが、VRでプレイすると気になるのでキチンと3D音響対応した方がリアリティが増して良いです。
それと忘れがちですが他プレイヤーにも聞こえるべきか判断し、必要に応じて同期処理を行います。

LightProbeの自動配置

tsubakit1.hateblo.jp
LightProbeHelperを使えば範囲内に指定した間隔で均等配置してくれるため楽です。以下は例です、実際はもっと間隔を空けて配置してBakeしています。

入室後にアバターSDK3製のアニメーションが正常動作しない

大きいUdonProgramを大量に使用していると、インスタンス入室してしばらくの間アニメーションが正常に再生されない(歩いてもホバリングする)状態になりました。これは使用するUdonProgram数を減らすことで時間軽減されました。

なお、何故かアバターSDK2では発生しません。SDK2と3で違いがあるため、これに限らずアバターアニメーション絡みのテストする時は両方でテストする事をオススメしておきます。

各プラットフォームのテスト

VRのPC/VRのPCはLocal Buildする事でテスト出来ますが、Oculus QuestはワールドをPrivate/Publicアップロードしなければテスト出来ません。
まだ非公開の状態なら問題ないのですが、公開済みの場合はQuestの動作確認を本番サーバーでテストさせられるツライ仕様となっています*5

マルチアカウントテスト方法

Local BuildによるマルチプレイでのVRCStationテストは出来ません。

Bug: Don't use the Chair when running Build & Test with multiple clients.
There's an issue right now that all of your avatars will get 'sat' into the chair, and it will be difficult to get them out. We'll remove this warning when that's fixed. For now, you'll need to Publish the scene to test out Stations in your world.

https://docs.vrchat.com/docs/using-build-test

このせいでテストのたびにprivate uploadする必要があり、開発を諦めたくなるほどコスト高でした。
なので以下の対応をしました。これでLocal Buildでもマルチテストが捗りました。
hyoromo.hatenablog.com

デバッグコマンド


右Shift+半角全角*6+1 or 2 or 3 or 4 or 5 or 6デバッグメニューが表示されます。
特に右Shift+半角全角+3で上図のように実行ログを見られるため、こちらは必ず使うことになるかと思います。

ちなみにログ自体は C:\Users\(user_name)\AppData\LocalLow\VRChat\VRChat\output_log_xx-xx-xx.txt にも出力されています。
ワールドが起動しなかったりクラッシュした時はこちらのログを確認ください。

写真撮影文化

VRChatは写真を撮る文化が強い印象があります。なので、撮影しやすい場所を2つ用意しました。

  1. ステージクリア後のクリアタイム
  2. ホームの全ステージのクリアタイム一覧
    最終ステージをクリアするとトータルタイム表示

#MazeInCube ハッシュタグ付けてTwitter投稿頂いている方がまだ少なくはありますが、撮影されている方を観測したので用意して良かったなと思っています。
ただ投稿されにくいのは撮影場所に #MazeInCube ハッシュタグ表記が無い事や、撮影に値するデザインでは無いからなのかもしれません。そこは要改善ポイントです。

ワールドのサムネイル画像

ワールドのサムネイル画像は以下に注意する必要があり、特に後者で文字が切れてしまわないよう注意が必要かもしれません。

  • 4:3画像
  • 一覧表示/ワールド読込中は上下左右が切れた画像で表示

サムネ用Prefabを用意しておいて、サムネ撮影が必要な時にUnityEditor拡張メニューから呼び出せるようにすると楽チンです。

締め

いろいろ漏れがあったり動画が古くて参考になりにくかったかもしれませんが以上となります。
こうすると良いよ!って情報があればコメントまたは直接私に会った時に伝えて貰えると嬉しいです。
ワールドに対するフィードバックも募集しておりますので、もしプレイされた方は #MazeInCube ハッシュタグを付けてTwitterに投稿して貰えると喜びます。


企画は1月、プロトタイプは2月の


から約半年掛けて7月にリリース。
平日夜、土日、祝日を使って大変な日々でしたがなんとかリリース出来て良かったです。。。

更新履歴

  • 2021/08/07
    Unity2019でデバッグメニューの呼び出しが「右Shift+@+数字」から「右Shift+半角全角+数字」に変わったので修正

*1:実行するPlayerが退室してしまったので

*2:例えば他の人からも見える自動ドアとか

*3:集中線

*4:どの位置に居ても同じボリュームで聞こえる

*5:VRChatは一度公開したワールドはPrivateでアップロード出来ないようです

*6:1の左にあるヤツ。Unity2019で@から変更された