Udonでプリ機を作った話

# はじめに
[VRChat Advent Calendar 2021](https://adventar.org/calendars/6466)の14日目の記事を書かせていただきます[mkc1370](https://twitter.com/mkc1370)（[mkc1370@VRC](https://twitter.com/mkc1370_vrc)）です。
前回はPetanocoさんの「[[VRChat] 相対位置を維持するワールド固定の解説](https://qiita.com/Petanoco/items/2fb06c5249673772c520)」でした。

今回はU#（Udon）で作成したプリ機の簡単な技術的な話を書きました。
前半はプリ機の概要で、後半はU#寄りな話です。
この記事で紹介しているプリ機を使ってみたい方は[こちらのイベント](https://twitter.com/VR_CeLiCa/status/1405117065271279622)をご覧ください。

**注意 : この記事は2021年12月14日に作成されたもので、VRChat SDKやUdon Sharpの更新によって情報が古くなる可能性があります。**

https://twitter.com/mkc1370/status/1405135719274672130

<img width="700" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/b9d96520-88d6-7a33-ede1-fa7c91a26609.png">

# 要約
* プリ機体験したい方は[こちらのイベント](https://twitter.com/VR_CeLiCa/status/1405117065271279622)へ
* 開発中に変数同期方法が何度か変わった
* 同期は大変
* FieldChangeCallbackは良い
* UdonBehaviourSyncModeは良い
* UIを改善したい
* U# 1.xの正式リリースが楽しみ

# 経緯
このプリ機の原案であるSDK2版プリ機は1年ほど前に[せりかさん](https://twitter.com/VR_CeLiCa)が作成していました。
当時のプリ機はSDK2ということもあり機能追加や複数台設置、Late Joiner対応はかなり難しい状態でした。
そこで、僕がちょうどUdonを触っていたということもありUdon対応と機能追加を担当することになりました。

https://twitter.com/VR_CeLiCa/status/1340544887431655425

# プリ機の機能
このプリ機には主に3個の機能（画面）があります。
それぞれの画面で選択している情報はそのインスタンスに居る人にも同期されていて、複数人で同時に操作することが可能です。

## レイアウト選択画面
この画面では撮影する台紙のレイアウトを決めます。
この画像の台紙の背景は無地ですが、必要に応じてオリジナルの背景を設定することもできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/95c3a0f7-2784-4154-a6bb-e7240adfdb69.png)

## 背面・前面画像選択画面
この画面ではそれぞれの写真の背面・前面の画像を選ぶことができます。
プリ機の写真のレイヤー構成は以下のようになっています。

* 前面画像
* 撮影画像（プレイヤー）
* 背面画像

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/1ee0d403-8f1b-9074-fa36-85f0c3026677.png)

## 撮影画面
この画面では今までの画面で選択した情報を元に撮影を行います。
音声案内に合わせて自動で撮影が進みます。
<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/504d98f6-8a2d-612d-4285-d4c2a0ca22ba.png">

# 苦労したこと
## 同期
プリ機の開発で一番苦労したのは同期です。
まず、U#（Udon）には主に`SendCustomNetworkEvent`と`UdonSynced`の2つの同期方法があります。

### SendCustomNetworkEvent
`SendCustomNetworkEvent`はRPCのように全てのクライアントで指定したメソッドを呼び出すことができます。
しかし、一般的なネットワークライブラリと違いメソッドに引数を渡すことができません。
そのため、このような方法で間接的に引数を渡す方法が使われることがあります。

```cs
SendCustomNetworkEvent(NetworkEventTarget.All, $"Foo{index}");
```
```cs
public void Foo0() => Foo(0);
public void Foo1() => Foo(1);
public void Foo2() => Foo(2);
public void Foo3() => Foo(3);
public void Foo4() => Foo(4);
public void Foo5() => Foo(5);
public void Foo6() => Foo(6);
```

これであれば意図した挙動は実現できるのですが、保守性がかなり厳しくなってしまいます。
そこで今回は後述の`UdonSynced`を使用して、引数付きのRPCのような挙動を実現しました。

### UdonSynced
`UdonSynced`はこのようにフィールドのアトリビュートに追加することで、そのフィールドの値を同期することができます。
ただし、同期できる型には制限があります。（詳しくは[こちら](https://github.com/MerlinVR/UdonSharp/wiki/vrchat-api#synced-variables)）

```cs
[UdonSynced]
private int _foo;
```

`UdonSyncMode`を指定して同期をするときの値の補完方法を設定することもできます。（今回は使用していません）

```cs
[UdonSynced(UdonSyncMode.Linear)]
private int _foo;
```

また、そのクラスのいずれかのフィールドに更新があった場合は`OnDeserialization()`で知ることができます。
このメソッドの挙動には何かと癖があり、VRChatの更新によって挙動が変わることが多いのでここでは詳しい内容については触れません。

```cs
public override void OnDeserialization()
{
    // いずれかのフィールドに更新があったよ
}
```

`UdonSynced`は便利な機能ではあるのですが、同期するフィールドの数を増やしたり変数同期をする`UdonBehaviour`が増えると動作が不安定になることがありました。
そのため、同期するフィールドは極力1つのクラスにまとめて、フィールドの型もできる限り小さいものにしています。

#### UdonSyncedの変化
プリ機の開発中に2回ほど変数同期の大幅な更新があり、更新の度にプリ機も更新してきました。

##### UnU（2021年5月）
UnU(Udon Networking Update)とは2021年5月にリリースされたUdonのネットワーク周りの大幅なアップデートです。（[リリースノート](https://docs.vrchat.com/docs/vrchat-202122)）
このアップデートによって、**オーナー**が変数を同期するタイミングを手動で設定できるようになりました。
また、`RequestSerialization`を使った変数同期はレスポンスが早く、`SendCustomNetworkEvent`よりも早く届く場合があります。

```cs
// 変数の値を更新する
_foo = 1;
// 変数の同期を要求する
RequestSerialization();
```

##### FieldChangeCallback (2021年7月）
`FieldChangeCallback`は2021年7月のアップデートで使うことができるようになりました。
これを使うことにより、`OnDeserialization`ではできなかった「どのフィールドの更新があったか」を知ることができます。
これは、Udon Graphでの`On Variable Changed Node`相当の機能です（[ドキュメント](https://docs.vrchat.com/docs/udon-node-graph#drag-and-drop-for-variables)）
<img width="20%" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/258f89fd-4ea8-e379-f603-050718885d18.png">
U#では次のようにして使うことができます。

```cs
[UdonSynced, FieldChangeCallback(nameof(Foo))]
private int _foo;

public int Foo
{
    set
    {
        // 値が変更されたときの処理
        _foo = value;
    }
    get => _foo;
}
```

### Synchronization Methodで詰まった話
少し話がずれますが、前述のUnUのアップデートで追加されたSynchronization Methodで少し詰まったことがありました。
Synchronization MethodはUdonBehaviourの同期方法を変更することができ、全く同期を必要としないものには`None`を設定すると負荷を下げることができます。
この設定はUdonBehaviourのInspectorから変更することができます。
<img width="500" alt="Attributeなし" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/e6ae30f7-f6fa-f0e5-a32c-c44b17931f8e.png">
UnUのアップデートと同時に不要なものは同期しない設定にしていました。
しかし、誤って必要なUdonBehaviourの設定も`None`にしてしまい、同期ができない状態になってしましました。
（これに気付くまで1時間以上掛かりました…）
そもそも、InspectorからそれぞれのGameObjectのコンポーネント毎にこの設定をするということ自体があまり便利ではない（コンポーネント毎ではなく、クラス毎に決めることの方が多いため）と思い、何かいい解決策が無いかと探していたところ、`UdonBehaviourSyncMode`のアトリビュートがあることを知りました。
このアトリビュートを使って`UdonSharpBehaviour`を継承したクラスの`BehaviourSyncMode`を指定することにより、Inspector側からSynchronization Methodを変更することができなくなります。

```cs
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncSample : UdonSharpBehaviour
{
}
```

**Before**
<img width="500" alt="Attributeなし" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/e6ae30f7-f6fa-f0e5-a32c-c44b17931f8e.png">
**After**
<img width="500" alt="Attributeあり" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/92d33b9b-560b-b853-2369-e0cf87a31ad0.png">

## 定期的な処理
プリ機開発当初はタイマー用のフィールドを用意して`Update()`内で`Time.deltaTime`分を引いて、指定秒数経過したらメソッドを呼び出すような処理をしていました。
2021年3月頃の更新で`SendCustomEventDelayedSeconds()`や`SendCustomEventDelayedFrames()`が使えるようになり、定期的にメソッドを呼び出すのが比較的簡単にできるようになりました。
しかし、`MonoBehaviour.CancelInvoke()`や`System.Threading.CancellationToken`相当の機能が無いためキャンセルの機構を作るのが少し難しいです。
現在はキャンセルされたかどうかのフラグをフィールドに持たせておいて、呼び出されるメソッドの最初にそのフラグを確認する処理を挟んでいます。

# 今後の予定
## 改善したいこと
UIが**"Made with unity"**な見た目でワールドや筐体のクォリティに比べてだいぶ浮いてしまっています。
実際のプリ機を参考にしたり、デザイナの方と相談してもう少しUnity臭さを無くしたUIにできたらなと思っています。
<img width="800" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/7fc186a7-d995-cdd2-effc-0508b1c235d8.png">

## U# 1.xの正式リリースが楽しみ
U#のメジャーアップデートであるU# 1.xのβ版がU#の開発者である[Merlinさん](https://twitter.com/MerlinVR_)のDiscordで配布されています。
この更新では以下のような様々な便利な機能が追加されています。
しかし、2021年10月下旬から更新がされていないということもあり、VRChatの更新に伴って現在のβ版が使えなくなる可能性もあったので今回は採用を見送りました。
U# 1.xの正式版がリリースされたら一気に乗り換える作業を進めようかなと思っています。

https://twitter.com/mkc1370_vrc/status/1439428694645227521

# おわりに
本来はもう少し踏み入った技術的な内容について書く予定でしたが、簡単な内容に留めることにしました。
単純に記事が長くなってしまうというのと、最近のU#について書かれている記事が少なかったので今回のような内容も少しは需要はあるのかなと思います。

Udonのギミックでガッツリ同期について考える必要のあるギミックを作ったのは今回が初めてで、最初に想定していた工数の10倍は掛かってしまいました。
今回のプリ機を通して学んだことは多いので、別で作っているUdon Graphをセルフホスティングするギミックにも活かしていく予定です。

今回は初めて技術的な記事をネットに公開してみました。
まだまだ不慣れなことが多いですが、何かイベントがあったときにでも何か公開できたらなと思います。

次回はshivaduke28さんの「[【VRCAdvent Calendar】UdonでRealtimeGIっぽいことをする仕組み](https://qiita.com/shivaduke28/items/ffc5795b93908f29b2d1)」です。