blog.s2n.tech

Unity向けのJoy-Conライブラリを作った話

公開

はじめに

私の大学では、11月に農工祭という農学部の学園祭があり、様々なサークルが展示や屋台を開催しています。我々MCC(Micro Computer Club)では毎年ゲームを出展しています。

今年は、SpellRushという新作ゲームを出展しました。 SpellRushはJoy-Conを杖に見立て、Joy-Conを振ることで魔法を撃ち合い戦う1vs1のゲームです。

実際のゲームの様子は以下の動画をご覧ください。

SpellRushはUnityで開発されているため、Joy-Conの入力をUnity上で取得できる必要があります。また、一般的なコントローラー入力以外に振りという動作があるため、ジャイロセンサーの値も必要です。

Unity向けのJoy-ConライブラリはすでにJoyconLibというライブラリがあるのですが、メンテナンスが6年前で止まっており、さらにその依存しているネイティブバイナリがApple Siliconに対応していないという問題がありました。

github.com

また、JoyconLibはボタンやジャイロセンサーの入力は取れるものの、Input Systemのような入力の管理は行っていないため、SpellRushではそれらの入力を自前で管理する必要がありました。

そこでJoyconLibを参考にしつつも、全く新しいJoy-Conライブラリを作成することにしました。

本記事では、このSpellRushの開発過程で作ったUnity向けJoy-Conライブラリ「UnityJoyCon」について紹介します。

初期構想

ひとまずJoyconLibと同じように、Joy-Conの入力を(センサー含めて)取得できることを目指します。 JoyconLibはHIDAPIという、C言語で書かれたHIDデバイスとの通信を行うライブラリを使用しています。ひとまずこれを使ってJoy-Conの入力を取得できるようにします。

HIDAPIはC言語で書かれたライブラリなので、UnityのC#から呼び出すにはFFIが必要です。 C#では[DllImport]という属性を使ってC言語の関数を呼び出すことができます。

public static unsafe class NativeMethods
{
const string __DllName = "hidapi";
[DllImport(__DllName, EntryPoint = "hid_init", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern int hid_init();
// 他の関数も同様に定義...
}

このようなメソッドと、C言語のライブラリ(.dll, .dylib, .so, .aなど)を用意することで、C#からC言語の関数を呼び出すことができます。しかしながら、C#から呼び出したい全ての関数に対してメソッドを用意するのは結構面倒です。そのため、自動生成するツールを使いました。

csbindgen

csbindgenはRustのコードからC#のメソッドを生成することができるRustのライブラリです。本来はRustのコードに対して使用するものですが、bindgenやccやcmakeといったcrateと組み合わせることで、CのライブラリをC#に簡単に持ち込むことができます。詳細はcsbindgenの作者の方の記事をご覧ください。

neue.cc

HIDAPIはcmakeでビルドされているため、Rustのbuild.rsで以下のような手順でC#のコードを自動生成しています。

  1. cmake crateを使い、HIDAPIをビルドし静的ライブラリ(.a)を生成する
  2. 生成したライブラリファイルをRustとリンクするように設定
  3. bindgen crateを使い、HIDAPIのヘッダーファイルからC→Rustのコードを生成する
  4. csbindgen crateを使い、Rust→C#のコードを生成する
  5. Rustのコードをビルド、ライブラリ(.dll, .dylib, .so, .a)を生成する

つまりRustを一度経由することで、CからC#のコードを自動生成しています。実際のbuild.rsは以下のリンクをご覧ください。

github.com

これによって、cargo buildのコマンド一つでHIDAPIをビルドしつつ、C#のコードを自動生成することができます。しかもRustはクロスビルドが比較的簡単なため、マルチプラットフォーム対応も容易です!(ただしCのライブラリに強く依存しているため、依存関係の解消が少々面倒ですが…)

Joy-Conとの通信

HIDAPIによって、Joy-Conとのパケットのやり取りができるようになりました。しかしながら、Joy-Conにどのようなパケットを送信すればよいのか、またJoy-Conからどのようなパケットが返ってくるのかは不明です。もちろん、これらの通信形式は公式から公開されているわけでもないため、どうにかしてパケットのフォーマットを調べる必要があります。幸いにも、先人たちがJoy-Conの通信形式を詳しく調査しGitHubで公開しているため、それを活用します。

github.com

詳細は省きますが、以下のようなパケットをJoy-Conに送信すれば、各種センサーの値を取得できるようになります。

  1. Joy-Conに保存されているスティックやジャイロセンサーのキャリブレーション(補正値)を取得する
  2. ジャイロセンサーを有効にする
  3. Joy-Conが60Hzでセンサーの値を送信するように設定する

これによって、Joy-Conからボタンやスティックやジャイロセンサー値が送信され続けるようになるため、これを読み出してキャリブレーション値を使って変換を行うことで、各種センサーの値を取得できるようになります。また、パケットは60Hzで送信され続けるため、Unity側でも高速かつ継続的にパケットを読み出し続ける必要があります。さらに、パケット読み出しはI/Oが生じるため、メインスレッドで行うのはパフォーマンスの観点から望ましくありません。そのため、パケットの読み出しは別スレッドで行うことでパフォーマンスの低下を防ぎます。

実装的には、パケットを読み出し続けるスレッドを用意し、新しいパケットが来たらデータを変換し、チャンネルを通してUnity側にデータを送信します。 Unity側ではチャンネルを監視し、新しいデータが来たらそれを読み出してゲーム内で使用します。

Input Systemとの統合

これで、Joy-Conの入力を取得できるようになりました。しかし、Unityで入力デバイスを管理するにはInput Systemを使用するのが一般的です。そこで、Joy-Conの入力をInput Systemに統合することで、Joy-Conの入力をゲーム内で使用しやすくします。

Input SystemはUnity上で入力デバイスを管理するためのシステムです。一般的なキーボード・マウスやゲームパッドの入力に加え、独自の入力デバイスを定義することも可能です。

nekojara.city

今回はJoy-Conの接続、切断をInput Systemで監視し、接続されたらHIDAPIを使って、パケットの送信と読み出しを行うようにしました。大体こんな感じのコードで実現できます。

[InitializeOnLoad]
[InputControlLayout(displayName = "Joy-Con (R)", stateType = typeof(JoyConRightInputState))]
public class JoyConRightInput : InputDevice, IInputUpdateCallbackReceiver
{
private JoyCon _joyCon;
static JoyConRightInput()
{
// Joy-ConのベンダーIDとプロダクトIDを指定して、このIDの組み合わせの場合はJoyConRightInputとなるように登録する
InputSystem.RegisterLayout<JoyConRightInput>(matches: new InputDeviceMatcher()
.WithInterface("HID")
.WithCapability("vendorId", 0x057e).WithCapability("productId", 0x2006));
}
protected override void OnAdded()
{
base.OnAdded();
// HIDAPIを使ってデバイスを開き、独自のJoyConクラスのインスタンスを作成する
var device = Hidapi.OpenDevice(0x057e, 0x2006, description.serial);
_joyCon = new JoyCon(device);
}
protected override void OnRemoved()
{
base.OnRemoved();
_joyCon.Dispose();
}
public void OnUpdate()
{
// チャンネルに来ているデータを読み出し、JoyConクラス内のステートを更新
_joyCon.Update();
var newState = new JoyConRightInputState
{
buttons = _joyCon.Buttons,
stick = _joyCon.Stick,
accel = _joyCon.Accel,
gyro = _joyCon.Gyro,
};
// 新しいステートをInputSystemに通知
InputSystem.QueueStateEvent(this, newState);
}
}

これによって、Joy-Conの入力をInput Systemで管理できるようになります。

ジャイロセンサーを元にした姿勢推定の実装

Joy-Conに搭載されているジャイロセンサーはいわゆる6軸IMUセンサーと呼ばれるもので、xyz軸方向での加速度と角速度を取得できます。このセンサーの値のみでは、Joy-Conの現在の姿勢(傾き)をそのまま取得することはできません。そのため、2つのセンサーの値を組み合わせて姿勢を計算する必要があります。

姿勢推定の方法にはいくつかありますが、今回は相補フィルタを使用した姿勢推定を実装しました。相補フィルタは簡単に言うと、それぞれのセンサーの長所と短所を補い合うように姿勢を推定する方法です。短時間スケールではジャイロの積分で姿勢変化を追いかけ、長時間スケールでは加速度センサーから重力方向を推定して、ドリフトを補正するような形で両者をブレンドしています。

なお、6軸IMUセンサーでの姿勢推定では、どうしてもヨー軸(水平回転)の値がドリフトしてしまいます。これは角速度センサーの値はノイズを含み、長時間積分するとドリフトが発生するためです。加速度センサーの重力方向の推定によって補正をしていますが、重力方向であるヨー軸方向は補正ができないためです。そのため、姿勢をリセットするメソッドを用意し、任意のタイミングで姿勢をリセットできるようにしています。

問題の発生と、実装方針の変更

この実装で本番の農工祭に臨んだのですが、かなりの不具合が発生してしまいました。具体的にはJoy-Conの接続が不安定であり、ボタン入力やジャイロセンサーの値が取得できないという不具合が頻発しました。

原因は完全に特定するには至らなかったのですが、おそらくOS側や他アプリ(Steamなど)もJoy-Con側とパケットを送受信しており、それらが競合してJoy-Conの状態が不安定になっていたと思われます。

また、ここまでHIDAPIを使ってJoy-Conとの通信を行ってきましたが、実はInput SystemでもHIDパケットのやり取りが可能です。

docs.unity3d.com

ということで、HIDAPIを使わずにInput Systemで全て完結するようにし、更に競合を防ぐように実装の方針を変更します。

HIDパケットの送信

HIDパケットの送信はExecuteCommandメソッドを使うことで行うことができます。送信するパケットの内容は、IInputDeviceCommandInfoを実装した構造体を定義し、それを使うことで送信することができます。 FourCCHIDOを指定します。これによって、HIDパケットの送信であることを明示します。

[StructLayout(LayoutKind.Explicit, Size = Size)]
internal struct JoyConOutputReport : IInputDeviceCommandInfo
{
public static FourCC Type => new('H', 'I', 'D', 'O');
public FourCC typeStatic => Type;
public const int Size = InputDeviceCommand.BaseCommandSize + 2;
[FieldOffset(0)] public InputDeviceCommand BaseCommand;
[FieldOffset(InputDeviceCommand.BaseCommandSize + 0)]
public byte ReportId;
[FieldOffset(InputDeviceCommand.BaseCommandSize + 1)]
public byte PacketNumber;
// ...
public static JoyConOutputReport Create(byte reportId, byte packetNumber)
{
return new JoyConOutputReport
{
BaseCommand = new InputDeviceCommand(Type, Size),
PacketNumber = packetNumber,
ReportId = reportId,
};
}
}
public class JoyConInput : InputDevice
{
protected override void OnAdded()
{
base.OnAdded();
// パケットをJoy-Conに向けて送信
var command = JoyConOutputReport.Create(0x01, 0x00);
ExecuteCommand(ref command);
}
}

HIDパケットの受信

HIDパケットの受信はInputDeviceIInputStateCallbackReceiverOnStateEventメソッドを実装することで行うことができます。このメソッドはデバイスに新しいステートが来た時に呼ばれるメソッドです。通常のデバイスの場合、ステートはInputSystemにより自動で処理され更新されますが、自前でパケットのパースや変換処理を行いたい場合はこのメソッドを実装します。

public class JoyConInput : InputDevice, IInputStateCallbackReceiver
{
public void OnStateEvent(InputDevice device, InputEventPtr eventPtr)
{
// ステートには通常のStateEventとDeltaStateEventがあるため、StateEventのみを処理
if (eventPtr.type != StateEvent.Type) return;
var stateEvent = StateEvent.From(eventPtr);
// HIDパケットの場合は`FourCC`が`HID`になるので、それ以外は無視
if (stateEvent->stateFormat != new FourCC('H', 'I', 'D')) return;
// パケットデータを元に変換処理を行い、新しいステートを作成
var state = new JoyConInputState
{
// ...
};
// 新しいステートをInputSystemに通知
InputState.Change(this, state, eventPtr: eventPtr);
}
}

Joy-Conの接続を安定させる

Joy-Conの接続を安定させるために、Joy-Conのパケットを監視し、一定時間パケットが送信されていない場合は初期化のパケットを再送信するようにしました。 IInputStateCallbackReceiverにはOnNextUpdateメソッドがあり、Updateの前に呼ばれるメソッドです。このメソッドを実装し、lastUpdateTimeを元に一定時間パケットが送信されていない場合は初期化のパケットを再送信するようにしました。

こちらは、InputSystemのSwitch Pro Controllerの実装を参考にしています。

github.com

完成したライブラリ

完成したライブラリは以下のリポジトリで公開しています。 Unity Package Manager経由でインストールできるようにしたので、皆さんのプロジェクトでも簡単に利用できます。

github.com

デモ動画もYouTubeで公開しています。ボタン入力やスティック入力のほか、ジャイロセンサーの値も取得できることがわかります。

最後に

今回がほぼ初めての本格的なUnity開発でしたが、なかなかいいものができたと思います。 UnityJoyConは今後も継続的にメンテナンスしていく予定であり、将来的にはSwitch2のJoy-Con2にも対応したいと思っています。

もしこのライブラリが良いと思った方は、ぜひStarを押してもらえると嬉しいです。また、不具合や改善点があれば、IssueやPull Requestをお願いします。