UdonSharp開発Tips パート2 (2024/2版)

京セラコミュニケーションシステム株式会社 技術開発センター
ICT技術開発部 先端技術開発課の上籠です。

初めてのQiita投稿となります。
本記事は[VRChatのワールド制作で使用できるUdonSharpの開発Tips](https://qiita.com/kccs_satoshi-koide/items/02e755ce35419a5989cb)のパート2になります。
今回は詳解編として、Udonの制約や同期関連の注意点などを中心に取り上げていきます。

## はじめに
### VRChatワールド "Kyocera Mobile World" について

https://vrchat.com/home/launch?worldId=wrld_16cda762-4503-4074-ae90-45e8bddaf407

先日VRChatで公開され好評をいただいたKyocera Mobile Worldですが、
京セラコミュニケーションシステム株式会社も開発に協力しております。

### 執筆経緯と目的
私はVRChatで2年ほどUdonSharpに触れてきました。
今回Kyocera Mobile Worldの物流倉庫のギミックを担当したのですが、その中で詰まりやすい点が多々あると感じたので、いくつかピックアップして共有したいと思います。

なお、UdonSharpおよびVRCSDKは日々更新されておりますので、2024年2月現在の情報としてご認識ください。

### 対象読者
* ある程度UdonSharpでコードを書いたことのある方
* UdonSharpの同期について基本的な知識のある方（同期モードContinuous, Manualの違いなど）
* VRChatを普段から使っており、ワールド制作でUdonSharpを使ってみたい方
* UdonSharpを使ってみたいが、実際に組む前に注意点を確認しておきたい方
### 各種バージョン情報
* Unity 2019.4.31
* VRChat SDK Base/Worlds 3.4.2

## Udonの制約

### UdonをアタッチしたGameObjectの数が多いとロードに時間がかかったり、ロードが終わらなくなったりする
100個前後までであればロードされるのですが、数百個程度になるとロードが極端に遅くなったり、そもそもロードが終わらなくなったりします。
そのためにUdonをアタッチしたGameObjectの数が多くならないように意識して設計する必要があります。

Kyocera Mobile Worldの物流体験エリアでは、ピックアップできる荷物に加えて、パレット、伝票、棚の荷物置き場とQRコードがありますが、この中でUdonが付いているのはピックアップできる荷物のみになっています。
他のオブジェクトについてはメインとなるギミックのManager側であらかじめGameObjectの参照を持っておき、OnColliderEnterやRaycastHit時にどのオブジェクトなのかを解決するといった方法やチェックしたいタイミングでオブジェクトの座標を元に判断するといった方法で実装しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583903/73073382-f5c4-9dc6-4414-c3a229ba16f0.png)



## 同期関連

### 位置同期オブジェクトの数はできるだけ少なくする
VRChatにおいてオブジェクトの位置や姿勢を同期したい場合は`VRCObjectSync`コンポーネントをアタッチすることが一般的です。
しかしこの`VRCObjectSync`は同期を継続的に行うため、多くの数を設置するとトラフィックを圧迫します。特にワールドにJoinしている方が増えるとその負荷が顕著になり、同期が不安定になります。

Kyocera Mobile Worldの物流体験コーナーにおいて`VRCObjectSync`が付与されているのはピックアップできる荷物・伝票のみとなっています。

また各プレイヤーが持っているスマートフォンの位置同期については`VRCObjectSync`を使用せず、独自に実装しています。
他のプレイヤーからは、持ち主となるプレイヤーが掴んでいる間のみスマートフォンが見えるようにしており、負荷の軽減を図っています。また掴んでいる間のみ手のボーン（非VRの場合は頭のボーン）に追従させることで、わずかなずれは発生するものの、掴んだ時と離した時に状態変化とオフセット位置・姿勢のみを同期するだけで済むようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583903/e36de56a-eae6-bc02-0cea-e5fd87700c8d.png)

### ワールドにJoinしてからUdonの同期が行われるようになるまで時間がかかる
Start()が呼び出された段階ではまだ同期用のネットワークが初期化されていません。そのため同期変数を使ったりOwnerを取得したりといったネットワークを使った処理を行おうとすると意図せぬ結果になります。
ネットワーク関連の処理を行う場合は、Start()が呼び出された後10秒ほど待機すると安定します。
`Networking.IsNetworkSettled`というネットワークの初期化が完了したかどうかを示すプロパティが[SDKで用意されている](https://udonsharp.docs.vrchat.com/vrchat-api#properties-1)のですが、こちらの確認だけでは安定しないことがありました。
```cs
    // [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] の想定
    
    private bool isInitialized = false;
    
    private void Start()
    {
        // ネットワークに関係ない部分の初期化処理を行う

        // 10秒経過したらネットワーク部分の初期化処理を行う
        SendCustomEventDelayedSeconds(nameof(_DelayedInit), 10.0f);
    }

    public void _DelayedInit() // SendCustomEvent系で呼び出されるメソッドはpublicに
    {
        if (Networking.IsOwner(gameObject))
        {
            // Ownerだったらここで同期変数を初期化する

            RequestSerialization();
        }

        isInitialized = true;
    }
```


### FieldChangeCallbackでほかの同期変数を参照しない
同期変数が更新されたタイミングで何か処理を行いたい場合は、`[FieldChangeCallback]`アトリビュートでプロパティを指定するのが簡単です。

```cs
    [UdonSynced][FieldChangeCallback(nameof(PlayerId))]
    private int playerId;
    public int PlayerId { 
        get { 
            return playerId;
        }

        set {
            playerId = value;
            // 同期変数が変更されたときの処理
            Debug.Log($"PlayerIdに{value}がセットされました");
        }
    }
```
しかし、`FieldChangeCallback`は実行順が保証されていないことから、ある同期変数の`FieldChangeCallback`が呼ばれるタイミングでは、他の同期変数にはまだ変更が反映されていないことがあります。
```cs
    // [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] の想定
    
    [UdonSynced][FieldChangeCallback(nameof(PlayerId))]
    private int playerId;
    public int PlayerId { 
        get { 
            return playerId;
        }

        set {
            playerId = value;
            // 同期変数が変更されたときの処理
            Debug.Log($"PlayerIdに{value}がセットされました");

            // 同時に変更したつもりでもPlayerNameにはまだ反映されていない場合があるので避けること
            Debug.Log($"PlayerNameは{PlayerName}です");
        }
    }

    [UdonSynced][FieldChangeCallback(nameof(PlayerName))]
    private string playerName;
    public string PlayerName { 
        get { 
            return playerName;
        }

        set {
            playerName = value;

            // 同期変数が変更されたときの処理
            Debug.Log($"PlayerNameに{value}がセットされました");

            // 同じようにまだ反映されていない場合があるので避けること
            Debug.Log($"PlayerIdは{PlayerId}です");
        }
    }

    // このようにOwnerが同期変数を変更する想定
    private int count = 0;
    private void IncrementPlayer()
    {
        if (!Networking.IsOwner(gameObject)) return;
        
        count++;
        PlayerId = count;
        PlayerName = $"Player{count}";
        RequestSerialization();
    }
```
同期変数が変更されたタイミングでほかの同期変数を確実に参照したい場合は`OnDeserialization()`を利用しましょう。
ただしOwnerでは`OnDeserialization()`が呼ばれませんので別のロジックを検討する必要があります。
```cs
    // [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] の想定
    
    [UdonSynced]
    private int playerIdSync;
    private int playerId;
    
    [UdonSynced]
    private string playerNameSync;
    private string playerName;

    public override void OnDeserialization() // Ownerでは呼ばれない
    {
        bool isChanged = false;
        
        // OnDeserializationではどの同期変数が変更されたかわからないので自分で確認する必要がある
        if (playerIdSync != playerId)
        {
            playerId = playerIdSync;
            isChanged = true;
        }
        if (playerNameSync != playerName)
        {
            playerName = playerNameSync;
            isChanged = true;
        }

        if (isChanged)
        {
            // 変更があった時の処理
            Debug.Log($"PlayerIdは{playerId}です");
            Debug.Log($"PlayerNameは{playerName}です");
        }
    }

    // このようにOwnerが同期変数を変更する想定
    private int count = 0;
    private void IncrementPlayer()
    {
        if (!Networking.IsOwner(gameObject)) return;
        
        count++;
        playerIdSync = count;
        playerNameSync = $"Player{count}";
        RequestSerialization();

        // Ownerで変更した同期変数に対応した処理
        playerId = playerIdSync;
        playerName = playerNameSync;
        Debug.Log($"PlayerIdは{playerId}です");
        Debug.Log($"PlayerNameは{playerName}です");
    }
```
`SendCustomNetworkEvent`によって発生するイベントと同期変数も同期される頻度・タイミングが異なるため、注意が必要です。

### OnPlayerJoinedで対象プレイヤーにOwnerを渡そうとすると失敗することがある
前提として、同期変数を変更するためには対象オブジェクトのOwnerになる必要があります。そのため、各プレイヤーが好きなタイミングで変更したい場合は、各プレイヤーごとに自身がOwnerとなっている同期用オブジェクトを割り当てるなどの工夫が必要になります。

そこで`OnPlayerJoined`イベントで入ってきたプレイヤーにOwnerを渡すような実装を考えるのですが、実際にやってみると一見動いているように見えていてもしばしば失敗するケースがあります。
悪いことに、渡す操作をしたプレイヤーで`Networking.GetOwner`を呼ぶと本来渡したかったプレイヤーが取得されることから、正常に渡せたように見えてしまいます。
この結果不整合が起き、誰もOwnerになっていない状況が起こります。
```cs
    // 一見うまくいくが、正常にOwnerが移らない場合がある
    public override void OnPlayerJoined(VRCPlayerApi player)
    {
        if (Networking.IsOwner(gameObject))
        {
            var syncObj = GetUnassignedSyncObj(); // 未割当の同期用オブジェクトを取得する想定
            if (syncObj != null)
            {
                Networking.SetOwner(player, syncObj.gameObject);
            }
        }
    }
```

この時、他のプレイヤーのログには対象オブジェクトのOwnerが見つからない旨の警告が出力されることもあります。
```
 [Network Processing] Could not locate owner on ～～(GameObject名)
```

前述した通り、Join直後のユーザーはネットワークの初期化が終わっていない可能性があるので、ある程度時間をおいてからOwnerを渡す必要があります。

## アニメーションとUdonの連携
### Udonで操作するプロパティやピックアップ可能なGameObjectをアニメーションで操作しない
Animation Clipで設定したプロパティはAnimatorが常に書き換えるため、Udon等でそのプロパティを変更しようとしてもすぐに元に戻されてしまいます。
`VRCPickup`も同様で、Positionなどを操作していると掴んで動かすことができなくなります。
そのようなオブジェクトのtransform.positionやrotationの操作をAnimation Clipに含めないようにしましょう。

### アニメーションからUdonのイベントを呼ぶ
Animation Clip内でAnimation Eventを登録して`SendCustomEvent(String)`を呼ぶことでUdonのメソッドを呼び出すことができます。
この時Animatorと同じGameObjectにUdonがアタッチされている必要があります。
![AnimationウィンドウでAdd eventして](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583903/8a493686-d0b2-9ea3-a02b-e600b4ef29cf.png)
![Inspectorから設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583903/6e52080a-7325-dd92-9f66-8c84360ccbdf.png)

なお、残念ながらセキュリティ上の理由でUdonのメソッドを直接呼び出すことはできませんので引数もつけられません。
そのためパート1でも説明したように[外部向けメソッドを小分けにする](https://qiita.com/kccs_satoshi-koide/items/02e755ce35419a5989cb#%E5%A4%96%E9%83%A8%E5%90%91%E3%81%91%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89%E3%82%92%E5%B0%8F%E5%88%86%E3%81%91%E3%81%AB%E3%81%99%E3%82%8B)必要があります。
（一部呼び出せるものもありますが、SDK側の[許可リスト](https://creators.vrchat.com/worlds/udon/animation-events/)に入っている必要があります。）

## 参考文献
公式のドキュメントは一通り目を通しておくことをおすすめします。

https://udonsharp.docs.vrchat.com/

https://creators.vrchat.com/worlds/udon/

## 最後に
UdonSharpでギミックを作っていくにあたり、通常のUnity-C#にはない制約や、同期周りの不安定さなど注意すべきことが数多くありますが、うまく扱えるとVRChatのワールドに様々な可能性を与えてくれます。

この記事がVRChatワールド開発の一助となれば幸いです。

お読みいただき、ありがとうございました。

## 所属部署について
### 隼人事業所
https://www.kccs.co.jp/company/maps/hayato/

隼人事業所は、工場の中に事務所があるという利点を活かし、ものづくりの現場に貢献できるシステム開発に取り組んでいます。
IT技術を活用して、新しい価値を創出をしていくことを目指しています。

### XR技術検証
近未来で活用できる技術開発の一環として、XR関連の技術開発を行っています。
XRとは、AR（拡張現実）、VR（仮想現実）、MR（複合現実）など、現実世界と仮想世界を融合する先端技術の総称です。Microsoft HoloLens 2、Meta Quest 2、Google Glassなどの先進的なデバイスを、どのように実務現場に適用できるかという視点で技術開発・検証を行っています。
京セラグループに所属している私たちは、これらの技術を工場DXにいかに適用できるのかに注力しながら活動しています。