【VRChat】 Udon開発する上での注意点【Unity】

# はじめに

この記事は`VRChat`における`Udon`および`UdonSharp`(`U#`)を使った際の備忘録です。
これから`Udon`を使い始める人のために書き連ねておきます。

**この記事は2020/5/26現在のVRChatを前提に書いています。**


# Udonとパフォーマンスチューニング

`Udon`は実質、UnityのAPIをラップして呼び出しているだけにすぎません。
そのため**Unityでプログラミングをするときと同じ様にパフォーマンスにもこだわる必要があります。**

## プロファイラを見よう

Unityには標準で`Profiler`という機能が備わっています。
1フレーム単位でどのような処理が実行され、それにどれくらいの時間がかかっているかをチェックすることができます。

![OpenProfiler.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/3c1dc1c5-6a82-c387-f39e-fb9d1df94f70.png)

![Profiler.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/7f4721e6-484d-8ff8-7446-16545e33bc7c.png)


詳しい使い方はこちらを参考にしてください。

* [Profiler ウィンドウ](https://docs.unity3d.com/ja/2018.4/Manual/ProfilerWindow.html)
* [【Unity】CPUプロファイラでパフォーマンスを改善する 前編](http://tsubakit1.hateblo.jp/entry/2016/05/09/073000)
* [【Unity】CPUプロファイラでパフォーマンスを改善する 後編](http://tsubakit1.hateblo.jp/entry/2016/06/20/222612)
* [【Unite 2017 Tokyo】最適化をする前に覚えておきたい技術](https://youtu.be/rvnsU8oCMcI)


なお、**プロファイラで動作を監視すること自体がかなりの負荷となります。**
プロファイラを使っている間はfpsがガタ落ちしますが、しょうがないと割り切ってください。
（普通のUnity開発なら回避策があるのですが、`VRChat`だと仕様上どうしようもできないです）

### Raw Hierarchyで調査

プロファイラの表示を`Raw Hierarchy`に切り替えて時間がかかっている順にソートすると何がボトルネックか調査することができます。
とくにこのモードだと`Udon VM`の中身の実行順も見ることが出来ます。

![Profiler2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/e194fce3-5e3f-a2d2-acaf-85a540e4d745.png)

`Udon`の仕様上、どのスクリプトであるか名前はわからないのですが、メソッド呼び出しの様子からどのスクリプトかあたりをつけることはできます。


## GCアロケートを避けよう

とくに負荷の原因となりやすいものは`GC Alloc`と表示されているものです。
これはUnity上で、プログラムを実行するために必要なメモリを確保する動作を表しています。
（`GCアロケート`と呼ぶ）

そしてこの確保したメモリですが、解放される瞬間に`VRChat`が一瞬フリーズしてしまいます。
（`GC`（ガベージコレクタ）が実行される、と呼びます）

`GC`が実行される頻度は少ないほどfpsに与える影響は小さくなります。
逆に高頻度で`GC`が実行されると、体感できるレベル（ひどいと数十fps）で影響がでてきます。
そのため`GC`の実行をさける、つまり`GC Alloc`の頻度を下げる工夫が必要となります。

### プリミティブ型以外はボックス化される

C#には[プリミティブ型（組み込み型）](https://docs.microsoft.com/ja-jp/dotnet/csharp/language-reference/builtin-types/built-in-types)というものがあります。たとば`int`や`float`など。
これらはC#に最初から定義されている型であり、基本的にGCを気にせずノーコストで利用することができます。

そしてUdonですが、**プリミティブ型以外の型を触るとボックス化されてGCアロケートが起きます**。
たとえ`Vector3`や`Quaternion`といった**構造体であってもGCアロケートします。**

このあたりは回避不可能なので、仕様と割り切って付き合っていくしか無いとは思います。

### `string`は避けよう

`C#`の仕様上、`string`（文字列）は定義するだけでかなりの`GC Alloc`を引き起こします。
そのため`Update()`で毎フレーム文字列を生成するなどしていると、パフォーマンスにかなりの悪影響を及ぼします。

極力`string`は使わない、使うにしても必要なタイミングで必要なだけ生成する工夫が必要です。

### `U#`は別コンポーネントのメソッド呼び出しがコスト

非常に便利な`UdonSharp`ですが、見えないところでコストがかかります。
それは`UdonSharpBehaviour`から別の`UdonSharpBehaviour`なオブジェクトのメソッドを呼びだす時です。

たとえば、次のような`U#`スクリプトがあったとして。

```cs:Runner
using UdonSharp;
using UnityEngine;

namespace DebugTest
{
    public class Runner : UdonSharpBehaviour
    {
        private Rigidbody _rigidbody;

        void Start()
        {
            _rigidbody = GetComponent<Rigidbody>();
        }

        public Vector3 GetCurrentVelocity()
        {
            return _rigidbody.velocity;
        }
    }
}
```

```cs:Observer
using UdonSharp;
using UnityEngine;

namespace DebugTest
{
    public class Observer : UdonSharpBehaviour
    {
        [SerializeField] private Runner _runner;

        private void Update()
        {
            // 取得するだけで何も使わない
            var velocity = _runner.GetCurrentVelocity();
        }
    }
}
```

これの`Observer.cs`側を`Udon Assembly`にトランスパイルした結果をみるとこうなっています。


```
_update:
    
    PUSH, __0_const_intnl_SystemUInt32
    
    # {

    # var velocity = _runner.GetCurrentVelocity();
    PUSH, _runner
    PUSH, __0_const_intnl_SystemString
    EXTERN, "VRCUdonCommonInterfacesIUdonEventReceiver.__SendCustomEvent__SystemString__SystemVoid"
    PUSH, _runner
    PUSH, __1_const_intnl_SystemString
    PUSH, __0_intnl_SystemObject
    EXTERN, "VRCUdonCommonInterfacesIUdonEventReceiver.__GetProgramVariable__SystemString__SystemObject"
    PUSH, __0_intnl_SystemObject
    PUSH, __0_intnl_UnityEngineVector3
    COPY
    PUSH, __0_intnl_UnityEngineVector3
    PUSH, __0_velocity_Vector3
    COPY
    PUSH, __0_intnl_returnTarget_UInt32 #Function epilogue
    COPY
    JUMP_INDIRECT, __0_intnl_returnTarget_UInt32
```

注目して欲しいところは、他の`UdonSharpBehaviour`へのメソッド呼び出しが`SendCustomEvent`と`GetProgramVariable`に変換されているところです。
（メソッドに引数を渡すと`SetProgramVariable`も追加される）

そしてこの`SendCustomEvent`と`GetProgramVariable`ですが、**なぜか`GC Alloc`します**。

![GCAlloc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/5cb0fe66-da9a-c300-3324-0e2e89b17e2f.png)
（Udon内部実装の問題なのでおそらく回避不可）

ということで、`U#`を用いた場合、気軽にメソッド呼び出しを実行するとそれだけで`GC Alloc`が発生します。
普通のUnity開発ではノーコストな操作が、`U#`ではコストがかかる点はかなり罠な気がします。



## OnTriggerStay大暴走

`UdonBehaviour`には`OnTriggerStay`が定義されています。
そのため**「`VRC_Pickup` + `UdonBehaviour`なオブジェクト」を一箇所に大量にまとめて配置すると`OnTriggerStay`が暴走します。**

![OnTriggerStay.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/a4161646-8c23-123a-3879-b0793bab6b1d.png)
（50個ほど重ねて配置した例）

数個程度なら問題ないですが、数十個レベルで一箇所にまとめると`fps`がガタ落ちするレベルで影響がでてきます。
アイテムを一箇所にまとめておいて擬似的な「無限湧き」を作るようなことはやめておきましょう。

# Udon Synced Variables役に立たない問題

結論からいうと`Udon Synced Variables`はパフォーマンスのために **「使わない」** が正解です。

`Udon`には`Udon Synced Variables`という機能があります。
こちらは指定したプリミティブな変数をネットワークをまたいで同期する機能です。
（`U#`でいうところの`[UdonSynced]`）

ですがこの`Udon Synced Variables`、挙動が結構ヤバイです。

* `Owner`は**常時**パラメータを相手に送信し続ける
* 転送量が増えると**パケットロスして不着となる**
* スループットがかなり低い

同期するオブジェクト、変数の数が増えると`Death Run Detected: dropped N events`というエラーが大量に出てきます。
これが発生してしまうと、変数同期の成功率が極端に下がってしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/bc8bedb0-6c9f-cf5b-76bb-697f045f1538.png)
(大量にパケロスしている様子)

そのため、`Udon Synced Variables`で大量のデータを同期することはまったくオススメできません。

たとえば、オブジェクトの位置と姿勢（`Vector3` + `Quaternion`）を`Udon Synced Variables`で同期するのは止めたほうがいいでしょう。
私が試した場合では**オブジェクト数が20個を超えたあたりからパケロスが発生しました。**
さらに`VRChat`の通信にかなりの負荷をかけるためか、`Player`の挙動までもが不安定になりました。

ちなみに、この仕様ではほぼ使い物にならないので[フィードバック報告済み](https://vrchat.canny.io/vrchat-udon-closed-alpha-feedback/p/adjusting-the-transmission-frequency-of-udon-synced-variables)ではあります。



#### 補足: Udon Synced Variablesについての公式フォーラムでの報告

`VRChat`のフォーラムの[こちらの投稿](https://ask.vrchat.com/t/how-to-sync-with-udon/449/6)では次のように報告されています。

* 2つの`Udon Synced Variables`な文字列をもつ`Udon Behaviour`をシーンにいくつか配置
* 8個置いた程度ではパケロスはほぼゼロ
* 16個置くとパケロスが発生する

とのことなので、`Udon Synced Variables`を使う場合はオブジェクト数が少ない場合のみにした方が無難でしょう。


### 文字列にエンコードして同期する、は高コスト

また、とある場所で「オブジェクトの状態を`string`にエンコードして`Udon Synced Variables`で同期する」という手法が提案されていました。

`Udon Synced Variables`で配列が同期できないのを回避するために編み出された手法ですが、こちらかなりコストが高いです。

* 大量の`string`を生成することによる`GC Alloc`
* 長い文字列を常時伝送するネットワークへの負荷

そのため本当にどうしようもないときの最終手段としとっておいて、常用はしないほうが無難でしょう。
（とはいえどこれしか方法が無いならば使わざるを得ないのが`Udon`のツライところなのですが…。）

# 位置同期

オブジェクトの位置を同期する方法ですが、次の2とおり（実質1とおり）があります。

* A: `Udon Synced Variables`で位置姿勢を送る
* B: `Udon Behaviour`の`Synchronize Position`を使う

Aのパターンは前述の問題があるのでオススメできません。
ということで実質的にBの「`Synchronize Position`」一択になります。


この`Synchronize Position`はちゃんと差分同期してくれるため、大量にオブジェクトがあってもネットワークへの負荷は小さいです。

![Synced.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/29ba6ba8-3da8-52db-d1a0-c0c2899cbe8e.png)


## Synchronize Positionの同期ズレ問題

`Synchronize Position`は差分同期してくれるためネットワーク負荷は小さいのですが、**大量にオブジェクトがある場合、後からワールドに参加した人には正しく位置と姿勢が同期されない場合**があります。
こちらはワールドにいるプレイヤー数とオブジェクト数によりますが、「5人以上かつ20個くらいオブジェクトを動かした」あたりから発生してきます。

原因はハッキリとはしていないのですが、どうも次の複数の問題が絡んでいるっぽいです。

* [オブジェクトの`Owner`が新規参加した人に正しく同期されず、`Master`が`Owner`にみえる問題](https://vrchat.canny.io/vrchat-udon-closed-alpha-bugs/p/owner-information-is-not-correctly-synced-to-new-players-when-there-are-many-udo)
* `Owner`がオブジェクトの位置同期が完了しない問題

前者についてはバグ報告済みですが、後者についてはいまいち挙動がつかめていないため報告していません。

### Synchronize Positionの同期ズレ対策

対処療法として、次の対策をいれましょう。
実際にモノレールワールドで実施している対策がこれです。

* **触れていないPickupオブジェクトはすべてMasterが所有権をもつ**
 * オブジェクトを持っている間は持っている人に`Owner`を渡す
 * 手を離したら`Master`に所有権を返すようにする
 * 若干安定するが、それでもまだ同期ズレは起きる

* **強制的に位置を同期する仕組みをいれる**
 1. `Master`側で同期対象のオブジェクトをすべて少しだけ位置と姿勢をズラす
 2. 数秒後に元の位置姿勢に戻す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/949e5f6b-36a4-05c9-2c14-ae254a7e42e1.png)
（同期ズレの発生をゼロにはできないので、同期ズレが起きる前提で対策した方が早い）


かなりツラミがある仕組みですが、現状これくらいしか大量のオブジェクトを安定して同期する方法がありません。

# まとめ

`Udon`つらいし、`UdonSharp`も結構ツライです。
それなりのUnity開発経験と、Unityでパフォーマンスチューニングをできるスキルが求められますね。