Quest上のUnity->ESP32のOSC通信

# はじめに
この記事は，[農工大アドベントカレンダー Advent Calendar 2024](https://qiita.com/advent-calendar/2024/tuat) 12日目の記事となります．

私が所属するサークルでのゲーム開発時にQuest上のUnityとESP32でOSC通信を担当したのでその実装を話していきます．

:::note warn
本記事ではQuest -> ESP32の一方向通信のみを行います．双方向通信やESP32->Questがしたい方は別の記事を参照してください．
:::

# 開発環境
- Windows11

# 使用環境
- Meta Quest 3
- ESP32 DEVKIT V1
- Unity 2022.3.36f1
- OSCJack 2.0.0

# サンプルリポジトリ
本記事で実装する内容は以下のリポジトリに置いてあります．

https://github.com/toufu-24/OSC-QuestUnity-ESP-Sample

# Unity
## ライブラリ準備
[OSCJack](https://github.com/keijiro/OscJack)というライブラリを用います．

:::note
[OscCore](https://github.com/stella3d/OscCore)というライブラリも存在しますが，少なくとも私の環境ではOscCoreはQuestからOSC通信が飛ばせなかったのでOscJackを使用します．
:::

READMEのHow To Installを参考にインストールしていきます．


`Edit`>`Project Settings`>`PackageManager`を開いて以下を追加します．
```txt
Name: Keijiro
URL: https://registry.npmjs.com
Scope: jp.keijiro
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/a909fdfb-7bdc-9676-ce5f-41eb3770bced.png)

そうしたら`Window`>`PackageManager`を開き，`Packages`を`My Registries`に変更します．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/afd6c511-e550-a149-6aa5-1ff85a8d228d.png)
スクロールしていくとOSC Jackがあると思うのでそれをインストールします．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/2074fada-e7f1-980b-0d29-d384609ccd41.png)

これでインストールができました．

## OSCメッセージの送信
コンポーネントでも送信できるようですが，今回はスクリプトを用いて送信を行います．
IPアドレスとポートを指定して，スペースキーかプライマリボタンが押されたときにアドレスを`/sample`として文字列を送信するスクリプトを適当な場所に配置します．今回は`Assets/Scripts/`直下にSendMessage.csという名前で置きます．

:::note
今回は文字列を送信しますが，intやfloatなども送信できるようです．
:::

```c#:SendMessage.cs
using UnityEngine;
using UnityEngine.XR;
using OscJack;

// OSCメッセージを送信するクラス
public class SendMessage : MonoBehaviour
{
    [SerializeField] private string ipAddress = "ESP's ipaddress"; // OSCメッセージを受信するデバイスのIPアドレス
    [SerializeField] private int port = 8000; // 送信先のポート番号
    private OscClient _client; // OSC通信を行うためのクライアント
    private const XRNode ControllerNode = XRNode.RightHand; // XRコントローラ（右手デバイス）を指定

    // コンポーネントが有効化されたときに呼び出される
    private void OnEnable()
    {
        _client = new OscClient(ipAddress, port); // 指定したIPアドレスとポート番号でOSCクライアントを初期化
    }

    // コンポーネントが無効化されたときに呼び出される
    private void OnDisable()
    {
        _client.Dispose(); // OSCクライアントを解放
    }

    private void Update()
    {
        // Spaceキーが押された場合
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // Debug.Log("Space key was pressed."); // ログを出力．適宜コメントアウトを外してください．
            _client.Send("/sample", "Hello, I am Windows!"); // OSCでメッセージを送信
        }
        // コントローラのプライマリボタン（例: OculusのAボタン）が押された場合
        else if (InputDevices.GetDeviceAtXRNode(ControllerNode) // 指定されたXRデバイス（右手コントローラ）の状態を取得
                     .TryGetFeatureValue(CommonUsages.primaryButton, out bool primaryButtonValue) && primaryButtonValue)
        {
            _client.Send("/sample", "Hello, I am Quest3!"); // OSCでメッセージを送信
        }
    }
}
```

このスクリプトをシーン上に配置してみましょう．
適当なシーンでCreateEmptyしてOSCSenderというオブジェクトを作って先ほどの`SendMessage.cs`をアタッチしましょう．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/e41aac28-ee8c-af2b-0bd7-8f44e614fd8b.png)

これでUnityから送信ができるようになりました．

## テスト
本当に送信できているかを確認してみましょう．この節はただのテストなので飛ばしていただいても構いません．

### 送信処理
ループバックアドレスに対してOSC通信を飛ばすことで，自分自身でOSC通信を受け取ります．
先ほど配置した`OSCSender`の`SendMessage`の`IPAdress`を`127.0.0.1`にします．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/6131d3f0-1dc7-3943-1faa-d7c584070296.png)
これでゲームを再生してスペースボタンを押すと，自分自身に対してOSCメッセージが送信されます．

### 受信処理

Unityで受信処理を書いてもいいのですが，せっかく通信を行うので，別の言語で受け取るところを見たいところです．「他の言語で受信処理を試してみたい」という方は[Pythonでの受信テスト](#pythonでの受信テスト)を参照ください．一方で，「後で結局ESPで受け取るんだし，環境構築などの手間を省きたいのでUnityで受信したい」という方は[Unityでの受信テスト](#unityでの受信テスト)を参照ください．

#### Pythonでの受信テスト
次のスクリプトを動かしましょう．`python-osc`というライブラリをインストールしてください．
```python
from pythonosc import dispatcher
from pythonosc import osc_server

# メッセージを処理する関数
def message_handler(address, *args):
    print(f"受信したアドレス: {address}")
    print(f"受信したデータ: {args}")

# ディスパッチャを作成してハンドラーを登録
dispatcher = dispatcher.Dispatcher()
dispatcher.map("/sample", message_handler)  # "/sample"アドレスに対応するハンドラーを登録

# サーバーを設定
ip = "127.0.0.1"  # 受信するIPアドレス
port = 8000       # 受信するポート番号

server = osc_server.BlockingOSCUDPServer((ip, port), dispatcher)

print(f"OSCサーバーが {ip}:{port} で開始されました...")
server.serve_forever()  # サーバーを起動
```
実行し，Unityエディタでゲームを再生してスペースボタンを押すと受信が確認できると思います．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/05d460da-5f7a-d5d9-9037-a7864d604286.png)

#### Unityでの受信テスト
受信するのみなので，コンポーネントを使って実装します．
`Assets` > `Create` > `ScriptableObjects` > `OSC Jack` > `Connection`で新たに`OSC Connection`を作成してください．今回は`Assets/`直下に配置します．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/d68adb13-aa84-8f1b-46c8-8e33bdc79093.png)
`Host`が`127.0.0.1`で，`Port`が`8000`であることを確認してください．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/a568e896-abcd-3e45-6aba-b0243bb02fd7.png)


次に，わかりやすさのために受信したメッセージを表示したいと思うので，テキストを配置してください．`ReceivedText`という名前で中央に配置しておきます．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/8f97974b-96f2-b6b3-5aed-3173ea7ecebd.png)

最後に，CreateEmptyしてOSCReceiverというオブジェクトを作って`EventReceiver`というスクリプトをアタッチしてください．
`Connection`を先ほどの`OSC Connection`，`OSC Adress`を`\sample`，`Data Type`を`String`にして，`String Event`に`ReceivedText`の`TextMeshProUGUI`>`text`を指定してください．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/9a7eb62b-ffb2-4890-7dd6-fd1f82c5a86b.png)

この状態でゲームを再生してスペースボタンを押すとテキストが変わると思います．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/efb6822a-b840-2392-13ed-74eb513a2591.png)
また，`Window`>`OSC Monitor`からもOSCの受信が確認できます．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/767668d7-1d45-3a9f-3415-295c8666331b.png)

# ESP32
Unityの準備ができたのでESP32で受信処理を書いていきましょう．
次のコードを書き込んでください．
```c
#include <WiFi.h>
#include <WiFiUdp.h>

// WiFi接続のためのSSIDとパスワード
const char* ssid = "SSID";
const char* password = "PASSWORD";

const int localPort = 8000;  // 受信するポート番号

const int ledPin = 2; // LEDのピン番号

WiFiUDP udp;

// OSCメッセージを表す構造体
struct OscMessage {
  bool isInt = false;
  bool isFloat = false;
  bool isString = false;
  int intValue = 0;
  float floatValue = 0.0f;
  char stringValue[256] = {0};
};

void setup() {
  Serial.begin(115200);

  // WiFi接続
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  Serial.println("Connected to WiFi");

  // UDPの初期化
  Serial.println(WiFi.localIP());
  udp.begin(localPort);
  Serial.printf("Listening on UDP port %d\n", localPort);

  // LEDを出力モードに
  pinMode(ledPin, OUTPUT);
  
  Serial.println("setup finished");
}

void loop() {
  // 受信バッファを設定
  char incomingPacket[255];
  
  // UDPでOSCメッセージを受信
  int packetSize = udp.parsePacket();
  if (packetSize) {
    int len = udp.read(incomingPacket, 255);
    if (len > 0) {
      incomingPacket[len] = '\0';  // パケットの末尾をNULLで終了
    }
    
    // OSCメッセージを解析
    OscMessage message = parseOscMessage(incomingPacket, len);
    
    // 受信したらLEDを点灯
    ledOn();
  }
}

// LEDを点灯させる
void ledOn() {
  digitalWrite(ledPin, HIGH);
  delay(250);
  digitalWrite(ledPin, LOW);
  delay(250);
} 

// 4バイト境界に揃えるためのパディング計算
int padSize(int size) {
  return (size + 3) & ~3;  // 4バイト境界に揃える
}

// 受信メッセージを解析して構造体を返す
OscMessage parseOscMessage(char* packet, int packetSize) {
  OscMessage message;

  // アドレス部分の抽出
  String address = String(packet);
  Serial.println("Received OSC address: " + address);

  // アドレス部分の終わりを見つける（4バイト境界に揃える）
  int addressEnd = padSize(address.length() + 1);

  // 型タグの位置を見つける
  int typeTagPos = addressEnd;

  // 型タグが存在し、"i"（整数）であることを確認
  if (packet[typeTagPos] == ',' && packet[typeTagPos + 1] == 'i') {
    // 値の位置を取得して整数として読み取る（型タグの4バイト後が値の開始位置）
    int valuePos = padSize(typeTagPos + 2);
    memcpy(&message.intValue, packet + valuePos, sizeof(message.intValue));
    message.intValue = ntohl(message.intValue);  // ネットワークバイトオーダーをホストバイトオーダーに変換
    message.isInt = true;
    Serial.println("Received int value: " + String(message.intValue));
  } 
  // 型タグが存在し、"f"（浮動小数点）であることを確認
  else if (packet[typeTagPos] == ',' && packet[typeTagPos + 1] == 'f') {
    int valuePos = padSize(typeTagPos + 2);
    uint32_t networkOrderValue;
    memcpy(&networkOrderValue, packet + valuePos, sizeof(networkOrderValue));
    networkOrderValue = ntohl(networkOrderValue);
    memcpy(&message.floatValue, &networkOrderValue, sizeof(message.floatValue));
    message.isFloat = true;
    Serial.println("Received float value: " + String(message.floatValue));
  }
  // 型タグが存在し、"s"（文字列）であることを確認
  else if (packet[typeTagPos] == ',' && packet[typeTagPos + 1] == 's') {
    int valuePos = padSize(typeTagPos + 2);
    strcpy(message.stringValue, (char*)(packet + valuePos));
    message.isString = true;
    Serial.println("Received string value: " + String(message.stringValue));
  } 
  else {
    Serial.println("Unexpected or missing type tag");
  }

  return message;
}
```

setup関数内でWi-Fiに接続し，loop関数内で受信の処理を行っています．Wi-Fiに接続し終わるとIPアドレスをシリアルモニターに表示するので，それをUnity側で設定してください．

:::note warn
parseOscMessage関数を自作しているためバグがある可能性があります．今回は使用していませんが，OSC受信用のライブラリもあるようなのでそれも検討してください．
:::

## 動作確認
ESP32を実行して，シリアルモニタに出力されたIPアドレスをUnityの`OSCsender`の`IPAddress`に入力してください．
そうしたらUnityでゲームをスタートさせてスペースキーを押してください．それでシリアルモニタにメッセージが表示されたら成功です．
上手くいかない場合，同じWi-Fiにつながっているかや，IPアドレスが間違っていないかなどを確認してください．

# Quest->ESP32
最後に，QuestにUnityをビルドしてみましょう．この記事を読んでいる人は大抵セットアップ済みだと思うので，VRに対応させる部分は今回は省略します．[サンプルリポジトリ](https://github.com/toufu-24/OSC-QuestUnity-ESP-Sample)や他記事を参考にしてください．
ビルドできたら，実行して右手のプライマリボタン(QuestのAボタン）を押してみてください．それでESP32のシリアルモニタに`Hello, I am Quest3!`というメッセージが届けばOKです．
![{5A71C0F4-6C4E-4C8E-B880-698888366918}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3721358/997bca33-2c08-df29-2de4-2f430b739bbe.png)


# おわりに
今回の開発時，QuestからESP32に通信しようとしたときに，シリアル通信が上手くいかず，OSC通信に渋々乗り換えてOscCoreで動かなかったとき絶望しました．OscJackでうまくいって良かったです．[先人様](https://yumulog.hatenablog.com/entry/2022/06/12/225737)に大感謝...

# 参考記事
https://qiita.com/aa_debdeb/items/34174f07909fa324e09c