ハツェの真時代傾向璋

興味を持ったことを書いていく鱗片的な場所から先の未来の場

シン・U# 入門 ②

こんばんは。ハツェです。
今回も、Udonスクリプトで制作するための記事を書いていきます。
今回は、Udonの同期関連について話して行けたらなと思っています。
あくまでもU#を扱っているので、この記事はコードを基礎的に書ける方を対象としています。予めご了承ください。
動作環境は、Unity : 2019.4.29f1、VRCSDK : 2021.08.11.15.16、U# : v0.20.2です。

目次


前回

前回は導入と簡単な処理をしました。
hatuxes.hatenablog.jp

今回の概要

今回は同期について紹介します。
変数操作自体は普通のコードとなんら変わりはないですが、同期の方法についてはVRChat独特の概念を持つので、少し混乱するかもしれません。
ですが、しっかりと頭で分けて一つずつ考えれば、内容自体はそこまで難しい話ではないと思います。
また、同期のいろはを書いているため前回よりもかなり長めな記事となっています。あらかじめご了承ください。
では、やっていきましょう!

同期について

SDK2では、ObjectSyncとかTriggerで同期を行っていましたが、Udonにも同期を行う仕組みがあります。
VRC_Trigger等を過去に使用したことが無い状態で初めてUdonを触るという方は、最初に以下の公式動画を見ると良いと思います。
www.youtube.com

基本となる同期は以下の三つです。

  1. ObjectSyncによる同期
  2. 変数単体の同期
  3. 処理の同期

それぞれ説明していきます。

ObjectSyncによる同期

SDK2と同様にObjectSyncを用いると、気軽にオブジェクトの同期が行えます。

f:id:hatuxes:20210319010639p:plain

ここで同期出来るのは、TransformとRigidBodyのみです。
ちなみに、ObjectSyncのOwnerは、最後にオブジェクトを動かした人になるらしいです。
一番下のAllow Ownership Transfer on Collisionは、他のコライダーと接触した時にOwnerを移すかどうかのチェックボックスです。あんまりちゃんと動いてくれないので、オフにしておくのが良いと思います。

また、連続同期設定をしたUdonBehaviorを使用すると、ObjectSyncが同期させるタイプを変更することは出来ます。
Transform同期のスムージングを解除したり、重力やキネマチックの可否を設定することが出来るみたいです。
詳しいことは、公式サイトをご覧ください。
docs.vrchat.com

変数の同期

Udonになって、変数ごとに同期がとれるようになりました。
UdonGraphでいうところのここですね。

f:id:hatuxes:20210319011028p:plain

このSyncはワールド全員に同期するだけでなく、後から来た人にも同期します。
この後から来た人にも同期するというのが、変数同期の一番の強みです。
U#では、変数の属性として[UdonSynced]を指定すると同期変数になります。
ただし、この属性をつけた変数はそのUdonBehaviorのOwnerにしか編集出来なくなるため、変数をいじる際にはSetOwner()等でOwnerを渡す必要が出てきます。

ちなみに、対応している変数の型はbool, char, byte, sbyte, short, ushort, int, uint, long, ulong, float, double, string, Vector2, Vector3, Vector4, Quaternion, VRCUrl, Color, Color32ですが、配列の同期に関しては現在string, VRCUrlのみ同期出来ないので注意が必要ですです。現在は変数か配列か気にせず同様の型が同期可能となっています。

変数の同期方法

変数を同期させる方法は二種類存在します。

  1. Continuous Sync - 連続同期
  2. Manual Sync - 手動同期

どの手法で同期させるかはGameObjectごとに決めることができ、加えて同期方法を指定する方法は、Inspectorから選択する方法とスクリプトから指定する方法の二種類があります。

Inspectorから同期方法を指定する

Inspectorから選択する場合、Synchronization Methodから選択出来ます。

f:id:hatuxes:20210319012805p:plain

欠点としては、複数人で扱うプロジェクトの場合、他人に勝手に変更されてしまう危険性もあるので注意が必要です。

スクリプト内から同期方法を指定する

一方、スクリプトから指定する場合、クラスの属性として指定します。

[UdonBehaviourSyncMode(BehaviourSyncMode.Continuous)]
public class ClassName : UdonSharpBehaviour { }

設定はAny Continuous Manual NoVariableSyncの四つから指定できます。 Any 以外を設定した場合、Inspectorから変更出来ないようにすることが出来ます。
f:id:hatuxes:20210319015657p:plain

また、NoVariableSyncを指定すると、同期変数が存在するコードを記述した場合にコンパイルエラーを出してくれるようになります。

f:id:hatuxes:20210522160729p:plain

便利でもありますが、裏では自動的に連続同期にされるようなので、通信量を少しでも節約したい人はManualを指定しておくと良いと思います。

Continuous Sync- 連続同期

連続同期とは、プレイヤーが更新するタイミングを指定せず、自動的に変数の値を同期させ続ける手法であり、現在はおおよそ0.2~2秒間隔で値を同期させ続ける仕様になっています。
例えば、色情報を常に更新し続けたいみたいな時に使えますね。
常に更新し続けるというのは便利なときもありますが、反対にこの特性から他の通信のために帯域を節約する理由で同期が更新されないこともあるという仕様になっています。つまり、同期の優先度は低めに設定されているということです。そのため、全ての同期設定をこれにするのはあまり良い手法ではないかな思います。

Manual Sync- 手動同期

手動同期とは、任意のタイミングでプレイヤーが変数の値を同期させる手法です。
基本的にこっちの方が確実に値を同期してくれます。
手動で値を更新するので、更新するためには変数の値を変更した後、UdonBehavior.RequestSerialization()を呼んであげると次の通信時に更新されます。
こちらは手動で更新するため帯域を喰う心配はないですが、弱点として唯一挙げるとするとならば、ObjectSyncを扱えないということがあります。

処理の同期

処理同期は、変数同期とは異なります。
例えば銃から弾を発射するというギミックを考えた際、弾が銃口から発射されるという処理が同期していないと完成しないわけです。
これはこれで便利なのですが一つ注意点として、変数同期と異なり現在のインスタンスにいる人には同期されますが、その後に入ってきた人には同期されません。この点は変数同期とかなり異なります。

その処理同期ですが、使うのはSendCustomNetworkEventです。
SendCustomNetworkEventは同期させたい処理を記述している関数の名前を指定してあげることで、処理を同期させることが出来ます。
関数名を指定する手法であるため、現状引数を扱う関数をSendCustomNetworkEventで呼ぶことは出来ません。引数を用いる手法は、要望が多いのでそのうち実装されるかなと...。
因みにUdonGraphにももちろん同じノードがあります。

f:id:hatuxes:20210319014210p:plain

Ownerを渡す

先述しましたが、同期変数はOwnerのみ編集することが出来ます。
そのため、別の人が同期変数に変更を加えるような操作を行う場合、基本的にはその人をOwnerにさせてあげる必要があります。
具体的にOwnerを別の人に移す場合、Network.SetOwnerを呼べば良いです。
Network.SetOwner関数を呼んだ場合、以下の一連の処理が発生します。

  1. (必須) Networking.SetOwner()を呼び、オーナー権限のリクエストをする。
  2. (任意) リクエストが発生した際にはOnOwnershipRequestイベントが呼ばれるので、そのリクエストを許可する場合はtrueを返す。falseを返した場合、Ownerは譲渡されずにこのまま処理が終わる。
  3. (任意) 前述の処理でtrueを返した場合はオーナーが譲渡され、その後OnOwnershipTransferredイベントがインスタンス内のすべてのユーザーのGameObjectで呼び出される。


SDKから移行してきた方へ
特徴的な箇所としては、リクエストの可否が出来るようになった点だと思います。
Udonをアップデートする過程で、旧Udonから移行してきて既存のシステムをそのまま使いたいと思う方も多いと思います。
仮にOnOwnershipRequestを記述しなかった場合、無条件でOwnerを移行する仕様になる(以下のコードを記述した時と同じ処理になる)ので特にコードを変更しなくても対応出来るため、そこまで意識する必要はありません。

public override OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner)
{
    return true;
}


実例

ここからは、いくつか例になるものを作っていこうと思います。
以下で使用するCanvasとParticleは、全てこのunitypackageに入れてあります。
必要に応じて使用してみてください。(自由に使用して結構です)
drive.google.com

パーティクルを発射する装置を作る

まずは処理同期からやってみましょう。
概要としては、Cubeをインタラクトしたら上からパーティクルが数秒ポロポロと落ちてくるものです。
まず、インタラクトに使用するCubeと以下からダウンロードしたパーティクルをシーンに設置します。
だいたいこんな感じで設置します。

f:id:hatuxes:20200413162524p:plain

そしたらCubeに以下のようなスクリプトをアタッチしましょう。

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

public class PlayParticle : UdonSharpBehaviour
{
    public ParticleSystem Particle;  // 再生するパーティクル本体

    public override void Interact()
    {
        // パーティクルが再生されてなかったら、再生する
        if (!Particle.isPlaying)
        {
            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, "Emit");
        }
    }

    public void Emit()
    {
        Particle.Play();
    }
}


これで、一応完成となります。少し解説しますね。
気になる所は20行目のここ。

if (!Particle.isPlaying)
{
    SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, "Emit");
}

SendCunstomNetworkEventが処理同期のための関数ということは同期の説明時にお話ししました。
この関数は第一引数に処理を同期させる人の種類、第二引数に同期させる処理を記述した関数の名前を指定します。
第一引数は現状VRC.Udon.Common.Interfaces.NetworkEventTarget.AllVRC.Udon.Common.Interfaces.NetworkEventTarget.Ownerの二つから選べます。
Allの方はインスタンスにいる全員に関数内の処理を実行します。
一方Ownerの方は、オーナーにのみ関数内の処理を実行します。
加えて、ifの中身はパーティクルの重複起動防止のために再生中かどうかの判定を行っています。
ちなみに、SendCunstomNetworkEventで呼ぶ関数はpublicである必要があります。

実行

実行画面は以下の通りです。(三人称視点の映像)

f:id:hatuxes:20200413165038g:plain

押した人は別の人なのに、ちゃんと自分の画面にもパーティクルが降り注いでますね。
これで処理同期が確認出来たことになりました。

カウントアップする装置を作る

次は、カウントアップ装置を作りながら変数同期の例を作成していきます。
まず始めに、上記のprefabの名前とかを少し変更して、以下のように三つのCanvasを作成しました。全く同じ感じでも良いですし、好きにアレンジしても構いません。
内容は左から、ローカル版、Owner経由版、Owner変更版として使って行く予定です。

f:id:hatuxes:20210519184305p:plain

まずはローカルのカウントアップ装置を作る

最初に、ベースとなるカウントアップ装置を作っていきます。ここは同期とは一切関係ありません。
概要としては、Cubeをインタラクトしたら数字が1ずつ増えていくものを作ります。
インタラクトさせるCubeにUdonBehaviorを追加して、以下のスクリプトをアタッチします。

using UdonSharp;
using UnityEngine.UI;

public class Local_Countup_System : UdonSharpBehaviour
{
    public Text DisplayDataText;  // データを表示するためのText

    private int _countData;           // データ本体

    void Update()
    {
        // 常時データをTextに出力する
        DisplayDataText.text = _countData.ToString();
    }

    // Cubeをインタラクトすることで、値を+1する処理
    public override void Interact()
    {
        _countData++;
    }
}


仕組みは単純で、インタラクトしたらint型の変数_countDataを1ずつ増やしていくだけです。

実行

実行画面は以下の通りです。

f:id:hatuxes:20210520144716g:plain

今度は、これを同期させてみましょう。

Ownerに加算処理をさせることで同期変数を操る

同期の説明時にもお話ししましたが、同期変数はそのUdonBehaviorのOwnerしか変更出来ません。
最初の手法では、Ownerに処理を任せることで同期変数を扱ってみようというパターンを紹介します。
ローカル版と同様、Cubeに以下のスクリプトをアタッチします。

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

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class ViaOwner_Countup_System : UdonSharpBehaviour
{
    [UdonSynced(UdonSyncMode.None)] private int _countData;        // データ本体

    public Text DisplayDataText;            // データを表示するText
    public Text OptionText;                 // 誰がOwnerかを表示するText



    private void Start()
    {
        // Onwerかどうかを表示
        SetOptionalText(Networking.LocalPlayer);
    }



    // Cubeをインタラクトしたときに呼ばれる
    public override void Interact()
    {
        var player = Networking.LocalPlayer;

        if (player.IsOwner(this.gameObject))
        {
            // Ownerが押したら、純粋にカウントアップする
            CountUp();
        }
        else
        {
            // Owner以外が押したら、Ownerにカウントアップさせるように命令する
            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(CountUp));
        }
    }

    // Owner以外のデータ表示処理
    public override void OnDeserialization()
    {
        DisplayDataText.text = _countData.ToString();
    }



    // Ownerが値を+1する処理
    public void CountUp()
    {
        _countData++;                                   // データ更新
        RequestSerialization();                     // 同期更新
        DisplayDataText.text = _countData.ToString();   // データ表示更新
    }

    // プレイヤーがOwnerかどうかをテキストで表示させる処理
    public void SetOptionalText(VRCPlayerApi player)
    {
        if (player.IsOwner(this.gameObject))
        {
            // Owner側の処理
            OptionText.text = $"<color=red>{player.displayName} is Owner!</color>";
        }
        else
        {
            // Owner以外の処理
            OptionText.text = $"{player.displayName} isn't Owner";
        }
    }
}


少し情報が多いので、一つずつ紹介していきましょう。

同期変数の書き方

まずは、後から来た人にも同様な値を見せたい変数に[UdonSynced]属性をつけます。

[UdonSynced(UdonSyncMode.None)] private int _countData;

UdonSyncModeは同期の仕方を設定するオプションです。
設定はNone Linear Smooth NotSyncedの四つから指定できます。
それぞれただの同期 リニア補間同期 スムーズ補間同期 同期しないです。
Linear Smoothについては、連続同期でのみ使用出来ます。手動同期では使えないので注意が必要です。
それぞれ動かしてみた感じとしては以下の通りで、体感としては下図のような感じになります。

f:id:hatuxes:20200413161827p:plain

上記動画を見てもわかる通り、IntやFloatなら問題ないですが、Quaternion等のLinearSmoothが正常に同期されていないため、素直にNoneを使用するのがオススメです。
また、今回は同期変数を使用し、かつ手動同期を用いるため、クラスの属性に[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]を付けました。(クラス属性を付けずにInspectorからManualSyncを選択しても構いません)

ManualSyncで変数を同期させる

同期の説明時にもお話ししましたが、UdonBehavior.RequestSerialization()を呼んだフレームから次の通信(正確には次のネットワークティック)で他の人にも同期されます。
難しいことを言っているように聞こえますが、結局RequestSerialization()関数を呼べば同期してくれるわけです。
実際に、変数を同期させている箇所はCountup()関数内で行っていました。

// Ownerが値を+1する処理
public void CountUp()
{
    _countData++;                                   // データ更新
    RequestSerialization();                     // 同期更新
    DisplayDataText.text = _countData.ToString();   // データ表示更新
}

まず、同期変数の値を+1し、その値を他の人にも同期させ、最後に自分の表示を更新するという流れです。
この関数はOwnerのみが実行されるように作っているため、問題はありません。

処理をOwnerに渡す

処理をOwnerに渡す方法は、SendCustomNetworkEventの第一引数をOwnerにするだけです。ちなみに、第二引数の関数名にはnameof()関数を用いると、楽にコードが書けるのでオススメです。

SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, "関数の名前");

今回は、インタラクトしたプレイヤーがOwnerかどうかで処理を分けました。

public override void Interact()
{
    var player = Networking.LocalPlayer;

    if (player.IsOwner(this.gameObject))
    {
        // Ownerが押したら、純粋にカウントアップする
        CountUp();
    }
    else
    {
        // Owner以外が押したら、Ownerにカウントアップさせるように命令する
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(CountUp));
    }
}

Ownerの場合は普通に処理を実行し、そうでない場合はOwnerに同じ処理を渡しています。
今回は説明のために丁寧に作っていますが、結局処理がOwnerに渡る意味では条件分岐なんかせずに、SendCustomNetworkEventだけInteract()関数内に書いても問題ないと思います。

Owner以外に同期データを反映させる

これはテクニックですが、OnDeserialization()内に同期変数を扱ったデータに関する処理を書くのが良いです。
Update()じゃないの?と思う方もいると思いますが、OnDeserialization()自体が同期変数を受け取った時に呼ばれる関数になっています。そのため、データの確実性的にOnDeserialization()内に書いた方が良いという訳です。
今回は、Owner以外の人に向けて同期変数_countDataの表示処理をOnDeserialization()内に書いています。

// Owner以外のデータ表示処理
public override void OnDeserialization()
{
    DisplayDataText.text = _countData.ToString();
}


追記
現在では、同期変数を扱う処理はOnDeserializationよりOnValueChangedを用いる方が良いとされています。
理由は、OnDeserializationよりも更新速度が速いため、OnDeserializationを用いた場合に同期ズレが発生していたような処理でもOnValueChangedを用いた場合は素直に同期処理出来るからです。
OnValueChangedについては、C#でいうプロパティの仕組みを用いるため、当記事では取り上げていません。
当記事を読んで同期の仕組みが理解できた上で気になる方は、以下の別記事を見てみることをオススメします。
hatuxes.hatenablog.jp

実行

実行画面は以下の通りです。(三人称視点の映像)
他人が押しても、自分が押しても問題なく動いています。英文が間違ってるのはミスです...

f:id:hatuxes:20210520153828g:plain

Ownerを移して自分で同期変数を操る

今度はもう一つの方法として、自分がOwnerになって同期変数を操作するパターンを紹介します。
ローカル版と同様、Cubeに以下のスクリプトをアタッチします。

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

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class OwnerTransfer_Countup_System : UdonSharpBehaviour
{
    [UdonSynced(UdonSyncMode.None)] private int _countData;      // データ本体

    public Text DisplayDataText;          // データを表示するText
    public Text OptionText;               // 誰がOwnerかを表示するText



    private void Start()
    {
        // Onwerかどうかを表示
        SetOptionalText(Networking.LocalPlayer);
    }



    // Cubeをインタラクトした時に呼ばれる
    // 新にOwnerになって、データを自身で更新する
    public override void Interact()
    {
        var player = Networking.LocalPlayer;
        Networking.SetOwner(player, this.gameObject);

        if (player.IsOwner(this.gameObject))
        {
            CountUp();
        }
    }

    // Owner以外のデータ表示処理
    public override void OnDeserialization()
    {
        DisplayDataText.text = _countData.ToString();
    }

    // SetOwnerのリクエストが飛んだ際に呼ばれる関数
    // 譲渡の可否を返す必要がある
    // trueを返すだけなので、書かなくても良い
    public override bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner)
    {
        return true;  // 譲渡を許可
    }

    // Ownerが移行した際の処理
    // このイベントはインスタンスにいる全員に発行されるので、Network.LocalPlayerを用いる
    // 引数のplayerは新たなオーナーを指す
    public override void OnOwnershipTransferred(VRCPlayerApi player)
    {
        SetOptionalText(Networking.LocalPlayer);
    }




    // Ownerが値を+1する処理
    public void CountUp()
    {
        _countData++; // データ更新

        // Owner変更後に即時更新すると、OnDeserializationに追いつかないことがあるため。少し遅延させている
        SendCustomEventDelayedSeconds(nameof(SerializeData), 0.4f);


        DisplayDataText.text = _countData.ToString();   // データ表示
    }

    // プレイヤーがOwnerかどうかをテキストで表示させる処理
    public void SetOptionalText(VRCPlayerApi player)
    {
        if (player.IsOwner(this.gameObject))
        {
            // Owner側の処理
            OptionText.text = $"<color=red>{player.displayName} is Owner!</color>";
        }
        else
        {
            // Owner以外の処理
            OptionText.text = $"{player.displayName} isn't Owner";
        }
    }

    public void SerializeData()
    {
        RequestSerialization();       // 同期更新
    }
}


仕組みは先程のOwnerに処理を渡す方法と同じです。そのため、異なっている箇所のみ説明を挟もうと思います。

Ownerを渡す流れ

同期の説明時にもお話ししましたが、SetOwner()をするとOnOwnershipRequest, OnOwnershipTransferredと順に処理が発生します。
今回の例で同じ箇所を追ってみましょう。
今回は、まずインタラクト時にSetOwner()を呼びました。

// Cubeをインタラクトした時に呼ばれる
// 新にOwnerになって、データを自身で更新する
public override void Interact()
{
    var player = Networking.LocalPlayer;
    Networking.SetOwner(player, this.gameObject);

    if (player.IsOwner(this.gameObject))
    {
        CountUp();
    }
}

その後、OnOwnershipRequest内でOwnerの譲渡を無条件で許可するように記述しました。

// SetOwnerのリクエストが飛んだ際に呼ばれる関数
// 譲渡の可否を返す必要がある
// trueを返すだけなので、書かなくても良い
public override bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner)
{
    return true;  // 譲渡を許可
}

そして、Ownerが受け渡された時に自身がOwnerかどうかのテキスト表記を更新するという書き方をしました。

// Ownerが移行した際の処理
// このイベントはインスタンスにいる全員に発行されるので、Network.LocalPlayerを用いる
// 引数のplayerは新たなオーナーを指す
public override void OnOwnershipTransferred(VRCPlayerApi player)
{
    SetOptionalText(Networking.LocalPlayer);
}

ちなみに、OnOwnershipTransferredの引数のplayerは新しくOwnerになったプレイヤーを指し、Network.LocalPlayerは単純にローカルプレイヤーを指します。
OnOwnershipTransferredは、Ownerだけとかではなくインスタンスの全員に発行されるので、この違いには注意が必要です。

実行

実行画面は以下の通りです。(三人称視点の映像)
他人が押しても動作しており、自分が押した際にもしっかりとOwnerが移った状態で変更されていることが分かります。英文が間違ってるのはミスです...

f:id:hatuxes:20210520155120g:plain


以上です!お疲れ様でした。
今回も、サンプルプロジェクトはGithubに置いておきます。
github.com

次回

次回は、UIでUdonを使う方法について触れていきます。
hatuxes.hatenablog.jp