VRChatゲームワールド「Magical Lost Dungeon」をUdonで作った話

はじめに

2022年5月にVRChatにダンジョン探索ワールド「Magical Lost Dungeon」をアップロードしました。ローグライク的なランダム性を取り入れつつ、VRでお手軽にダンジョンを潜る体験ができるワールドを目指して製作しました。

そこからひと月ばかりバグ修正・性能改善を行ってきましたがある程度落ち着いた(ほんとに落ち着いたか?)ので、得た知見・製作で工夫したポイントをまとめてみました。ちなみに本記事で述べる「Udon」はUdonGraphではなくU#(UdonSharp)を指します。
本記事は備忘録の役割としてのまとめに近いものがあるので、読みづらい分かりづらい点はあると思います。また、製作当時に自分が理解した内容で書いているため、仕様に対して正確性に欠ける箇所がありますことご了承ください。

Udon製作 ~同期編~

仕様理解:同期の種類とメリデメ

VRChatゲームワールド製作で大体の製作者が悩まれるのはVRChatの同期仕様かと思います(私見)。すべてのオブジェクトは同期を明示しない限りローカルで動くため、今回のワールドのように他プレーヤと協力しながら進めるゲームワールドでは、ゲームの同期方法について検討することが必須となります。
VRChatでは大きく分類して3種類の同期から何を使うか選択することになります。ここで簡単に特徴をまとめます。より詳しい情報は他の方の同期についての記事や公式ページをチェックしてください。

(1)オブジェクト位置同期(Object Sync)
VRChat提供のコンポーネント「VRC Object Sync」をつけるとオブジェクトの位置・回転をVRChat側が自動で同期してくれます。シンプルなPickupオブジェクトの同期にはこれで大体解決するのではないでしょうか。
・メリット:制作側で同期を意識することが殆ど不要となります。
・デメリット:次で述べるManual Syncとの同オブジェクトでの併用ができません。位置・回転以外の情報は別Udonで管理することになり、構成が複雑化する問題があります。

(2)変数同期(Manual Sync)
Udon内で[UdonSynced]属性を定義した変数を同期変数として扱えます。
オブジェクトのオーナーがRequestSerialization()メソッドを呼ぶと次の通信で同期が行われます。非オーナーは同期されるとOnDeserializationイベントが発火するのでそこで処理を実行できます。もしくは同期変数にFieldChangeCallback属性を付与することで同期変数が更新された時の処理を指定できます。
(もう一つの変数同期として一定タイミングで自動同期するContinuous Syncがありますが正直Manual Syncが登場した現在は性能面安定面双方で使う理由がない、と判断しています。)
・メリット:同期されるまでがイベント同期に比べ高速です。アクションなど高速な同期が求められるゲームワールドでは変数同期でなるべく同期を実現させたいところです。
・デメリット:同期タイミングまでに複数回変数の値を更新した場合、途中の値を取得することができません。RequestSerialization()はあくまで次の通信で同期を行うことをリクエストするだけなので呼び出し時点の値が同期されるわけではありません。また、インスタンスに一人しかいない時はOnDeserializationは呼ばれないので一人の時用の記述が必要です。

(3)イベント同期
SendCustomNetworkEvent()メソッドで同Udon内のメソッドを同期実行させることができます。引数でオーナーのみ実行、全員の環境で実行を選択できます。また、オーナーにかかわらずSendCustomNetworkEventは呼び出せます。
・メリット:全員に同じ処理を実行させることができるので、同期の記述が書きやすくなります。
・デメリット:引数指定をすることができません。状態値によって挙動を変える場合、パターン分の関数を用意する必要があります。なお、イベント同期のタイミングと変数同期のタイミングは異なるので同期変数を使ってのコントロールはこのタイミング問題を考慮しなければなりません。

仕様理解:VRCInstantiateという制約

Magical Lost Dungeonでは理由は後述しますがアイテム・敵オブジェクトをVRCInstantiateによって動的生成しています。
しかしながら、こちらのVRCInstantiateで作成したオブジェクト、一切同期しません。Object SyncもManual Syncも動作しません。(イベント同期は試していませんが)
これは致命的です。

実装:イベントメッセージ同期方式

ゲーム中は、ダメージ量・状態異常付与フラグ・魔法発射方向・敵の移動目標地点座標…様々な値を同期し、且つ、エフェクトの表示・アニメーションの再生・敵の生成…といったイベントを同期実行しなければなりません。
また、上記のVRCInstantiateの制約のため、オブジェクト自身に同期変数を定義できません。
そこで、今回の製作では解決策として「イベントメッセージ同期方式」を採用し構築しました。

以下、方式の簡易説明。
■イベントメッセージ
下記のような変数のセットをイベントメッセージとして扱います。(構造体定義が現Udonでは使えないので変数のセット、として扱います。)引数としてメッセージで渡したい型を用意しています。引数を増やしすぎると1メッセージの容量が大きくなるので最終的に最低必要数となるように調整しています。
・イベントID
・イベント実行No
・メインのオブジェクトインスタンスNo
・ターゲットのオブジェクトオブジェクトNo
・int引数×4
・float引数×2
・Vector3引数
・Quaternion引数

●イベントID
イベントメッセージ固有のIDです。イベントを送るオーナーのプレーヤーIDと送ったイベントのカウンタから算出されるため、まず重複しない値となります。

●イベント実行No
イベントスクリプト(後述)の登録番号です。

オブジェクトインスタンスNo
ゲーム上で扱うオブジェクトに対し、割り当てている固有Noです。イベントがオブジェクトをアクティブにする際に新規で割り当てます。同期するイベントIDから計算して割り当てられるため、VRCInstantiateなどで動的生成したオブジェクトに対しても各環境で同一オブジェクトとして紐づけることができるようになります。イベントメッセージでは、イベントのメイン(誰が)・ターゲット(誰に)という2種類のオブジェクトインスタンスNoを登録できるようにしています。

◆イベントスクリプト(EventScript)
イベントスクリプトは実行するイベントメソッドやメソッドが参照するオブジェクト情報の詳細を定義したUdonとなります。Magical Lost Dungeonではアイテムを使用する・敵に攻撃を当てる・敵が魔法を使う、などアクションが発生するとイベントスクリプトのNo(イベント実行No)やパラメータ(引数)を指定したイベントメッセージを同期変数に追加し、各環境でイベントマネージャーからイベントスクリプトを取得しています。

「テレポートの魔法書」用イベントスクリプトの例

◆イベントメッセンジャー(EventMessenger)
 各プレーヤーに1つずつオーナーを割り当てたManual SyncなUdonです。
①イベントを発生させる際は各オブジェクトのUdonから自オーナーのEventMessengerのメッセージ追加メソッド(AppendEventMessage)を呼び出してイベントメッセージを追加します。イベントメッセージの各変数は同期変数となっており、メッセージ追加メソッドの最後にRequestSerializationを呼び出しています。イベントメッセージ追加の際、イベントID算出用のカウンタを1増やします。
②同期通信が発生するまで、イベントメッセージは蓄積されます。一度に送信できる許容量を超えた場合はストックイベントメッセージに保存されます。
③OnDeserializationイベントが発生するとすべてのイベントメッセージをイベントマネージャーにコピーします。オーナーの場合はOnPreSerializationイベントで同様にイベントマネージャーにコピーします。(ただし、イベントIDのMAX値が前回の通信時を超えているかチェックし、増えていない場合は処理を実施しないようにしています。)
④同期通信が完了するとUdonはオーナー環境でOnPostSerializationイベントを呼び出しますので、そこで溜まっているイベントメッセージをクリアします。ストックイベントメッセージがある場合はイベントメッセージに移動させます。

以下にEventMessengerの実際のコードを載せます。(EventManagerやありきのコードなので単体では動きませんが上記説明文の補足として)

/***********************************************
* EventMessenger.cs
*
* Copyright (c) 2022 べるべる
*
* This software is released under the MIT License.
* http://opensource.org/licenses/mit-license.php
************************************************/

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

namespace BBRPG
{
    [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
    public class EventMessenger : UdonSharpBehaviour
    {
        // 一回で同時に同期可能なイベントの数(増やしすぎると通信量圧迫)
        private const int maxEventSize = 20;

        // 1ユーザーで管理できるイベントID数(EventManagerの値と合わせる)
        private const int idMaxNum= 100000;
        // 同期するオーナー情報。4byte
        [UdonSynced(UdonSyncMode.None)]
        private int ownerId = -1;
        public int OwnerId
        {
            get { return ownerId; }
        }

        // 現在の最大イベント固有ID。4byte
        [UdonSynced(UdonSyncMode.None)]
        private int maxEventId = 0;
        // 現在の送信済み最大イベント固有ID。4byte
        [UdonSynced(UdonSyncMode.None)]
        private int maxSendEventId = 0;
        private int localMaxEventId = 0;

        // 同期するイベントデータ。maxEventSize = 20の時の最大送信量は1.26kb?
        // イベント固有ID 4byte * 20 = 80byte
        [UdonSynced(UdonSyncMode.None)]
        private int[] eventId = new int[maxEventSize];
        // 実行イベントNo 2byte * 20 = 40byte
        [UdonSynced(UdonSyncMode.None)]
        private ushort[] eventExecuteNo = new ushort[maxEventSize];
        // イベント主体者(プレーヤー・敵・アイテム・アクションオブジェクト・イベントオブジェクト)の実体No 4byte * 20 = 80byte
        [UdonSynced(UdonSyncMode.None)]
        private int[] mainObjectInstanceNo = new int[maxEventSize];
        // イベント対象者(プレーヤー・敵・アイテム・アクションオブジェクト・イベントオブジェクト)の実体No 4byte * 20 = 80byte
        [UdonSynced(UdonSyncMode.None)]
        private int[] targetObjectInstanceNo = new int[maxEventSize];

        // 引数float型 4byte * 2 * 20 = 160byte
        // (多次元配列が同期されるのか不明なので別変数化)
        [UdonSynced(UdonSyncMode.None)]
        private float[] arg_float_A = new float[maxEventSize];
        [UdonSynced(UdonSyncMode.None)]
        private float[] arg_float_B = new float[maxEventSize];
        //[UdonSynced(UdonSyncMode.None)]
        //private float[] arg_float_C = new float[maxEventSize];
        //[UdonSynced(UdonSyncMode.None)]
        //private float[] arg_float_D = new float[maxEventSize];
        // 引数int型 4byte * 4 * 20 = 320byte
        [UdonSynced(UdonSyncMode.None)]
        private int[] arg_int_A = new int[maxEventSize];
        [UdonSynced(UdonSyncMode.None)]
        private int[] arg_int_B = new int[maxEventSize];
        [UdonSynced(UdonSyncMode.None)]
        private int[] arg_int_C = new int[maxEventSize];
        [UdonSynced(UdonSyncMode.None)]
        private int[] arg_int_D = new int[maxEventSize];
        // 引数Vector3型 12byte * 20 = 240byte
        [UdonSynced(UdonSyncMode.None)]
        private Vector3[] arg_Vector3 = new Vector3[maxEventSize];
        // 引数Quarternion型 16byte * 20 = 320byte
        [UdonSynced(UdonSyncMode.None)]
        private Quaternion[] arg_Quaternion = new Quaternion[maxEventSize];

        // 実行済みかを管理する
        private bool[] doneFlag = new bool[maxEventSize];

        // 一度のメッセージで送れなかった時用のストック変数
        // 例:エリア攻撃を30体の敵に対して当てた時など
        private const int maxStockSize = 150;
        private int[] eventIdStock = new int[maxStockSize];
        private ushort[] eventExecuteNoStock = new ushort[maxStockSize];
        private int[] mainObjectInstanceNoStock = new int[maxStockSize];
        private int[] targetObjectInstanceNoStock = new int[maxStockSize];
        private float[] arg_float_AStock = new float[maxStockSize];
        private float[] arg_float_BStock = new float[maxStockSize];
        //private float[] arg_float_CStock = new float[maxStockSize];
        //private float[] arg_float_DStock = new float[maxStockSize];
        private int[] arg_int_AStock = new int[maxStockSize];
        private int[] arg_int_BStock = new int[maxStockSize];
        private int[] arg_int_CStock = new int[maxStockSize];
        private int[] arg_int_DStock = new int[maxStockSize];
        private Vector3[] arg_Vector3Stock = new Vector3[maxStockSize];
        private Quaternion[] arg_QuaternionStock = new Quaternion[maxStockSize];

        private EventManager eventManager = null;
        private VRCPlayerApi localPlayer;

        void Start()
        {
            localPlayer = Networking.LocalPlayer;
            ResetArray();
        }

        public override void OnPlayerJoined(VRCPlayerApi player)
        {
            if (player == null) return;
            RequestSerialization();
        }

        /// <summary>
        /// オーナーを対象プレーヤーに変更
        /// </summary>
        public bool ChangeOwner(VRCPlayerApi player)
        {
            localPlayer = Networking.LocalPlayer;
            if (!Networking.IsOwner(localPlayer, this.gameObject)) Networking.SetOwner(localPlayer, this.gameObject);
            ownerId = player.playerId;
            maxEventId = (int)player.playerId * idMaxNum;
            maxSendEventId = maxEventId;
            RequestSerialization();
            return false;
        }

        /// <summary>
        /// オーナーとデータ情報をリセット
        /// </summary>
        public void ResetOwnerAndData()
        {
            localPlayer = Networking.LocalPlayer;
            if (!Networking.IsOwner(localPlayer, this.gameObject)) return;
            ownerId = -1;
            for (int i = 0; i < maxEventSize; i++)
            {
                // とりあえずeventIdさえ0にしておけば各種処理は行われない
                eventId[i] = 0;
            }
            RequestSerialization();
        }

        /// <summary>
        /// 各値をリセットする
        /// </summary>
        private void ResetArray()
        {
            for (int i = 0; i < maxEventSize; i++)
            {
                eventId[i] = 0;
                eventExecuteNo[i] = 0;
                mainObjectInstanceNo[i] = 0;
                targetObjectInstanceNo[i] = 0;
                arg_float_A[i] = 0;
                arg_float_B[i] = 0;
                //arg_float_C[i] = 0;
                //arg_float_D[i] = 0;
                arg_int_A[i] = 0;
                arg_int_B[i] = 0;
                arg_int_C[i] = 0;
                arg_int_D[i] = 0;
                arg_Vector3[i] = Vector3.zero;
                arg_Quaternion[i] = Quaternion.identity;
            }
            for (int i = 0; i < maxStockSize; i++)
            {
                eventIdStock[i] = 0;
                eventExecuteNoStock[i] = 0;
                mainObjectInstanceNoStock[i] = 0;
                targetObjectInstanceNoStock[i] = 0;
                arg_float_AStock[i] = 0;
                arg_float_BStock[i] = 0;
                //arg_float_CStock[i] = 0;
                //arg_float_DStock[i] = 0;
                arg_int_AStock[i] = 0;
                arg_int_BStock[i] = 0;
                arg_int_CStock[i] = 0;
                arg_int_DStock[i] = 0;
                arg_Vector3Stock[i] = Vector3.zero;
                arg_QuaternionStock[i] = Quaternion.identity;
            }
        }

        /// <summary>
        /// EventManagerクラスの参照をセットする
        /// </summary>
        public void SetReferenceEventManager(EventManager em)
        {
            eventManager = em;
        }

        /// <summary>
        /// イベントメッセージを追加する
        /// </summary>
        public void AppendEventMessage(ushort executeNo, int mainNo, int targetNo, float arg_f_a, float arg_f_b,/* float arg_f_c, float arg_f_d, */int arg_i_a, int arg_i_b, int arg_i_c, int arg_i_d, Vector3 arg_v, Quaternion arg_q)
        {
            localPlayer = Networking.LocalPlayer;
            if (localPlayer == null) return;
            if (!Networking.IsOwner(localPlayer, this.gameObject)) return;
            for (int i = 0; i < maxEventSize; i++)
            {
                // 空きがあれば登録する
                if (eventId[i] == 0)
                {
                    maxEventId += 1;
                    if (maxEventId >= (int)(localPlayer.playerId + 1) * idMaxNum)
                    {
                        maxEventId = (int)localPlayer.playerId * idMaxNum;
                    }
                    maxSendEventId += 1;
                    if (maxSendEventId >= (int)(localPlayer.playerId + 1) * idMaxNum)
                    {
                        maxSendEventId = (int)localPlayer.playerId * idMaxNum;
                    }
                    eventId[i] = maxEventId;
                    eventExecuteNo[i] = executeNo;
                    mainObjectInstanceNo[i] = mainNo;
                    targetObjectInstanceNo[i] = targetNo;
                    arg_float_A[i] = arg_f_a;
                    arg_float_B[i] = arg_f_b;
                    //arg_float_C[i] = arg_f_c;
                    //arg_float_D[i] = arg_f_d;
                    arg_int_A[i] = arg_i_a;
                    arg_int_B[i] = arg_i_b;
                    arg_int_C[i] = arg_i_c;
                    arg_int_D[i] = arg_i_d;
                    arg_Vector3[i] = arg_v;
                    arg_Quaternion[i] = arg_q;
                    RequestSerialization();

                    eventIdStock[i] = maxEventId;
                    eventExecuteNoStock[i] = executeNo;
                    mainObjectInstanceNoStock[i] = mainNo;
                    targetObjectInstanceNoStock[i] = targetNo;
                    arg_float_AStock[i] = arg_f_a;
                    arg_float_BStock[i] = arg_f_b;
                    //arg_float_CStock[i] = arg_f_c;
                    //arg_float_DStock[i] = arg_f_d;
                    arg_int_AStock[i] = arg_i_a;
                    arg_int_BStock[i] = arg_i_b;
                    arg_int_CStock[i] = arg_i_c;
                    arg_int_DStock[i] = arg_i_d;
                    arg_Vector3Stock[i] = arg_v;
                    arg_QuaternionStock[i] = arg_q;
                    return;
                }
            }

            // 空きがなかった場合、ストック変数の方にのみ格納する
            for (int i = maxEventSize; i < maxStockSize; i++)
            {
                if (eventIdStock[i] == 0)
                {
                    maxEventId += 1;
                    if (maxEventId > (int)(localPlayer.playerId + 1) * idMaxNum)
                    {
                        maxEventId = (int)localPlayer.playerId * idMaxNum;
                    }
                    eventIdStock[i] = maxEventId;
                    eventExecuteNoStock[i] = executeNo;
                    mainObjectInstanceNoStock[i] = mainNo;
                    targetObjectInstanceNoStock[i] = targetNo;
                    arg_float_AStock[i] = arg_f_a;
                    arg_float_BStock[i] = arg_f_b;
                    //arg_float_CStock[i] = arg_f_c;
                    //arg_float_DStock[i] = arg_f_d;
                    arg_int_AStock[i] = arg_i_a;
                    arg_int_BStock[i] = arg_i_b;
                    arg_int_CStock[i] = arg_i_c;
                    arg_int_DStock[i] = arg_i_d;
                    arg_Vector3Stock[i] = arg_v;
                    arg_QuaternionStock[i] = arg_q;
                    return;
                }
            }
        }

        /// <summary>
        /// Ownerは同期が終わったタイミングでClearEventMessageを実行する。
        /// </summary>
        public override void OnPostSerialization(VRC.Udon.Common.SerializationResult result)
        {
            if (result.success == false)
            {
                RequestSerialization();
                return;
            }
            if (maxSendEventId > localMaxEventId || (localMaxEventId - maxSendEventId > idMaxNum - maxStockSize))
            {
                localMaxEventId = maxSendEventId;
                ClearEventMessage();
            }
        }

        /// <summary>
        /// Ownerは同期が終わったタイミングでCopyEventMessageToManagerを実行する。
        /// </summary>
        public override void OnPreSerialization()
        {
            if (maxSendEventId > localMaxEventId || (localMaxEventId - maxSendEventId > idMaxNum - maxStockSize))
            {
                 CopyEventMessageToManager();
            }
        }

        /// <summary>
        /// Owner以外は同期したタイミングでCopyEventMessageToManagerを実行する。
        /// </summary>
        public override void OnDeserialization()
        {
            if (maxSendEventId > localMaxEventId || (localMaxEventId - maxSendEventId > idMaxNum - maxStockSize))
            {
                localMaxEventId = maxSendEventId;
                CopyEventMessageToManager();
                for (int i = 0; i < maxEventSize; i++)
                {
                    doneFlag[i] = false;
                }
            }
        }

        void Update()
        {
            if (Networking.LocalPlayer == null) return;
            // オーナーIDがセットされたらオブジェクトオーナーを受け持つ
            if (ownerId == Networking.LocalPlayer.playerId)
            {
                if (!Networking.IsOwner(Networking.LocalPlayer, this.gameObject))
                {
                    Networking.SetOwner(Networking.LocalPlayer, this.gameObject);
                }
            }
            // 一人の時だとシリアライズ同期のイベントは発生しないので、人数を判定して実行する
            if (VRCPlayerApi.GetPlayerCount() == 1)
            {
                if (maxSendEventId > localMaxEventId || (localMaxEventId - maxSendEventId > idMaxNum - maxStockSize))
                {
                    localMaxEventId = maxSendEventId;
                    CopyEventMessageToManager();
                    ClearEventMessage();
                }
            }
            // 複数人の時はPostSerializationでメッセージクリアするので、それまではフラグでメッセージをコピー済みか管理する
            else if (ownerId == Networking.LocalPlayer.playerId && Networking.IsOwner(Networking.LocalPlayer, this.gameObject))
            {
                if (maxSendEventId > localMaxEventId || (localMaxEventId - maxSendEventId > idMaxNum - maxStockSize))
                {
                    CopyEventMessageToManager();
                }
            }
        }

        /// <summary>
        /// イベントメッセージをManagerの管理下にコピーする
        /// </summary>
        private void CopyEventMessageToManager()
        {
            if (eventManager == null) return;
            for (int i = 0; i < maxEventSize; i++)
            {
                if (eventId[i] != 0 && doneFlag[i] == false)
                {
                    doneFlag[i] = true;
                    eventManager.AppendEventMessageToList(
                        eventId[i],
                        eventExecuteNo[i],
                        mainObjectInstanceNo[i],
                        targetObjectInstanceNo[i],
                        arg_float_A[i],
                        arg_float_B[i],
                        //arg_float_C[i],
                        //arg_float_D[i],
                        arg_int_A[i],
                        arg_int_B[i],
                        arg_int_C[i],
                        arg_int_D[i],
                        arg_Vector3[i],
                        arg_Quaternion[i]
                        );
                }
            }
        }

        /// <summary>
        /// イベントメッセージを削除する
        /// </summary>
        private void ClearEventMessage()
        {
            bool ReRequestFlag = false;
            for (int i = 0; i < maxEventSize; i++)
            {
                doneFlag[i] = false;
                if (eventId[i] != 0)
                {
                    // メッセージをクリア
                    eventId[i] = 0;
                    eventExecuteNo[i] = 0;
                    mainObjectInstanceNo[i] = 0;
                    targetObjectInstanceNo[i] = 0;
                    arg_float_A[i] = 0f;
                    arg_float_B[i] = 0f;
                    //arg_float_C[i] = 0f;
                    //arg_float_D[i] = 0f;
                    arg_int_A[i] = 0;
                    arg_int_B[i] = 0;
                    arg_int_C[i] = 0;
                    arg_int_D[i] = 0;
                    arg_Vector3[i] = Vector3.zero;
                    arg_Quaternion[i] = Quaternion.identity;
                    // ストック変数もクリア
                    eventIdStock[i] = 0;
                    eventExecuteNoStock[i] = 0;
                    mainObjectInstanceNoStock[i] = 0;
                    targetObjectInstanceNoStock[i] = 0;
                    arg_float_AStock[i] = 0;
                    arg_float_BStock[i] = 0;
                    //arg_float_CStock[i] = 0;
                    //arg_float_DStock[i] = 0;
                    arg_int_AStock[i] = 0;
                    arg_int_BStock[i] = 0;
                    arg_int_CStock[i] = 0;
                    arg_int_DStock[i] = 0;
                    arg_Vector3Stock[i] = Vector3.zero;
                    arg_QuaternionStock[i] = Quaternion.identity;
                }
            }
            // もし、ストック変数にまだ中身がある場合、最初にイベントメッセージに追加する
            for (int i = maxEventSize; i < maxStockSize; i++)
            {
                eventIdStock[i - maxEventSize] = eventIdStock[i];
                eventExecuteNoStock[i - maxEventSize] = eventExecuteNoStock[i];
                mainObjectInstanceNoStock[i - maxEventSize] = mainObjectInstanceNoStock[i];
                targetObjectInstanceNoStock[i - maxEventSize] = targetObjectInstanceNoStock[i];
                arg_float_AStock[i - maxEventSize] = arg_float_AStock[i];
                arg_float_BStock[i - maxEventSize] = arg_float_BStock[i];
                //arg_float_CStock[i - maxEventSize] = arg_float_CStock[i];
                //arg_float_DStock[i - maxEventSize] = arg_float_DStock[i];
                arg_int_AStock[i - maxEventSize] = arg_int_AStock[i];
                arg_int_BStock[i - maxEventSize] = arg_int_BStock[i];
                arg_int_CStock[i - maxEventSize] = arg_int_CStock[i];
                arg_int_DStock[i - maxEventSize] = arg_int_DStock[i];
                arg_Vector3Stock[i - maxEventSize] = arg_Vector3Stock[i];
                arg_QuaternionStock[i - maxEventSize] = arg_QuaternionStock[i];
                if (eventIdStock[i] == 0) break;
                eventIdStock[i] = 0;
                eventExecuteNoStock[i] = 0;
                mainObjectInstanceNoStock[i] = 0;
                targetObjectInstanceNoStock[i] = 0;
                arg_float_AStock[i] = 0;
                arg_float_BStock[i] = 0;
                //arg_float_CStock[i] = 0;
                //arg_float_DStock[i] = 0;
                arg_int_AStock[i] = 0;
                arg_int_BStock[i] = 0; 
                arg_int_CStock[i] = 0;
                arg_int_DStock[i] = 0;
                arg_Vector3Stock[i] = Vector3.zero;
                arg_QuaternionStock[i] = Quaternion.identity;
                // メッセージを上書き
                if (i < (maxEventSize * 2))
                {
                    eventId[i - maxEventSize] = eventIdStock[i - maxEventSize];
                    eventExecuteNo[i - maxEventSize] = eventExecuteNoStock[i - maxEventSize];
                    mainObjectInstanceNo[i - maxEventSize] = mainObjectInstanceNoStock[i - maxEventSize];
                    targetObjectInstanceNo[i - maxEventSize] = targetObjectInstanceNoStock[i - maxEventSize];
                    arg_float_A[i - maxEventSize] = arg_float_AStock[i - maxEventSize];
                    arg_float_B[i - maxEventSize] = arg_float_BStock[i - maxEventSize];
                    //arg_float_C[i - maxEventSize] = arg_float_CStock[i - maxEventSize];
                    //arg_float_D[i - maxEventSize] = arg_float_DStock[i - maxEventSize];
                    arg_int_A[i - maxEventSize] = arg_int_AStock[i - maxEventSize];
                    arg_int_B[i - maxEventSize] = arg_int_BStock[i - maxEventSize];
                    arg_int_C[i - maxEventSize] = arg_int_CStock[i - maxEventSize];
                    arg_int_D[i - maxEventSize] = arg_int_DStock[i - maxEventSize];
                    arg_Vector3[i - maxEventSize] = arg_Vector3Stock[i - maxEventSize];
                    arg_Quaternion[i - maxEventSize] = arg_QuaternionStock[i - maxEventSize];
                    maxSendEventId += 1;
                    if (maxSendEventId > (int)(localPlayer.playerId + 1) * idMaxNum)
                    {
                        maxSendEventId = (int)localPlayer.playerId * idMaxNum;
                    }
                    ReRequestFlag = true;
                    RequestSerialization();
                }
            }
            if (ReRequestFlag)
            {
                 RequestSerialization();
            }
        }
    }
}

◆イベントマネージャ(EventManager)
イベントメッセンジャーから受け取ったイベントメッセージを解析し、各処理を実行するUdonとなります。
イベントスクリプトの一覧を管理し、イベント実行Noからの検索を可能としています。また、ゲーム中のオブジェクトの一覧を種類別に配列で管理し、オブジェクトインスタンスNoからの検索を可能としています。
①イベント実行Noからイベントスクリプトを検索し、スクリプトを取得します。
②オブジェクトインスタンスNoからオブジェクトの実体を検索し、取得します。
③スクリプトから実行するメソッドとその引数を解析し、順番に実行していきます。
④スクリプトを最終行まで実行し終えたらイベントメッセージを削除します。

とっても雑な図解

本方式で既存同期方式の問題点に対応し、複雑なデータのやり取りが高速(※)に出来るようになりました。
また、イベントメッセンジャーでの同期に統一することで各オブジェクトのUdonが記述しやすくなりました。(後述しますがプレーヤーパラメータのみ別の同期Udonを使っています。)

※UdonSharpの弱みとして、他Udonの変数やメソッドを参照するとgc allocによる処理負荷が発生します。そのため、必ずEventManagerから各オブジェクトのUdonを操作しないといけないという点で負荷が発生しているのでそこが十分高速か、は検討課題。

Udon製作 ~アイテム編~

仕様理解:オブジェクトプールと動的生成

VRChatのゲームワールドにおいて、オブジェクトは事前にワールド側で必要数用意しておくのが処理負荷の意味でも適しています(私見)。
例えば銃を使うワールドでは銃は人数分用意しておいて、プレーヤー登録イベントが発生したときに一つアクティブにする…弾は銃につき10発程度オブジェクト用意しておき、発射イベントが発生したときに一つアクティブにする/着弾時に非アクティブにする…といったやり方です。このやり方はオブジェクトプールで検索すると色々説明が出てきます。
しかしながら、現状のUdonはアクティブ/非アクティブに関係なくUdonコンポーネントのあるオブジェクトがScene内に多いほどワールドのロードに時間がかかる問題があります(体感)。10個が50個になる程度ならばあまり感じませんが、これが100個が500個になると結構変わります。

一方、ゲーム中にオブジェクトを追加するのは動的生成となります。
VRChatの動的生成はVRCInstantiateメソッドが用意されています。※
Udonを含んだオブジェクトを動的生成すると負荷が体感できるレベルで重い問題があり、その場合ゲーム中というよりはなるべくゲーム開始前や切り替わり(暗転)中に実行する方がいいです。
また、もう一度書きますが、こちらのVRCInstantiateで作成したオブジェクト、一切同期しません。
更に言うと、複製元のオブジェクトのUdonのUpdate関数がなぜか動かなくなります。

※なお、現在のU# 1.0ではVRCInstantiateは非推奨となり、Instantiateが使用できるようになっています。

さて、Magical Lost Dungeonでは様々なアイテム(消費アイテム57種類 装備アイテム67種類)を取り扱えるようにする必要がありました。
また、アイテムは20個まで各プレーヤーでバッグ内に保存できる仕様です。
もちろん、アイテムはOnPickup系のイベントを使うためにUdonコンポーネントの付与が必須です。
となると、いくつ事前に作成すればよいか?という問いかけをした時点でオブジェクトプールは使えないことになります。
対策としては①実体(Pickupをつけるオブジェクト)用のUdonとパラメータ用のUdonを分け、実体を最小限にする②動的生成をする の2択かと考えられますが、実装難易度の都合、②の動的生成を選択しました。

実装:アイテムオブジェクト

同期編で実装したイベントメッセージ同期方式に則るようオブジェクトインスタンスNoで管理されるアイテムオブジェクト(ItemObject)のUdonを用意し、各アイテムごとに設定をしています。
アイテムのデフォルト効果やアイテムドロップのレアリティなどをInspector上で設定できるようにしています。

アイテムオブジェクト「みならいの剣」の例

実装:ObjectSyncに頼らない位置同期

アイテムオブジェクト自体は動的生成のために同期ができませんが、アイテムを持っていることは正しく他環境のプレーヤーに見えている必要があります。
そこで、プレーヤーにつき1つパラメータを管理するManual SyncなUdon(プレーヤーステータス(PlayerStatus))を用意し、持っているアイテム情報(オブジェクトインスタンスNo)と、持ち手とのアイテムの相対位置をPickup時に同期変数に保存させるようにしました。

/// <summary>
/// ピックアップしたプレーヤーの手との相対位置を計測して格納する
/// </summary>
public void CalcItemPickupTra()
{
    if (!vrcPickup.IsHeld) return;
    if (vrcPickup.currentHand == VRC_Pickup.PickupHand.Left)
    {
        if (playerStatus.PickupingItemObjectInstanceNoInLeft != objectInstanceNo) return;
        playerStatus.ItemPosInLeft = Quaternion.Inverse(localPlayer.GetBoneRotation(HumanBodyBones.LeftHand)) * (this.transform.position - localPlayer.GetBonePosition(HumanBodyBones.LeftHand));
        playerStatus.ItemRotInLeft = Quaternion.Inverse(localPlayer.GetBoneRotation(HumanBodyBones.LeftHand)) * this.transform.rotation;
    }
    if (vrcPickup.currentHand == VRC_Pickup.PickupHand.Right)
    {
        if (playerStatus.PickupingItemObjectInstanceNoInRight != objectInstanceNo) return;
        playerStatus.ItemPosInRight = Quaternion.Inverse(localPlayer.GetBoneRotation(HumanBodyBones.RightHand)) * (this.transform.position - localPlayer.GetBonePosition(HumanBodyBones.RightHand));
        playerStatus.ItemRotInRight = Quaternion.Inverse(localPlayer.GetBoneRotation(HumanBodyBones.RightHand)) * this.transform.rotation;
    }
    playerStatus.DoRequest();
}

持っているプレーヤー以外の環境で適用させる場合は下記のようなコードでアイテムの位置を合わせています。

if (isInLeftHand == true) //左手に持っている場合フラグ
{
    this.transform.rotation = player.GetBoneRotation(HumanBodyBones.LeftHand) * tmpPlayerStatus.ItemRotInLeft;
    this.transform.position = player.GetBonePosition(HumanBodyBones.LeftHand) + player.GetBoneRotation(HumanBodyBones.LeftHand) * tmpPlayerStatus.ItemPosInLeft;
}
else
{
    this.transform.rotation = player.GetBoneRotation(HumanBodyBones.RightHand) * tmpPlayerStatus.ItemRotInRight;
    this.transform.position = player.GetBonePosition(HumanBodyBones.RightHand) + player.GetBoneRotation(HumanBodyBones.RightHand) * tmpPlayerStatus.ItemPosInRight;
}

この方式によりObjectSyncを使わずとも見た目上の位置がプレーヤー環境間で一致(ただし、VRChat側のIK計算の差による誤差は生じる)するようになります。

実装:アイテムの重複防止

Magical Lost Dungeonではセーブ機能を用意しているため、通常ではアイテムをバッグから出した後、同じデータをロードすることでいくらでも複製できてしまいます。そこで、簡易対策としてGUIDをアイテム生成時に作成し紐づけています。GUIDは一意に決まる識別子で原則重複することがない値になります(詳しくはググってください)。
アイテム生成時(バッグ取り出し時)にダンジョン上のアイテムのGUIDと比較し、一致していた場合、重複アイテムを削除する仕組みとしています。
GUIDはそのままではイベントメッセージで送れませんので、下記のようにint配列に変換して送っています。

/// <summary>
/// GUIDをイベントメッセージ用の配列int型で返す
/// </summary>
/// <returns></returns>
public int[] GetIntParamFromGuid(Guid tmpGuid)
{
    string guidstr = tmpGuid.ToString("N");
    byte[] guidbyte = tmpGuid.ToByteArray();
    int[] guidint = new int[4];
    //イベントメッセージ用に変換
    guidint[0] = (int)(guidbyte[0]) + (int)(guidbyte[1] << 8) + (int)(guidbyte[2] << 16) + (int)(guidbyte[3] << 24);
    guidint[1] = (int)(guidbyte[4]) + (int)(guidbyte[5] << 8) + (int)(guidbyte[6] << 16) + (int)(guidbyte[7] << 24);
    guidint[2] = (int)(guidbyte[8]) + (int)(guidbyte[9] << 8) + (int)(guidbyte[10] << 16) + (int)(guidbyte[11] << 24);
    guidint[3] = (int)(guidbyte[12]) + (int)(guidbyte[13] << 8) + (int)(guidbyte[14] << 16) + (int)(guidbyte[15] << 24);

    return guidint;
}

Udon製作 ~敵編~

実装:エネミーオブジェクト

同期編で実装したイベントメッセージ同期方式に則るようオブジェクトインスタンスNoで管理されるエネミーオブジェクト(EnemyObject)のUdonを用意し、各敵ごとに設定をしています。
敵のパラメータ(今回の製作では「種族」として定義)や行動ルーチンは別Udonで管理し、NavMesh制御やターゲット率計算といった共通処理を記述しています。
なお、エネミーオブジェクトも動的生成を採用しています。

エネミーオブジェクト「スライム」の例

実装:敵の同期管理

(1)位置同期
NPC(敵)を動かす際、VRChatはNavMeshによるマップ上の経路探索・移動に対応しているため要件を満たすのであれば積極的に使いたいかと思われます。
協力してプレイするゲームワールドでは敵の位置同期が重要となってきますが、敵の位置を毎フレーム送信して上書き続けるのは非効率な上に、動きが滑らかではありません。
そこで、Magical Lost Dungeonでは敵の位置同期は最初のみ行い、NavMeshの目標地点(NavMeshAgent.destination)情報を目標地点を更新するたびに同期する & ターゲットプレーヤー(後述)情報を同期してターゲット方向を向かせるという方法を取ることで"おおむね"同期する敵を作っています。

(2)ターゲット率同期
各エネミーオブジェクトはターゲット率すなわち各プレーヤーに対しての優先度を随時計算しています。接近・攻撃・仲間回復などのプレーヤーのアクションでターゲット率は大きく変動し、一番大きいターゲット率のプレーヤーをターゲットプレーヤーとして接近・攻撃対象としています。
敵と一番向き合う必要があるプレーヤー環境で各種計算が管理されるべきという考えで、エネミーオブジェクトのオーナーはターゲットプレーヤーとしています。オーナーはVRchatのオブジェクトオーナーのことではなく変数ownerIdで管理されている値で、ownerId=ローカルプレーヤーIDを満たす時のみ処理を実行する、といった役割を持ちます。
ターゲット率はオーナー(ターゲットプレーヤー)の環境で実行され、定期的にイベントメッセージでターゲット率を同期しています。その際、オーナー(ターゲットプレーヤー)の切り替えも判定しています。

(3)アクション(アニメーション)同期
敵のアクションはアニメーションで管理しています。
Magical Lost Dungeonの敵のアニメーションはすべて下図のようなシンプルな構成としています。
どのアニメーションを実行するかはアニメーションパラメータ「animState」の値で分岐させており、「animState」パラメータはエネミーオブジェクトでイベントメッセージ経由で更新して制御しています。アニメーション中のイベント発生タイミングはアニメーションクリップ内でAnimation Event(SendCustomEvent(メソッド名))を指定し、エネミーオブジェクトで拾うことで制御しています。

敵のアニメーター

(4)ダメージ同期
敵のダメージ処理は下記順番で実施しています。必ずオーナー環境上で計算させることでタイミングによる結果ズレを起きにくくしています。
①プレーヤーB(非オーナー)が攻撃をプレーヤーB環境でヒットさせた場合、ダメージや状態異常フラグをイベントメッセージ送信
②オーナー環境で敵のパラメータをもとに実ダメージ量や状態異常の発生を判定し、結果をイベントメッセージ送信
③結果として送られてきたダメージ・状態異常を各環境でHPに反映。オーナーは死亡判定を満たした場合、さらに死亡結果をイベントメッセージで送信

Udon製作 ~当たり判定編~

実装:アタックオブジェクト

敵の攻撃・罠・味方の回復といった当たり判定を利用したもののうち敵・プレーヤの当たり判定以外のものはすべてアタックオブジェクトとして管理しています。
アタックオブジェクトはアイテムや敵と異なり、頻繁な出現・消滅が求められるため、オブジェクトプール方式を採用しています。
しかしながら、そのままUdonをオブジェクトに追加するとロード時間の長期化問題が発生するため、
・アタックオブジェクト(AttackObject)
・アタックオブジェクトタイプ(AttackObjectType)
・実体(コライダー・モデル・リジッドボディ)
にオブジェクトを分け、アタックオブジェクトをイベントから生成する時に、挙動を定義した「アタックオブジェクトタイプ」とオブジェクトプールから1つ取り出した「実体」を紐づけして使うことでUdonの数を抑えるようにしました。
(ちなみにこの改修はリリース後に行っています。リリース前=すべてのオブジェクトにUdonを追加している状態ではワールドに入ることもできないという報告が多かったため。。)

ファイアボルト用アタックオブジェクトタイプの例

おわりに

パラメータベースのダメージ計算、セーブ/ロードテキストの作成方法やアイテム・敵のランダム生成などまだ書けることは色々あるのですが、いったん書きたいことを優先で書きました。疑問点あれば可能な限り応えたいとは思います。

1月から開発を開始して途中様々な壁にぶつかりましたが何とか形にできてよかったです。
何かしらの情報が参考になれば幸いです。

ではでは。


この記事が気に入ったらサポートをしてみませんか?