🕌

【VR】筋電を使ってゲームコントローラーを作る

2024/12/09に公開

今回作ったものいいよね

せっかくVRゲームするなら、ハンドコントローラーなしでやりたくない?
ってことで筋電センサを使って魔法を発射できるシステムを作った。
動作↓

動作環境

器具

  • Meta Quest 2
  • Arduino Uno R4 wifi
  • Grove-EMG Sensor v1.1 SCH

Unity

  • Unity 2021.3.42f1
  • XR Interaction Toolkit 2.6.3
  • XR Plugin Management 4.40
  • OpenXR Plugin 1.11.0
  • XR Hands 1.4.3

Arduino

  • Arduino IDE 2.3.2(バージョン1.xは非対応)
  • arduinoFFT 1.6.2(バージョン2.xは非対応)

概要

ざっくりした流れはこんな感じ

筋電センサを使って筋肉の動きをセンシング→FFT解析→UDPでUnityに送信→Unity側で受信して魔法を発射。

実装

arudino側

概要

筋電センサから受け取った値をFFTで分析し、力が入ったことを感知したらUDPで送信する。

準備

arduinoFFTライブラリをインストールする。以下の画面を参考に。

コード

コードは以下。arduino_secrets.hのSECRET_SSID SECRET_PASS IP_ADDRESS[4]に自分のwifiの情報を入れる。

arduino_secrets.h
#define SECRET_SSID "" // wifiのSSID
#define SECRET_PASS "" // wifiのパスワード

const uint8_t IP_ADDRESS[4] = {, , , };
const unsigned int LOCAL_PORT = 8889;
fft_udp_sender.ino
#include <SPI.h>
#include <WiFiS3.h>
#include <WiFiUdp.h>
#include "arduino_secrets.h"
#include "arduinoFFT.h"

// 設定の代入
char ssid[] = SECRET_SSID;
char pass[] = SECRET_PASS;  
IPAddress destinationIP(IP_ADDRESS[0], IP_ADDRESS[1], IP_ADDRESS[2], IP_ADDRESS[3]);
WiFiUDP Udp;

// FFT設定
const uint16_t SAMPLES = 64;              // 常に2のべき乗
const uint16_t PAST_SAMPLES = 48;         // 過去サンプル数
const double SAMPLING_FREQUENCY = 2000.0; // サンプリング周波数
const uint16_t FFT_RANGE_START = 6;       // FFT計算対象範囲開始
const uint16_t FFT_RANGE_END = 28;        // FFT計算対象範囲終了
const uint16_t SIGNAL_THRESHOLD = 200;    // 信号の閾値

arduinoFFT FFT;
double vReal[SAMPLES];
double vImag[SAMPLES];
double vReal_fft[SAMPLES];

bool rightMuscleActive = false;
byte dataToSend = 0b00000000;
byte SendNumber = 0b00000010; // 送る数

void setup() {
  Serial.begin(115200);
  connectToWiFi();
  initializeFFT();
}

void loop() {
  updateSamples();
  performFFT();
  processFFTData(vReal_fft, SAMPLES / 2);
}

// --- WiFi接続 ---
void connectToWiFi() {
  WiFi.begin(ssid, pass);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Udp.begin(LOCAL_PORT);
  Serial.println("\nWiFi connected");
}

// --- FFT初期化 ---
void initializeFFT() {
  for (uint16_t i = 0; i < SAMPLES; i++) {
    vReal[i] = analogRead(A1);
    vImag[i] = 0.0;
  }
}

// --- サンプル更新 ---
void updateSamples() {
  // 過去のサンプルを更新
  for (uint16_t i = 0; i < PAST_SAMPLES; i++) {
    vReal[i] = vReal[i + 8];
    vImag[i] = 0.0;
    vReal_fft[i] = vReal[i];
  }

  // 新しいサンプルを読み込み
  for (uint16_t i = PAST_SAMPLES; i < SAMPLES; i++) {
    vReal[i] = analogRead(A1);
    vImag[i] = 0.0;
    vReal_fft[i] = vReal[i];
  }
}

// --- FFT実行 ---
void performFFT() {
  FFT = arduinoFFT(vReal_fft, vImag, SAMPLES, SAMPLING_FREQUENCY);
  FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD);
  FFT.Compute(FFT_FORWARD);
  FFT.ComplexToMagnitude();
}

// --- FFTデータ処理 ---
void processFFTData(double *fftData, uint16_t bufferSize) {
  int signalSum = calculateSignalSum(fftData, bufferSize);

  // デバッグ出力
  Serial.print(700);  // 表示の最大値
  Serial.print(" , ");
  Serial.print(-50);  // 表示の最小値
  Serial.print(" , ");
  Serial.print(SIGNAL_THRESHOLD);  // 閾値
  Serial.print(" , ");
  Serial.println(signalSum);

  // 信号に応じた処理
  handleSignal(signalSum);
}

// --- 信号値の集計 ---
int calculateSignalSum(double *fftData, uint16_t bufferSize) {
  int sum = 0;
  for (uint16_t i = FFT_RANGE_START; i < FFT_RANGE_END; i++) {
    if (fftData[i] < 1000) {
      sum += fftData[i];
    }
  }
  return sum;
}

// --- 信号処理 ---
void handleSignal(int signalSum) {
  dataToSend = 0b00000000;
  if (signalSum > SIGNAL_THRESHOLD) {
    if (!rightMuscleActive) {
      rightMuscleActive = true;
      dataToSend |= SendNumber;  
      sendUDPData(dataToSend);
    }
  } else {
    if (rightMuscleActive) {
      rightMuscleActive = false;
    }
  }
}

// --- UDPデータ送信 ---
void sendUDPData(byte data) {
  Udp.beginPacket(destinationIP, LOCAL_PORT);
  Udp.write(data);
  Udp.endPacket();
}

配線

筋電センサとArduinoの接続は以下

実行しシリアルプロッターで見ると以下の感じ
緑が閾値で黄色が筋電の値

脱力時

力を入れたとき

unity側

概要

Unityでは、Arduinoから送られてきたデータを受信し、魔法の弾を生成&発射する。
ハンドトラッキングのやり方は以下とかを参考に。
https://donabenabe.hatenablog.com/entry/TryXRHands

コード

PlayerAttack.cs
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;

public class PlayerAttack : MonoBehaviour
{
    // Public Variables
    public GameObject MagicBullet; // 発射する弾のオブジェクト
    public float ShotSpeed = 500; // 弾の速度
    public float ShotInterval = 1.0f; // 射撃の間隔(秒)

    // Private Variables
    private UdpClient udpClient; // UDP通信
    private Thread receiveThread; // データ受信スレッド
    private bool udpMessageReceived = false; // UDPメッセージ受信フラグ
    private float lastShotTime = 0f; // 最後に射撃した時間
    public int listenPort = 8888; // UDP受信ポート番号

    void Start()
    {
        // UDPクライアントと受信スレッドの初期化
        udpClient = new UdpClient(listenPort);
        receiveThread = new Thread(ReceiveData)
        {
            IsBackground = true
        };
        receiveThread.Start();
        Debug.Log("UDP Receiver started.");
    }

    void Update()
    {
        // マウスクリックで射撃
        if (Input.GetMouseButtonDown(0) && lastShotTime > ShotInterval)
        {
            MagicAttack();
        }

        // UDPメッセージを受信している場合
        if (udpMessageReceived && lastShotTime > ShotInterval)
        {
            MagicAttack();
            udpMessageReceived = false; // フラグをリセット
        }

        // 時間更新
        lastShotTime += Time.deltaTime;
    }

    void ReceiveData()
    {
        // UDPメッセージ受信処理
        IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, listenPort);
        while (true)
        {
            try
            {
                byte[] receiveBytes = udpClient.Receive(ref remoteEndPoint);
                string receiveString = Encoding.ASCII.GetString(receiveBytes);
                Debug.Log("Received UDP message: " + receiveString);

                // UDPメッセージ受信フラグを立てる
                udpMessageReceived = true;
            }
            catch (Exception e)
            {
                Debug.LogError("Error receiving UDP data: " + e.Message);
            }
        }
    }

    private void OnApplicationQuit()
    {
        // アプリケーション終了時のリソース解放
        if (receiveThread != null && receiveThread.IsAlive)
        {
            receiveThread.Abort();
        }
        udpClient?.Close();
    }

    void MagicAttack()
    {
        // 弾を生成し、発射する
        GameObject bullet = Instantiate(MagicBullet, transform.position, Quaternion.Euler(transform.parent.eulerAngles.x, transform.parent.eulerAngles.y, 0));
        Rigidbody bulletRb = bullet.GetComponent<Rigidbody>();
        bulletRb.AddForce(transform.forward * ShotSpeed);

        // 射撃時間をリセット
        lastShotTime = 0;

        // 3秒後に弾オブジェクトを破棄
        Destroy(bullet, 3.0f);
    }
}

作業

  1. ハンドトラッキングをできるように
  2. 右手のR_PalmSphereを追加
  3. SpherePlayerAttack.csをアタをッチ
  4. 弾用のプレハブを作成(rigitbodyをアタッチ)し、PlayerAttack.csにアタッチ
  5. Sphereの位置と角度を調節しmeshcolliderをオフに

セキュリティ

PC実行時

Unityで外部デバイスと通信する際、Windowsのセキュリティに引っかかる場合がある。ここでは簡易的に載せておく。参考サイトは以下
https://qiita.com/get_itchy_feet/items/0f144f49d5ee951943af
設定項目は以下
コントロールパネル→システムとセキュリティ→Windows Defender ファイアウォール→詳細設定→受信の規則→Unity関係をすべて許可
以下の状態に

それでもだめならファイアウォールを一時切る。
設定→ネットワークとインターネット→Wi-Fi→~のプロパティ→ファイアウォールとセキュリティ設定の構成→~(アクティブ)→オフ

オキュラス実行時

オキュラスでもUDPの許可が必要。ここでも簡易的に載せておく。参考サイトは以下
https://zenn.dev/nop/articles/70e62f19916c3f
unity上でProject Settingsを開き
Player -> Other Settings-> Internet Access
をAutoからRequireに

また、Project Settingsから
XR Plug-in Management -> OpenXR
AndroidタブからMeta Quest Supportの歯車マークを選択

Force Remove Internet Permissionのチェックボックスを外す

ここまでで作業は終了。お疲れさまでした!

参考一覧

https://donabenabe.hatenablog.com/entry/TryXRHands
https://qiita.com/get_itchy_feet/items/0f144f49d5ee951943af
https://zenn.dev/nop/articles/70e62f19916c3f

Discussion