M1YamAのアイコン画像
M1YamA 2021年02月09日作成 (2021年02月09日更新)
製作品 製作品 閲覧数 5357
M1YamA 2021年02月09日作成 (2021年02月09日更新) 製作品 製作品 閲覧数 5357

ESP32とUnityのBLE通信で3Dモデルを触れるペンを作ってみた

ESP32とUnityのBLE通信で3Dモデルを触れるペンを作ってみた

はじめに

皆さんは触りたい3Dモデルに出会ったことあるだろうか?私は富士山周辺の地形図や大仏の螺髪などを触りたかった。つぶつぶが気持ちよさそう。
最近流行りのARとか拡張現実は視覚の体験ばかりで、3Dモデルを触れるような触覚に訴える体験ができると面白いと思い、簡単な機構で作ってみた。

機構は簡単でも通信が難しかったので、次に試みる誰かのために詳細を載せる。

動作イメージ

完成形は以下の動画を見て頂ければイメージしやすいと思われる。

原理

サーボモータの角度変化によってペン先の長さが変わるデバイスを製作した。
iPad上に 3D モデルを表示し、触れた部分のモデルの高さによってペン先を伸縮させ、3D モデルを振れた時の触覚を疑似体験できる。
iPadのUnityアプリとESP32の通信はBLEを用いた。

実装方法

ハードウェア

以下に全体図を示す。
キャプションを入力できます

機構自体は単純で、サーボモータの回転を直動に変えるためにラックピニオン機構を用いた。
全てのパーツを3Dプリンタで出力したため、軽量で摩擦も小さいためリニアガイドなども不要だった。
使用したサーボモータはMG90Sだが、後述するように少しトルクが足りない気がしたのでMG92Bの方が良いかもしれない。どちらも動作角は180°で、0.1秒/60°(5.0V)程度とめっちゃ早い。このデバイスは早さが正義なのでありがたい。
キャプションを入力できます

ピニオンギアはモジュール1の歯数20で、細かい高さの変化に対応できるようにできる限り直径の小さいものにした。
これによりストロークは31mm程度になった。

回路はESP32開発ボードだけで完結するためかなりコンパクト。機能としてはUnityアプリから送信される3Dモデルの高さを表すbyte型文字列を受け取り、それをサーボのライブラリを用いてサーボに角度指令を送る。簡単そうだが、自分に型変換の知識が少ないのとUnityからESP32で受信する際の例を見つけることができなかったのでかなり苦労した。
環境構築はこれとかを読むと良さそう。
「ファイル>スケッチ例>ESP32 BLE Arduino>BLE_uart」を少し変えたコードである。ソフトウェアの項で説明するUnityアプリから文字情報を受信すると、シリアルモニタに表示されるはずである。

ESP32側のコード

#include <BLEDevice.h> #include <BLEServer.h> #include <BLEUtils.h> #include <BLE2902.h> #include <ESP32Servo.h> #include <WiFiMulti.h> #define SERVICE_UUID "00002220-0000-1000-8000-00805f9b34fb" #define CHARACTERISTIC_UUID_RX "00002222-0000-1000-8000-00805f9b34fb" #define CHARACTERISTIC_UUID_TX "00002221-0000-1000-8000-00805f9b34fb" //UUIDはUnityアプリと合わせる #define servoPIN 26 //サーボの出力ピンを指すピン番号 BLEServer *pServer = NULL; BLECharacteristic * pTxCharacteristic; bool deviceConnected = false; bool oldDeviceConnected = false; boolean bleDataIsReceived; std::string storedValue; byte *data1; portMUX_TYPE storeDataMux = portMUX_INITIALIZER_UNLOCKED; Servo myservo; class MyServerCallbacks: public BLEServerCallbacks { void onConnect(BLEServer* pServer) { deviceConnected = true; }; void onDisconnect(BLEServer* pServer) { deviceConnected = false; } }; class MyCallbacks: public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *pCharacteristic) { //受けとり部分 std::string rxValue = pCharacteristic->getValue(); data1 = pCharacteristic->getData(); if (data1 > 0) { portENTER_CRITICAL_ISR(&storeDataMux); storedValue = rxValue; bleDataIsReceived = true; portEXIT_CRITICAL_ISR(&storeDataMux); } } }; void setup() { Serial.begin(115200); WiFi.disconnect(true); bleDataIsReceived = false; // Create the BLE Device BLEDevice::init("ESP32"); // Create the BLE Server pServer = BLEDevice::createServer(); pServer->setCallbacks(new MyServerCallbacks()); // Create the BLE Service BLEService *pService = pServer->createService(SERVICE_UUID); // Create a BLE Characteristic pTxCharacteristic = pService->createCharacteristic( CHARACTERISTIC_UUID_TX, BLECharacteristic::PROPERTY_NOTIFY ); pTxCharacteristic->addDescriptor(new BLE2902()); BLECharacteristic * pRxCharacteristic = pService->createCharacteristic( CHARACTERISTIC_UUID_RX, BLECharacteristic::PROPERTY_WRITE ); pRxCharacteristic->setCallbacks(new MyCallbacks()); // Start the service pService->start(); // Start advertising pServer->getAdvertising()->start(); Serial.println("Waiting a client connection to notify..."); myservo.setPeriodHertz(50); myservo.attach(servoPIN); myservo.write(0); } void loop() { byte bytebuf; if (deviceConnected) { portENTER_CRITICAL_ISR(&storeDataMux); if (bleDataIsReceived) { bleDataIsReceived = false; Serial.println("received string:"); data1[sizeof(data1)] = '\0'; Serial.println(*data1); int angle = int((180-*data1) * 180/50); myservo.write(angle); pTxCharacteristic->setValue(storedValue); pTxCharacteristic->notify(); } portEXIT_CRITICAL_ISR(&storeDataMux); delay(10); // bluetooth stack will go into congestion, if too many packets are sent } if (!deviceConnected && oldDeviceConnected) { delay(500); // give the bluetooth stack the chance to get things ready pServer->startAdvertising(); // restart advertising Serial.println("start advertising"); oldDeviceConnected = deviceConnected; } if (deviceConnected && !oldDeviceConnected) { // do stuff here on connecting oldDeviceConnected = deviceConnected; } }

ソフトウェア

UnityはBLE通信機能を持っていない。そのため、デバイス側のOSに合わせた言語でデバイス側のプログラムを記述し、それとUnityを繋げるパイプラインスクリプトを書く必要がある。探せばいくつかサンプルは出てくるものの、とても大変そうだったので諦めて既存のアセットを購入することにした。

これを買おう。たったの$20!正直学生には高かったが、これを買わないならapple developper programの有料会員になって$100払って時間も溶かしていたので安く済んだ方だと思う。(developperの無料会員には週10回のビルドまでしか許されておらず、デバッグのためにはそれ以上のビルドが必要そうであることは自明)
画面が大きいからという理由でiPadをメインデバイスにしたが、数列を送信する機能はiOS限定らしかったのでむしろ良かった。

アプリの概要

次にアプリの概要について説明する。アプリはメインメニュー、平面モード、回転モードの3つのSceneで構成されていて、メインメニューのシーンは平面モードもしくは回転モードに移行するためのシーン。回転モードは平面モードに回転を行えるようにするGameObjectが付属している以外は大きな違いは無いが、2本指で画面を振れて指を動かすことでモデルが回転し、それによってユーザーの好みの角度でモデルの凹凸を感じられるようにした。平面モードのツリー構造を以下に示す。

キャプションを入力できます

Canvasは描画される領域を定めてUI設計の助けにするもので、このCanvas以下のObjectがアプリの機能のほとんど。
Select Panelは1個のメインメニューに戻るボタンと9個のモデルを選ぶボタンで構成されており、カメラから見てModel Panelの上にSelect Panelが配置されている。そのため、モデルのボタンを押すとSelect Panelが消えて指定したモデルがModel Panel上に表示される。
Select Panel
ボタンに画像を埋め込んでいて、それを押すと以下のような画面に遷移する。
Model Panel

Canvasにはタップした場所の高さを取得して高さをBLEで送信するスクリプトがアタッチされており、それによって指定したIDのUUIDを持つデバイスと通信が可能になっている。仕組みとしては最初にScanを行い、UUIDが一致するデバイスが見つかったら通信を確立する。デバイスのRxに相当するポートのUUIDに対し、タップした場所の高さに相当する角度指令をbyte型で送信する。

通信について

通信機能の構成はこんな感じ。
キャプションを入力できます
BLEについてはこのサイトなどを読むと理解が進む。

前述のアセットの使い方を調べるとSimpleTestなるサンプルが登場するが、今は削除されたのか自分のExampleフォルダには入っていなかったのでこのGitHubのSimpleTestをコピペした。これだけでアセットの機能を用いてBLEでの送受信が行える。

アプリの機能について

UnityのRayという機能とMeshColliderという機能を用いた。タップした場所から画面奥側にRayを射出し、RayがColliderに衝突した高さの座標を求めることで高さを取得する。
キャプションを入力できます

モデルは鍵やチキンなどの他に、国土地理院から地形図データを取得して加えることにした。Unityで扱えるOBJデータにするためにはMeshLabなどを用いると良い。
平面に向く地形図を含めた前述の9つのモデルと、360°楽しみたいものをを6つ集めた回転モード用のモデルの計15のモデルを入れているが、好みに合わせて変更なども可能。
回転モードのSelect Panel

まとめ

触覚とは少し異なった体験だったが、何かを触っているように感じることはできた。斜面や小さな段差はリアルだが、壁面など大きく高さが変わる部分は違和感が大きい。サーボの応答をより早くすれば改善されるかもしれない。個人的に好きなのは鍵の段差を乗り越えるところと、大仏の螺髪の凸凹だ。何とも言えない感触が気持ちよかった!!!

開催されるかどうかは不明ですが、第94回五月祭に展示する予定なのでもし触りたい方がいらっしゃるなら私と会いましょう。

3
ログインしてコメントを投稿する