three.js を使って簡単な 3D ルーレットアプリを作った

## 概要

ブラウザ上で JavaScript を用いて 3D 表現が出来る、three.js というライブラリがあります。今回は、こちらの three.js を用いて簡単なアプリを作ってみた、という話をしようと思います。特に複雑なものを作ったわけではないので、実際の仕様やコードについても説明を加えながら進めたいと思います。

参考: 【[threejs.org](https://threejs.org/)】

（今回は NSSOL Advent Calendar 2021 に参加するということで、新規開発して記事まで書くぞと意気込んでおりましたが、気づけば投稿日まで短いということで、過去に作ったものの紹介をさせていただきます。）

## リンク

まず、作ったものの紹介です。GitHub Pages で公開しております。

↓実際のアプリ↓

https://yuichisemura.github.io/lunchNoCohoGaRoulette/

↓リポジトリ↓

https://github.com/YuichiSemura/lunchNoCohoGaRoulette

3D表現のイメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/b56ad0e9-3b13-30af-5e7f-de9afafd4ac9.png)

## アプリ概要とユースケース

簡単に言えば、ルーレットです。幾つかの選択肢がから、ある1つを（ほぼ）ランダムで選びたいときに使えます。

想定する具体的なユースケースとしては、**「お昼にランチに行きたいけれど、どこの店に行こうか悩んでしまったとき」**を想定しています。　あらかじめ、このアプリには、NSSOL シス研の所在するみなとみらいにあるお店が3個登録されております。プラスで選択肢の中に入れたい店があればそれを入力して、ルーレットを回します。そして、止まったお店でランチします。特に「これが食べたい！」という強い気持ちがない場合に使えるアプリとなっています。

（シス研の一部の方は迷うことなく「陳麻婆豆腐」という中華料理店に行くので、あまり必要のないアプリです。）

参考: 【[陳麻婆豆腐](https://chenmapo.jp/restaurant/yokohama/minatomirai/)】

以下は、陳麻婆豆腐とMcDonaldと五右衛門で迷うことにした様子です。（アプリの初期設定です。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/9a602aac-fed5-5eb2-8d30-5a383efba181.png)

このようにランチの店を選ぶのに使えたら良いなと思って作ったため、タイトルを「ランチの候補がルーレット」と命名しております。ふむ、何やら意味深長な語感をしていますね。皆様は気にせず、適当にルーレットとお呼びください。

他の利用実績としては、何か会話をしなければならないときに話題を候補の中に突っ込んで回します。例えば「最近ハマってること」「悲しかった話」「大学生の時の話」のような感じです。色々話せることはあるけれど何から話そうか、という状態の話題選びなら手助け出来るかもしれません。

補足: 同じくみなとみらいの【[陳建一麻婆豆腐店](https://www.sisen.jp/shop/minatomirai/)】も美味しいです。名前が非常に似ていますが、別の店です。僕はどちらも好きです。

### アイデンティティ

とはいえ、「いや、このアプリって普通のルーレットじゃん！（関東弁）」とおっしゃる方と、「普通のルーレットやん！」とおっしゃる方が居ると思いますが、まさにもっともなご指摘です。今私が説明した内容だけでは、世にごまんとあるルーレットアプリと同じでしょう。というわけで、私のほうで一応それらとの差別化要素についても考えておりまして、それが以下の3つとなります。

- 3D なのでルーレットが回ってる途中もカメラで遊べる。
- 仕様として、陳麻婆豆腐に止まりやすい。
- URL に候補情報が含められるため、ブックマークで候補を保存したり、他人と共有することが出来る。

では、これから実装のお話をしていこうと思います。

## アプリのレイアウト

はじめに、全体レイアウトについて紹介する。大きく分けて 4 つのコンポーネントがある。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/0f610971-0c50-9315-6195-646197942648.png)

- ルーレット
- 抽選リスト
- START/STOP ボタン
- リスト保存用 URL

## three.js を使った実装

次に three.js を用いた JavaScript のコードを交えながら説明したいと思う。（が、正直調べればいくらでも説明が出てくるので、説明はあまり必要ないのかもしれない。three.js は世界でも利用が多いライブラリで、非常に使いやすい。）

今回説明するのは、以下の内容だ。

- [HTML での設定](#html-での設定)
- [JavaScript 側での初期設定](#javascript-側での初期設定)
- [各オブジェクトの配置](#各オブジェクトの配置)
- [画面サイズ変更時処理](#画面サイズ変更時処理)
- [ルーレットの回転](#ルーレットの回転)

順に説明していこうと思う。

### HTML での設定

three.js に加え、OrbitControls.js が必要となる。（今回は要素の取得やイベント設定のために、jqueryも使用している。）

```html
<script src="hogehogecdn/jquery.min.js"></script>
<script src="hogehogecdn/three.min.js"></script>
<script src="js/OrbitControls.js"></script>
<script src="js/roulette.js"></script>
```

roulette.js に様々な設定やオブジェクトの配置処理を書いていくことになる。

### JavaScript 側での初期設定

同じリポジトリに[描画デモ用](https://yuichisemura.github.io/lunchNoCohoGaRoulette/demo.html)のページを作ってみた。これがルーレットを表示する機能以外を省いたものとなる。本体の方にも同じようなコードが書かれている。

- myCanvas という id を持つ canvas を取得し、WebGLRenderer を作成。
    - レンダラーは画面に描画するためのオブジェクト。
    - ピクセル比、サイズ、影を有効にする、などの設定を行う。
- シーンを作成。
    - シーンはカメラで撮影する世界のようなもの。そこにオブジェクトを配置していく。
- カメラを作成。
    - 視野角、アスペクト比、初期位置を設定。
- カメラ操作をマウスで行うために、OrbitControls を作成。
    - マウス左でカメラ角度移動、右で場所移動、中でズーム。
    - タッチ 1 本で角度移動、2 本で場所移動&ズーム
- シーン上にオブジェクトを配置する。
    - 光源、地面・壁、針、円盤
- 毎フレーム行う処理を書く。
- 画面ロード時に以上の初期設定を行う設定をする。

以下は、デモ用ページの JavaScript のコードである。

```javascript
// キャンバスの初期設定
function setThree() {
  // サイズを指定
  const width = $("#myCanvas").parent().width() - 20;
  const height = 700;
  const ereaSize = 400;
  // レンダラーを作成
  renderer = new THREE.WebGLRenderer({
    canvas: document.querySelector('#myCanvas'),
    antialias: true
  });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(width, height);
  renderer.shadowMap.enabled = true;
  // シーンを作成
  scene = new THREE.Scene();
  // カメラを作成
  camera = new THREE.PerspectiveCamera(45, width / height);
  camera.position.set(0, ereaSize * 0.4, ereaSize * 0.5);
  camera.lookAt(ORIGIN);
  // カメラコントロールの設定
  var controls = new THREE.OrbitControls(camera, renderer.domElement);
  // 光源を作成
  addLight(scene, ereaSize);
  // 地面を作成
  addFloor(scene, ereaSize);
  // 針を作成
  addCone(scene, ereaSize);
  // サイズ3のルーレット作成
  addCylinders();
  // 毎フレーム実行
  tick();

  function tick() {
    renderer.render(scene, camera);
    requestAnimationFrame(tick);
  }
}
// 画面ロード時にsetThreeを実行する
window.addEventListener('load', setThree);
```

この辺りは[^1]に詳細な説明がある。

### 各オブジェクトの配置

シーンにオブジェクトを配置していくことになるが、今回置いているものは4種類だ。

- ライト、環境光
- 床・壁（水色のメッシュ）
- 針（コーン）
- ルーレット本体（円柱）

#### ライト・環境光

PointLight と AmbientLight というものを使っている。どちらも必須だと思っている。

- PointLight
    - 点光源。ある位置から全方向に発せられる光である。
- AmbientLight
    - 環境光源。世界全体を均一に照らすような光である。

参考情報は[^2]に載っている。

```js
// 照明の追加
function addLight(scene, ereaSize) {
  const spotLight = new THREE.PointLight(0xFFFFFF, 3.4, 6000, 2.0);
  spotLight.position.set(ereaSize * 0.2, ereaSize * 0.2, ereaSize * 0.2);
  spotLight.castShadow = true; // 影を落とす設定
  scene.add(spotLight);

  // 環境光
  const ambientLight = new THREE.AmbientLight(0xFFFFFF, 2);
  scene.add(ambientLight);
}
```

例として、それぞれ片方だけのキャプチャを載せる。まず SpotLight のみ。

![spotlight.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/16e53fc9-740b-3eef-294d-7bfaa532358d.png)

次に AmbientLight のみ。

![ambientlight.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/c2a058a8-1c40-1b0f-bc6a-852ec1d52742.png)

そして両方。これがベストだと思う。点光源は立体感を出すために、環境光源は世界全体の明るさを底上げするために使える。

![doublelight.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/2b57b4b7-bfee-b901-ed2c-a4a4096126e1.png)


#### 床と壁

ここでは表面からだとしっかり色づいて見えるが、反対からだと透明に見える素材を使っている。内方向からだけ見えるように内方向に表面を設定し、外からは見えるけど中からは外が見えない状況を作り出す。

```js
// 床壁を追加
function addFloor(scene, ereaSize) {
  const floorGeometry = new THREE.PlaneGeometry(ereaSize, ereaSize);
  const floorMaterial = new THREE.MeshStandardMaterial({
    color: 0x66BBDD,
    roughness: 0.03,
    metalness: 0.75
  });
  const floorXZ = new THREE.Mesh(floorGeometry, floorMaterial);
  floorXZ.rotation.x = -Math.PI / 2;
  floorXZ.position.set(0, -ereaSize / 2, 0);
  scene.add(floorXZ);
  // 同様の記述が、立方体の面を作るためで全部で6つ
}
```

こんな感じ。

![mesh.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/c8aa78ec-1630-d1b0-0e0c-dc607c1dfb83.png)


#### 針

床壁のところで説明しなかったが、Mesh には geometry と material という2つの要素が必要となる。geometry は形を決定付けるもので、material はその質感や色を決めるものだ。roughness は表面の粗さ、metalness は金属感を表すものだ。それぞれベストな質感になるように、数値をいじる必要がある。（0~1 の間の値）

今回は ConeBufferGeometry を使用して、円錐を作成している。

```js
// ルーレットの針を追加
function addCone(scene) {
  var geometry = new THREE.ConeBufferGeometry(3, 40, 30);
  var material = new THREE.MeshStandardMaterial({
    color: 0xffff00,
    roughness: 0.03,
    metalness: 0.6
  });
  var cone = new THREE.Mesh(geometry, material);
  cone.position.set(0, 65, -80);
  cone.rotation.set(-Math.PI * 1.2, 0, 0);
  scene.add(cone);
}
```

実は絶妙に浮いている。

![cone.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/039b2499-4e4d-045e-bcfc-04bc56f5a59e.png)

#### ルーレット本体

少しだけ複雑なので処理概要を説明する。

- 基本的には CylinderGeometry で円柱を追加している。
- ルーレットは1つの円を色んな色で分けるため、色ごとに Mesh を分けている。
    - 全体の角度を等分し扇形の円柱になっている。
    - 各 Mesh の初期位置を少しずつずらしている。
- Material で色を決定する際に、ランダムで振り分けられた色を使用する。
    - 色は hsl[^3] を利用し、彩度と輝度を揃えて、色相だけを変化させる。
    - 基本的に色相はランダムに、かつ隣り合う色は遠くなるように決定する。
- それぞれの Mesh に対し、さらに数字の Mesh を乗せるように配置する。
    - TextGeometry を利用する。
- ルーレット全体をグループ化することで回転しやすくする。

最大の 10 個を設置した場合だ。色は本当にランダムなので、めちゃくちゃダサい色になることもある。下の画像は比較的うまくいったときのものだ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/387790/3999b97d-fd12-d14c-6fbd-457c1a4ceecf.png)

<details><summary>コード全体が大きいので折りたたみに</summary><div>

```js
// 円盤作り
function addCylinders() {
  // データ作り
  var lunch = ["陳麻婆豆腐", "陳麻婆豆腐", "陳麻婆豆腐"];
  // 色のランダム生成
  var color = [];
  var memo = Math.random() * 360;
  for (var j in lunch) {
    if (lunch.length > 2 && j == lunch.length - 1) {
      var avg = (color[0] + color[j - 1]) / 2;
      const h = Math.abs(color[0] - color[j - 1]) > 180 ? avg : avg + 180;
      color.push(h);
    } else {
      const h = (memo + 90 + Math.random() * 180) % 360;
      color.push(h);
      memo = h;
    }
  }

  scene.remove(rouletteGroup);
  rouletteGroup = new THREE.Group();

  // ケーキの追加
  for (var i in lunch) {
    (function(i, color, rouletteGroup) {

      // 円の追加
      var han = 60;
      var rad = Math.PI * 2 * i / lunch.length + Math.PI * (1 + 2 / lunch.length);
      var geometryc = new THREE.CylinderGeometry(
        80, 80, 30, 300, 10, false, -rad, Math.PI * 2 / lunch.length);
      var materialc = new THREE.MeshStandardMaterial({
        color: `hsl(${color[i]}, 90%, 70%)`,
        roughness: 0.02,
        metalness: 0.7
      });
      materialc.side = THREE.DoubleSide;
      var circle = new THREE.Mesh(geometryc, materialc);
      circle.position.set(0, 30, 0);
      circle.receiveShadow = true;
      rouletteGroup.add(circle);

      // 数字の追加
      var loader = new THREE.FontLoader();
      loader.load('json/helvetiker_bold.typeface.json', function(font) {
        var text = Number(i) + 1; /* lunch[i].toString() */
        var textGeometry = new THREE.TextGeometry(String(text), {
          font: font,
          size: 15,
          height: 10,
          curveSegments: 12
        });
        var materials = [
          new THREE.MeshBasicMaterial({
            color: 0xFFFFFF
          }),
          new THREE.MeshBasicMaterial({
            color: 0xAAAAAA
          })
        ];
        var textMesh = new THREE.Mesh(textGeometry, materials);

        // 文字オブジェクトのサイズ取得と微調整
        textMesh.geometry.computeBoundingBox();
        var box = textMesh.geometry.boundingBox.clone();
        var center = box.getCenter(new THREE.Vector3());
        textMesh.position.set(-center.x, 0, center.z * 2);
        textMesh.rotation.set(-Math.PI * 0.5, 0, 0);
        textMesh.castShadow = true;

        // 文字オブジェクトのグループ作成
        var rouletteGroupchild = new THREE.Group();
        rouletteGroupchild.add(textMesh);

        // 文字オブジェクトの回転的位置
        var txtrad = -(Math.PI * 2 * i / lunch.length + Math.PI * 1.5 + Math.PI * 1 / lunch.length);
        rouletteGroupchild.position.set(han * Math.cos(txtrad), 40, han * Math.sin(-txtrad));

        // 文字オブジェクト自体の回転
        var txtrad2 = -(Math.PI * 2 * i / lunch.length + Math.PI * 1 / lunch.length);
        rouletteGroupchild.rotation.set(0, txtrad2, 0);

        // 追加
        rouletteGroup.add(rouletteGroupchild);
      });
    })(i, color, rouletteGroup);
  }
  scene.add(rouletteGroup);
}
```

</div></details>

### 画面サイズ変更時処理

正直こう書いておけば良い！　みたいな感じだ。このへんは[^4]を参考にしている。

```js
// 画面サイズ変更時の処理
function onResize() {
  // サイズを取得
  const width = $("#myCanvas").parent().width() - 20;
  const height = $("#myCanvas").height();
  // レンダラーのサイズを調整する
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(width, height);
  // カメラのアスペクト比を正す
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}
// リサイズイベント発生時に実行
window.addEventListener('resize', onResize);
```

### ルーレットの回転

[three.js の導入](#threejs-の導入)の章にあるとおり、tick() には毎フレーム行う処理が書かれている。
[描画デモ用](https://yuichisemura.github.io/lunchNoCohoGaRoulette/demo.html) では特に表示しなかったが、本体ではルーレットの回転処理を行っている。

```js
function tick() {
  renderer.render(scene, camera);
  if (gameStatus === 1) {
    speedCurve = Math.max(0, Math.min(speedCurve + speedCurveCurvePlus, speedPlus));
    speed = Math.max(0, Math.min(speed + speedCurve, speedMax));
    rouletteGroup.rotation.y = rouletteGroup.rotation.y + Math.PI * speed
  } else if (gameStatus === 2) {
    speedCurve = Math.max(speedPlus / 80, Math.min(speedCurve - speedCurveCurveMinus, speedPlus));
    speed = Math.max(0, Math.min(speed - speedCurve, speedMax));
    rouletteGroup.rotation.y = rouletteGroup.rotation.y + Math.PI * speed
    if (speed === 0) {
      gameEnd();
    }
  }
  requestAnimationFrame(tick);
}
```

ここでは、60fps の環境ではそれなりにいい感じにルーレットが回るようになっている。ここで言う「いい感じ」というのは「最大速度は速いけど、STOP を押した後の減速もなかなか速い。でも止まりそうで止まらないし、あ～～一体どこに止まるんだろう！？！？」という演出が出来る状態のことを言う。

ルーレットの回転に関わる変数はこんな感じである。（自分でも久しぶりに見るので詳細は覚えていない。）
`rouletteGroup`というのは、ルーレット全体をグループ化したもので、これの rotation を変化させることでルーレットが回転する。この rotation を変化させるために、speed や speedMax などの数値を設定している。この数値や計算のおかげで、まあいい感じになっていると思っているので、一度試してみてほしい。ちょっと焦らされるはず。（もちろん手っ取り早く決めて欲しい人からしたら、いい感じではない。）

```js
var rouletteGroup;
// ゲームの状態 0=待機状態 1=加速状態 2=減速状態
var gameStatus = 0;
// ルーレットの速度
var speed = 0;
// ルーレットの最高速度
var speedMax = 0.196;
// ルーレットの加速度
var speedPlus = 0.001;
// ルーレットの加速度の微分
var speedCurve = 0;
// ルーレットの加速度の微分の加速時の微分
var speedCurveCurvePlus = 0.00003;
// ルーレットの加速度の微分の減速時の微分
var speedCurveCurveMinus = 0.0000027;
```

## URLについて

一応説明すべきことは終わったが一つだけ補足する。

ver1.1 の追加機能である URL 機能についてだ。これのやりたいことは「URLのクエリパラメータを読むことでリストを再現出来る。」というものだ。これを実現するには、以下のようなことが必要だ。

- クエリパラメータを入力として候補を復元出来る。
- 候補の状況を監視する。変更時に URL を出力する。ついでに現在の URL も変える。

### 入力

クエリパラメータを取得して、パースして、要素を作ってぶちこんでいるだけだ。

### 出力

`history.replaceState()`を用いることで、現在のクエリパラメータを変更することが出来る。
基本的にURLを候補の要素から読み取り、クエリパラメータっぽくしてからURLボックスに書いたり、`history.replaceState()`を呼び出したりしているだけだ。

```js
// URLボックスの設定、URLの書き換え
function setUrlBox() {
  new_query = "";
  for (c of document.getElementById("input_lunchbox").children) {
    new_query += encodeURIComponent(String($(c).find(".form-control")[0].value)) + ",";
  }
  new_query = new_query.substring(0, new_query.length - 1);
  document.getElementById("url_box").value = location.origin + location.pathname + "?list=" + new_query;
  new URL(location).searchParams.set("list", new_query);
  // URLの書き換え
  history.replaceState(null, document.title, location.pathname + "?list=" + new_query);
}
```

ちなみに、この setUrlBox() を初回ロード時に行っているため、アクセス後はすぐに↓のような URL に変更される。

`https://yuichisemura.github.io/lunchNoCohoGaRoulette/?list=陳麻婆豆腐,McDonald,五右衛門`

## まとめ

今回は、three.js を用いた簡単なアプリの説明を行った。2 年ほど前に作ったアプリでしたが、今回記事を書くにあたって色々と機能追加やリファクタリングを行いました。他にも作りたいアプリがあるので、完成したら Qiita もセットで書こうと思います。今後ともよろしくおねがいします。では、失礼いたしま～～す（退室ボタン）。

## 今後の課題

- スマホでの利用を考え、レスポンシブデザインにする。ルーレットのみをズームアップする機能を追加する。
- 飲み会などの利用可能性を考え、接待モードを追加する。
- 白背景と鮮やかな色で目がチカチカするため、ダークモードに対応する。（おもちゃ機能）
- 現在、リフレッシュレートが 60 の端末でいい感じ（前述のやつ）になる設定をしているため、端末依存性を減らす。

[^1]: [three.js超入門 第1回 レンダリングまでの流れ](https://qiita.com/watabo_shi/items/bf9bcd4569b6d480c608)

[^2]: [Three.js ライト機能まとめ - ICS MEDIA](https://ics.media/tutorial-three/light_variation/)

[^3]: [配色を考えるのが面倒ならhslを使おう](https://qiita.com/anchoor/items/5edd2a67340770a8ca44)

[^4]: [Three.jsでの最適なリサイズ処理](https://ics.media/tutorial-three/renderer_resize)