Three.jsで立方体を転がすサンプルを作ろう

three.jsはjavascriptで簡単に3D表現を行うためのライブラリです。
今回はこれを使って、立方体を転がすアニメーションを作っていきます。

※説明の都合上**GIFアニメを多用する**ためご注意ください。

# 作成したもの
![cube13-2.gif](https://qiita-image-store.s3.amazonaws.com/0/105321/04d34f1a-13cd-13ac-248e-0eab23ae251b.gif)

https://codepen.io/masaoblue/pen/ZXGZwG?editors=1111

# はじめに

## 使用するライブラリ
- [three.js](https://threejs.org/)(r86)
- [OrbitControls.js](https://gist.githack.com/mrflix/8351020/raw/3d45ef56da946d6e1ae3ad30e4a4d7f992892eed/OrbitControls.js)(three.jsのプラグイン)
    - CDNには上がってなかったので[Gistから直接参照](http://qiita.com/cognitom/items/041b48d8cf746ab54f06)
- [es6-tween](https://github.com/tweenjs/es6-tween)(v3.6.0)

# 1. 初期設定
以下の記事を参考にThree.jsの初期設定を行います。
[【Qiita】three.jsで3Dを作りたい！](http://qiita.com/kurokuma/items/0def9a7092e9ce011653)

以降は、今回追加する設定です。

## グリッド配置
cubeのサイズに合わせてグリッド(床部分)を表示します。

```js
var cube_size = 1;   // cubeのサイズ
var grid_count = 10; // グリッドの分割数
var grid_size = grid_count * cube_size;
var grid = new THREE.GridHelper(grid_size, grid_count);
grid.material.color = new THREE.Color(0xaaaaaa);
scene.add(grid); 
```

## 面ごとに色を変える
回転が分かるよう、立方体の対面ごとに違う色をつけます。

```js
var geometry = new THREE.BoxGeometry(cube_size, cube_size, cube_size);
// 6面分のmaterialを設定
var materials = [
  new THREE.MeshLambertMaterial({color: 0xE9546B}), // right
  new THREE.MeshLambertMaterial({color: 0xE9546B}), // left
  new THREE.MeshLambertMaterial({color: 0x00A95F}), // front
  new THREE.MeshLambertMaterial({color: 0x00A95F}), // back
  new THREE.MeshLambertMaterial({color: 0x187FC4}), // top
  new THREE.MeshLambertMaterial({color: 0x187FC4}), // bottom
];
var cube = new THREE.Mesh( geometry, materials );
cubes.push(cube);
```

## 軸線を表示
回転や移動方向を明確にするため、cubeとgridに軸線を表示します。

```js
// X軸: red, Y軸: green, Z軸: blue 
var line_size = cube_size;
var add_line = (obj, end_pos, color) => {
  var start_pos = new THREE.Vector3(0, 0, 0);
  var g = new THREE.Geometry();
	g.vertices.push(start_pos);
	g.vertices.push(end_pos);
	var material = new THREE.LineBasicMaterial({linewidth: 4, color: color});
	var line = new THREE.Line(g, material);
	obj.add(line);
}
// gridに表示
var grid_half = grid_size / 2;
add_line(grid, new THREE.Vector3( grid_half, 0, 0 ), "#ff0000");
add_line(grid, new THREE.Vector3( 0, grid_half, 0 ), "#00ff00");
add_line(grid, new THREE.Vector3( 0, 0, grid_half ), "#0000ff");
// cubeに表示
add_line(cube, new THREE.Vector3( line_size, 0, 0 ), "#ff0000");
add_line(cube, new THREE.Vector3( 0, line_size, 0 ), "#00ff00");
add_line(cube, new THREE.Vector3( 0, 0, line_size ), "#0000ff");
```

## TWEEN.autoPlay(true)
es6-tweenのアニメーションを有効にするため```TWEEN.autoPlay(true)```を実行します。

```js
// render() の後に以下を実行
TWEEN.autoPlay(true);
```

## ここまでの成果物
![2017-9-17_16-57-22_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/420bed9e-6086-ff27-30ac-d23d40d210e8.png)

https://codepen.io/masaoblue/pen/EwVvxz?editors=1111

# 2. tweenを使った単純な移動と回転
まずは、tweenを使用して単純な横移動と回転を行ってみます。

## 横(x軸)方向の移動
```js
// TWEEN.autoPlayの後に以下を実行
var origin_pos = cube.position.clone();
var move_axis = 'x';         // 移動方向
var move_offset = cube_size; // 移動距離
var from_param = {x: 0};     // tween開始時の値
var to_param = {x: 1};       // tween終了時の値
var duration = 1000;         // 単位はミリ秒
var tween = new TWEEN.Tween(from_param)
  .to(to_param, duration)
  .easing(TWEEN.Easing.Linear)
  .on('update', ({x}) => {
    // xには0～1までの値がカウントアップしながら渡されてくる
    cube.position[move_axis] = origin_pos[move_axis] + (x * cube_size * move_offset);
  })
tween.start();
```

## Quaternionによる回転
three.jsでは回転の制御に「Rotation」と「Quaternion」が使用できます。
Rotationの場合、三軸(x, y, z)全てについて回転を加えた時に予期せぬ動きとなるため、
今回はQuaternionを使用します。
【参考】[【THREE.jsの基礎】Quaternionでローカル座標の回転を取り扱う](http://qiita.com/Guvalif/items/ab0c847390adbb8e4a06)

```js
var origin_pos = cube.position.clone();

// tween.on('update')内に以下を追記
  var axis = new THREE.Vector3(0, 0, -1); // 回転軸
  var rad90 = Math.PI / 2; // ラジアン90°
  var rad = rad90 * x;
  var new_q = origin_quaternion.clone()
  var target = new THREE.Quaternion();
  // 指定した軸に対して回転を加える
  target.setFromAxisAngle(axis, rad);
  new_q.multiply(target);
  cube.quaternion.copy(new_q)
```

### **補足**
three.jsでオブジェクトを回転させる場合、
**回転軸を奥に向けて時計回りが「プラスの回転」**となります。

![2017-9-16_15-41-45_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/66f530af-eb83-1e64-eada-4d311b8bc7fe.png)


このため、例えば次の2つの実行結果は同じです。

```js
// z軸の負の方向に向かって時計回りに回転
var axis = new THREE.Vector3(0, 0, -1)
var rad = rad90 * x;
// z軸の正の方向に向かって半時計回りに回転
var axis = new THREE.Vector3(0, 0, 1)
var rad = -rad90 * x;
```

## ここまでの成果物
![cube7.gif](https://qiita-image-store.s3.amazonaws.com/0/105321/8f656103-6f2c-ccf7-1d0b-1924d30f1320.gif)

https://codepen.io/masaoblue/pen/GMprLN

# 3. もっと自然な動きにしたい

一応動いているように見えますが、床から下にはみ出しているなど少し不自然です。
これを修正するため、まずは理想の動作を考えます。

## 理想の動作
面の上で立方体を綺麗に転がした場合、
ある頂点を基準に回転するような動きになるはずです。

![2017-9-16_16-7-47_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/889c00e6-3bec-6f62-4a1a-caeb8ba77ebd.png)

これを見ると、cubeの中心位置は円弧を描くように移動することが分かります。
  
しかし、現状はオブジェクトの高さを変更していないため、
そこに理想との乖離がある状態だと想定できます。

![2017-9-16_16-6-51_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/ee88edd1-5dcf-75c1-02fd-3e304ff32061.png)

では、この問題を解決していきましょう。

## 高さの算出
三角関数を使い、オブジェクトが移動する時の高さを求めていきます。
そんなの覚えてない！という方はこちらを見て一緒に思い出しましょう。(全く覚えてなかった)
【参考】[進研ゼミ高校講座 sin，cos，tanの値の覚え方](http://kou.benesse.co.jp/nigate/math/a14m0313.html)

まず、角度θにおける高さをyとした時、sinθの定義から以下の式を算出できます。

![2017-9-16_16-12-52_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/b6cea2cb-1f6a-fcd0-fbff-ec1d4040395c.png)

## 移動距離の算出
高さと同様、実は横方向の移動距離もズレてしまっています。
これは**角度θが1°増加した時、横方向の移動距離が一定ではない**ことが原因です。

θの変化に合わせて適切に移動させるため、移動距離をxと置き
cosθの定義を使って以下を算出しましょう。

![2017-9-17_17-13-29_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/c043cf5a-f557-08cf-6af8-74a7b346dcfe.png)


## 移動動作を実装する
上記で求めた式を元に、移動の動きを実装してみたものがこちらです。

```js
.on('update', ({x}) => {
  var r = cube_half * Math.sqrt(2); // 中心の回転半径
  var center_angle = 45 + (90 * x); // 現在の中心位置の角度（移動開始時は45°）
  var center_rad = center_angle * Math.PI / 180; // 角度をラジアンに変換
  // 移動中のcube中心の高さ (sinθ * r)
  // cubeサイズの半分を引くことで、移動開始時点からの増加分だけを取得する
  var current_height = Math.sin(center_rad) * r - cube_half;
  // 移動中のcube中心の位置 (α - (cosθ * r))
  // 移動方向の逆転を考慮してmove_offset(1 or -1)を掛け合わせる
  var current_move = (cube_half - (Math.cos(center_rad) * r)) * move_offset;

  // 移動前のpositionを基準に計算
  cube.position[move_axis] = origin_pos[move_axis] + current_move;
  cube.position.y = origin_pos.y + current_height;

  var axis = new THREE.Vector3(0, 0, -1); // 回転軸
  var new_q = origin_quaternion.clone()
  var target = new THREE.Quaternion();
  var rad90 = Math.PI / 2; // ラジアン90°
  var rad = rad90 * x;
  target.setFromAxisAngle(axis, rad);
  new_q.multiply(target);
  cube.quaternion.copy(new_q)
})
```

## ここまでの成果物
![cube10.gif](https://qiita-image-store.s3.amazonaws.com/0/105321/26035885-26db-7675-15fb-fa3b59335f92.gif)
(色が変に残っているのはgifアニメの都合です)

https://codepen.io/masaoblue/pen/PJPKWJ

# 4. 連続動作させるための調整
これまでに、1方向に回転し続けることができるようになりました。
以降はもっと色んな方向の回転に挑戦してみます。

## x軸で回転した後、z軸で回転させたい
例えば一度右に移動し、次は手前に転がしたいという場合、単純に考えると

* x方向に移動する時はz軸で回転
* z方向に移動する時はx軸で回転

と書いておけば良さそうです。
まずはこれを実装してみましょう。

```js
// move_axisとrot_axisの定義を変更
var move_axis = Math.random() > 0.5 ? 'x' : 'z'; // 移動方向を50%の確率で切替
var rot_axis = move_axis == 'x' ? 'z' : 'x';     // 移動方向に応じて回転軸設定
```

しかし、これだけでは以下のような結果になります。

![cube2.gif](https://qiita-image-store.s3.amazonaws.com/0/105321/971fcf29-c6e2-7eb6-7828-7486698df1b5.gif)

このことから、cubeオブジェクトに回転を加えると、「空間全体の軸(world軸)」ではなく
**「cubeオブジェクトのx軸(local軸)」を使った回転が行われる**という事が分かりました。

## ローカル回転軸の計算
今回は、解決方法として「cubeオブジェクトの中心から空間全体のx方向に1移動したworld座標」を
「cubeオブジェクトのlocal座標に変換する」という方法を使います。
(もっと素直な方法があれば教えてください。)

```js
// 移動方向に対する回転軸を求める
var normal_unit = 1;
var rot_axis_v = new THREE.Vector3();
rot_axis_v[rot_axis] = normal_unit;
var nextr = origin_pos.clone().add(rot_axis_v); // Cubeの中心から、求めたい方向に1移動した座標を算出

// world座標をlocalの座標に変換
// ※ worldToLocalが正常に動かない(?)事があるため、normalizeは必須
var rot_vec = cube.worldToLocal(nextr.clone()).normalize();

// rot_vecからlocal軸(x, y, z)を取得
// 本来なら(1, 0, 0)や(0, 0, -1)のような値が出てくる想定だが、
// 何度も回転させるとなぜか(1, 0.49999999, 0)のような値になるので
// 絶対値がnormal_unitと同じ方向を正として扱う
var local_rot_axis;
if(Math.abs(rot_vec.x) == normal_unit) local_rot_axis = 'x';
if(Math.abs(rot_vec.y) == normal_unit) local_rot_axis = 'y';
if(Math.abs(rot_vec.z) == normal_unit) local_rot_axis = 'z';
var axis= new THREE.Vector3();
axis[local_rot_axis] = normal_unit;

// .on('update')の中にある「var axis」の定義は削除する
```

上記では``worldToLocal``を使っていますが、何度も回転させていくと
``[1, 0, 0]`` や ``[0, 0, -1]`` のような綺麗な値ではなく
``[1, 0.49999999, 0]`` という値になってしまいます。
(使い方が悪いのか、単純に不具合なのかは不明です)

そのため、**絶対値が1になっている方向が正しい**として計算を進めると、
一応上手く状態になりました。

## 回転方向を計算する
回転軸を正常に取るとこのような動きになりました。

![cube6-2.gif](https://qiita-image-store.s3.amazonaws.com/0/105321/97906a5f-c5a1-b5a3-5ddf-83972537be4a.gif)

逆回転してしまっている部分もありますが、ここまで来ればあと少し。
状況を整理すると、以下の動きになっているようです。

- x方向に移動する際、プラス移動(→)の時は正常で、**マイナス移動(←)するときに逆回転**になっている
- z方向に移動する際、**プラス移動(↓)する時が逆回転**で、マイナス移動(↑)する時は正常に動いている

移動方向によって逆回転するタイミングが異なっていますが、
これは回転軸の関係性による事象です。

![2017-9-17_7-58-4_00.png](https://qiita-image-store.s3.amazonaws.com/0/105321/e0d1c1b3-b378-af0d-1545-1c387cb2160e.png)

これに対処するため、以下の処理を加えます。

```js
// 回転軸の向きと移動方向から、回転するべき方向(時計回り, 半時計回り)を判定
var axis_dir = rot_vec[local_rot_axis];
var rot_dir = 1;
if((move_axis == 'x' &&
     ((axis_dir > 0 && move_offset > 0) ||
      (axis_dir < 0 && move_offset < 0))
   ) ||
   (move_axis == 'z' &&
     ((axis_dir > 0 && move_offset < 0) ||
      (axis_dir < 0 && move_offset > 0))
   )
  ) {
  rot_dir = -rot_dir;
}

// .on('update')を変更
  // radを求める時にrot_dirを掛け合わせる
  var rad = rad90 * x * rot_dir;
```

## ここまでの成果物
![cube11.gif](https://qiita-image-store.s3.amazonaws.com/0/105321/ddd58763-0bdd-3183-d665-94842e32f236.gif)
(色が変に残っているのはgifアニメの都合です)

https://codepen.io/masaoblue/pen/eGpgLL

やっとまともに動くようになりましたね！

# 5. おまけ
この後cubeを複数個配置したり、端まで転がった時に
Gridからはみ出さないよう設定したりしたものが[冒頭のURL](https://codepen.io/masaoblue/pen/ZXGZwG?editors=1111)の実装です。 

アニメーションをループさせるのにsetIntervalを使用した所、
**Chromeで別タブを開いて戻ってきた時に動作がおかしくなる不具合**が発生しています。

恐らく[tween.repeat](https://github.com/tweenjs/tween.js/blob/master/examples/08_repeat.html)を使うべきなのかなぁと思いますが、
今は修正する元気がないのでこのままにしておきます。

# さいごに
3D空間をいじるのは非常に楽しいです。
でも一度詰まると対応方法が分からず
数日返ってこれなくなることがあるので注意が必要です。

## 余談
この記事を書くにあたり、[Microsoft Office365 Solo](https://www.microsoft.com/ja-jp/store/d/office-365-solo/CFQ7TTC0K5BC/0004?icid=JP_Office_LinkNav_1_O365_NA_NA_17052017)を契約してPowerPoint2016で製図してみました。
図形の合成で半円を作れたり、標準図形の「円弧」が汎用的だったりと最高に便利だったので、
皆さんもぜひ使ってみてください。

それでは。