『AR檸檬🍋』WebXRでこっそりレモン🍋を置く【クソアプリ】

(元ネタ) 『檸檬』

https://www.aozora.gr.jp/cards/000074/files/424_19826.html

>えたいの知れない不吉な塊が私の心を始終圧えつけていた。

憂鬱な気持ちを抱える主人公はレモン🍋の美しさに惹きつけられる。気を紛らわすようにレモン🍋を爆弾💣に見立て、丸善の書棚へ残し立ち去る...

## WebXRでこっそりレモン🍋を置く

日頃のストレスを放置してしまうと心身ともに悪影響です。
健やかに生き抜くためにこっそりレモン🍋を置いてストレス発散しましょう。

https://twitter.com/tenugui_taro/status/1549845672102629377

## 使用技術

- [WebXR](https://developer.mozilla.org/ja/docs/Web/API/WebXR_Device_API/Fundamentals)：専用のデバイスは必要とせずに、ブラウザで拡張現実（AR）や仮想現実(VR)を提供できる。
- [Three.js](https://threejs.org/)：HTML5で3Dコンテンツを制作できるJavascriptライブラリ。アプリケーションのレンダリング周りを管理する。

:::note info
[WebXR とは何であり、何でないのか](https://developer.mozilla.org/ja/docs/Web/API/WebXR_Device_API/Fundamentals#webxr_%E3%81%A8%E3%81%AF%E4%BD%95%E3%81%A7%E3%81%82%E3%82%8A%E3%80%81%E4%BD%95%E3%81%A7%E3%81%AA%E3%81%84%E3%81%AE%E3%81%8B)
> WebXR はレンダリング技術ではないので、三次元データを管理したり、ディスプレイにレンダリングしたりする機能は提供しません。これは重要な事実として覚えておいてください。 WebXR はシーンを描画する際に関連するタイミングやスケジューリング、さまざまな視点を管理しますが、モデルをロードして管理する方法や、レンダリングしてテクスチャを貼る方法などはわかりません。
:::


## Vite + Vue3 + Three.js 環境構築

#### [Vite](https://ja.vitejs.dev/)によるプロジェクト準備

個人的に慣れている[Vue.js](https://v3.ja.vuejs.org/)を選んでいますがお好みでどうぞ。
ビルドツールには[create-vue](https://github.com/vuejs/create-vue)を使用しています。

```
npm init vue@3 # オプションが表示されるので好きに設定する。
```

#### Three.jsのインストール

```
npm install three
npm install --save-dev @types/three # TypeScriptでは型もインストール
```

適当なコンポーネントを作成し動作確認しておきます。

![スクリーンショット 2022-07-16 18.56.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/6dc0d480-4a3e-d368-5fd0-d513a0f7ab5e.png)

```vue:ThreejsTest.vue
<script setup lang="ts">
import * as THREE from "three";

/* SCENE,CAMERA */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

/* RENDERER */
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

/* 立方体生成 */
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

camera.position.z = 5;

/* フレームごとに実行されるアニメーション */
function animate() {
  requestAnimationFrame(animate);

  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;

  renderer.render(scene, camera);
}

animate();
</script>

<template>
  <h1>Threejs Test</h1>
</template>
```

`Scene`や立方体生成については簡単ですが解説を書いているのでよければ参照ください。

https://qiita.com/tenugui_taro/items/0d6625b2d6f1963b89ab#scenecamerarenderer%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B

## アプリケーションからWebXRがサポートされているか調べる

現時点で`WebXR Device API`の対応状況は下記の通り。

https://caniuse.com/?search=webxr

![スクリーンショット 2022-07-16 21.11.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/ecf35625-b420-54e9-f01c-b2d462381aa3.png)

また、使用するデバイスでサポートされているかは下記サイトにアクセスすると確認できます。

https://immersive-web.github.io/webxr-samples/

![スクリーンショット 2022-07-16 21.45.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/c33bf3bf-5706-88d7-b552-75445e08761a.png)

アプリケーションでも`WebXR Device API`や`immersive-ar`がサポートされているか調べる必要がありますが、Three.jsで提供されている`ARButton`を使用すると簡単に実装できます。
`ARButton`がデバイスのWebXRサポート状況を表示してくれます。

![スクリーンショット 2022-07-17 21.30.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/b1d74311-4d36-4c87-b172-0857827bf7b0.png)

```typescript
import { ARButton } from "three/examples/jsm/webxr/ARButton.js";

/* ARButton */
document.body.appendChild(
  ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
);
```

## 平面検出

ARコンテンツにおいてユーザーエクスペリエンスを向上させるには、現実空間との融合が重要です。
現実空間の平面を検出し配置することで、実際にレモンが存在するように感じさせることができます。

Three.jsで提供されているサンプルを参考に平面検出を実装してみます。

https://threejs.org/examples/?q=hittest#webxr_ar_hittest

#### WebXR Device APIを有効にする

[WebGLRenderer](https://threejs.org/docs/#api/en/renderers/WebGLRenderer)を利用して、`WebXR Device API`を使えるようにします。
また、デバイスのWebXRサポート状況を表示する`ARButton`も追加しておきます。

```typescript
/* RENDERER */
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true; // WebXR Device APIを有効化

/* ARButton */
document.body.appendChild(
  ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
);
```

#### 平面上にマーカーを表示する

サンプルでは、[RingGeometry](https://threejs.org/docs/#api/en/geometries/RingGeometry)をマーカーとして利用しています。
また、毎フレームごとに[XRHitTestResult](https://developer.mozilla.org/en-US/docs/Web/API/XRHitTestResult)からマーカーの位置を変更しています。

![スクリーンショット 2022-07-17 21.54.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/88fc3092-2990-1071-b8a3-62b2f1100b13.png)


```typescript
function render(timestamp: number, frame: XRFrame) {
    if (frame) {

      // ...

      if (hitTestSource) {
        const hitTestResults = frame.getHitTestResults(hitTestSource);

        if (hitTestResults.length) {
          /* XRHitTestResultがあれば、マーカーの位置を変更 */
          const hit = hitTestResults[0];

          reticle.visible = true;
          reticle.matrix.fromArray(
            hit.getPose(referenceSpace)!.transform.matrix
          );
        } else {
          /* XRHitTestResultがなければ、マーカーを非表示に */
          reticle.visible = false;
        }
      }
    }

    // ...

  }
```

下記は平面検出サンプルを適当にコンポーネント化したものです。

```vue:HitTest.vue
<script setup lang="ts">
import * as THREE from "three";
import { ARButton } from "three/examples/jsm/webxr/ARButton.js";
import { onMounted } from "vue";

onMounted(() => {
  init();
});

const init = () => {
  let hitTestSource: XRHitTestSource | null = null;
  let hitTestSourceRequested = false;

  /* Container */
  const container = document.getElementById("three-container");
  if (!container) {
    console.log("sorry cannot get three-container");
    return;
  }

  /* SCENE,CAMERA */
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    70,
    window.innerWidth / window.innerHeight,
    0.01,
    20
  );

  /* Light */
  const light = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 1);
  light.position.set(0.5, 1, 0.25);
  scene.add(light);

  /* RENDERER */
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.xr.enabled = true;
  container.appendChild(renderer.domElement);

  /* ARButton */
  document.body.appendChild(
    ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
  );

  /* Geometry */
  const reticle = new THREE.Mesh(
    new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2),
    new THREE.MeshBasicMaterial()
  );
  reticle.matrixAutoUpdate = false;
  reticle.visible = false;
  scene.add(reticle);

  const geometry = new THREE.CylinderGeometry(0.1, 0.1, 0.2, 32).translate(
    0,
    0.1,
    0
  );

  /* Controller */
  const controller = renderer.xr.getController(0);
  controller.addEventListener("select", onSelect);
  scene.add(controller);

  function onSelect() {
    if (reticle.visible) {
      const material = new THREE.MeshPhongMaterial({
        color: 0xffffff * Math.random(),
      });
      const mesh = new THREE.Mesh(geometry, material);
      reticle.matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
      mesh.scale.y = Math.random() * 2 + 1;
      scene.add(mesh);
    }
  }

  /* ウィンドウリサイズ対応 */
  window.addEventListener("resize", onWindowResize);
  function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize(window.innerWidth, window.innerHeight);
  }

  // フレームごとに実行されるアニメーション
  animate();

  function animate() {
    renderer.setAnimationLoop(render);
  }

  function render(timestamp: number, frame: XRFrame) {
    if (frame) {
      const referenceSpace = renderer.xr.getReferenceSpace();
      if (!referenceSpace) {
        console.log("sorry cannot get renderer referenceSpace");
        return;
      }

      const session = renderer.xr.getSession();
      if (!session) {
        console.log("sorry cannot get renderer session");
        return;
      }

      if (hitTestSourceRequested === false) {
        session.requestReferenceSpace("viewer").then((referenceSpace) => {
          session.requestHitTestSource!({ space: referenceSpace })!.then(
            function (source) {
              hitTestSource = source;
            }
          );
        });

        session.addEventListener("end", function () {
          hitTestSourceRequested = false;
          hitTestSource = null;
        });

        hitTestSourceRequested = true;
      }

      if (hitTestSource) {
        const hitTestResults = frame.getHitTestResults(hitTestSource);

        if (hitTestResults.length) {
          const hit = hitTestResults[0];

          reticle.visible = true;
          reticle.matrix.fromArray(
            hit.getPose(referenceSpace)!.transform.matrix
          );
        } else {
          reticle.visible = false;
        }
      }
    }

    renderer.render(scene, camera);
  }
};
</script>

<template>
  <header class="header">
    <div class="header__inner">
      <h1 class="header__title">Hit Test</h1>
    </div>
  </header>
  <div id="three-container"></div>
</template>

<style scoped>
.header {
  padding: 20px;
  height: 80px;
}

.header__inner {
  max-width: 1230px;
  padding-right: 15px;
  padding-left: 15px;
  margin-right: auto;
  margin-left: auto;
}

.header__title {
  font-size: 32px;
}
</style>
```

## 3Dモデルの読み込みと配置

Three.jsでの3Dモデル読み込みには、モデルの拡張子ごとにローダーを選択して実装します。
[three.js provides many loaders](https://github.com/mrdoob/three.js/tree/dev/examples/jsm/loaders)

今回は`.glb`モデルを使用するので、[GLTFLoader](https://threejs.org/docs/index.html?q=GL#examples/en/loaders/GLTFLoader)を使用します。

```typescript
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

function loadModel(){
  const loader = new GLTFLoader().setPath("./assets/");

  /* モデルのロード */
  loader.load('model.glb', function (gltf) {
    const mesh = gltf.scene.children[0];

    /* モデルのサイズや位置調整 */
    const s = 0.35;
    mesh.scale.set(s, s, s);
    mesh.position.y = 15;
    mesh.rotation.y = - 1;

    mesh.castShadow = true;
    mesh.receiveShadow = true;

    /* Sceneへ追加 */
    scene.add(mesh);
  });
}
```

3Dモデルの読み込みができたので、検出された平面上に画面タップで配置するようにします。
`renderer.xr.getController(0)`を取得することで画面タップ時の処理を登録できます。

[WebXRManager.getController](https://threejs.org/docs/#api/en/renderers/webxr/WebXRManager.getController)

```typescript
/* Controller */
const controller = renderer.xr.getController(0);
controller.addEventListener("select", onSelect);
scene.add(controller);

// 画面タップ時のイベントハンドラー
function onSelect() {
  if (reticle.visible) {
      model.visible = true;
      // マーカーの位置にモデルを配置
      model.position.setFromMatrixPosition(reticle.matrix);
    }
  }
}
```

下記は3Dモデルの読み込みと配置を適当にコンポーネント化したものです。

```vue:ModelTest.vue
<script setup lang="ts">
import * as THREE from "three";
import { ARButton } from "three/examples/jsm/webxr/ARButton.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { onMounted } from "vue";

onMounted(() => {
  init();
});

const init = () => {
  let hitTestSource: XRHitTestSource | null = null;
  let hitTestSourceRequested = false;
  let model: THREE.Object3D<THREE.Event> | null = null;

  /* Container */
  const container = document.getElementById("three-container");
  if (!container) {
    console.log("sorry cannot get three-container");
    return;
  }

  /* Scene */
  const scene = new THREE.Scene();

  /* Camera */
  const camera = new THREE.PerspectiveCamera(
    30,
    window.innerWidth / window.innerHeight,
    1,
    5000
  );
  camera.position.set(0, 0, 250);

  /* Light */
  const light = new THREE.DirectionalLight();
  light.position.set(0.2, 1, 1);
  scene.add(light);

  /* Renderer */
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.xr.enabled = true;
  container.appendChild(renderer.domElement);

  /* ARButton */
  document.body.appendChild(
    ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
  );

  /* Reticle */
  const reticle = new THREE.Mesh(
    new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2),
    new THREE.MeshBasicMaterial()
  );
  reticle.matrixAutoUpdate = false;
  reticle.visible = false;
  scene.add(reticle);

  /* Model */
  const loader = new GLTFLoader().setPath("./assets/");
  loader.load("model.glb", function (gltf) {
    const mesh = gltf.scene.children[0];

    /* モデルのサイズや位置調整 */
    const s = 0.03;
    mesh.scale.set(s, s, s);
    mesh.position.y = 15;
    mesh.rotation.y = -1;

    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.visible = false;

    model = mesh;

    scene.add(model);
  });

  /* Controller */
  const controller = renderer.xr.getController(0);
  controller.addEventListener("select", onSelect);
  scene.add(controller);

  function onSelect() {
    if (model && reticle.visible) {
      if (model.visible) {
        model.position.setFromMatrixPosition(reticle.matrix);
      } else {
        model.visible = true;
        model.position.setFromMatrixPosition(reticle.matrix);
      }
    }
  }

  /* ウィンドウリサイズ対応 */
  window.addEventListener("resize", onWindowResize);
  function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize(window.innerWidth, window.innerHeight);
  }

  // フレームごとに実行されるアニメーション
  animate();

  function animate() {
    renderer.setAnimationLoop(render);
  }

  function render(timestamp: number, frame: XRFrame) {
    if (frame) {
      const referenceSpace = renderer.xr.getReferenceSpace();
      if (!referenceSpace) {
        console.log("sorry cannot get renderer referenceSpace");
        return;
      }

      const session = renderer.xr.getSession();
      if (!session) {
        console.log("sorry cannot get renderer session");
        return;
      }

      if (hitTestSourceRequested === false) {
        session.requestReferenceSpace("viewer").then((referenceSpace) => {
          session.requestHitTestSource!({ space: referenceSpace })!.then(
            function (source) {
              hitTestSource = source;
            }
          );
        });

        session.addEventListener("end", function () {
          hitTestSourceRequested = false;
          hitTestSource = null;
        });

        hitTestSourceRequested = true;
      }

      if (hitTestSource) {
        const hitTestResults = frame.getHitTestResults(hitTestSource);

        if (hitTestResults.length) {
          const hit = hitTestResults[0];

          reticle.visible = true;
          reticle.matrix.fromArray(
            hit.getPose(referenceSpace)!.transform.matrix
          );
        } else {
          reticle.visible = false;
        }
      }
    }

    renderer.render(scene, camera);
  }
};
</script>

<template>
  <header class="header">
    <div class="header__inner">
      <h1 class="header__title">Hit Test</h1>
    </div>
  </header>
  <div id="three-container"></div>
</template>

<style scoped>
.header {
  padding: 20px;
  height: 80px;
}

.header__inner {
  max-width: 1230px;
  padding-right: 15px;
  padding-left: 15px;
  margin-right: auto;
  margin-left: auto;
}

.header__title {
  font-size: 32px;
}
</style>
```

## おわり

WebXRを利用して平面検出と3Dモデル配置を試してみました。
専用デバイスを必要としないため様々なユーザーに提供することができる...といいですが各ブラウザのWebXRサポートを待つ状況です。

## 参考

- [#WebXR ( WebVR/WebAR ) の現状確認 2021 Winter](https://zenn.dev/ikkou/articles/c2832ccbe6a078)
- [Learn to create WebXR, VR and AR, experiences using Three.JS](https://www.udemy.com/course/learn-webxr/)