プロジェクト概要
Sony Spatial Reality Display向けのUnityアプリを開発した。ESP32デバイスのIMU・ボタン・設置状態を連携する双方向通信基盤を実装した。7人チームのハッカソンで、自分は通信処理まわりを主担当として実装した。
システム構成
入力(ESP32 → Unity)
- IMU姿勢:
imu,x,y,z,w - ジャンプ入力:
jump,0|1 - 台座設置状態:
placed,0|1
出力(Unity → ESP32)
- LED面指定:
top,{panelId} - 振動:
haptic - デバイス再起動:
reboot
自分の担当範囲
- Unity側のデバイス通信基盤
- Serial / OSC Transportの抽象化
- ScriptableObject Channelによる入力・出力イベント設計の実装
- IMU座標変換、スムージング、キャリブレーション
- Serial接続のリトライ、停止時のClose/Dispose処理
- 実機検証用のOSC/Serialテスト導線
アーキテクチャの設計意図
Transport / Channel を中心にした分離
通信方式とゲームロジックを直接結合しないため、TransportとScriptableObject Channelを分離した。ScriptableObject Channelやイベント駆動の方針はチーム内で相談しながら決め、Serial / OSCの差し替えやゲーム側で扱いやすい形に実装を整理した。
Service層も用意していたが、最終的にはChannelを直接読む場面が多かった。地上・地下ステージではIMUのQuaternionをそのまま使えばよく、最終ステージではコントローラーをハンドルのように扱い、右回転・左回転として解釈する必要があった。ギミックごとにIMUの使い方が違ったため、共通Serviceに寄せきるより、Channelを境界にして各処理側で解釈する構成にした。
| 層 | 役割 |
|---|---|
| Transport | Serial / OSC の受信・送信、プロトコル変換 |
| Channel | ScriptableObject による状態・イベント共有 |
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,0でRawIsActiveをfalseに戻す。
public void Publish(bool isActive)
{
if (isActive) { RawIsActive = true; PulseVersion++; }
else { RawIsActive = false; }
HasReceived = true;
}
入力を使う側ではjump,0を一度受け取って_retriggerArmedがtrueに戻るまで次のジャンプを受け付けない。さらに前回受理から_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を経由した次の入力だけを新しいジャンプとして受け付ける。
得られた効果
- Serial / OSCを差し替えても、ゲームロジックへの変更を抑えて検証できる
- ESP32側のプロトコル変更を主にTransport層で吸収できる
- Unity Editor停止時のポート掴みっぱなしを抑制できる
- IMUのノイズや初期姿勢差を吸収し、操作感を安定化できる
- チームメンバーは通信仕様を意識せず、Channel経由でデバイス入力を利用できる
- 実機がない環境でもOSCテストツールで入力・出力の確認ができる
主要ファイル
Assets/_Contents/Piramura/Scripts/Device/Transport/SerialDeviceTransport.csAssets/_Contents/Piramura/Scripts/DeviceInput/Transport/OscDeviceInputTransport.csAssets/_Contents/Piramura/Scripts/DeviceInput/Channels/Assets/_Contents/Piramura/Scripts/DeviceInput/ImuRotationService.csAssets/_Contents/Piramura/Scripts/DeviceInput/QuaternionSmoother.cs