0

Unity × ESP32 双方向通信基盤の設計と実装

2026/6/9 // k1npira_dev

プロジェクト概要

Sony Spatial Reality Display向けのUnityアプリを開発した。ESP32デバイスのIMU・ボタン・設置状態を連携する双方向通信基盤を実装した。7人チームのハッカソンで、自分は通信処理まわりを主担当として実装した。

システム構成

Unity × ESP32 通信基盤のアーキテクチャ図

入力(ESP32 → Unity)

出力(Unity → ESP32)

自分の担当範囲

アーキテクチャの設計意図

Transport / Channel を中心にした分離

通信方式とゲームロジックを直接結合しないため、TransportとScriptableObject Channelを分離した。ScriptableObject Channelやイベント駆動の方針はチーム内で相談しながら決め、Serial / OSCの差し替えやゲーム側で扱いやすい形に実装を整理した。

Service層も用意していたが、最終的にはChannelを直接読む場面が多かった。地上・地下ステージではIMUのQuaternionをそのまま使えばよく、最終ステージではコントローラーをハンドルのように扱い、右回転・左回転として解釈する必要があった。ギミックごとにIMUの使い方が違ったため、共通Serviceに寄せきるより、Channelを境界にして各処理側で解釈する構成にした。

役割
TransportSerial / OSC の受信・送信、プロトコル変換
ChannelScriptableObject による状態・イベント共有

Transport はSerial/OSCの差異を吸収し、上位層では通信方式を意識しなくて済むようにする。

/// Serial でデバイスと双方向通信する Transport 実装。
/// System.IO.Ports 依存はこのクラスの中だけに閉じ込める。

Channel はTransportとゲーム側の処理をつなぐScriptableObject。通信方式を差し替えても、上位層は同じChannelを読むだけで済む。

public void Publish(Quaternion quaternion)
{
    LatestQuaternion = quaternion;
    HasReceived = true;
    OnQuaternionPublished?.Invoke(quaternion);
}

ゲームロジック側はChannelから必要な入力状態を読む。Serial文字列やOSCアドレスを知らずに利用できる。

public Quaternion LatestQuaternion => _smoothedQuaternion;
public bool JumpTriggeredThisFrame { get; private set; }
public bool IsPlaced => _placedChannel != null && _placedChannel.IsPlaced;

実装中に出た問題と対応

1. Serial受信方式の見直し

当初はDataReceivedイベント方式を試したが、Unity上での扱いが難しかったため、Update()内でReadExisting()するポーリング方式に変更した。ReadLine()は改行まで待つため、行が途中で分割された場合にブロックやタイムアウトの影響を受けやすい。そこで、受信チャンクをStringBuilderに蓄積し、改行が揃った時点で1行としてキューへ積む方式にした。

private void Update()
{
    if (_serialPort != null && _serialPort.IsOpen)
    {
        var chunk = _serialPort.ReadExisting();
        if (!string.IsNullOrEmpty(chunk))
        {
            lock (_bufferLock)
            {
                _receiveBuffer.Append(chunk);
                EnqueueCompletedLines();
            }
        }
    }

    while (_receivedLines.TryDequeue(out var line))
        ProcessLine(line);
}

private void EnqueueCompletedLines()
{
    var newlineLength = Newline.Length;
    while (true)
    {
        var buffer = _receiveBuffer.ToString();
        var index = buffer.IndexOf(Newline, StringComparison.Ordinal);
        if (index < 0)
            return;

        var line = buffer.Substring(0, index);
        _receiveBuffer.Remove(0, index + newlineLength);
        _receivedLines.Enqueue(line);
    }
}

この実装により、imu,x,y,z,w\nのような1行が複数チャンクに分かれて届いても、改行が揃うまで処理せず、完成した行だけをProcessLine()に渡せる。

2. Unity側の更新をUpdateに寄せる

受信データはConcurrentQueueに積み、Update()で取り出して処理する。Unity APIやScriptableObject更新をメインスレッド側に寄せ、通信処理との境界を明確にした。

private readonly ConcurrentQueue<string> _receivedLines = new();
private readonly object _bufferLock = new();

3. Play停止時にSerialポートを解放する

Serialポートは実機接続タイミングに依存するため、キャンセル可能な接続リトライを実装した。GetCancellationTokenOnDestroy()とリンクし、Destroy時にもリトライ待機を止められる。

_connectCts = CancellationTokenSource.CreateLinkedTokenSource(
    this.GetCancellationTokenOnDestroy());

ConnectWithRetryAsync(_connectCts.Token);

Play停止時はOnDisable()が入口になる。接続リトライのキャンセル、送信用Channelの購読解除、SerialPortのClose/Dispose、受信キューとバッファのクリアを順に実行する。

private void OnDisable()
{
    _connectCts?.Cancel();
    _connectCts?.Dispose();
    _connectCts = null;

    if (_topChannel != null)
        _topChannel.OnCommand -= OnTopCommand;

    CleanupSerialPort();

    while (_receivedLines.TryDequeue(out _)) { }
    lock (_bufferLock) { _receiveBuffer?.Clear(); }
}

CleanupSerialPort()はClose失敗時のリトライを含め、最終的に必ずDispose()して参照をnullに戻す。Unity Editor停止後にSerialポートが掴まれたままになる問題を抑制している。

private void CleanupSerialPort()
{
    if (_serialPort == null) return;

    if (_serialPort.IsOpen)
    {
        const int maxRetry = 5;
        for (var i = 0; i < maxRetry; i++)
        {
            try { _serialPort.Close(); break; }
            catch { if (i < maxRetry - 1) Thread.Sleep(100); }
        }
    }

    _serialPort.Dispose();
    _serialPort = null;
}

4. ESP32の姿勢をUnity向けに変換する

ESP32のセンサー座標系とUnity座標系の差分はTransport内で変換し、ゲーム側ではUnity向けのQuaternionとして扱えるようにした。

private static Quaternion ConvertToUnityQuaternion(float x, float y, float z, float w)
{
    return new Quaternion(y, -z, -x, w);
}

5. IMU入力のSlerpスムージング

IMUの細かい揺れを抑えるため、クォータニオンをSlerpで平滑化。フレームレート差で挙動が変わらないようTime.deltaTimeで補正している。

float frameAlpha = 1f - Mathf.Pow(1f - _alpha, Time.deltaTime * 60f);
_smoothed = Quaternion.Slerp(_smoothed, raw, frameAlpha);

6. キャリブレーション基準の保存・復元

シーン遷移をまたいでIMUの基準姿勢を保存し、復帰後に再利用する。

_calibrationChannel.Save(_baseRotation);
_suspendedRotation = RelativeRotation;
_suspended = true;

7. 連続送信される状態入力を扱う

Serialでは同じ状態が連続送信されるため、placedは前回値と比較して変化時のみPublishする。

if (_lastPlacedState == isPlaced)
    return;

_lastPlacedState = isPlaced;
_placedChannel?.Publish(isPlaced);

ジャンプ入力はより複雑な制御が必要だった。jump,1を受け取るたびにPulseVersionを増やし、jump,0RawIsActivefalseに戻す。

public void Publish(bool isActive)
{
    if (isActive) { RawIsActive = true; PulseVersion++; }
    else { RawIsActive = false; }
    HasReceived = true;
}

入力を使う側ではjump,0を一度受け取って_retriggerArmedtrueに戻るまで次のジャンプを受け付けない。さらに前回受理から_minTriggerIntervalSeconds秒未満の入力は無視する。

if (!_jumpChannel.RawIsActive)
    _retriggerArmed = true;

var pulseVersion = _jumpChannel.PulseVersion;
if (pulseVersion == _lastPulseVersion)
    return;

_lastPulseVersion = pulseVersion;

if (_retriggerArmed && Time.time - _lastAcceptedTime >= _minTriggerIntervalSeconds)
{
    JumpTriggeredThisFrame = true;
    _lastAcceptedTime = Time.time;
    _retriggerArmed = false;
}

これにより、ESP32側からjump,1が連続送信されても押しっぱなしとして扱い、jump,0を経由した次の入力だけを新しいジャンプとして受け付ける。

得られた効果

主要ファイル

関連リンク