使用方法

先にセットアップを完了してください。まずBasic Sampleでの操作を説明し、続いて同じクライアントAPIをアプリケーションから使う方法を説明します。

Basic SampleやSDKを使うアプリケーションを別のPCで実行する場合は、信頼できるLANから接続するの設定を行ってください。

サンプルシーン

シーン用途
BasicSample.unityDivingStationからのデータ、入力、本体振動を確認。球と円柱で骨格を表示
HandVisualizer.unityUnity公式XR Handsから別途Import。OpenXR runtimeが公開する26関節の手を表示

Basic Sampleは、左側に左右の関節角度パネル、右上に手のプレビュー、その下にMAGNETRA入力パネルを表示します。手のプレビューは球と円柱で骨格を描きます。

表示の見方

  • LEFT HAND / RIGHT HANDには20個の関節角度を度単位で表示します。
  • MAGNETRAには6種類のボタン状態とアナログ値を表示します。黄色はタッチ、緑色は押下です。グリップ力量は0〜1に正規化されています。
  • 手のプレビューには、選択したRawまたはRetarget済みスケルトンを表示します。形状とあわせて追跡状態を確認してください。
  • 左右の状態表示には、選択したグローブID、バッテリー、外部電源の状態を表示します。バッテリーを取得できない場合はbattery invalidと表示します。外部電源の検出だけでは充電中とは判断できません。
  • セッションの状態表示には、接続診断、実際の受信・送信ポート、通知キュー・欠落数、パースエラー数を表示します。グローブとの接続が切れてデータが止まるとStaleになり、再接続すると操作なしでReceivingに戻ります。

未受信または古くなった入力については、バー、軸マーカー、骨格を消し、過去の値が現在値に見えないようにしています。サンプルのFreshnessSeconds=0.5は変更可能なデモ用の判断基準であり、実測したデバイス更新間隔の保証値ではありません。

骨格の色は、選択中のストリームの追跡状態を表します。

骨格の表示意味
グレーTrackedまたはNotAttempted
金色IkFailed:IK処理が失敗した状態。診断用の表示であり、制御、補間、速度推定には使用しない
非表示データ未受信、古いデータ、またはグローブが選択されていない状態

詳細な状態と生成時刻はAPIのTracking、GeneratedAtで取得できます。

レンダーパイプラインとマテリアル

Basic Sampleの骨格表示はBuilt-in、URP、HDRPに対応します。Built-inでは同梱マテリアルを使用し、URP/HDRPでは対応するLitマテリアルへ自動で切り替えます。グレー・金色の設定は引き継ぎますが、照明や露出により見え方は変わります。同梱のマテリアルアセットやプロジェクトの描画設定は変更しません。URP/HDRPのパッケージと描画設定は、使用するプロジェクト側で用意してください。

表示色を変える場合は、左右のSkeletonRendererのTrackedMaterialとDiagnosticMaterialを設定します。Built-inのStandardマテリアルは自動で対応し、手動で変換する必要はありません。独自のシェーダーを使う場合は、そのプロジェクトのレンダーパイプラインに対応するマテリアルを指定してください。公式HandVisualizerのVR設定はセットアップを参照してください。

左右のグローブを選択する

左右のドロップダウンにはAuto (one glove)があります。選択したホストに、その側の接続済みグローブがちょうど1台ある場合だけ自動選択します。左グローブが2台ある場合などは、使用するIDを明示的に選択してください。

明示的に選んだIDが見つからなくなっても、選択は保持され、利用できない状態として表示されます。別のグローブへ自動で切り替わることはありません。同じIDから新しいデータが届くと、そのIDで表示を再開します。PairIdは関連情報であり、APIでは片手ごとのグローブIDを指定します。

本体を振動させる

  • Fキーで選択した左グローブ、Jキーで選択した右グローブに送信します。
  • 対応する画面上の振動ボタンでも送信できます。
  • 最初のコントローラー状態を基準として取得したあとは、選択したグローブのAボタンが新たに押された場合も振動を送信します。

サンプルの送信値は周波数160 Hz、振幅0.2、時間0.1秒です。結果欄にはローカルUDP送信の成功またはエラーを表示します。実際に振動したことを確認する応答はありません。デバイスは接続済みで、振動対応を報告している必要があります。

サンプルはプロジェクトで有効な入力バックエンドを使用します。Legacy Input ManagerまたはBothでは従来のキーボード入力、New-input-onlyでは導入済みInput SystemとそのUIモジュールを使用します。SDKはActive Input Handlingを変更しません。F/Jを試す際はGameビューまたはPlayerにキーボードフォーカスを合わせてください。

RawとRetarget済みスケルトンを比較する

隣接するRaw skeleton/Retargeted skeletonボタンで、手のプレビューに表示する骨格を選びます。選択中のボタンは青色になります。RetargetedはDivingStationの有効なRetarget profileで変換済みのスケルトンです。Rawは追跡と関節計算を終えた、Retarget前の骨格です。

両ストリームの追跡状態が異なる場合があります。IkFailedとNotAttemptedの値は診断表示に使用できますが、制御、補間、速度推定の入力にしてはいけません。これらの用途にはTrackedを使用し、データの新しさも確認してください。

Retarget profileの表示情報は、接続後に骨格データが届き次第、自動で取得します。その後は約10秒ごとに更新し、Profileが変わった場合はすぐに問い合わせます。左右で同じProfileを使う場合は1回の問い合わせにまとめ、異なる場合は左右の名前を表示します。未取得時は待機中・取得中の表示になり、応答後に名前を表示します。起動直後に通知が欠落した場合は、次の更新まで取得中の表示が続くことがあります。名前と説明は追加のメタデータであり、応答を待たずに骨格を表示できます。ローカルなタイムアウトと、サーバーによる「profileが存在しない」という応答は別の結果です。

停止と再開

Stop / startボタンはクライアントを破棄して作り直します。停止すると過去の表示を消去し、UDPソケットを解放します。新しいセッションには新しいデータが必要です。また、DivingStationの再承認が必要になる場合があります。既存セッションを保ちながら承認やデータを待つ場合は、APIのResume(hostId)または同じ設定のConnect(options)で登録を再試行してください。

模擬データで表示を確認する

グローブがなくても、UnityエディターまたはPlayerを起動引数-contactglove-mock付きで起動すると、開発者向けの模擬データでサンプルの表示を確認できます。画面にはSIMULATED DATAと表示されます。実際のグローブには接続しないため、実機の追跡や振動の確認にはなりません。

SDKのスケルトンとXR Handsの違い

Basic Sampleと公式HandVisualizerは、異なる経路から手のデータを取得します。

項目SDKのBasic Sample公式HandVisualizer
データの取得先DivingStation Protocol v2OpenXR runtimeのXRHandSubsystem
関節の位置25個。手首を原点とするメートル値WristとPalmを含む26個
回転親関節に対する19個の回転各関節のpose
表示位置の基準アプリケーションが配置する手の表示ルートruntimeの追跡原点とXR Origin

SDKの受信値を独自に表示するときは、座標変換と表示ルートを確認してください。XR HandsのposeはすでにUnityの座標で取得できるため、そのposeにUnityPoseConverterを適用しないでください。Unityのワールド座標で配置するときはXR Originを考慮します。Unityの手データモデルも参照できます。

SDKの25関節とXR Handsの26関節は、それぞれの関節の対応を使って扱います。配列をそのまま交換しないでください。SDKのRaw/Retarget、コントローラー入力、本体振動はBasic Sampleで確認し、VRの手表示はHandVisualizerで確認します。

シーンからAPIへアクセスする

ContactGloveBehaviourはContactGloveClientを保持し、Updateで通知を処理し、所有するコンポーネントが無効化・破棄されたときに停止します。既存コンポーネントをInspectorでスクリプトに割り当て、起動後にClientを取得してください。

using MeltInterfaceTechnologies.ContactGlove.V2;
using MeltInterfaceTechnologies.ContactGlove.V2.Unity;
using UnityEngine;

public sealed class ReadGlove : MonoBehaviour
{
    public ContactGloveBehaviour Connection;
    public string HostId = "localhost";
    public string GloveId;

    private void Update()
    {
        var client = Connection != null ? Connection.Client : null;
        if (client == null || string.IsNullOrEmpty(GloveId)) return;

        var key = new DeviceKey(HostId, GloveId);
        if (!client.TryGetDevice(key, out var device) ||
            device.Connection != ConnectionState.Connected) return;

        if (client.TryGetErgonomics(key, out var angles) &&
            angles.Tracking == TrackingState.Tracked &&
            angles.AgeSeconds <= 0.5)
        {
            float indexDipDegrees = angles.Angles[6];
            // Consume a fresh, tracked value in your application.
        }
    }
}

GloveIdにはGetDevices(HostId)またはサンプルに表示されたIDを設定します。この例では、最初に見つかったデバイスを自動で選びません。0.5秒は例としての判断基準です。アプリケーションに合った閾値を設定してください。

このスクリプトから追加でPumpNotificationsを呼ぶ必要はありません。ContactGloveBehaviourが通知処理を担当します。クライアントを停止して作り直した場合は、以前の購読トークンを破棄し、新しいクライアントで購読し直してください。

複数台と複数ホストの運用

DeviceKeyはHostIdとGloveIdの組み合わせです。異なるホストが同じグローブIDを報告しても区別できます。ContactGloveBehaviour.Hostsに異なるHostOptionsを追加し、取得・コマンド送信時に対応するホストIDを指定してください。

GetDevices(hostId)は、指定したホストから受信したDeviceメタデータを列挙します。メタデータのConnectionと、各ストリームの有無・経過時間・追跡状態を個別に確認してください。

UDPポートの変更

セッションの開始前に、ホストのServerPort、ReceivePort、SendPortを変更します。ServerPortはDivingStation側、残りの2つはSDK側のローカルポートです。ローカルポートの0は起動時に一度だけ割り当てられ、セッション中は維持されます。

Inspectorの値を変更しても、実行中のセッションには反映されません。意図的に停止・開始すると新しい設定を適用します。接続先を変えずに再試行する場合は、Resumeか、最初と同じホストID・IP・設定ポートでConnectを呼びます。最初に指定した0を、後で割り当てられたポート番号に置き換えて渡すと、異なる設定として拒否されます。

コントローラー入力の取得

if (client.TryGetController(key, out var input) && input.AgeSeconds <= 0.5)
{
    ButtonState a = input.A;
    float joystickX = input.JoystickX;
    float gripForce = input.GripForce;
}

ボタン状態はUnpressed、Touching、Pressedです。操作を一度だけ実行するには、現在値と以前に受信した値を比較してください。最初のスナップショットは新たな押下ではなく基準値として扱います。切断、入力の期限切れ、通知欠落が起きたら、保持している押下状態を解除してください。失われたUDPの入力変化を復元することはできません。

振動コマンドの送信

OperationResult result = client.Vibrate(key, 160f, 0.2f, 0.1f);
if (!result.Succeeded)
    Debug.LogWarning($"Vibration rejected: {result.Code} {result.Detail}");

引数の順番は周波数、振幅、時間です。許容範囲は100〜500 Hz、0〜1、0.001〜1秒で、境界値を含みます。範囲外の値はclampせず拒否します。再接続後にコマンドを再送することはありません。

スケルトンとProfileの取得

if (client.TryGetSkeleton(key, out var skeleton))
{
    string profileUuid = skeleton.ProfileUuid;
    Vector3 indexTip = UnityPoseConverter.Position(skeleton.Positions[9]);
    // indexTip is relative to the wrist, in meters, in Unity coordinates.
    // Check Tracking and AgeSeconds before using it for control.
}

Retarget前のsource skeletonにはTryGetRawSkeleton、5個の推定センサー中心poseにはTryGetRawSensorTransformsを使用します。手の表示ルートはアプリケーションで配置します。VRでSDKのスケルトンを使う場合は、外部追跡からルートposeを取得し、装着オフセットや座標系を合わせる処理をアプリケーション側で実装してください。

Profile名が必要な場合はSubscribeProfileResponseとSubscribeProfileTimeoutを購読し、RequestRetargetProfileInfo(hostId, profileUuid, out requestId)を呼びます。返された購読トークンは必要な間保持し、使用後に破棄してください。応答の対応付けと状態値はAPIリファレンスで説明しています。

単独で接続を管理する最小コンポーネント

独自コンポーネントでセッションを管理する場合は、次の例を使用できます。ContactGloveQuickStart.csとして保存してGameObjectに追加し、IDを確認したあとGloveIdを明示的に設定してください。同じクライアントを管理するContactGloveBehaviourを重ねて作成する必要はありません。

using System;
using MeltInterfaceTechnologies.ContactGlove.V2;
using UnityEngine;

public sealed class ContactGloveQuickStart : MonoBehaviour
{
    public string IpAddress = "127.0.0.1";
    public string GloveId = "";
    private const string HostId = "local";
    private ContactGloveClient client;
    private IDisposable deviceSubscription;
    private double nextLogAt;

    private void OnEnable()
    {
        client = new ContactGloveClient();
        var result = client.Connect(new HostOptions
        {
            HostId = HostId,
            IpAddress = IpAddress,
            ServerPort = 25791,
            ReceivePort = 0,
            SendPort = 0
        });
        if (!result.Succeeded)
        {
            Debug.LogError($"ContactGlove: {result.Code} {result.Detail}");
            StopClient();
            return;
        }
        deviceSubscription = client.SubscribeDevice(HostId, device =>
            Debug.Log($"Glove {device.Key.GloveId}: {device.Connection}"));
    }

    private void Update()
    {
        if (client == null) return;
        client.PumpNotifications();
        if (string.IsNullOrEmpty(GloveId) || MonotonicTime.Now < nextLogAt)
            return;
        nextLogAt = MonotonicTime.Now + 1.0;
        var key = new DeviceKey(HostId, GloveId);
        if (client.TryGetDevice(key, out var device) &&
            device.Connection == ConnectionState.Connected &&
            client.TryGetErgonomics(key, out var angles) &&
            angles.Tracking == TrackingState.Tracked &&
            angles.AgeSeconds <= 0.5)
        {
            Debug.Log($"Index DIP: {angles.Angles[6]} degrees");
        }
    }

    // Call deliberately from your UI, rather than on every Update.
    public void VibrateSelected()
    {
        if (client == null || string.IsNullOrEmpty(GloveId)) return;
        var result = client.Vibrate(
            new DeviceKey(HostId, GloveId), 160f, 0.2f, 0.1f);
        Debug.Log($"Vibration submission: {result.Code}");
    }

    public void RetryRegistration()
    {
        if (client == null) return;
        var result = client.Resume(HostId);
        Debug.Log($"Registration retry: {result.Code} {result.Detail}");
    }

    private void OnDisable() => StopClient();
    private void OnDestroy() => StopClient();
    private void OnApplicationQuit() => StopClient();

    private void StopClient()
    {
        deviceSubscription?.Dispose();
        deviceSubscription = null;
        client?.Dispose();
        client = null;
    }
}

Connectはローカル通信を開始し、受信は非同期に行われます。ConsoleへのID表示はDeviceレコードが届いてから始まります。この例は1つの関節角度を最大1秒に1回表示し、実機を自動で振動させません。