JOBA無改造で遊べるコンテンツを模索してみる

# はじめに
ドーモ、@waffle_maker です。
軽く自己紹介をすると、普段はシステム開発の仕事をする傍らで、OcuFesに参加してOculus Riftの布教活動を行ったり、VRコンテンツを開発しています。

代表作は、Oculus Riftと乗馬マシンを組み合わせた乗馬コンテンツ「Hashilus（ハシラス）」ですが、特殊デバイスなしで何時でも何処でも遊べるコンテンツを作りたい今日この頃です。

本記事は、[Oculus Rift Advent Calendar 2014](http://qiita.com/advent-calendar/2014/oculus-rift)の第9日目の記事となります。

# やりたいこと
乗馬マシンを魔改造しなくても遊べる家庭用コンテンツを作りたい！
Hashilusは、USBで制御できるよう改造した乗馬マシンを使って**映像に体感を合わせ**ますが、今回は乗馬マシンの自動コースを利用して**体感に映像を合わせ**たいと思います。

![2014-12-09 08.02.02.jpg](https://qiita-image-store.s3.amazonaws.com/0/34264/e47ce3d6-0df1-a024-79d3-c65fabee56e4.jpeg)

当初は、自動コースの動作を録画して目視でデータ化する予定でしたが、Oculus Riftの各種センサで乗馬マシンの動作を学習させてみようと思います。
これによって、特定メーカーの特定機種でなくても遊べるかも知れません。
（@mkt_ さんのご協力に感謝いたします！）

# 開発環境
開発環境は以下の通りです。
* Microsoft Windows 8.1
* Unity Pro 4.6.0f3
* Oculus Unity Integration 0.4.4
* Oculus Runtime 0.4.4
* Oculus Rift DK2
* National EU6441

# さっそくセンサ値を取得してみる
以下のコードで頭の位置と傾きを取得します。

```csharp
	void FixedUpdate () {
		OVRPose pose = OVRManager.display.GetHeadPose();
		Vector3 position = pose.position;
		Quaternion rotation = pose.orientation;
	}
```
	
取得した情報はCSVファイルに保存しておき、再利用できるようにしておきます。

```csharp
	public string path;
	private bool isRecording;
	private StreamWriter sw;

	void Start () {
	}
	
	void FixedUpdate () {
		// 記録中のみ処理を行う.
		if(isRecording){
			// Oculus Riftのトラッキング情報を取得.
			OVRPose pose = OVRManager.display.GetHeadPose();
			Vector3 position = pose.position;
			Quaternion rotation = pose.orientation;
			
			// CSVに1行出力.
			sw.WriteLine ("{0:f},{0:f},{0:f},{0:f},{0:f},{0:f},{0:f}", position.x, position.x, position.x, rotation.x, rotation.y, rotation.z, rotation.w);
		}
	}
	
	// Update is called once per frame
	void Update () {
		if (isRecording) {
			// 記録中にスペースキーを押下すると記録を終了.
			if(Input.GetKeyUp(KeyCode.Space)){
				isRecording = false;
				sw.Close ();
			}
		} else {
			// スペースキーを押下すると記録を開始.
			if(Input.GetKeyUp(KeyCode.Space)){
				isRecording = true;
				DateTime date = DateTime.Now;
				string filename = path + "/ovr_" + date.ToString("yyyyMMdd_HHmmss") + ".csv";
				FileStream fs = new FileStream(filename, FileMode.CreateNew, FileAccess.Write);
				sw = new StreamWriter (fs, Encoding.GetEncoding ("UTF-8"));
			}
		}
	}
```

さっそく乗馬マシンに固定してみます。

https://vine.co/v/Or6hJgK1hXe

おお、取れてる！

自動コースのログをここに置きますのでご興味がある方はどうぞ。
https://www.dropbox.com/sh/bzut9h6wkn2rgwd/AADmJXPrPvfZpXUtv97tj_wUa?dl=0

# 乗馬マシンの速度と傾斜を求めてみる
次に・・・
揺れまくる乗馬マシンから取得したセンサ値から、乗馬マシンの速度と傾斜を求めたいと思います。

先ほど保存したCSVファイルを読み込んで、リストに突っ込んでみる。

```csharp
	private List<OVRPose> LoadPoseList(string filename){
		List<OVRPose> poseList = new List<OVRPose>();
		FileStream fs = new FileStream(filename, FileMode.Open, FileAccess.Read);
		StreamReader sr = new StreamReader (fs, Encoding.GetEncoding ("UTF-8"));
		while(sr.Peek() > -1){
			string line = sr.ReadLine();
			string[] tokens = line.Split(',');
			OVRPose pose = new OVRPose();
			float px = float.Parse(tokens[0]);
			float py = float.Parse(tokens[1]);
			float pz = float.Parse(tokens[2]);
			float rx = float.Parse(tokens[3]);
			float ry = float.Parse(tokens[4]);
			float rz = float.Parse(tokens[5]);
			float rw = float.Parse(tokens[6]);
			pose.position = new Vector3(px,py,pz);
			pose.orientation = new Quaternion(rx,ry,rz,rw);
			poseList.Add(pose);
		}
		sr.Close ();
		return poseList;
	}
```

## 速度を求める
乗馬マシンの揺れの周波数から速度を求める事ができるはず！
乗馬マシンはデンプシーロールの様な感じで８の字に動きますので、Z軸回転に注目し中央を通過してから、再び中央を通過するまでの間隔を速度として利用します。

## 傾斜を求める
X軸回転をそのまま傾斜として使うのですが、速度を求める際に合わせて傾斜を取得することでブレが少なくなるはず。

何となく作って見たらこんな感じ。

```csharp
		// 各回転軸の平均値を求める.
		float totalX = 0.0f;
		float totalY = 0.0f;
		float totalZ = 0.0f;
		foreach ( OVRPose pose in poseList ) {
			totalX += pose.orientation.eulerAngles.x;
			totalY += pose.orientation.eulerAngles.y;
			totalZ += pose.orientation.eulerAngles.z;
		}
		float averageX = totalX / poseList.Count; 
		float averageY = totalY / poseList.Count;
		float averageZ = totalZ / poseList.Count;

		// Z軸回転が中央付近になる間隔で速度を求める.
		// Z軸回転が中央付近になった際のX軸回転で傾斜を求める.
		List<SubdividedRoute> routeList = new List<SubdividedRoute> ();
		float prevZ = 0.0f;
		int prevCenterIndex = 0;
		for(int i = 0; i < poseList.Count; i++){
			OVRPose pose = poseList[i];
			
			// Z軸回転が平均値を跨いだら中央に来たと判断.
			float poseZ = pose.orientation.eulerAngles.z;
			if(prevZ < averageZ && poseZ >= averageZ || prevZ >= averageZ && poseZ < averageZ){
				float poseX = pose.orientation.eulerAngles.x;
				
				// 前に中央に来たときからの間隔で速度を判定.
				// 一定フレーム以上経過した場合のみ.
				float interval = i - prevCenterIndex;
				if ( interval >= MIN_INTERVAL ){
					
					// 速度を求める.
					float speed = STRIDE / ( Time.fixedDeltaTime * interval );

					// 傾斜を求める.
					float tilt = poseX - averageX;
					
					// 結果をリストに追加.
					SubdividedRoute route = new SubdividedRoute();
					route.beginTime = prevCenterIndex * Time.fixedDeltaTime;
					route.endTime = i * Time.fixedDeltaTime;
					route.speed = speed;
					route.tilt = tilt;
					routeList.Add(route);

					// 最後に中央になったインデックスを保持.
					prevCenterIndex = i;
				}
			}
			prevZ = poseZ;
		}
```

# 移動経路の生成
ライド系コンテンツの中でも、プレイ時間やBGMに合わせるタイプのものだと、スプラインカーブに沿った移動がやりにくいです。
なぜなら、コントロールポイント間の移動経路が膨らんでしまうため、所要時間が読みにくいのです。
そこで、力技ではありますが、FixedUpdateに合わせた0.02秒ごとの位置と回転をリスト化します。

```csharp
		Vector3 prevPosition = Vector3.zero;
		Quaternion prevRotation = Quaternion.identity;
		for(int i=0; i<routeList.Count; i++){
			SubdividedRoute route = routeList[i];
			
			// 移動距離を求める.
			float distance = DISTANCE_RATIO * route.speed;

			// 傾斜を求める.
			float tilt = route.tilt * TILT_RATIO;

			// 経路情報をリストに追加.
			Quaternion rotation = Quaternion.Euler(tilt, 0, 0);
			Vector3 position = prevPosition + rotation * new Vector3(0, 0, distance);
			Route route2 = new Route();
			route2.position = position;
			route2.rotation = rotation;
			route2.beginTime = route.beginTime;
			route2.endTime = route.endTime;
			route2List.Add(route2);
			
			// 前回の位置と回転を保持.
			prevPosition = position;
			prevRotation = rotation;
		}
```

# プレイヤを経路に追従させる

力技で作った経路情報のリストにそってプレイヤを移動させてみる。
このスクリプトを馬のモデルにアタッチすると・・・

```csharp
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;

public class FollowRoute : MonoBehaviour {

	private float startTime = float.MinValue;
	private List<Learning.Route> routeList;
	private int prevIndex = int.MinValue;

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
		// 開始前は追従させない.
		if(startTime == float.MinValue){
			return;
		}

		// 経過時間に応じた経路情報を特定.
		float time = Time.time - startTime;
		Learning.Route temp = new Learning.Route ();
		for(int i=prevIndex; i<routeList.Count; i++){
			if(time >= routeList[i].beginTime){
				temp = routeList[i];
				prevIndex = i;
				continue;
			}
			break;
		}
		
		// 特定した経路に追従.
		transform.position = temp.position;
		transform.rotation = temp.rotation;
	}

	// 追従を開始.
	public void StartRun(List<Learning.Route> routeList){
		this.routeList = routeList;
		prevIndex = 0;
		startTime = Time.time;
	}
}
```

歩いた！！
・・・で、でも、補間していないのでガックガクですね。
酔うやつです(；´Д｀)

https://vine.co/v/OrFuV7lOdhT

![2014-12-09 21.39.39.jpg](https://qiita-image-store.s3.amazonaws.com/0/34264/4fe6a72e-7e03-f2de-9036-331989f43c84.jpeg)

# 見えてきた課題と今後の展望
* 速度にバラツキが出てしまう。
 Z軸の往路/復路は、片方だけを利用した方が良いかも？
 平滑化した方が良いかも？

* 傾斜が平坦になってしまう。
 X軸回転の基準が平均なので、前傾中心や、後傾中心の自動コースが平坦になってしまう。

* 経路が一直線になっている。
 低速時に左右にカーブするなどしたい。
 カーブしたらしたで補間は必要になりそう。

* 地形が平坦でつまらない。
 Terrainの自動生成を行いたい。
 ボクセル系アセットでトンネルありの立体的な地形とか面白そう。

# 最後に

Hashilusは長崎ハウステンボスで常設展示しています。

2014/12/13からは新たに「[鳥獣ライド～巨大昆虫の暴走～](http://www.huistenbosch.co.jp/event/hashilus/)」を展示いたします。

また以下のイベントで遊んで頂くことができます。

* 2014/12/13 - [TMCN x Education (コミュニティ総会Vol.11)](http://tmcn.doorkeeper.jp/events/16719)
* 2014/12/22-28 - [ウルトラ有馬記念](http://jra.jp/ultra_arima/)

宣伝になってしまいましたが、次回は @mishima さんです。
お楽しみに～