UdonSharp で自動ドアを作った件について（引き戸タイプ編）

:::note
「Unity 自動ドア」で検索すると、作例がいくつか見つかりました。
 2021年9月29日現在では、それらで見られる方法に実装を変えています。
 以下の内容は、上述の内容を知らない状態で執筆したものです。
:::

# はじめに
どうも、「バーチャルためにならない改変お姉さん」の水無月せきなです。
VRChat でドアを開ける機会は意外と少ない気がするのですが、いざ開けるとなると私は多少面倒に感じます。
スライドする引き戸なら手を横に動かすだけですが、手前に引く（あるいは奥に向かって押す）タイプのドアは、体を移動させないと開けるのが難しい場合もあります。

ほんの些細なことではありますが、ふと「 VR なんだし、自動で家のドアが開いても良いんじゃないか」と思ったので、そのギミックを作りました。
[BOOTH](https://booth.pm/ja/items/3294470 "自動ドア【VRChat Worldギミック（SDK3）】") と [GitHub](https://github.com/MinadukiSekina/Automatic_Door_Unit "Automatic_Door_Unit") にパッケージ化して置いていますので、物だけ使いたい方はそちらからご自由にお使いください。

ここから先は、UdonSharpでどう実装したかみたいなお話です。

# 環境
Windows 10 ( 20H2 )
Unity 2019.4.30f1
VRCSDK3-AVATAR-2021.09.03.09.25_Public
UdonSharp_v0.20.3

#「自動で開閉するドアを SDK3 で作りたい！」
ヒントは VRChat 公式のサンプルである VRChat Examples のシーンにありました。
![VRChat_1920x1080_2021-09-21_19-52-56.361.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1843472/1d0eed67-b1ff-6bdf-d0e4-9172e8de0ac9.png)
プレイヤーが特定のエリアに入ったらキューブが飛んでくるものがあります。
このキューブが飛んでくる動作をドアが開閉する（＝ドアのオブジェクトが初期位置・開いた位置へ移動する）動作に置き換えれば、まるっと解決しそうですよね？
「何だ簡単じゃん！」と思って実装を調べてみたら、公式のサンプルなので当然 Udon Graph です。

いくつものノード同士が “noodles”（公式がこう呼んでいます）で結ばれる様は、まさしく "Udon" 。

「なるほど、わからん」

麺をすすろうにも箸で掴むことすら難儀に感じてお手上げ……とは言え、ここで諦めたら本当に終了です。
ぼんやりとでも流れを追って考えた結果、

1. プレイヤーが IsTrigger = true のコライダーに接触してイベント発生（`OnPlayerTriggerEnter` をオーバーライド）。
1. 人数のカウントをプラス。
1. 開いている状態へ移動。
1. プレイヤーが IsTrigger = true のコライダーから離れた時にイベント発生（`OnPlayerTriggerExit` をオーバーライド）。
1. 人数のカウントをマイナス。
1. 閉じている状態へ移動。

大まかにはこの流れになりました。
ということで、UdonSharp で書いていきます。

# いざ実装
実際のコードが見たいという方もいらっしゃるでしょうから、先に実装内容を示します。

```C#:Automatic_Door_Unit.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

namespace MinadukiTei.Products
{
    [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
    public class Automatic_Door_Unit : UdonSharpBehaviour
    {
        // 動かす対象と目的地（グローバル座標）は指定してもらう
        [SerializeField] private GameObject target;
        [SerializeField] private Vector3 destination;
        [UdonSynced(UdonSyncMode.None)] private Vector3 defaultPosition;
        [UdonSynced(UdonSyncMode.None)] private int triggerPlayerCount;

        void Start()
        {
            // 初期化
            defaultPosition = target.transform.position;
            triggerPlayerCount = 0;
        }

        private void Update()
        {
            if(triggerPlayerCount == 0)
            {
                // 誰も触れていないなら元の位置に戻す
                if (target.transform.position == defaultPosition) return;
                target.transform.position = Vector3.MoveTowards(target.transform.position, defaultPosition, Time.deltaTime);
            }
            else
            {
                // 誰かが触れているなら目標の位置に動かす
                if (target.transform.position == destination) return;
                target.transform.position = Vector3.MoveTowards(target.transform.position, Destination, Time.deltaTime);
            }
        }

        public override void OnPlayerTriggerEnter(VRCPlayerApi player)
        {
            if (Networking.IsOwner(player, this.gameObject))
            {
                // オブジェクトのオーナーなら純粋にカウントアップ
                CountUp();
            }
            else
            {
                // オブジェクトのオーナーでなければオブジェクトのオーナーに実行要求
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(CountUp));
            }
        }

        public override void OnPlayerTriggerExit(VRCPlayerApi player)
        {
            if (Networking.IsOwner(player, this.gameObject))
            {
                // オブジェクトのオーナーなら純粋にカウントダウン
                CountDown();
            }
            else
            {
                // オブジェクトのオーナーでなければオブジェクトのオーナーに実行要求
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(CountDown));
            }
        }

        public void CountUp()
        {
            // 人数をプラスして、同期要求
            triggerPlayerCount += 1;
            RequestSerialization();
        }

        public void CountDown()
        {
            // 人数をマイナスして、同期要求
            triggerPlayerCount -= 1;
            if (triggerPlayerCount < 0) triggerPlayerCount = 0;
            RequestSerialization();
        }
    }
}
```

[GitHub](https://github.com/MinadukiSekina/Automatic_Door_Unit "Automatic_Door_Unit")に置いているコードとコメントなどが異なりますが、執筆時点でロジック自体は変わりません。

## ドアを動かすクラスと同期方法の指定
```C#:Automatic_Door_Unit.cs
namespace MinadukiTei.Products
{
    [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
    public class Automatic_Door_Unit : UdonSharpBehaviour
```

名前空間が２層になっているのは、Editor 拡張などを別にして書こうとした名残です（今回はしませんでした）。

冒頭部分は基本的に自動生成のままですが、変数の同期方法をクラスの属性として定義しています。こうすると、Inspector からは同期方法を変更できなくなります。
`Continuous`（連続）ではなく `Manual`（手動）を選択した理由ですが、より確実に同期を行いたかったからです。「え？」と思われる方もいらっしゃるでしょうが、[こちら](https://hatuxes.hatenablog.jp/entry/2021/05/24/171942#Continuous-Sync--%E9%80%A3%E7%B6%9A%E5%90%8C%E6%9C%9F "シン・U# 入門 ②")の記事によれば、連続同期は自動的に同期が行われる反面、通信における優先度が低く同期が更新されないこともあるそうです。一方の手動同期は逐一同期の更新を行わなければなりませんが、基本的に同期が行われるそうです。
なので、あえて `Manual` を指定しています。

## メンバ変数の宣言

```C#:Automatic_Door_Unit.cs
        [SerializeField] private GameObject target;
        [SerializeField] private Vector3 destination;
        [UdonSynced(UdonSyncMode.None)] private Vector3 defaultPosition;
        [UdonSynced(UdonSyncMode.None)] private int triggerPlayerCount;
```

`[Serializefield]` は Private でも Inspector に表示してくれるおまじないと認識して付けてます。
Inspector 上で動かすオブジェクト（今回の場合は扉）と移動先の座標を指定してもらう感じです。

オブジェクトの初期位置（＝`defaultPosition`＝閉じる時の移動先）は後述する初期化の時に取得するようにしています。
`triggerPlayerCount` は、２人以上のプレイヤーが扉の前に立った場合に、その内の１人が離れてもドアが開いている状態を維持できるようにするためのカウンターです。
プレイヤーの動作（近づく・離れる）に応じてこのカウンターを増減させ、参照することでオブジェクトの移動先を変えます。

`[UdonSynced(UdonSyncMode)] ` を付けた変数は同期変数となり、プレイヤー間で同期する対象になります。 

## 初期化

```C#:Automatic_Door_Unit.cs
        void Start()
        {
            // 初期化
            defaultPosition = target.transform.position;
            triggerPlayerCount = 0;
        }
```

動かす対象のオブジェクトの初期位置を取得し、プレイヤーのカウントを０にしています。
`Start()` は２番目以降にインしたプレイヤーでは処理されないそうです。

## 対象を動かす

```C#:Automatic_Door_Unit.cs
        private void Update()
        {
            if(triggerPlayerCount == 0)
            {
                // 誰も触れていないなら元の位置に戻す
                if (target.transform.position == defaultPosition) return;
                target.transform.position = Vector3.MoveTowards(target.transform.position, defaultPosition, Time.deltaTime);
            }
            else
            {
                // 誰かが触れているなら目標の位置に動かす
                if (target.transform.position == destination) return;
                target.transform.position = Vector3.MoveTowards(target.transform.position, Destination, Time.deltaTime);
            }
        }
```

今回のメイン部分です。
オブジェクトをスクリプトから動かす方法はいくつかあるようですが、今回は Position と `Vector3.MoveTowards()` を選びました。
他には Rigidbody で物理演算を使用する方法もあるようですが、対象の Rigidbody を取得する必要があるのが面倒だなと思い、また、ドアを動かしたいだけなので衝突判定も要らないだろうということで、選びませんでした。

毎フレーム呼ばれる `Update()` で対象の Position に `Vector3.MoveTowards()` の戻り値を代入することによって、疑似的にスライド移動をさせています。
既に書いた通り、ドアの前に立っている人数のカウント（＝`triggerPlayerCount `）で移動先の座標を分けており、結果として人感式の自動ドアの動作にしています。

## プレイヤーの接近・離脱を感知する
```C#:Automatic_Door_Unit.cs
        public override void OnPlayerTriggerEnter(VRCPlayerApi player)
        {
            if (Networking.IsOwner(player, this.gameObject))
            {
                // オブジェクトのオーナーなら純粋にカウントアップ
                CountUp();
            }
            else
            {
                // オブジェクトのオーナーでなければオブジェクトのオーナーに実行要求
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(CountUp));
            }
        }

        public override void OnPlayerTriggerExit(VRCPlayerApi player)
        {
            if (Networking.IsOwner(player, this.gameObject))
            {
                // オブジェクトのオーナーなら純粋にカウントダウン
                CountDown();
            }
            else
            {
                // オブジェクトのオーナーでなければオブジェクトのオーナーに実行要求
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(CountDown));
            }
        }
```
このスクリプトを付けているオブジェクトに Box Collider（ IsTrigger = true ）を付けているので、プレイヤーがそのコライダーの中に入った（ `OnPlayerTriggerExit` ）・出た（ 
`OnPlayerTriggerExit` ）タイミングで呼ばれるイベント内で処理を呼び出しています。

## 人数のカウントと手動同期

```C#:Automatic_Door_Unit.cs
        public void CountUp()
        {
            // 人数をプラスして、同期要求
            triggerPlayerCount += 1;
            RequestSerialization();
        }

        public void CountDown()
        {
            // 人数をマイナスして、同期要求
            triggerPlayerCount -= 1;
            if (triggerPlayerCount < 0) triggerPlayerCount = 0;
            RequestSerialization();
        }
```

プレイヤーの行動によって発生するイベントで呼び出す処理ですが、再三書いている通り、基本的にはカウンタを増減しているだけです。
変数の同期方法として `Manual` を指定しているため、`RequestSerialization()` で同期の更新を行っています。

# 問題点
とりあえず動きとしては実装できましたが、おそらく__負荷は高い__方法です。
常に人数をチェックしてオブジェクトの座標を計算して移動させるため、少なくとも低くは無いと思っています。座標の計算を一部しないで良いように `if (target.transform.position == defaultPosition) return;` のようにしていますが、むしろ悪化させているかもしれません。

Twitter で指摘を受けましたが、Animation で実装するなど負荷軽減が一番の課題だと思っています。

また、この実装では１つのドアしか動かせないため、複数のドアを開閉する場合はその数だけこのスクリプトの付いたオブジェクトを設置しなければなりません。
前述の負荷軽減と合わせて、複数でも１つのオブジェクトで動かせる方が良いかなとも思っています。

# おわりに
今回は実装した自動ドアの内容について書き連ねましたが、一番 VR で操作が面倒な（と私が思っている）開き戸を実装していません。
こちらについては、今後開発を進めたいと思います。

# 参考

https://qiita.com/yuyujiteki/items/bb9ebdf4eb9bc5a0cdb7

https://qiita.com/OKsaiyowa/items/167a0a6afa536c33fc38

https://qiita.com/JunShimura/items/ab243cbd29e63e4f27c5

https://qiita.com/yuki2006/items/a60fc92e033dddcb3a83

https://squmarigames.com/2018/11/08/unity-beginner-translate/