AR.jsとthree.jsでマーカーをタップできるようにする

AR.jsとthree.jsを使ったMarker Based ARでマーカーをタップできるようにしてみます。

作成したサンプルはマーカーを認識すると、「Tap Marker!」という文字が表示されます。映像からはわからないですが、マーカーをタップするとタップ位置にランダムな高さのキューブを作成します。マーカー外をタップした場合は何も起きません。
![arjs-threejs-tap-marker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/00594484-c6d5-916b-ae05-346a0c5c169e.gif)

コード全文は以下のようになっています。AR.jsをthree.jsで使用する基本的な方法は過去に[記事](https://qiita.com/aa_debdeb/items/4edf6a2e053e02305ef5)を書いたので、そちらを参照してください。このサンプルを実行するには`data`というディレクトリを作成して、その中に[camera_para.dat](https://github.com/AR-js-org/AR.js/blob/3.1.0/data/data/camera_para.dat)、[patt.hiro](https://github.com/AR-js-org/AR.js/blob/3.1.0/data/data/patt.hiro)、[helvetiker_bold.typeface.json](https://github.com/mrdoob/three.js/blob/dev/examples/fonts/helvetiker_bold.typeface.json)を配置する必要があります。


```html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
    <title>Tappable Marker with AR.js and Three.js</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/110/three.js"></script>
    <script src="https://raw.githack.com/AR-js-org/AR.js/3.1.0/three.js/build/ar.js"></script>
  </head>
  <body style='margin: 0px; overflow: hidden;'>
    <script>
      const renderer = new THREE.WebGLRenderer({
        antialias: true,
        alpha: true
      });
      renderer.setClearColor(new THREE.Color(), 0);
      renderer.setSize(640, 480);
      renderer.domElement.style.position = 'absolute';
      renderer.domElement.style.top = '0px';
      renderer.domElement.style.left = '0px';
      document.body.appendChild(renderer.domElement);

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera();
      scene.add(camera);

      const arToolkitSource = new THREEx.ArToolkitSource({
        sourceType: 'webcam'
      });

      arToolkitSource.init(() => {
        setTimeout(() => {
          onResize();
        }, 2000);
      });

      addEventListener('resize', () => {
        onResize();
      });

      function onResize() {
        arToolkitSource.onResizeElement();
        arToolkitSource.copyElementSizeTo(renderer.domElement);
        if (arToolkitContext.arController !== null) {
          arToolkitSource.copyElementSizeTo(arToolkitContext.arController.canvas);
        }
      };

      const arToolkitContext = new THREEx.ArToolkitContext({
        cameraParametersUrl: 'data/camera_para.dat',
        detectionMode: 'mono'
      });

      arToolkitContext.init(() => {
        camera.projectionMatrix.copy(arToolkitContext.getProjectionMatrix());
      });

      const marker = new THREE.Group();
      scene.add(marker);

      const arMarkerControls = new THREEx.ArMarkerControls(arToolkitContext, marker, {
        type: 'pattern',
        patternUrl: 'data/patt.hiro',
        changeMatrixMode: 'modelViewMatrix',
      });

      const markerPlane = new THREE.Mesh(
        new THREE.PlaneBufferGeometry(1, 1),
        new THREE.MeshBasicMaterial({
          colorWrite: false,
          depthWrite: false,
        })
      );
      markerPlane.rotation.x = -0.5 * Math.PI;
      marker.add(markerPlane);

      const raycaster = new THREE.Raycaster();
      renderer.domElement.addEventListener('click', (event) => {
        const element = event.currentTarget;
        const x = event.clientX - element.offsetLeft;
        const y = event.clientY - element.offsetTop;
        const w = element.offsetWidth;
        const h = element.offsetHeight;
        const mouse = new THREE.Vector2((x / w) * 2 - 1, -(y / h) * 2 + 1);
        raycaster.setFromCamera(mouse, camera);

        const intersects = raycaster.intersectObject(markerPlane);
        if (intersects.length !== 0) {
          const intersect = intersects[0];
          const height = 0.1 + Math.random() * 0.4;
          const cube = new THREE.Mesh(
            new THREE.BoxBufferGeometry(0.15, height, 0.15),
            new THREE.MeshNormalMaterial()
          );
          cube.position.copy(marker.worldToLocal(intersect.point));
          cube.position.y += 0.5 * height;
          marker.add(cube);
        }
      });

      const loader = new THREE.FontLoader();
      loader.load('data/helvetiker_bold.typeface.json', (font) => {
        const textGeom = new THREE.TextBufferGeometry('Tap Marker!', {
            font: font,
            size: 0.2,
            height: 0.04,
          });
        textGeom.center();
        const text = new THREE.Mesh(
          textGeom,
          new THREE.MeshNormalMaterial()
        );
        text.position.set(0, 0.75, 0);
        marker.add(text);
      });

      requestAnimationFrame(function animate(){
        requestAnimationFrame(animate);
        if (arToolkitSource.ready) {
          arToolkitContext.update(arToolkitSource.domElement);
        }
        renderer.render(scene, camera);
      });
    </script>
  </body>
</html>
```

---

サンプルコードについて、マーカーのタップに関連する箇所を説明していきます。基本的な考え方としてはマーカーと同じ位置・大きさに見えない平面を作成して、その平面とタップ位置との交差判定を[THREE.Raycaster](https://threejs.org/docs/#api/en/core/Raycaster)で行うという流れになります。

まずカメラです。AR.jsではカメラの射影行列をAR.js側が設定するため基底クラスの`THREE.Camera`を使用すればいいのですが、`THREE.Raycaster`のサポートチェックを回避するために`THREE.PerspectiveCamera`を使用します。

```js
const camera = new THREE.PerspectiveCamera();
```

マーカーと同じ位置・大きさに見えない平面を追加します。`colorWrite`と`depthWrite`を`false`にすることで色も深度も描画されなくなります。

```js
const markerPlane = new THREE.Mesh(
  new THREE.PlaneBufferGeometry(1, 1),
  new THREE.MeshBasicMaterial({
    colorWrite: false,
    depthWrite: false,
  })
);
markerPlane.rotation.x = -0.5 * Math.PI;
marker.add(markerPlane);
```

`THREE.Raycaster`を生成して、canvasがタップされたら先程作成した平面とタップ位置の交差判定を行います。交差している場合にはキューブを追加します。`intersect.point`には交差位置がワールド座標系で含まれているので、ローカル座標系に変換してから`marker`に追加しています。`THREE.Raycaster`を使った交差判定については以下の記事を参考にしました。
[Three\.jsでオブジェクトとの交差を調べる \- ICS MEDIA](https://ics.media/tutorial-three/raycast/)

```js
const raycaster = new THREE.Raycaster();
renderer.domElement.addEventListener('click', (event) => {
  const element = event.currentTarget;
  const x = event.clientX - element.offsetLeft;
  const y = event.clientY - element.offsetTop;
  const w = element.offsetWidth;
  const h = element.offsetHeight;
  const mouse = new THREE.Vector2((x / w) * 2 - 1, -(y / h) * 2 + 1);
  raycaster.setFromCamera(mouse, camera);

  const intersects = raycaster.intersectObject(markerPlane);
  if (intersects.length !== 0) {
    const intersect = intersects[0];
    const height = 0.1 + Math.random() * 0.4;
    const cube = new THREE.Mesh(
      new THREE.BoxBufferGeometry(0.15, height, 0.15),
      new THREE.MeshNormalMaterial()
    );
    cube.position.copy(marker.worldToLocal(intersect.point));
    cube.position.y += 0.5 * height;
    marker.add(cube);
  }
});
```