Web技術(A-Frame)でVRを実現する夢を見た


この記事は「[株式会社オープンストリーム "小ネタ" Advent Calendar 2020](https://qiita.com/advent-calendar/2020/opst-mini)」の25日目にしたかった記事です。

## はじめに

2年前、Webページを作るだけでARを実現するAR.jsを試しました。
[Web技術(AR.js)でARを実現する夢を見た
](https://speakerdeck.com/ysd_marrrr/webji-shu-ar-dot-js-dearwoshi-xian-surumeng-wojian-ta)

AR.jsとA-Frameと一緒に使う構成でしたが、A-Frame自体はWebでVR体験を構築するフレームワークです。

「VR機器は持ってないし😕」と後回しにしていると今年になって **手に入りやすくなったOculus Quest2が登場しました。** Oculus Quest2のWebブラウザでもWebVRがサポートされていることから「Webの技術でVR体験が作れるのでは」と思いはじめました。

この記事では

 * A-FrameでVRを実現する
 * Oculus Touchコントローラーでオブジェクトを動かす

ところまでを試しました。

## A-FrameでVRを実現する

### A-Frameをセットアップする

A-Frameで3Dが表現できればVR用に特別な設定やライブラリは必要ありません。

A-Frameのドキュメントにある[Getting Startedのコード](https://aframe.io/docs/1.1.0/introduction/)そのままですが、3Dの物体を配置するコードだけで特にVRについて記述しなくても標準でVR対応になります。

次の内容でHTMLファイルを作成します。

```html:index.html
<html>
  <head>
    <script src="https://aframe.io/releases/1.1.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
      <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
      <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
      <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
      <a-sky color="#ECECEC"></a-sky>
    </a-scene>
  </body>
</html>
```

### glTFモデルを使う

プリミティブな物体のほかにモデルを読み込んでWebVRに使うことができます。A-FrameはOBJ形式のファイルにも対応していますが、glTF形式でバージョン2.0のモデルを推奨しています。

Web技術でARを試した時もGoogle Polyのモデルを使ったのでこれをVRでも試したいのですが、2021年6月30日にGoogle Polyのサービスが終了します。他の方法でモデルを用意するしかありません。

ここでは"glTF Sample Models"のglTFモデルを使います。
https://github.com/KhronosGroup/glTF-Sample-Models

A-Frameでプリミティブな物体を配置するときのように `a-entity` でモデルを配置します。
また、モデルの読み込みは `a-assets` を使います。`a-entity` で指定できるように `id` を設定します。

配置しているオブジェクトの状況によって **モデルが大きすぎたり小さすぎたりほかのオブジェクトの後ろに隠れちゃったり** とモデルが正しく表示されない場合がありますので、 `a-entity` に対して適宜調整します。

```html
<a-scene>
  <a-assetes>
    <a-asset-item id="fox" src="./fox-embedded.gltf"></a-asset-item>
  </a-assets>
    
  <a-entity gltf-model="#fox" scale="0.02 0.02 0.02" position="0 0 -3" rotation="0 -20 0"></a-entity>
</a-scene>
```

`fox-embedded.gltf` の中身は次のファイルです🦊
https://github.com/KhronosGroup/glTF-Sample-Models/blob/master/2.0/Fox/glTF-Embedded/Fox.gltf


### 作ったWebVRを試す

HTMLファイルをそのまま開いても動作しますが、Oculus Questのブラウザーからアクセスできるように、Web上でサンドボックス的に使えるGlitchを使います。
[この記事で使っているGlitchのプロジェクトはこちらです](https://glitch.com/edit/#!/aframe-webvr-sample-20201225)

Show -> Next to The Codeをクリックするとソースコードを編集しながらその隣で動作確認できます(各オブジェクトのposition, rotationなどを適当にいじってイイ感じに配置しましょう✨)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/771a5130-cff9-bee8-7998-78cc4c95023b.png)


ソースコードではなくてVRを全面に表示したいときは Share ボタンをクリックして Project linksのLive siteのURLを使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/2c30db1b-fada-dbe4-36d9-02815bfb2b49.png)


パソコンで動作確認するときは 方向キー(もしくはWASDキー)とマウス操作で視点を動かせます。

VRヘッドセットでは右下に表示されている "VR" のボタンをクリックしてVRモードに入ります。
「没入感のあるWebXRセッションへのアクセスをリクエストしています」と表示された場合は「許可」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/b057094b-0cee-ecf8-76bc-9764bcf624c1.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/42a18405-3a71-928c-5f22-197017e3feea.png)

この時点でVRになって表示されます！
コントローラーが表示されませんが、これにはA-Frameがコントローラーを認識できるようにコードが必要です。

HTTPでアクセスすると「VRを有効にするにはHTTPSでアクセスするように」求められます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/e4f0a8e8-5032-f4c4-7764-8d5822decc5f.png)


## Oculus Touchコントローラーを使えるようにする

### コントローラーを認識させる

コントローラーを使うには `laser-controls` を配置します。VRアプリでコントローラーが出てくるときによくある（？）コントローラーからレーザーが出てマウスカーソルの代わりをしてくれるアレです。

`laser-controls` を使うと以下のコントローラーすべてに対してA-Frameが認識するようです。

 * vive-controls
 * oculus-touch-controls
 * daydream-controls
 * gearvr-controls
 * windows-motion-controls

左右のコントローラーがあるため `hand: left` などを `laser-controls` に設定します。

また、コントローラーのどのボタンが押されたかわかるようにテキストを配置しますが、カメラを配置してからその子としてテキストを配置するとカメラに追従して表示されます。

```js
      <a-entity id="ctlL" laser-controls="hand: left"></a-entity>
      <a-entity id="ctlR" laser-controls="hand: right"></a-entity>

      // コントローラーの操作状況が分かるようにカメラにテキストを配置
      <a-entity camera look-controls position="0 1.5 0">
        <a-text id="txt" value="test" position="0 -0.7 -1" scale="0.5 0.5 0.5" align="center" color="#000000"></a-text>
      </a-entity>
```


### コントローラーを操作した時のイベントを設定する

コントローラーをA-Frameで設定するには、コントローラーにコンポーネントを紐づけます。

コンポーネントには初期化する際の処理、毎フレームに(1秒間に90回)実行したい処理等を設定でき、これを再利用できます。Oculus Touchコントローラーは左右の2つあるので、今回は同じコンポーネントを紐づけて同じ挙動にします。


初期化する `init()` 内でコントローラーのボタンが押された時のイベントを設定します。

```js
    <script>
      AFRAME.registerComponent("vr-controller", {
        init: function() {
          const txt = document.getElementById("txt");
          const el = this.el;
           
          el.addEventListener('triggerdown', function (event) {
            txt.setAttribute("value", "Trigger down");
          });
          el.addEventListener('triggerup', function (event) {
            txt.setAttribute("value", "Trigger up");
          });
          
          el.addEventListener('gripdown', function (event) {
            txt.setAttribute("value", "Grip down");
          });
          el.addEventListener('gripup', function (event) {
            txt.setAttribute("value", "Grip up");
          });
        }
      });
    </script>
```

そして、コンポーネントをコントローラーに紐づけるときは `a-entity` にコンポーネント名を指定します。

```html
<a-entity id="ctlL" laser-controls="hand: left" vr-controller></a-entity>
<a-entity id="ctlR" laser-controls="hand: right" vr-controller></a-entity>
```


### コントローラーでオブジェクトをつかむ

このままではコントローラーから出ているレーザーは飾りです。レーザーとオブジェクトがぶつかったかどうかを判定するために `raycaster` を追加します。

`objects` にはraycasterが拾うオブジェクトをCSSセレクターのように指定し、 `far` はraycasterを設定したオブジェクト(この場合はコントローラー)と「ぶつかっている」とみなす最大の距離を指定します。

```html
<a-entity id="ctlL" laser-controls="hand: left" raycaster="objects: .collidable; far: 2" controller></a-entity>
<a-entity id="ctlR" laser-controls="hand: right" raycaster="objects: .collidable; far: 2" vr-controller></a-entity>
```

次に、コントローラーで操作させたいオブジェクトに `raycaster` で指定したclassを追加します。

```html
<a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" class="collidable"></a-box>
<a-entity gltf-model="#fox" scale="0.02 0.02 0.02" position="0 0 -3" rotation="0 -20 0" class="collidable"></a-entity>
```

オブジェクトとコントローラーから出ているレーザーがぶつかったときはどうするか、そのときにコントローラーのボタンを押しているとどうしたいのかをスクリプトとして記述します。
[こちら](https://www.slideshare.net/ssuserc0d7fb/aframeoculus-questwebvr-151354119)と同じ処理です。

```js
    <script>
      AFRAME.registerComponent("vr-controller", {
        init: function() {
          const txt = document.getElementById("txt");
          const el = this.el;
          
          // raycasterにぶつかったオブジェクト
          el.selectedObject = null;
          
          // コントローラーのグリップボタンが押されているかを表現する
          el.grip = false;
          
          el.addEventListener('gripdown', function (event) {
            txt.setAttribute("value", "Grip down");
            el.grip = true;
          });
          el.addEventListener('gripup', function (event) {
            txt.setAttribute("value", "Grip up");
            el.grip = false;
          });
          
          el.addEventListener("raycaster-intersection", function(e) {
            txt.setAttribute("value", "Raycaster intersection");
            this.selectedObject = e.detail.els[0];
          });
          el.addEventListener("raycaster-intersection-cleared", function(e) {
            txt.setAttribute("value", "");
            this.selectedObject = null;
          });
        },
      
        tick: function() {
          var el = this.el;
          if (!el.selectedObject) {
            return;
          }
          if (!el.grip) {
            return;
          }

          // オブジェクトとぶつかったraycaster(コントローラー原点の座標)を取得
          var ray = el.getAttribute("raycaster").direction;
          
          // コントローラーから見たraycasterとの方向のみを取り出す
          var p = new THREE.Vector3(ray.x, ray.y, ray.z);
          p.normalize();
          
          // raycasterとぶつかった部分と同じ位置にオブジェクトを動かす場合はfarと同じにする
          // 引き寄せる場合はfarよりも小さくする
          p.multiplyScalar(2);

          // コントローラー原点からワールド原点に変換して新しいオブジェクトの位置を計算する
          el.object3D.localToWorld(p);
          
          // オブジェクトを移動させる
          el.selectedObject.object3D.position.set(p.x, p.y, p.z);
        }
      });
    </script>
```

コントローラーのグリップボタンが押されているかどうかは `grip` 変数に格納します。

raycasterにオブジェクトがぶつかった場合は `raycaster-intersection` イベントが発生します。複数のオブジェクトを拾うことができますが、最初にぶつかったオブジェクトのみを選択するようにします。

`tick()` が毎フレームごとに実行される関数になります。ここでコントローラーでオブジェクトをつかんだ時(コントローラーのraycasterがオブジェクトにぶつかっていて、かつコントローラーのグリップボタンが押されている時)はオブジェクトをraycasterが向いている方向に動かします。

 ![com.oculus.browser-20201228-162451.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/f50478a0-09c4-83dc-a688-6fe4052d3656.jpeg)


VR機器のコントローラーに依存するため、Oculus QuestのブラウザーでVRモードを有効にしないと試すことができません。

近づかないとraycasterの判定がシビアになるので、サンプルではraycasterとオブジェクトがぶつかったときに "Raycaster intersection" が表示されるようにしています。この状態からグリップを握ってコントローラーを動かすとオブジェクトが動きます。

モデル🦊のほうは……モデルの中心を動かす関係で上方向に飛んじゃって「どこいくねーん」になります。オブジェクトに応じて移動する位置を変更すると解決できそうです。

そのほかにもテレポートの機能など実装している例があります。試してみましょう！
🦊.｡o(メニューのON/OFFとかどのように実装しているんだろう…jQuery的なつらさが出そう…)

## 参考

Oculus QuestとA-Frameで始めるWebVR - tks_yoshinagaの日記
https://tks-yoshinaga.hatenablog.com/entry/oculus_quest_aframe

A-Frameで始めるOculus Quest対応WebVR
https://www.slideshare.net/ssuserc0d7fb/aframeoculus-questwebvr-151354119