大熊 元気と申します。
IoTとVRを組み合わせてデジタルツインとか、何か付加価値を出したいと思いながら日々 遊んでいます。
##バイタルセンサとVRを組み合わせてみました
- ANT+で脈拍数の取得
- VR空間で脈拍数を表示
スマートウォッチからの脈拍データをVR内に持ってきたのですが、何かに使えませんか? #xrtech
— 大熊 元気@デジタルツイン (@JNTEST23) 2018年10月21日
とりあえずアルコールが入って脈拍がうなぎ登りです。 pic.twitter.com/wMh7if2Pi5
- 脈拍数に応じてエフェクトの変化
スマートウォッチで計測した脈拍数をVR空間に持ってきて、値の表示とエフェクトの変更ができました。
— 大熊 元気@デジタルツイン (@JNTEST23) 2018年11月26日
コントローラーをぶん回すと脈拍数が上昇して、世界が暗くなってプールから光が出てくるという謎設定ですが、思い通りに動いてくれました。#Unity3d #SteamVR #WinMR pic.twitter.com/cGzt28uygU
##構成図
◆構成図はこんな感じ
◆ハードウェアはこんな感じ
・スマートウォッチ:GARMIN vívosmart®4 (ANT+対応のものが必要で、GARMIN社がANT+の生みの親)
・ANT+ドングル:AZ4U (ANT+をPCで受けるために必要。適当に選定)
・PC、HMD:SteamVRが動くやつ(参考→https://qiita.com/GenkiOkuma/items/62d1c7f20f8e75d0662e)
◆Version
Unity : 2018.2.14f1
SteamVR : 2.0.1
##ANT+のUSBドングルからNode.jsまで
USBドングルから脈拍数を取得するためにこちらのライブラリを使います。https://github.com/Loghorn/ant-plus
これをインストールして、USBドングルから脈拍数を取得するNoede.jsを書きます。
npm install ant-plus
サンプルに入っているtwo-sticks.jsを下記のように改造してUSBドングルからUnityへと脈拍数を中継するサーバとします。
<変更点>
・stick(ANT+のUSBドングル)2本を1本に変更
・deviceIDを区別するように変更
const Ant = require('ant-plus');
const stick = new Ant.GarminStick2;
const sensor = new Ant.HeartRateSensor(stick);
var deviceID = null;
if (process.argv.length >= 3) {
deviceID = process.argv[2];
}
sensor.on('hbData', function (data) {
if (deviceID == null) {
console.log(data.DeviceID, data.ComputedHeartRate);
} else if (deviceID == data.DeviceID) {
console.log(data.ComputedHeartRate);
}
});
stick.on('startup', function () {
sensor.attach(0, 0);
});
if (!stick.open()) {
console.log('Stick not found!');
}
これでスマートウォッチからANT+のドングルを経由してPCまで脈拍数を持ってこれました。
##Node.jsからUnityまで
凹み様のAssetを使います。https://github.com/hecomi/NodejsUnity
関連記事はこちら→http://tips.hecomi.com/entry/2014/04/21/001606
これによって、Unityのexeから外部のNode.jsを起動して連携できます。ここでは上記で作成した脈拍数を取得するNode.jsを仕込みます。
<使い方>
・UnityのAssetとしてNodejsUnityをインポート
・NodejsUnity/Assets/StreamingAssets/node.exe が古いので新しいものに差し替える
・起動したいNode.jsをUnity Project/Assets/StreamingAssetsの下に置く
・NodeJs.csを下記のように改造する。
(変更点→起動したいNode.jsのパスを指定、出力先を2つ定義)
#region [ member variables ]
public string DeviceID = "";
private string scriptPath = "HeartRate/main.js";
private System.Diagnostics.Process process_ = null;
private List<string> logs_ = new List<string>();
public int logLength = 10;
public string log = "";
public Text DisplayedText;
public Text DisplayedText1;
private SynchronizationContext Context;
(変更点→脈拍数をエフェクトの変化に使うためにSensorValueを定義、Contextでスレッドを分ける、脈拍数をテキスト2か所に出力させる)
void OnOutputData(object sender, System.Diagnostics.DataReceivedEventArgs e)
{
if (logs_.Count > logLength) logs_.RemoveAt(0);
logs_.Add(e.Data);
log = "";
logs_.ForEach(line => { log += line + "\n"; });
Context.Post(_ => {
float.TryParse(e.Data, out SensorValue);
if (DisplayedText != null)
{
DisplayedText.text = e.Data;
}
if (DisplayedText1 != null)
{
DisplayedText1.text = e.Data;
}
}, null);
}
・表示先のテキストをドラッグする。
(ここでは、目の前に常に表示されるテキストと空間に固定されたテキストの2か所を作りました。)
スクリプト全体はこんな感じ
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using System.Threading;
public class HeartRateSensorJs : SensorDeviceBase
{
#region [ static members ]
#if UNITY_STANDALONE_WIN
private static readonly string NodeBinPath = "node.exe";
#endif
#if UNITY_STANDALONE_OSX
private static readonly string NodeBinPath = "node";
#endif
private static readonly string NodeScriptPath = ".node/";
private static string NodeBinFullPath;
private static string NodeScriptFullPath;
private static bool IsInitialized = false;
#endregion
#region [ member variables ]
public string DeviceID = "";
private string scriptPath = "HeartRate/main.js";
private System.Diagnostics.Process process_ = null;
private List<string> logs_ = new List<string>();
public int logLength = 10;
public string log = "";
public Text DisplayedText;
public Text DisplayedText1;
private SynchronizationContext Context;
#endregion
#region [ getter / setter ]
public bool isRunning { get; set; }
#endregion
#region [ member functions ]
private void SetFullPath()
{
if (!IsInitialized)
{
NodeBinFullPath = System.IO.Path.Combine(Application.streamingAssetsPath, NodeBinPath);
NodeScriptFullPath = System.IO.Path.Combine(Application.streamingAssetsPath, NodeScriptPath);
IsInitialized = true;
}
}
void Awake()
{
Context = SynchronizationContext.Current;
isRunning = false;
SetFullPath();
Run();
}
void OnApplicationQuit()
{
if (process_ != null && !process_.HasExited)
{
process_.Kill();
process_.Dispose();
}
}
void Run()
{
if (isRunning)
{
Debug.LogError("Already Running: " + scriptPath);
return;
}
process_ = new System.Diagnostics.Process();
process_.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden;
process_.StartInfo.FileName = NodeBinFullPath;
process_.StartInfo.Arguments = NodeScriptFullPath + scriptPath + " " + DeviceID;
process_.StartInfo.CreateNoWindow = true;
process_.StartInfo.RedirectStandardOutput = true;
process_.StartInfo.RedirectStandardError = true;
process_.StartInfo.UseShellExecute = false;
process_.StartInfo.WorkingDirectory = NodeScriptFullPath;
process_.OutputDataReceived += OnOutputData;
// process_.ErrorDataReceived += OnOutputData;
process_.EnableRaisingEvents = true;
process_.Exited += OnExit;
process_.Start();
process_.BeginOutputReadLine();
process_.BeginErrorReadLine();
isRunning = true;
}
void Stop()
{
if (!isRunning)
{
Debug.LogError("Already Stopping: " + scriptPath);
return;
}
isRunning = true;
process_.Kill();
process_.Dispose();
}
void OnOutputData(object sender, System.Diagnostics.DataReceivedEventArgs e)
{
if (logs_.Count > logLength) logs_.RemoveAt(0);
logs_.Add(e.Data);
log = "";
logs_.ForEach(line => { log += line + "\n"; });
Context.Post(_ => {
float.TryParse(e.Data, out SensorValue);
if (DisplayedText != null)
{
DisplayedText.text = e.Data;
}
if (DisplayedText1 != null)
{
DisplayedText1.text = e.Data;
}
}, null);
}
void OnExit(object sender, System.EventArgs e)
{
isRunning = false;
if (process_.ExitCode != 0)
{
Debug.LogError("Error! Exit Code: " + process_.ExitCode);
}
}
private void OnDestroy()
{
if (process_ != null && !process_.HasExited)
{
process_.Kill();
process_.Dispose();
process_ = null;
}
}
#endregion
}
##UnityからHMDまで
ここでは割愛します。こちらの資料を参考にどうぞ。
脈拍数に比例してエフェクトやライトを変化させたりできます。ご要望があれば記事書きます。
##まとめ&考察
・デジタル空間と物理空間がリアルタイムにシンクロしてデジタルツインな感じになった。
・プレイヤーの(自覚がない)状態によってVRコンテンツが動的に変化するイメージがわいた。
・何の役に立つかは不明...。とりあえず楽しい!
以上です。ありがとうございました。
アドベントカレンダー、この後も楽しいエンジニアが続きます!