ハツェの真時代傾向璋

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

UdonのLate-Joiner対応3選

こんばんは。ハツェです。お久しぶりです。
Udonに関する様々な記事や作成例が公開されてきたおかげか、Udonで面白いものを作る人が増えましたね。嬉しい話です。
さて今回は、VRChat Advent Calendar 2021の記事として参加しており、主に同期周りの話をしようと思います。
動作環境は、Unity : 2019.4.31f1、VRCSDK : 2021.11.24.16.19、U# : v0.20.3です。

目次


今回の概要

今回はワールドのインスタンスに後からJoinしてきた人(以降Late-Joinerと呼ぶ)に対して、それまでの処理や変数を同期させる方法について語ります。
私自身も作っていてどう作るんだっけ?って悩むことが何回かあったので、情報をまとめる感じで伝えられたらと思います。
それではやっていきましょう!

前置き

今回は一応、bool, int, animationの三つを同期させる方法を各種紹介します。

f:id:hatuxes:20211214144748p:plain

また、変更させるAnimatorは以下の通りになっているものとします。

f:id:hatuxes:20211214171804p:plain

その① : 素直に同期変数を用いる

最初は王道の方法を紹介します。というか特段の事情が無ければ、同期変数を用いてあげる方が早いですし、安定しますし、公式も推奨しているぐらいなので、この方法がいいと思います。
Late-Joiner対応は、OnValueChangedの仕組みで勝手にやってくれます。

スクリプト

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

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class UseSyncVariable : UdonSharpBehaviour
{
    private const string DEFAULT_STATE_NAME = "Init";
    private const string ANIMATION_STATE_NAME = "PingPong";

    [SerializeField] private Text _boolLabel;
    [SerializeField] private Text _intLabel;
    [SerializeField] private Animator _animator;

    [UdonSynced, FieldChangeCallback(nameof(BoolData))] 
    private bool _boolData;
    [UdonSynced, FieldChangeCallback(nameof(IntData))] 
    private int _intData;
    [UdonSynced, FieldChangeCallback(nameof(AnimData))]
    private bool _animData;

    public bool BoolData
    {
        get => _boolData;
        set
        {
            _boolData = value;

            // 実際の処理
            _boolLabel.text = $"bool: {_boolData}";
        }
    }

    public int IntData
    {
        get => _intData;
        set
        {
            _intData = value;

            // 実際の処理
            _intLabel.text = $"int: {_intData}";
        }
    }

    public bool AnimData
    {
        get => _animData;
        set
        {
            _animData = value;

            // 実際の処理
            _animator.Play(_animData ? ANIMATION_STATE_NAME : DEFAULT_STATE_NAME);
        }
    }


    public void ToggleBool()
    {
        // 同期変数変更するためのオーナー譲渡
        SetOwner();

        // 値変更
        BoolData = !BoolData;
        RequestSerialization();
    }

    public void AddInt()
    {
        // 同期変数変更するためのオーナー譲渡
        SetOwner();

        // 加算処理
        IntData++;
        RequestSerialization();
    }

    public void ToggleAnimation()
    {
        // 同期変数変更するためのオーナー譲渡
        SetOwner();

        // 値変更
        AnimData = !AnimData;
        RequestSerialization();
    }


    private void SetOwner()
    {
        Networking.SetOwner(Networking.LocalPlayer, this.gameObject);
    }
}

メリット

  • SDKベースに作れるので楽
  • 同期ミスがほとんどない
  • setterに処理内容を書いておけば良いので、値を変更するだけで動いてくれる
  • Late-Joinerのためのコードが必要ない

デメリット

  • 同期変数の数に限りがある

その② : 一度Ownerに問い合わせて同期させる

Ownerの値に応じて、それに適した処理をSendCustomNetworkEventで全員に送る方法です。引数を持てない現状では、boolのような二値データの処理に使うぐらいが限界です。intのような想定出来ない値になるもの対しては数値範囲を制限して使う場合に限り、この方法が使えると思います。
Late-Joiner対応は、OnPlayerJoined関数内で行っています。

スクリプト

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

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class ViaOwnerRequest : UdonSharpBehaviour
{
    private const string DEFAULT_STATE_NAME = "Init";
    private const string ANIMATION_STATE_NAME = "PingPong";

    [SerializeField] private Text _boolLabel;
    // [SerializeField] private Text _intLabel;
    [SerializeField] private Animator _animator;

    private bool _boolData;
    // private int _intData;
    private bool _animData;

    public override void OnPlayerJoined(VRCPlayerApi player)
    {
        // Late-Joiner対応箇所
        if (Networking.LocalPlayer.IsOwner(this.gameObject))
        {
            // Ownerがtrueの状態になっていたら、一度全員にtrue処理を渡す
            if (_boolData)
            {
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(BoolTrueProcess));
            }
            if (_animData)
            {
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(AnimationTrueProcess));
            }
        }
    }


    public void ToggleBool()
    {
        // _boolData = !_boolDataと同じ処理内容
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, _boolData ? nameof(BoolFalseProcess) : nameof(BoolTrueProcess));
    }

    public void BoolTrueProcess()
    {
        // _boolDataがfalseの人にだけ処理させる(Late-Joiner対応にも使うため)
        if (!_boolData)
        {
            _boolData = true;
            // 実際の処理
            UpdateBoolLabel();
        }
    }

    public void BoolFalseProcess()
    {
        _boolData = false;
        // 実際の処理
        UpdateBoolLabel();
    }


    public void AddInt()
    {
        // SendCustomNetworkEventに引数が使えたら、以下のようなコードで処理が出来そう
        // _intData++;
        // SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, ReceivedIntProcess(_intData));
    }

    public void ToggleAnimation()
    {
        // _animData = !_animDataaと同じ処理内容
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, _animData ? nameof(AnimationFalseProcess) : nameof(AnimationTrueProcess));
    }

    public void AnimationTrueProcess()
    {
        // _animDataがfalseの人にだけ処理させる(Late-Joiner対応にも使うため)
        if (!_animData)
        {
            _animData = true;
            // 実際の処理
            UpdateAnimation();
        }
    }

    public void AnimationFalseProcess()
    {
        _animData = false;
        // 実際の処理
        UpdateAnimation();
    }


    private void UpdateBoolLabel()
    {
        _boolLabel.text = $"bool: {_boolData}";
    }

    private void UpdateAnimation()
    {
        _animator.Play(_animData ? ANIMATION_STATE_NAME : DEFAULT_STATE_NAME);
    }
}

メリット

  • 同期変数を用いない
  • 連打されても比較的崩れない

デメリット

  • 関数の数が増えるので、コードが煩雑になりやすい
  • 値一個ずつに対して関数を作成するので、intのようなほぼ無限に関数が必要になるような場合には使えない

その③ : ObjectSyncを活用して同期させる

最後は同期はObjectSyncに任せて、そのtransformの値を使って疑似的に同期処理をしようという方法です。
Late-Joiner対応は、同じくOnPlayerJoined関数内で行っています。

f:id:hatuxes:20211214170547g:plain

スクリプト

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

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class UseTransformData : UdonSharpBehaviour
{
    private const string DEFAULT_STATE_NAME = "Init";
    private const string ANIMATION_STATE_NAME = "PingPong";

    [SerializeField] private Text _boolLabel;
    [SerializeField] private Text _intLabel;
    [SerializeField] private Animator _animator;
    [SerializeField] private Transform _transformForData;


    public override void OnPlayerJoined(VRCPlayerApi player)
    {
        // Late-Joiner対応箇所
        if (player.isLocal)
        {
            // Late-Joinerにだけ、ObjectSyncのTransform値を再取得させる
            UpdateBoolLabel();
            UpdateIntLabel();
            UpdateAnimation();
        }
    }


    public void ToggleBool()
    {
        SetOwner();

        // x座標が四捨五入で1ならtrue、0ならfalseという扱い
        // 処理内容はbool値を反転させているだけ
        var position = _transformForData.position;
        position.x = Mathf.RoundToInt(position.x) == 1 ? 0 : 1;
        _transformForData.position = position;

        // 全員にテキストの更新をさせる
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(UpdateBoolLabelBody));
    }

    public void UpdateBoolLabel()
    {
        // すぐ実行すると、Owner以外は同期に時間がかかるため、誤った結果にならないように遅延させている
        SendCustomEventDelayedSeconds(nameof(UpdateBoolLabelBody), 0.1f, VRC.Udon.Common.Enums.EventTiming.Update);
    }

    public void UpdateBoolLabelBody()
    {
        // 実際の処理
        _boolLabel.text = $"bool: {Mathf.RoundToInt(_transformForData.position.x) == 1}";
    }

    public void AddInt()
    {
        SetOwner();

        // y座標の値をそのままintの値とする
        // 処理内容はintをインクリメントしているだけ
        var position = _transformForData.position;
        position.y = Mathf.RoundToInt(position.y) + 1;
        _transformForData.position = position;

        // 全員にテキストの更新をさせる
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(UpdateIntLabel));
    }

    public void UpdateIntLabel()
    {
        // 実際の処理
        _intLabel.text = $"int: {Mathf.RoundToInt(_transformForData.position.y)}";
    }

    public void ToggleAnimation()
    {
        SetOwner();

        // z座標が四捨五入で1ならtrue、0ならfalseという扱い
        // 処理内容はbool値を反転させているだけ
        var position = _transformForData.position;
        position.z = Mathf.RoundToInt(position.z) == 1 ? 0 : 1;
        _transformForData.position = position;

        // 全員にテキストの更新をさせる
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(UpdateAnimation));
    }

    public void UpdateAnimation()
    {
        // すぐ実行すると、Owner以外は同期に時間がかかるため、誤った結果にならないように遅延させている
        SendCustomEventDelayedSeconds(nameof(UpdateAnimationBody), 0.1f, VRC.Udon.Common.Enums.EventTiming.Update);
    }

    public void UpdateAnimationBody()
    {
        // 実際の処理
        _animator.Play(Mathf.RoundToInt(_transformForData.position.z) == 1 ? ANIMATION_STATE_NAME : DEFAULT_STATE_NAME);
    }


    private void SetOwner()
    {
        // UdonとObjectSync両方のOwnerになる必要がある
        var localPlayer = Networking.LocalPlayer;
        Networking.SetOwner(localPlayer, this.gameObject);
        Networking.SetOwner(localPlayer, _transformForData.gameObject);
    }
}

メリット

  • 同期変数を用いない

デメリット

  • データ用のTransformが原点から離れすぎると、浮動小数の精度が悪くなり、座標がブレブレになる
  • コードが煩雑
  • 処理を連続で行うと、値が人によってズレる

おわりに

今回はLate-Joinerにも使える同期方法を三つ紹介しました。
結論としてはやっぱり同期変数を使う方法が一番良いです。何しろ記述が楽ですし、安定してデータが届きます
他二つも紹介しましたが、特段の理由がない限りは使わない方が良いでしょう。私としてはオススメ出来ません。
今回のサンプルはGithubに置いてあります。
ライセンスを確認したうえで、お使いください。
github.com
以上です。
VRChat Advent Calendar 2021の明日の記事は、bd_さんの記事になりますー

一定間隔で処理をするやつの正攻法と裏技

こんばんは。ハツェです。
今回はミニtipsみたいなお話です。
SDK2時代のOnTimerをUdonならどう実装するかみたいな話です。
動作環境は、Unity : 2019.4.30f1、VRCSDK : 2021.09.03.09.25、U# : v0.20.3です。

目次


今回の概要

一定間隔で処理を行う方法の一例を紹介します。
参考程度に見てもらえればと思います。

通常の例

だいたい「Unity 一定間隔」とかで検索すると、Updateで時間ごとに処理を走らせるコードが出てくると思います。

[SerializeField] private float _timeInterval;
private float _timeElapsed;

private void Update()
{
    _timeElapsed += Time.deltaTime;
    
    if (_timeElapsed  > _timeInterval)
    {
        /*-- 一定間隔で実行したい処理 --*/

        // 経過時間を元に戻す
        _timeElapsed = 0f;
    }
}

これ自体はよくある実装例であり、かつ単純なコードで済みます。

DelayedSecondsを使った例

一方で、コルーチンを使った方法というのもまたUnityに存在しています。
ですが、Udonではコルーチンを使うことは現状出来ません。
しかし、SendCustomEventDelayedSecondsを使うことで似たようなことが出来ます。

[SerializeField] private float _timeInterval;

private void Start()
{
    DoFuction();
}

public void DoFuction()
{
    /*-- 一定間隔で実行したい処理 --*/

    if (_timeInterval > 0)
    {
        SendCustomEventDelayedSeconds(nameof(DoFuction), _timeInterval);
    }
}

この方法を使うことで、Updateの毎フレーム処理を軽減することが出来ます。
しかし、SendCustomEventDelayedSecondsは中断機能などを備えていないため、一度実行させてしまったら、対象の関数は絶対実行されます。
なので、やっぱり途中でやめる!みたいなものを追加したい場合は、if文に条件を加えたりする必要があると思います。

あとがき

今回は、SendCustomEventDelayedSecondsを使ったOnTimer代替実装を紹介しました。
おそらく非同期実装だと思っているので、複雑なものを実装するときは少し躊躇するかなとも思っています。
他の方法としては過去に、Sphereをバウンドさせ、着地した時に処理を走らせることで、一定間隔ごとに処理を行わせるみたいなテクニックもありました。
それ以外にも面白い方法とかありましたら、是非ともコメントにて教えて頂けると記事ネタにもなるので助かります。
以上です!ありがとうございました。