6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自転車型入力デバイスのシステム構築

Last updated at Posted at 2025-07-27

1. 導入

1-1. 前置き

はじめまして。
日本総合システムの千葉と申します。よろしくお願いいたします。
普段は仙台でエンジニアとして働いています。

今回、ご縁があって、Qiita記事のために筆を執った次第です。
(ここでかっこよくEnterキーを叩く)

それでは本題に入っていきます。

エアロバイク

my new gear...

早速私事で恐縮なのですが…。
健康維持のためにエアロバイクを購入しました。
トレーニング負荷の管理に利用できるのではないかと考え、
一緒にケイデンスセンサも購入しました。
(私の購入したエアロバイクは、ペダルの回転数などが表示されないため、
ケイデンスセンサを外付けすることで負荷の管理が可能になります。)

※ここで、ケイデンスとは、単位時間当たりの回転数を指します。
この記事内では、ペダルを1分間に何回回転させるかの値とし、単位はrpm(回転数/分)とします。

そして、
「せっかくケイデンスセンサを購入したし、負荷管理以外にも何か用途がないかな~」
と考え、ケイデンスセンサの使い方をネットで調べていたところ、
Zwiftというサービスを見つけました。

Zwiftというのは、ケイデンスセンサの入力を利用して自分のアバターを操作することで、遠くにいる相手とレースをしたり、屋内にいながら広い空間をサイクリングしたり、そんな仮想体験を提供してくれるサービスのようです。すてき。

仮想体験 というと何だか敷居が高く感じます。

しかし、現在は便利な世の中になりました。

国土交通省がPLATEAUなるプロジェクトのもと、日本各地の3次元GISデータを公開しています。
さらにはUnity向けの開発用SDKも提供されており、Unityの仮想空間上に土地や建物データをインポートするのは、実は相当敷居の低い状態となっています。

仙台駅東口

この画像は、仙台駅東口周辺の3DデータをUnityに取り込んだ様子です。
(右手奥に見えますのが、仙台支社のオフィスのある仙台MTビルです。)

走るための仮想空間はある。
あとは、そこを自転車で走るだけ。

イントロが長くなりましたが、今回は

ケイデンスセンサの入力を受け付け、
Unityアプリケーションを操作し、
Zwiftのようなサイクリング仮想体験システムを作成したい!

といった流れで開発を進めていき、
その結果、途中で頓挫するまでの流れを紹介していきます。
(先にオチを述べてしまうなんて、結論ファーストでビジネスマンの鑑ですね。)

1-2. 開発の意図や目的

仙台支社では、技術MTGなる勉強会を行っています。
その勉強会では、テーマやジャンルを設定しないで、参加者が以下の内容を持ち寄る勉強会となっています。

  • 最近業務で遭遇したアクシデントについて
  • 最近興味を持っている技術について

私は基本的にプライベートな時間で自主学習的に開発を行うことも多く、後者のテーマを持ち寄ることが多いです。というのも、業務だとなかなかこういう開発をしようと思ってもできないことが多いのです。

今回の記事の内容も、プライベートな時間でやっていたものを支社内の技術MTGで共有し、さらにそれを編集して社外向けに公開しています。
この経緯は、後の技術スタック策定などの意思決定に大きな影響を与えているため、記事の冒頭で明示的に記しておきます。

1-3. 環境情報

  • ケイデンスセンサ:上記で説明済
  • Unityアプリケーション動作PC
    • OS:Windows 11
    • Bluetooth接続可能
  • Unityエディタ:2021.3.9f1

1-4. 開発の要点

さて、今回の開発で肝となるのは以下の2つの機能です。

  • 機能A:ケイデンスセンサからの入力を取得する。
  • 機能B:取得した入力値によってUnityアプリケーションを操作する。

完成イメージ

上記のイメージ図を見てみると、
機能Aが 「ケイデンスセンサ→疑似的なゲームパッド」
機能Bが 「疑似的なゲームパッド→Unityアプリケーション」 に相当します。

上記の課題をクリアしてしまえば、今回の開発の要点にあたるインターフェースの話は解決するため、キーボードやゲームパッドで操作する通常のUnityアプリケーション開発と何ら変わりなく開発ができそうです。

2. 機能A:ケイデンスセンサからの入力を取得する

2-1. GATT通信の利用

さて、はじめにケイデンスセンサから値をどう受け取るかについて調査を行います。

調べてみると、今回使用するケイデンスセンサは、Bluetooth Low Energy(以降「BLE」と表記する)といった規格で通信を行うようでした。
さらに詳細を述べると、BLEではGATT通信という手法を用いて、親機・子機の通信を行っています。

GATT通信の基本的な流れは以下となります。

  • 子(一方向):特定のサービス(UUID指定)を検知し、親を検出する。
  • 親・子(双方向):サービスに付随する特性(UUID指定)に対し、その属性の読み書きを行う。

今回はケイデンスセンサを経由してのGATT通信を行いたいので、以下を調べる必要があります。

  • ケイデンスセンサで使用可能なサービスのUUID。
  • サービスに付随する特性のUUID。

GATT通信のサービスに関する規格は公式ドキュメントによってまとめられています。
日本語が世界共通言語になってくれないかな~と思いながら、涙目で英語のドキュメントを読みます。バベルの塔を作った先人を恨む。

利用できそうなサービスと、それに付随する特性は以下でした。

上記のサービス・特性を利用してGATT通信をすればよいことがわかりました。

2-2. 通信内容の解析

利用するGATT通信のサービス・特性がわかったところで、次は、それらの通信内容をどのように取得し、解析するかについて調べていきます。

今回Unityアプリケーションを動作させる環境は、我が家の私用PC、Windows11です。

Windows11ではWindowsランタイムAPIが整備されています。
特にC#からGATT通信に関連するAPIを呼ぶ場合は、名前空間Windows.Devices.Bluetooth.GenericAttributeProfileのクラスを用いればよさそうです。

ただし、WindowsランタイムAPIはUnityアプリケーションから呼ぶことはできない(※)ので、一度中継係となるような .NET アプリケーションを作成し、いったんそこでGATT通信の内容を取得、解析することを目標とします。

(※:Unityはマルチプラットフォーム向けにビルドが可能です。逆に、特定のプラットフォームでのみ使用可能なAPIを含むことは通常できないと思われます。仮に特定のプラットフォーム向けの機能を追加するとしたらregionなどを使用し、プラットフォームごとに実装をするのでしょうか。)

さて、.NET アプリケーションはWPFを用いてGUIとして作成します。
設計指針はMVVMとし、Model層を特にクリーンアーキテクチャ的に作成します。

完成イメージ

Model層

ドメインモデルの実装

まず、ケイデンスセンサから取得できる情報をクラスとします。
以降、「CSC」という接頭辞が頻出しますが、これは「cyclying speed-cadence」の略だととらえてください。

/// <summary>
/// Cyclying speed-cadence(CSC)に関する情報。
/// </summary>
public record CscInfo
{
    /// <summary>
    /// ケイデンス[rpm]。
    /// </summary>
    public double Cadence { get; init; }

    /// <summary>
    /// 角速度[rad/s]。
    /// </summary>
    public double Speed { get; init; }
}

次に、このCscInfoを受け取るためのインターフェースを用意します。
なお、このインターフェースがIDisposableを継承しているのは、このインターフェースが実質イベント監視の役割を担うため、Dispose時に監視を取りやめる処理が必要になることを想定しているからです。

/// <summary>
/// <see cref="CscInfo"/>の値を受け取る機能を持つインターフェース。
/// </summary>
public interface ICscInfoReceiver : IDisposable
{
    /// <summary>
    /// <see cref="CscInfo"/>の値変更時に行う処理。
    /// </summary>
    public Action<CscInfo>? OnValueChanged { get; set; }
}

最後に、物理的なデバイス情報をクラスとして実装します。

/// <summary>
/// デバイス情報。
/// </summary>
public record CscDevice
{
    /// <summary>
    /// インスタンスを初期化します。
    /// </summary>
    /// <param name="name">デバイス名。</param>
    /// <param name="cscInfoReceiver"><see cref="ICscInfoReceiver"/>の実装。</param>
    public CscDevice(string name, ICscInfoReceiver cscInfoReceiver)
    {
        this.Name = name ?? throw new ArgumentNullException(nameof(name));
        this.CscInfoReceiver = cscInfoReceiver ?? throw new ArgumentNullException(nameof(cscInfoReceiver));
    }

    /// <summary>
    /// デバイス名。
    /// </summary>
    public string Name { get; }

    /// <summary>
    /// <see cref="ICscInfoReceiver"/>の実装。
    /// </summary>
    public ICscInfoReceiver CscInfoReceiver { get; }
}

これで、必要なドメインモデルがそろいました。

リポジトリの実装

次は、リポジトリを作成します。

まずはじめに、先ほど定義したデバイス情報に関するリポジトリを作成します。
今回はデバイス情報を取得するだけになるので、GetAll相当のメソッドがあれば大丈夫です。

/// <summary>
/// <see cref="CscDevice"/>のリポジトリ。
/// </summary>
public interface ICscDeviceRepository
{
    /// <summary>
    /// <see cref="CscDevice"/>の一覧を非同期で取得します。
    /// </summary>
    /// <returns><see cref="CscDevice"/>の一覧。</returns>
    Task<IEnumerable<CscDevice>> GetCscDevicesAsync();
}

また、最終的にはCscInfoをUnityアプリケーション向けに受け渡しすることになるため、CscsInfoに特化したリポジトリも作成しておきます。これは、Add相当のメソッドがあれば大丈夫です(先のデバイス取得メソッドと異なり、非同期メソッドでないのは、イベント発火させるためです)。

/// <summary>
/// <see cref="CscInfo"/>のリポジトリ。
/// </summary>
public interface ICscInfoRepository
{
    /// <summary>
    /// <see cref="CscInfo"/>を追加します。
    /// </summary>
    /// <param name="cscInfo"><see cref="CscInfo"/>。</param>
    void Add(CscInfo cscInfo);
}

リポジトリはこの2つで大丈夫そうです。

サービス層

ドメインモデルとリポジトリが出来上がれば、次はインフラかサービスに着手できると思います。

自分は先にサービスに着手し、最終的にこんな感じで実装がうまくいくといいなあ~というイメージをあらかじめ固めておくのが好きなので、今回はその順番で実装を行います。
はじめに、インターフェースを作成します。

/// <summary>
/// デバイス関連のサービス。
/// </summary>
public interface ICscInfoService
{
    /// <summary>
    /// <see cref="CscDevice"/>の一覧を非同期で取得します。
    /// </summary>
    /// <returns><see cref="CscDevice"/>の一覧。</returns>
    Task<IEnumerable<CscDevice>> GetCscDevicesAsync();

    /// <summary>
    /// <see cref="CscInfo"/>を送信します。
    /// </summary>
    /// <param name="cscInfo"><see cref="CscInfo"/>。</param>
    void Send(CscInfo cscInfo);
}

このインターフェースを実装するクラスは、先のリポジトリを用いれば楽に実装ができそうです。

/// <summary>
/// <see cref="ICscInfoService"/>の実装クラス。
/// </summary>
internal class CscInfoService : ICscInfoService
{
    private readonly ICscDeviceRepository cscDeviceRepository;
    private readonly ICscInfoRepository cscInfoRepository;

    /// <summary>
    /// インスタンスを初期化します。
    /// </summary>
    /// <param name="cscDeviceRepository"><see cref="ICscDeviceRepository"/>の実装。</param>
    /// <param name="cscInfoRepository"><see cref="ICscInfoRepository"/>の実装。</param>
    public CscInfoService(ICscDeviceRepository cscDeviceRepository, ICscInfoRepository cscInfoRepository)
    {
        this.cscDeviceRepository = cscDeviceRepository ?? throw new ArgumentNullException(nameof(cscDeviceRepository));
        this.cscInfoRepository = cscInfoRepository ?? throw new ArgumentNullException(nameof(cscInfoRepository));
    }

    /// <inheritdoc/>
    public Task<IEnumerable<CscDevice>> GetCscDevicesAsync()
    {
        return this.cscDeviceRepository.GetCscDevicesAsync();
    }

    /// <inheritdoc/>
    public void Send(CscInfo cscInfo)
    {
        this.cscInfoRepository.Add(cscInfo);
    }
}

これで、サービス層の実装が完了しました。

インフラ層(GATT通信)

残るはインフラ層です。
インフラ層は今回の実装の肝となる以下の2機能が入るため、集中して実装します。

  • 機能A:ケイデンスセンサからの入力を取得する。
  • 機能B:取得した入力値によってUnityアプリケーションを操作する。

特に、機能Bについてはこの時点でどう実現するかを決定していません。
問題を先延ばしにするといえば聞こえが悪いですが、先に機能Aを実装します。

さて、機能Aの実装については、先にも述べた通り、名前空間Windows.Devices.Bluetooth.GenericAttributeProfileのクラスを利用すればOKです。
具体的にどうするかについてはコードで提示したく思います。
結構がっつり書いていますが、気になる方はコメントを追いながらコードを読んでみてください。

/// <summary>
/// GATTを利用する<see cref="ICscDeviceRepository"/>実装クラス。
/// </summary>
public class CscDeviceGattRepository : ICscDeviceRepository
{
    /// <summary>
    /// シングルトンインスタンス。
    /// </summary>
    public static readonly CscDeviceGattRepository Instance = new CscDeviceGattRepository();

    private readonly IList<CscDevice> cscDevices = new List<CscDevice>();

    private CscDeviceGattRepository()
    {
        // デフォルトコンストラクタの隠蔽を行う。
    }

    /// <inheritdoc/>
    public async Task<IEnumerable<CscDevice>> GetCscDevicesAsync()
    {
        // すでに監視しているケイデンスセンサ一覧に対して、破棄処理を行う。
        foreach (var cscDevice in this.cscDevices)
        {
            cscDevice.CscInfoReceiver.Dispose();
        }

        // 先に調べた「Cycling Speed and Cadence」のUUIDをキーとして、サービスを取得する。
        var agsFilter = GattDeviceService.GetDeviceSelectorFromUuid(GattServiceUuids.CyclingSpeedAndCadence);

        // 取得したサービスを持つデバイスの一覧を取得する。(この時点でケイデンスセンサ一覧を取得することになる。)
        var deviceInformationCollection = await DeviceInformation.FindAllAsync(agsFilter, null);

        // ケイデンスセンサ一覧を消去する。
        this.cscDevices.Clear();

        // すべてのデバイスに対して、GATT通信時のイベント発火機能を付与する。
        foreach (var deviceInformation in deviceInformationCollection)
        {
            try
            {
                // デバイスに結び付くサービスを取得する。
                var gattDeviceService = await GattDeviceService.FromIdAsync(deviceInformation.Id);
                if (gattDeviceService == null)
                {
                    continue;
                }

                // サービスの特性のうち、ケイデンスセンサの測量値を取得する。
                var characteristicsResult = await gattDeviceService.GetCharacteristicsForUuidAsync(GattCharacteristicUuids.CscMeasurement);
                var cscMeasurement = characteristicsResult.Characteristics.SingleOrDefault();
                if (cscMeasurement == null)
                {
                    continue;
                }

                // ケイデンスセンサの測量値に対して通知許可を与える。
                await cscMeasurement.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.Notify);
                
                // CscInfoを受け取るためのインターフェースに実態を与える。
                var cscInfoReceiver = new CscInfoGattReceiver(cscMeasurement);
                var cscDevice = new CscDevice(deviceInformation.Name, cscInfoReceiver);

                // 監視対象に追加する。
                this.cscDevices.Add(cscDevice);
            }
            catch (COMException)
            {
                // デバイス次第ではCOMExceptionが頻出するため、今回は無視する。
                continue;
            }
        }

        return this.cscDevices;
    }

    // ICscInfoReceiverの実装クラス。
    private class CscInfoGattReceiver : ICscInfoReceiver
    {
        private const int TimeFactor = 1024;
        private readonly GattCharacteristic cscMeasurement;

        // 角速度、ケイデンスともに累積値が取得されるため、直前の値を覚えておく必要がある。
        private uint previousCumulativeWheelRevolutions = 0;
        private ushort previousLastWheelEventTime = 0;
        private ushort previousCumulativeCrankRevolutions = 0;
        private ushort previousLastCrankEventTime = 0;

        // コンストラクタ。
        public CscInfoGattReceiver(GattCharacteristic cscMeasurement)
        {
            this.cscMeasurement = cscMeasurement;
            this.cscMeasurement.ValueChanged += (_, args) =>
            {
                // GattCharacteristicからの入力値をドメインモデルに変換する。
                CscInfo cscInfo = this.Parse(args.CharacteristicValue);
                if (this.OnValueChanged != null)
                {
                    // ドメインモデルに対して、イベントを発火する。
                    this.OnValueChanged(cscInfo);
                }
            };
        }

        /// <inheritdoc/>
        public Action<CscInfo>? OnValueChanged { get; set; }

        /// <inheritdoc/>
        public void Dispose()
        {
            this.cscMeasurement.Service.Dispose();
        }

        // 引数はGATT通信で取得したバイナリである。
        private CscInfo Parse(IBuffer buffer)
        {
            // このメソッド内では、バイナリの解析を行っている。
            var data = new byte[buffer.Length];
            DataReader.FromBuffer(buffer).ReadBytes(data);
            var span = new ReadOnlySpan<byte>(data);
            var offset = 1;

            var flags = span[0];
            double speed = 0;
            if ((flags & 0x01) != 0)
            {
                // 角速度の取得を行っている。
                var cumulativeWheelRevolutions = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, sizeof(uint)));
                offset += sizeof(uint);
                var lastWheelEventTime = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, sizeof(ushort)));
                offset += sizeof(ushort);

                double diffOfRevolutions = cumulativeWheelRevolutions - this.previousCumulativeWheelRevolutions;
                double diffOfTime = (lastWheelEventTime - this.previousLastWheelEventTime) / TimeFactor;
                if (diffOfTime != 0)
                {
                    speed = diffOfRevolutions / diffOfTime;
                }

                this.previousCumulativeWheelRevolutions = cumulativeWheelRevolutions;
                this.previousLastWheelEventTime = lastWheelEventTime;
            }

            double cadence = 0;
            if ((flags & 0x02) != 0)
            {
                // ケイデンスの解析を行っている。
                var cumulativeCrankRevolutions = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, sizeof(ushort)));
                offset += sizeof(ushort);
                var lastCrankEventTime = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, sizeof(ushort)));

                double diffOfRevolutions = cumulativeCrankRevolutions - this.previousCumulativeCrankRevolutions;
                double diffOfTime = (lastCrankEventTime - this.previousLastCrankEventTime) / TimeFactor;
                if (diffOfTime != 0)
                {
                    cadence = (diffOfRevolutions * 60.0) / diffOfTime;
                }

                this.previousCumulativeCrankRevolutions = cumulativeCrankRevolutions;
                this.previousLastCrankEventTime = lastCrankEventTime;
            }

            // 計算結果に対して、丸め込みを行っている。
            speed = Math.Clamp(speed, -10, 10);
            cadence = Math.Clamp(cadence, -300, 300);

            var cscInfo = new CscInfo()
            {
                Speed = speed,
                Cadence = cadence,
            };

            return cscInfo;
        }
    }
}

これで、GATT通信に関するインフラ層が完成しました。
ここまでの実装内容で、通信内容の取得、解析まで実装できています。

3. 機能B:取得した入力値によってUnityアプリケーションを操作する

3-1. 通信内容の共有

ここまでの実装で、通信内容を取得することができました。
ここからは、この通信内容をどのようにUnityアプリケーションに伝搬するかを考えます。

まずはじめに思いつくアイデアとしては、ローカルAPIを立てて .NET アプリケーションからUnityアプリケーションに対して通信を行う方法です。
この方法はシンプルかつ汎用性が高いため、通常の業務であれば即採用なのですが、簡単にできてしまいそうで、面白みに欠けます。せっかく休日の時間を割いて開発をしているのですから、普段業務で採用しないような、ちょっとひねった方法はないかを考えてみます。

そこで調査を行ってみると、MemoryMappedFileという機能を見つけました。
こちらの機能は、ざっくりいうとローカルの共有メモリを読み書きする(メモリファイルマップを管理する)ことができる機能です。
さらに、メモリは同時管理が可能で、シークを介さずにランダムアクセスが可能であることから、プロセス間通信の共有メモリを作成するのに適しているとのことです。
たしかに、メモリ共有と聞くと、なんだか、ローカルAPIよりも、データの伝搬速度が速いのかも?という気がします。そういう気がしますよね。そう、するんですよ。

というわけで、機能Bの実現にあたり、MemoryMappedFileを利用して、.NET アプリケーションで取得した入力値をUnityアプリケーションへ伝搬させることを考えてみます。

3-2. 排他制御の実装

ところで、MemoryMappedFileを用いて共有メモリで値の書き出しを行うとして、これをUnityアプリケーションから読み込む場合に、排他制御を行う必要がありそうです。
.NET アプリケーション側が書き込みを行っているときはUnityアプリケーションは読み込みを行わない方がよいですし、逆にUnityアプリケーション側が読込を行っているときは .NET アプリケーションは書き込みを行わない方がよいでしょう。

C#では、同一アプリケーション内で排他制御を行う場合はTask.csやThread.csを利用した非同期処理を用いることが多いです。しかし、今回は同一アプリケーションではなく個別の複数アプリケーションなので、同じアプローチで問題が解決できるかは微妙なところです。

そこで、また調査に入ります。
少し調査をしてみたところ、今度はMutexという機能を見つけました。

Mutexは、通常同一のアプリケーションが複数個同時に起動しないようにする用途で用いられることが多く、何かしらの識別子をキーとして、すでに動作しているほかのアプリケーションを監視することが可能なようです。

これを用いれば、 排他制御をしながらUnityアプリケーションへ入力値を伝搬できるのでは? という淡い希望を胸に、開発を再開します。

Model層

インフラ層(MemoryMappedFile)

CscInfoの内容をMemoryMappedFileに書き出すようなリポジトリは、次のようにして実装します。
長々と書いていますが、MemoryMappedFileへのストリーム作成の前後にMutexの取得を行っていることさえつかめれば問題ありません。

/// <summary>
/// <see cref="MemoryMappedFile"/>を利用する<see cref="ICscInfoRepository"/>実装クラス。
/// </summary>
public class CscInfoMemoryMappedFileRepository : ICscInfoRepository, IDisposable
{
    private readonly string applicationName;
    private readonly Mutex mutex;
    private MemoryMappedFile? memoryMappedFile;

    /// <summary>
    /// インスタンスを初期化します。
    /// </summary>
    /// <param name="applicationName">アプリケーション名。</param>
    public CscInfoMemoryMappedFileRepository(string applicationName)
    {
        this.applicationName = applicationName ?? throw new ArgumentNullException(nameof(applicationName));
        this.mutex = new Mutex(false, $"{this.applicationName}_mutex", out _);
    }

    /// <inheritdoc/>
    public void Add(CscInfo cscInfo)
    {
        var getMutex = false;
        try
        {
            // 1ミリ秒待機してからMutexの取得を試みる。
            getMutex = this.mutex.WaitOne(1);
            if (!getMutex)
            {
                return;
            }

            // すでにMemoryMappedFileに書き込みがある場合は破棄する。
            this.memoryMappedFile?.Dispose();

            // MemoryMappedFileを新しく作成する。
            this.memoryMappedFile = MemoryMappedFile.CreateOrOpen(this.applicationName, 104857600L);
            using (var writer = new StreamWriter(this.memoryMappedFile.CreateViewStream()))
            {
                // CscInfoの内容を書き込む。
                writer.WriteLine(cscInfo.Speed.ToString());
                writer.WriteLine(cscInfo.Cadence.ToString());
            }
        }
        finally
        {
            if (getMutex)
            {
                // Mutex取得済である場合は、Mutexを解放する。
                this.mutex.ReleaseMutex();
            }
        }
    }

    /// <inheritdoc/>
    public void Dispose()
    {
        // アプリケーション終了時などに解放忘れがないようにしておく。
        this.memoryMappedFile?.Dispose();
        this.mutex?.Dispose();
    }
}

View層

XAMLで画面を作成します。
ここはあまり凝っても仕方がないので、さらっと作成します。
画面

適当に こんな感じで いいのでは(無季自由律俳句)。

ViewModel層

メイン画面のビューモデルでは、GATT通信の値変更時にMemoryMappedFileへの書き出しを行うよう紐づけを行います。
(本当は、ほかにもプロパティやメソッドがあるのですが、最低限GATT通信にかかわる箇所のみを記載しています。)

/// <summary>
/// メイン画面のビューモデル。
/// </summary>
public class MainWindowViewModel : BindableBase
{
    private readonly ICscInfoService cscInfoService;

    private CscDevice? selectedCscDevice = null;
    private bool canUseAsGamepad = true;

    /// <summary>
    /// インスタンスを初期化します。
    /// </summary>
    /// <param name="cscInfoService"><see cref="ICscInfoService"/>の実装。</param>
    public MainWindowViewModel(ICscInfoService cscInfoService)
    {
        this.cscInfoService = cscInfoService ?? throw new ArgumentNullException(nameof(cscInfoService));
    }

    /// <summary>
    /// 選択中の<see cref="CscDevice"/>。
    /// </summary>
    public CscDevice? SelectedCscDevice
    {
        get => this.selectedCscDevice;
        set
        {
            if (!this.SetProperty(ref this.selectedCscDevice, value))
            {
                return;
            }

            if (this.SelectedCscDevice == null)
            {
                return;
            }

            this.SelectedCscDevice.CscInfoReceiver.OnValueChanged = this.OnValueChanged;
        }
    }

    /// <summary>
    /// ゲームパッドとして利用するかどうか。
    /// </summary>
    public bool CanUseAsGamepad
    {
        get => this.canUseAsGamepad;
        set => this.SetProperty(ref this.canUseAsGamepad, value);
    }

    private void OnValueChanged(CscInfo cscInfo)
    {
        if (this.CanUseAsGamepad)
        {
            try
            {
                this.cscInfoService.Send(cscInfo);
            }
            catch (Exception e)
            {
                // 失敗時は握りつぶす
            }
        }
    }
}

最後に、入力をGATT通信、出力をMemoryMappedFileによるデータ伝搬となるようにリポジトリの依存性注入を行えばOKです。

これで、.NET アプリケーション側からデバイスを取得し、Unityアプリケーションへ入力値を受け渡すところまで実装を進めました。

3-3. Unity操作の実装

最後に、Unityアプリケーションから共有メモリ内の入力値を読み込む機能を実装します。

今回は、Unityのデバイス入力管理機能にあたるInputSystemを用いて、疑似的なゲームパッドとして扱えるようにします。

具体的には以下のクラスを作成します。

  1. ケイデンスセンサの入力レイアウトを持つクラス。
  2. ケイデンスセンサから入出力を行うデバイスのクラス。
  3. ケイデンスセンサを登録するクラス。

それぞれ、Unity公式ドキュメントにしたがって実装を行います。

はじめに、ケイデンスセンサの入力レイアウトを持つクラスを作成します。
入力レイアウトとは、ざっくりいうとそのデバイスの持つ入力の種類や構成をまとめたものです。入力レイアウトの例を挙げますと、

  • キーボード:Spaceキー、Enterキー、…
  • マウス:右クリックボタン、左クリックボタン、ホイール、マウスの位置、…
  • ゲームパッド:Aボタン、十字キー、Lスティック、…

といったところでしょうか。
今回は、ケイデンスセンサから取得可能な値である角速度とケイデンスの2つを入力レイアウトに含めます。

/// <summary>
/// ケイデンスセンサの<see cref="IInputStateTypeInfo"/>実装クラス。
/// </summary>
public struct CadenceSensorInputStateTypeInfo : IInputStateTypeInfo
{
    /// <inheritdoc/>
    public readonly FourCC format => new FourCC("_CSC");

    [InputControl(name = "speed", layout = "Button")]
    public float speed;

    [InputControl(name = "cadence", layout = "Button")]
    public float cadence;

    /// <inheritdoc/>
    public override string ToString()
    {
        return $"{nameof(CadenceSensorInputStateTypeInfo)}[{nameof(this.speed)}={this.speed}, {nameof(this.cadence)}={this.cadence}]";
    }
}

次に、デバイスのクラスを作成します。
入力値の読み込みは、.NET アプリケーション側の入力値書き出しと対になるイメージで、MappedMemoryFileとMutexを利用して実装します。
またもや長々と書いていますが、OnUpdateメソッドが肝となります。

/// <summary>
/// ケイデンスセンサ。
/// </summary>
#if UNITY_EDITOR
[InitializeOnLoad]
#endif 
[InputControlLayout(displayName = nameof(CadenceSensor), stateType = typeof(CadenceSensorInputStateTypeInfo))]
[UnityEngine.Scripting.Preserve]
public class CadenceSensor : HID, IInputUpdateCallbackReceiver
{
    private static readonly string ApplicationName = "CadenceSensor";
    private Mutex mutex;

    static CadenceSensor()
    {
        InputSystem.RegisterLayout<CadenceSensor>(name: nameof(CadenceSensor));
    }

    [RuntimeInitializeOnLoadMethod]
    static void Initialize()
    {
        Debug.Log($"{nameof(Initialize)} : {nameof(CadenceSensor)}");
    }

    /// <summary>
    /// 現在のケイデンスセンサ。
    /// </summary>
    public static CadenceSensor Current { get; private set; }

    /// <summary>
    /// 角速度。
    /// </summary>
    public ButtonControl Speed { get; private set; }

    /// <summary>
    /// ケイデンス。
    /// </summary>
    public ButtonControl Cadence { get; private set; }

    protected override void OnAdded()
    {
        Debug.Log($"{nameof(CadenceSensor)} : {nameof(OnAdded)}");
        base.OnAdded();
        this.mutex = new Mutex(false, $"{ApplicationName}_mutex", out _);
    }

    protected override void OnRemoved()
    {
        Debug.Log($"{nameof(CadenceSensor)} : {nameof(OnRemoved)}");
        base.OnRemoved();
        this.mutex?.Dispose();
    }

    /// <inheritdoc/>
    public override void MakeCurrent()
    {
        Debug.Log($"{nameof(CadenceSensor)} : {nameof(MakeCurrent)}");
        base.MakeCurrent();
        Current = this;
    }

    /// <inheritdoc/>
    protected override void FinishSetup()
    {
        Debug.Log($"{nameof(CadenceSensor)} : {nameof(FinishSetup)}");
        base.FinishSetup();
        this.Speed = GetChildControl<ButtonControl>("speed");
        this.Cadence = GetChildControl<ButtonControl>("cadence");
    }

    public void OnUpdate()
    {
        Debug.Log($"{nameof(CadenceSensor)} : {nameof(OnUpdate)}");
        var getMutex = false;
        try
        {
            getMutex = this.mutex.WaitOne(1);
            if (!getMutex)
            {
                return;
            }

            // MemoryMappedFileからデータを読み込む
            using (var memoryMappedFile = MemoryMappedFile.OpenExisting(ApplicationName))
            {
                using (var reader = new StreamReader(memoryMappedFile.CreateViewStream()))
                {
                    // 書き込みの内容をもとに、読み込みを行う。
                    var info = new CadenceSensorInputStateTypeInfo();
                    info.speed = float.Parse(reader.ReadLine());
                    info.cadence = float.Parse(reader.ReadLine());

                    // 取得内容をもとにイベントを発火させる。
                    Debug.Log($"{nameof(CadenceSensor)} : {nameof(info)}={info}");
                    InputSystem.QueueStateEvent(this, info);
                }
            }
        }
        catch (IOException)
        {
            // 接続先がいないだけなので無視する。
        }
        catch (Exception e)
        {
            Debug.LogWarning(e);
        }
        finally
        {
            if (getMutex)
            {
                // Mutexを解放する。
                this.mutex.ReleaseMutex();
            }
        }
    }
}

ここまでで、ケイデンスセンサの入力レイアウトならびにその取得ができるようになりました。

最後に、ケイデンスセンサを登録するクラスを作成します。
以下のコンポーネントは、ケイデンスセンサを有効化したいシーン上のオブジェクトにアタッチします。

/// <summary>
/// <see cref="CadenceSensor"/>の有効化を行うコンポーネント。
/// </summary>
public class CadenceSensorSupport : MonoBehaviour
{
    private void OnEnable()
    {
        Debug.Log($"{nameof(CadenceSensorSupport)} : {nameof(OnEnable)}");
        InputSystem.AddDevice<CadenceSensor>(name: nameof(CadenceSensor));
    }

    private void OnDisable()
    {
        Debug.Log($"{nameof(CadenceSensorSupport)} : {nameof(OnDisable)}");
        var device = InputSystem.devices.FirstOrDefault(x => x is CadenceSensor);
        if (device != null)
        {
            InputSystem.RemoveDevice(device);
        }
    }
}

さて、この時点で、動作確認を行います。
デバッグ実行を行い、Unityエディタ上からケイデンスセンサをデバイス登録されていることを確認します。
InputSystem1

さらに、先の「CadenceSensor」の詳細を開き、指定したレイアウトが登録されていることを確認します。
(なお、このスクリーンショットを撮影した際は、ケイデンスセンサと接続を行っていないので、イベントは検知されていません。)
InputSystem2

次に、InputActionを用いてイベント発火時の動作を紐づけます。
InputAction1

この際、キーボードからも動作紐づけを行うことで、デバッグのたびに毎回自転車をこぐようなことをしなくても、キーボードから操作が可能です。
今回は、キーボードのEnterキーを押すと、自転車を漕ぐのと同じ動作をするようにしておきます。

最後に、適当なオブジェクトを用意して、入力に応じて動作するように実装します。

デモシーン

具体的には以下のコードが動きます。
MVPパターンで実装するならば、Presenterが担う役割になるかと思います。

// シーン起動時に動作する形で実装する。
private void Start()
{
    this.actions = new DemoActions();
    this.actions.main.move.performed += this.OnMove;
    this.actions.main.move.canceled += this.OnMove;
    this.actions.Enable();
}

private void OnMove(InputAction.CallbackContext context)
{
    // ゲームパッドからの入力値を角速度に設定する。
    var speed = context.ReadValue<float>();
    this.SetSpeed(speed);
}

private void SetSpeed(float speed)
{
    // 操作オブジェクトの角速度を設定する。
    this.player.speed = speed;

    // 字幕にケイデンスセンサからの入力を表示する。
    this.view.SetSpeed(speed);
}

これで、Unityアプリケーションの実装も完了です。ぱちぱち。

4. 開発後の感想

4-1. 動作確認

最終的な成果品は以下の通りです。

成果品

下図、元々のイメージで、疑似的なゲームパッドと見なしていた箇所が、

  • WindowsランタイムAPI
  • MemoryMappedFile+Mutex
  • InputSystem
    のトリプルコンボによって実装されていますね。

元々のイメージ

それではさっそく動作確認を行っていきます。

はじめに、.NET アプリケーション側でデバイス検知をしてみましょう。

画面

おお。
なんだかうまく取得できていそうです。

次に、実際に操作をしてみた様子が以下の動画です。

画面

…それなりには、思った通りに動いていますね。
ただし、そこまで滑らかな動きではないような気もします。

特に、以下の点が気になります。

  • 画面外にある入力を受け付けるPCとケイデンスセンサの距離が結構離れているせいか、やや通信に不安があります。(調べてみるとBLEデバイスは省電力である一方、通信範囲などは若干非力なようでした。)
  • 排他制御のせいか、動作にラグがあるように感じます。漕ぐペースを変えた時、すぐに値に反映されてくれません。
  • 入力値がざっくりしすぎていて、計算ロジックに不安があります。これについては、もしかしたらセンサ側の問題なのかもしれませんが…。

というわけで、プライベートで勝手につくった成果物としては十分なクオリティですが、業務で使用する場合は気になるクオリティとなりました。

ちなみに、はじめに思い付いたローカルAPI利用だと、.NET アプリケーションからUnityアプリケーションへ、一方向のデータ受け渡しになるので、排他制御が不要になり、上記の問題をしっかり解決してくれそうです。
「メモリ共有の方が速そう!」という気持ちに委ねて実装を行いましたが、この判断こそ、運命の分岐点。選んだ道はいまいちだったかもしれません。それでも、僕たちは選んだ道を進むしかないんだ。

さて、本来試してみたかった機能や設計については動作を確認できたので、現時点での成果物はキリがいいと判断し、ここで開発を中断します。頓挫。

(業務外のプライベートにおける開発は、納期や責任がないゆえの自由があるため興味ドリブンで好き勝手に楽しく学習できる一方、自由ゆえにクオリティの追求を妨げる要因がないために際限なく学習を続けられてしまうのが問題だと感じています。ちょうどいいところでやめる理由を見つけたり、事前に「有料のライブラリやアセットは使わない」みたいなルールを設けておいたりして、際限なく学習を続けないようにセーブするのも、個人開発の重要な点なのかな、と思います。)

4-2. 明示的なオチ

先ほど挙げた問題は、実は些細な問題です。

本当に重大な問題に気づいてしまいました。

この自転車型入力デバイスは、角速度と回転数のみを入力としています。
この角速度と回転数といった2つのパラメータ、計算される値こそ違いますが、早く漕げば両方とも値は大きくなり、遅く漕げば両方とも値は小さくなります。つまるところ、実際に操作するときには、同義な内容であり、可能な操作としては「ペダルを漕ぐ」のみです。
また、方向転換やブレーキ機能などに相当する入力は一切ありません。

これがどういうことか。

そう、今回作成した自転車型入力デバイス利用システムは、
「ボタンが1つしかないゲームパッド」
なのです。壮大な開発過程のわりに、機能が貧弱。

想像してみてください。
Enterキーしかないキーボード、左クリックしかできないマウス、Aボタンしかないゲームパッド。ゲーム性のゲの字もありませんわ。

初代たまごっちは、あの小さな機体にボタンが3つもあるんですよ。
そう考えると、世の中のおもちゃ業界はすごい。

はい。
動作自体は期待されたものが完成しました。
それは、とても良いことだと思うのですが、

「ボタン1つで作れるおもしろいゲーム…?」

というところで行き詰ってしまいました。
接続先をモバイル端末にして、タッチパッド上に仮想のボタンなんかを配置すれば、もっといろいろできそうですが、キリがよいのでここでゲームの開発は断念しました。
(仙台をバイクで駆け回るところを楽しみにしていた仙台ファンの皆様、ごめんなさい。)

ということで、出来上がったものは粗品でした。

切ない。

4-3. 学びと反省

ネガティブな気持ちで〆るのもよくないので、お気持ち程度にポジティブな話を。

出来上がったものが粗品であれ、開発過程を自分で考え設計し、それが思うように動いた時の達成感は大きかったです。
また、普段の業務とは異なり、プライベートにおける開発、学習だったため、失敗が許されるゆえの創意工夫ができました。
特にこれらの経験は、仕事や資格勉強ではなかなか味わうことのできないとてもよい経験だった(と自らに言い聞かせたい)と思います。

それにしても、開発過程で運動をしたので、汗をかきました。
シャワー浴びてビールを飲みます。ちゃんちゃん。

4-4. 〆の言葉

いかがでしたか。

今回紹介したシステムは、
「自転車型入力デバイスでアプリケーションを操作する」
といったものでした。

私は普段、防災・減災分野に関する研究開発を行っています。その中で、
「歩行型入力デバイスでアプリケーションを操作する」
といった業務に携わっています。

この業務では、東日本大震災を疑似体験するためのシステム構築を行っており、デバイス入力によるVR空間歩行機能の実装に加え、震災以前のGISデータを利用しての3Dモデル作成や、避難体験者の行動ログを解析するためのツール作成等を行っています。

2024年度は気仙沼市にて、アプリケーションの体験展示も実施しました。
詳細はこちら。宮城においでよ。

弊社の中ではかなり特殊な部類の業務だと思いますが、
「日本総合システムはこういう技術も扱える会社です!」
ということをアピールして、〆の言葉にしたいと思います。

長々とご拝読いただき、ありがとうございました。
(感謝の意を込めて、かっこよくEnterキーを叩く)

4-5. 参考文献

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?