【PlayCanvas】Meta Questのブラウザで動くWebXRのシーンを作成してみる（準備〜実機で確認まで）

### PlayCanvasでMeta Questのブラウザで動くVRのシーンを作ってみよう
WebGLベースのゲームエンジンPlayCanvasで[WebXR Device API](https://developer.mozilla.org/ja/docs/Web/API/WebXR_Device_API)を利用したVRのシーンを作る方法を紹介します。今回作るプロジェクトが動作する端末は2022年2月22日現在はAndroidのスマートフォンでの3DoFおよびQuestブラウザ / Steam VR上での6DoFの動作を確認しています。



### 完成シーン

この記事で作成プロジェクトはこちらからアクセスできます。
※WebXR Device APIに対応していない場合は利用できません。
https://playcanv.as/p/jDg3BAic/

※PCで確認する場合にはChromeにヘッドマウントディスプレイのエミュレーターの[拡張機能](https://qiita.com/yushimatenjin/items/924d7ef515b1065487d4)を入れてご確認ください。


### 体験中のイメージ

#### Androidスマートフォン(3DoF)
<img src="https://files.gitbook.com/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FKnjaQSvjY5s9wbax7YuD%2Fuploads%2F2yoMItfdTBTHzMaUo1Bq%2FScreenshot_20220217_134355_com.android.chrome.jpg?alt=media&token=cd654b04-4053-47de-bc94-4e59a5eb8d16" width="300" />
<img src="https://i.imgur.com/knqpy5Y.png" width="200" />

#### Questブラウザ(6DoF)
<img src="https://user-images.githubusercontent.com/39250588/155060817-ee20d1fc-ddc3-4373-bcb1-e237d1281333.gif" width="300" align="center" />

<img src="https://i.imgur.com/y6TMHLD.png" width="200" />


### 準備
- PlayCanvasのアカウント(https://playcanvas.jp/)
- Oculus Quest(Android端末)

Oculus Quest / Androidで利用できるWebXRを利用したシーンの構築をします。
PlayCanvasを使ってブラウザで開発を進めていくためパソコンにインストールするソフトウェアはありません。準備としてPlayCanvasのアカウントの作成をお願いします。

#### PlayCanvasについて
<img src="https://i.imgur.com/qtEJo6H.png" width="250" />

PlayCanvasはJavaScriptで記述されたWebGLのエンジンで、最近GitHubのスター数が7000を超えました。SaaS型のエディターを兼ね備えているゲームエンジンです。[engine](https://github.com/playcanvas/engine)については主にSnapのメンバー[Will Eastcott](https://twitter.com/willeastcott)を中心にOSSとして公開及び継続的に開発が進められています。Engine単体として利用する場合には、Three.js, Babylon.jsなどのWebGLライブラリと似た使い方を使用することができます。またビジュアルエディターとコードエディターが存在しており。エディターはクラウド上でプロジェクトの作成から公開までが出来ます。FBXやOBJといった3Dモデルの素材については、ブラウザへドラッグアンドドロップをすることでGUIから操作・配置ができるエディターがあります。

#### 素材について
キャラクターや移動のスクリプトについては事前に用意されている素材を使用します。スターターキットは、キャラクターの3Dモデル、キャラクターの移動が事前に入っているPlayCanvasのプロジェクトです、PlayCanvasの公開プロジェクトをコピー（Fork）する機能を使用して作成していきます。

### プロジェクトの準備 - スターターキットを使用する
他のプロジェクトをForkという形で複製することができます。
この機能を使用して、プロジェクトを元にプロジェクトを作成していきます。

#### 1. フォーク(プロジェクトの複製)
![](https://i.imgur.com/9jMKP5Q.png)

https://playcanvas.com/project/883637/overview/
PlayCanvasのアカウントが作成できましたらこちらからこちらのリンクをクリックして
上記リンクからプロジェクトにアクセスして「Forkボタン」をクリックします。

#### 2. プロジェクトのフォーク
![](https://i.imgur.com/hRWlgF4.png)

プロジェクト名を入力して(好きな名前で大丈夫です)FORKをしてください。 

#### 3. エディタを起動
![](https://i.imgur.com/WsIaIhm.png)
プロジェクトページの中にある、EditorボタンをクリックしPlayCanvas Editorを起動します。

#### 4. シーンの選択
![](https://i.imgur.com/JW0xN19.png)
シーンの選択画面がでてきますので、Mainというシーンを選択してください。

#### 5. シーンが開かれます
![](https://i.imgur.com/5vvXZin.png)
これでシーンが開かれました、次は、スターターキットの紹介になります。


### プロジェクトの中身について
![](https://i.imgur.com/dM2rLSW.jpg)
フォークした後のシーンはこのような構成となっております。
左側のメニューの`ヒエラルキー`には、今回ステージとして利用する（`Mapエンティティ`, `Playerエンティティ`)と`Directional Light`が設定されています。下側メニューのASSETには今回使用する素材が配置されています。

#### プロジェクトを起動
右上のLaunchボタンからプロジェクトを起動することができます。
![](https://i.imgur.com/amv50QL.jpg)

#### 起動されたプロジェクト

![](https://i.imgur.com/R2UgKZ4.jpg)

起動されたプロジェクトは現在このような状態となっております。
ここからカメラをWebXR用のカメラとして利用していきます。



### PlayCanvasでWebXRシーンの構築

PlayCanvasでシーンを構築するためには、最小で構成する場合には「シーン」「VR用カメラ」の２つのエンティティが必要となります。、またVRのシーンへ入るためにはユーザーからの入力（クリックやタッチなど）の動作が必要です。

PlayCanvasでWebVRのシーンに入るための手順です。
1. VR用のカメラ及びシーンを準備(PlayCanvasのヒエラルキーで作成)
2. ユーザーがボタンを押すことでVRシーンへ入る


### VR用のカメラ/シーンの準備 &　設定
VRのカメラの設定・コントローラーの設定・VR空間での移動のスクリプトを含んでいるスクリプトとなります。こちらを利用するための準備をします。Playerエンティティにスクリプトを追加して設定をするところまでを行います。

#### 1. ADD COMPONENTをクリック

ヒエラルキー上から`Player`を選択してください、選択したあと右側の`インスペクター`から「ADD COMPONENT」をクリックします。
![](https://i.imgur.com/k7ycF9e.png)

#### 2. SCRIPTコンポネントをクリック


#### 3. ADD SCRIPT　-> VR Starter Kitを選択
「ADD SCRIPT」 -> 「`vr-starter-kit.js`」を選択して。
スクリプトコンポーネントに追懐するためのスクリプトを追加します。

![](https://i.imgur.com/n2k4rT1.png)

これでスクリプトを追加することが出来ました。このあとスクリプトに対しての初期設定を行っていきます。初期設定として、カメラのコントローラーの設定をします。設定には、PlayCanvasのスクリプト属性という機能を利用します。

#### 追加されたvr-starter-kit.jsについて
```javascript
const EVENT = {
  ADD_CONTOLLER: "add",
  SELECT: pc.EVENT_SELECT,
  VR_END: "end",
  VR_ENTER: "vr:enter",
};

class VRStarterKit extends pc.ScriptType {
  initialize() {
    this.controllers = []; // コントローラー用の配列
    this.app.isSupported = this.app.xr.supported && pc.XRTYPE_VR; // 起動時XRのサポートをしているか確認
    // WebXR(VR)のAPIに対応してる場合
    if (this.app.isSupported) {
      this.app.on(EVENT.VR_ENTER, this.enterVr, this); // EVENT.VR_ENTERのイベントが発火された場合 enterVr関数を実行
      this.app.xr.input.on(EVENT.ADD_CONTOLLER, this.createController, this); // コントローラーが追加された場合に発火
      this.app.keyboard.on(pc.EVENT_KEYDOWN, this.exitVr, this); // キーボードが押された場合に発火
      // this.app.xr.input.on(EVENT.SELECT, () => { }, this);
      // this.app.xr.on(EVENT.VR_END, async () => { });
    }

    this.movementSpeed = 2.5; // 移動速度
    this.rotateSpeed = 2; // 回転速度
    this.rotateThreshold = 0.5; // 回転のしきい値
    this.rotateResetThreshold = 0.25; // しきい値
    this.lineColor = new pc.Color(1, 1, 1); // コントローラーから出る線の色を指定（白）
    this.tmpVec = new pc.Vec3(); // {x: 0, y: 0, z: 0}
  }

  exitVr(e) {
    const { key } = e;
    // Escキーが押された場合
    if (key === pc.KEY_ESCAPE) {
      this.vrCamera.camera.endXr(() => { }); // Xrのモードを抜ける
    }
  }

  async enterVr() {
    // WebXR(VR)のAPIに対応してる場合
    if (this.app.isSupported) {
      const result = this.vrCamera.camera.startXr(
        pc.XRTYPE_VR,
        this.XRReferenceSpaceType,
        {
          callback: (error) => {
            if (error !== null) alert(error);
          },
        }
      ); // カメラコンポーネントのstartXr実行しVRシーンへ切り替える
      console.log(result);
    } else {
      alert("未対応のブラウザです。");
    }
  }

  createController(inputSource) {
    const entity = this.controllerEntity.resource.instantiate(); // コントローラーをテンプレートからインスタンス化
    this.entity.addChild(entity); // Player配下に配置
    entity.inputSource = inputSource; // コントローラーの情報を登録
    this.controllers.push(entity); // エンティティをシーン上に配置
    inputSource.on("remove", () => {
      // コントローラー削除された際の処理を追加
      this.controllers.splice(this.controllers.indexOf(entity), 1);
      entity.destroy();
    });
  }

  xrMovement(dt) {
    let lastRotateValue = 0;
    const tmpVec2A = new pc.Vec2();
    const tmpVec2B = new pc.Vec2();
    const tmpVec3A = new pc.Vec3();
    for (let i = 0; i < this.controllers.length; i++) {
      const inputSource = this.controllers[i].inputSource;
      if (inputSource.handedness === pc.XRHAND_LEFT) {
        tmpVec2A.set(inputSource.gamepad.axes[2], inputSource.gamepad.axes[3]);
        if (tmpVec2A.length()) {
          tmpVec2A.normalize();
          tmpVec2B.x = this.vrCamera.forward.x;
          tmpVec2B.y = this.vrCamera.forward.z;
          tmpVec2B.normalize();

          const rad = Math.atan2(tmpVec2B.x, tmpVec2B.y) - Math.PI / 2;
          const t = tmpVec2A.x * Math.sin(rad) - tmpVec2A.y * Math.cos(rad);
          tmpVec2A.y = tmpVec2A.y * Math.sin(rad) + tmpVec2A.x * Math.cos(rad);
          tmpVec2A.x = t;
          tmpVec2A.scale(this.movementSpeed * dt);
          this.entity.translate(tmpVec2A.x, 0, tmpVec2A.y);
        }
      } else if (inputSource.handedness === pc.XRHAND_RIGHT) {
        const rotate = -inputSource.gamepad.axes[2];
        if (lastRotateValue > 0 && rotate < this.rotateResetThreshold) {
          lastRotateValue = 0;
        } else if (lastRotateValue < 0 && rotate > -this.rotateResetThreshold) {
          lastRotateValue = 0;
        }
        if (lastRotateValue === 0 && Math.abs(rotate) > this.rotateThreshold) {
          lastRotateValue = Math.sign(rotate);
          tmpVec3A.copy(this.vrCamera.getLocalPosition());
          this.entity.translateLocal(tmpVec3A);
          this.entity.rotateLocal(0, Math.sign(rotate) * this.rotateSpeed, 0);
          this.entity.translateLocal(tmpVec3A.scale(-1));
        }
      }
    }
  }

  xrContoller() {
    for (let i = 0; i < this.controllers.length; i++) {
      const inputSource = this.controllers[i].inputSource;
      if (inputSource.grip) {
        this.controllers[i].setLocalPosition(inputSource.getLocalPosition()); // コントローラーの角度をコントローラーの角度に合わせる
        this.tmpVec.copy(inputSource.getDirection());
        this.tmpVec.scale(100).add(inputSource.getOrigin());
        this.app.drawLine(inputSource.getOrigin(), this.tmpVec, this.lineColor); // 線を引く
      }
    }
  }
  update(dt) {
    this.xrContoller(); // コントローラーの位置を同期
    this.xrMovement(dt); // キャラクターの位置を動かす
  }
}

pc.registerScript(VRStarterKit);

VRStarterKit.attributes.add("vrCamera", { type: "entity" });
VRStarterKit.attributes.add("controllerEntity", {
  type: "asset",
  assetType: "template",
});

VRStarterKit.attributes.add("XRReferenceSpaceType", {
  type: "string",
  enum: [
    { "bounded-floor": "bounded-floor" },
    { local: "local" },
    { "local-floor": "local-floor" },
    { unbounded: "unbounded" },
    { viewer: "viewer" },
  ],
  default: "local-floor",
});

```
###### スクリプト属性

> スクリプトのアトリビュート機能は、スクリプト内で使用する変数をPlayCanvasエディタ内で編集することができるようにする便利な機能です。この機能を使うことで、一度コードを書いた後にエンティティごと作られるインスタンスにそれぞれ違うパラメータを設定する調整ができるようになります。これにより、アーティスト、デザイナーやその他のプログラマーではないチームメンバーがコードを書かずに値を変更できるにプロパティを露出させることができます。
> https://developer.playcanvas.com/ja/user-manual/scripting/script-attributes/

今回のスクリプトでは`vrCamera`, `controllerEntity`, `XRReferenceSpaceType`の3つの値を設定できるようになっています。
それぞれの値を設定します。

#### 4. SELECT ENTITYを選択
SELECT ENTITYをクリックして、カメラとして利用するエンティティを追加していきます。
![](https://i.imgur.com/KDvTfAf.png)

#### 5. VR Cameraを選択
ヒエラルキー上から`VR Camera`を選択します。
![](https://i.imgur.com/0vWqhj7.png)


#### 6.controllerEntityを選択
次にコントローラーとして利用するためのテンプレートを追加していきます。
スクリプト属性の`contorllerEntity`を選択します。
![](https://i.imgur.com/bSX29aJ.png)

#### 7. Controllerを選択
このテンプレートの値は`ASSET`にあるものを追加します。
ハンズオン資料内の`Controller`テンプレートを追加してください。
![](https://i.imgur.com/jphqEZU.png)

これでPlayCanvas側のシーンの準備は完了です。


#### vr-starter-kitの中身について

##### ① WebXRに対応している端末かどうかの判定
今回のプロジェクトでは、`pc.app.xr.supported`と`pc.XRTYPE_VR`に対応しているかどうかを確認しています。`this.app.isSupported`の場合にその他のVR関係の処理を追加していきます。

```javascript
this.app.isSupported = this.app.xr.supported && pc.XRTYPE_VR; // 起動時XRのサポートをしているか確認
    // WebXR(VR)のAPIに対応してる場合
if (this.app.isSupported) {
}
```

##### ② camera.startXrを実行してVRに入る
カメラエンティティ内の`startXr`を利用してVRシーンに入ります。このメソッドを実行するためには、事前にユーザーから入力が必要です。そのため次にUIを作成して、ユーザーがボタンをクリックしたら発火される`vr:enter`イベントでメソッドを実行するようにします。

```javascript
  this.app.on(EVENT.VR_ENTER, this.enterVr, this); // EVENT.VR_ENTERのイベントが発火された場合 enterVr関数を実行      
  async enterVr() {
   // ...
   this.vrCamera.camera.startXr(pc.XRTYPE_VR, this.XRReferenceSpaceType)
  // ...
  }
```

##### ③ escキーが押されたらVRを抜ける
キーボードが押された際に`this.enterVr`を実行します。入力されたキーが`esc`キーだった場合には`endXr`を実行し終了します。
```javascript
 this.app.keyboard.on(pc.EVENT_KEYDOWN, this.exitVr, this); // キーボードが押された場合に発火
 exitVr(e) {
    const { key } = e;
    // Escキーが押された場合
    if (key === pc.KEY_ESCAPE) {
      this.vrCamera.camera.endXr(() => { }); 
    }
  }
```

##### ④ VRのコントローラーについて
`createController`は実行された端末にコントローラーが存在する場合に実行します。
Questなどのコントローラーを追加した際に実行されます。Androidなどのスマートフォンの場合には実行されません。そのタイミングでテンプレートとして設定したエンティティをシーン上に追加します。

```javascript
this.controllers = []; // コントローラー管理用の配列
this.app.xr.input.on(EVENT.ADD_CONTOLLER, this.createController, this); // コントローラーが追加された場合に発火

  createController(inputSource) {
    const entity = this.controllerEntity.resource.instantiate(); // コントローラーをテンプレートからインスタンス化
    this.entity.addChild(entity); // Player配下に配置
    entity.inputSource = inputSource;
    this.controllers.push(entity);  // コントローラーの情報を登録
    inputSource.on("remove", () => {
      // コントローラー削除された際の処理を追加
      this.controllers.splice(this.controllers.indexOf(entity), 1);
      entity.destroy();
    });
  }

```

##### ⑤ コントローラーの左右の判定について

```
 if (inputSource.handedness === pc.XRHAND_LEFT) {
  
 } else if (inputSource.handedness === pc.XRHAND_RIGHT) {
 
 }
```

### UIの作成
![](https://i.imgur.com/UXKYHp5.jpg)
こちらのようなVRシーンへ入るためのUIを実装していきます。
PlayCanvasでcanvas外にUIを実装する場合については、HTML / CSSを記述する必要がありますが、今回は、[pcui](https://playcanvas.github.io/pcui/getting-started/)を利用して作成していきます。

##### pcuiについて
![](https://i.imgur.com/IQG5tQ7.png)

PlayCanvasでUIを作成するためには、PlayCanvasチームの作成しているUIライブラリです。
このライブラリを使用するとPlayCanvasでのUIの実装が比較的簡単に行うことが出来ます。
詳細については、ボタンやスライダーなどの実装が[Storybook](https://playcanvas.github.io/pcui/storybook/?path=/story/input-booleaninput--main)上で公開されています。


##### アセット（スクリプト）の読み込み順について
![](https://i.imgur.com/IQG5tQ7.png)

こちらのライブラリについては事前にインポートしています。このようにインポートしているライブラリについては、PlayCanvasのロード時に読み込まれるようになっているため、エディタでスクリプトを記述する際には、グローバルに展開されているpcuiのメソッドを利用します。

![](https://i.imgur.com/eGeEHW9.png)
また、事前に読み込まれているスクリプトの一覧については、エディタ左メニュー下部の「`SETTINGS`」 -> 「`SCRIPT LOADING ORDER`」から確認できます。


#### 1. Rootエンティティを選択
ヒエラルキーからRootを選択します。
![](https://i.imgur.com/nmzKLqQ.png)

#### 2. Add Scriptをクリック
SCRIPTコンポーネントが追加されているので、「ADD SCRIPT」をクリックします。
![](https://i.imgur.com/MEBspPs.png)

#### 3. ui.jsを追加
スクリプトの一覧が表示されますのでui.jsを選択してください。
![](https://i.imgur.com/boqocn5.png)

```javascript
class Ui extends pc.ScriptType {
  initialize() {
    this.setupMainContainer();
    this.setupMainPane();
    this.addInfoBox(
      "概要",
      `このハンズオンはPlayCanvasのチュートリアルとして公開されているプロジェクトを利用してWebXR Device APIで動作するVRを体験するハンズオンです。`
    );
    this.addEnterVrButton();
  }

  addEnterVrButton() {
    const { Button } = pcui;
    const button = new Button({
      text: "VRシーンに入る",
    });
    button.on("click", () => {
      this.app.fire("vr:enter");
    });
    this.pane.appendChild(button.dom);
  }

  addInfoBox(title, text) {
    const { InfoBox } = pcui;
    const infobox = new InfoBox({
      title,
      text,
    });
    this.pane.appendChild(infobox.dom);
  }

  setupMainContainer() {
    const { Container } = pcui;
    const container = new Container({
      grid: true,
    });
    container.style.position = "absolute";
    container.style.height = "100%";
    container.style.width = "100%";
    container.style.zIndex = 10;
    document.body.appendChild(container.dom);
    this.ui = container.dom;
  }

  setupMainPane() {
    const { Panel } = pcui;
    const panel = new Panel({
      flex: true,
      collapsible: true,
      collapsed: false,
      collapseHorizontally: true,
      removable: false,
      headerText: "PlayCanvasハンズオンについて",
    });
    const content = panel.content;
    panel.style.width = "285px";
    this.ui.appendChild(panel.dom);
    this.pane = content.dom;
  }
}

pc.registerScript(Ui, "ui");
```



##### ui.jsについて

##### ① ボタンを追加し、ボタンが押されたらイベントを発火
pcuiを利用してボタンを追加します。このボタンはクリックされた場合に`vr:enter`イベントを発火します。これにより、VRカメラの`enterVr`を実行します。
```javascript
  addEnterVrButton() {
    const { Button } = pcui;
    const button = new Button({
      text: "VRシーンに入る",
    });
    button.on("click", () => {
      this.app.fire("vr:enter");
    });
    this.pane.appendChild(button.dom);
  }
```
ボタンクリックで`vr-starter-kit.js`の`enterVr`実行されます。
```javascript
this.app.on(EVENT.VR_ENTER, this.enterVr, this); // EVENT.VR_ENTERのイベントが発火された場合 enterVr関数を実行
```
ui.jsの`addEnterVrButton`でボタンが押された時に、PlayCanvasで`vr:enter`イベントを発火するボタンを追加しています。UI上からクリックを取得することが出来たので次はこのUIのイベントが発火されたときの処理を記述していきます。


#### 4. 起動する
![](https://i.imgur.com/W45R9V7.jpg)

ui.jsを追加するとこのようにPlayCanvasのシーンを起動した後にUIが表示されるようになります。今回のプロジェクトについては設定はこれで完了です。ここまで出来たら実機で確認をしてみます。現在の`launch`から始まるUIについては、ログインしているユーザーのみ利用可能なためこのプロジェクトを共有する場合には`Publish`をする必要があります。


### 作ったプロジェクトを公開する。
プロジェクトを作成する事ができました。作ったプロジェクトをウェブ上で共有します。PlayCanvasでは作成したゲームをウェブ上ですぐに公開できます。

#### 1. PUBLISH/DOWNLOADをクリック

![](https://i.imgur.com/SqgsRf7.png)

#### 2. PUBLISH TO PLAYCANVASをクリック

![](https://i.imgur.com/0S4TSy2.png)

#### 3. PUBLISH NOWを選択

![](https://i.imgur.com/NTXqi5T.png)

#### 4. URLを共有しよう！
![](https://i.imgur.com/voefEGa.png)
PUBLISHが成功するとBUILDSに共有できるURLが生成されます。 こちらを共有することで他の方への共有などができます。

### 完成シーン
##### Androidスマートフォン
<img src="https://files.gitbook.com/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FKnjaQSvjY5s9wbax7YuD%2Fuploads%2F2yoMItfdTBTHzMaUo1Bq%2FScreenshot_20220217_134355_com.android.chrome.jpg?alt=media&token=cd654b04-4053-47de-bc94-4e59a5eb8d16" width="300" />
<img src="https://i.imgur.com/knqpy5Y.png" height="150" width="200" />

##### Questブラウザ
<img src="https://user-images.githubusercontent.com/39250588/155060817-ee20d1fc-ddc3-4373-bcb1-e237d1281333.gif" width="300" align="center" />
<img src="https://i.imgur.com/y6TMHLD.png" height="150" width="200" />

このような形でUIと組み合わせてWebXRのシーンを構築することが出来ました。
WebXRのシーン上ではPlayCanvasの2D Screen / 3D Screenのコンポーネント等を利用することが出来るのでVR上でのUIを作る事ができます。そちらにつきましては、別記事または追記予定です。

今回のプロジェクトで質問や意見がありましたら。[@mxcn3](https://twitter.com/mxcn3)まで連絡をお願いします。


その他関連
- [PlayCanvasタグの付いた記事一覧](https://qiita.com/tags/playcanvas)
</font>

### PlayCanvasのユーザー会のSlackを作りました！

 少しでも興味がありましたら、ユーザー同士で解決・PlayCanvasを推進するためのSlackを作りましたので、もしよろしければご参加ください！

- [日本PlayCanvasユーザー会 - Slack](https://join.slack.com/t/playcanvasjphq/shared_invite/zt-9aihkaep-TNA04tqgvYDFhBJABLLckw)