AR.jsでAR（拡張現実）アプリを作成

AR（Augmented Reality、拡張現実）とは、現実世界に仮想的な視覚情報を追加して、現実の環境を拡張する技術です。ARには「マーカー型」「GPS型」「空間認識型」「物体認識型」の種類がありますが、本稿では「マーカー型」ARを利用したアプリの開発について説明します。AR機能はAR.jsライブラリで実現されています。

# マーカー型のAR
マーカー型のARとは、ビジョンベースのARで、画像をトリガーとして使用します。ある画像を認識し、その画像の特徴点を一致させることで、ARコンテンツを自動的に表示することができます[[1]](https://www.japancv.co.jp/column/4190/)。よく使われるものは以下のHiroの画像です。このアプリでも、この「Hiro」画像を認識してARコンテンツを表示するようになっています。

![hiro.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921051/99b9462f-7aca-4daf-287d-b12c36d7990c.jpeg)

![IMG_3053.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921051/81fb6663-5646-b9e1-6446-d6ed5b1cce93.jpeg)
（本稿のアプリで表示されたAR像）

# AR.jsでマーカー型のAR機能を開発
今回は、JavascriptのライブラリであるAR.jsを使用して、ARを実現しました。AR.jsは、Web ARの開発に使用されるライブラリです。。このライブラリは@dsudoさんが[「AR.js の世界へようこそ！ ３歩でわかる お手軽 拡張現実」](https://qiita.com/dsudo/items/58718e9e3c870e6b92e6)で紹介されています。

# ディレクトリ構成

```
.
├── index.html
├── routes.js
├── server.js
├── public
│   ├── sound
│   ├── littlest_tokyo (AR model folder)
│   ├── shiba (AR model folder)
│   ├── horse (AR model folder)
│   └── elephant (AR model folder)
└── scripts
    ├── gesture-detector.js
    ├── gesture-handler.js
    └── soundhandler.js

```

# index.htmlに以下のスクリプトを貼り付けます

以下のスクリプトをindex.htmlのheadに埋め込むと、AR.jsライブラリを使うことができます。

```html
<!--> index.html <--->
<!doctype HTML>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
        <script src="https://kit.fontawesome.com/14b20d6d18.js" crossorigin="anonymous"></script>
        <script src="https://aframe.io/releases/1.4.0/aframe.min.js"></script>
        <script src="https://raw.githack.com/AR-js-org/AR.js/master/aframe/build/aframe-ar.js"></script>
        <script src="https://rawgit.com/donmccurdy/aframe-extras/master/dist/aframe-extras.loaders.min.js"></script>
        <script src="https://supereggbert.github.io/aframe-htmlembed-component/dist/build.js"></script>
        ...
        ...

```

# ARのコンテンツ
次に、index.htmlのbodyに以下のコードを入れます。



```html
<!--> index.html <--->
  ....
    <body>
      <a-scene 
        arjs
        embedded
        renderer="logarithmicDepthBuffer: true;"
        vr-mode-ui="enabled: false"
        id="scene"
      >
            <a-assets>
                <a-asset-item id="model" src="public/elephant/scene.gltf" ></a-asset-item>
            </a-assets>
            <a-marker preset="hiro" 
                raycaster="objects: .clickable"
                emitevents="true"
                cursor="fuse: false; rayOrigin: mouse;"
                id="markerA"
              >
                <a-entity 
                    position="0 0 0"
                    scale="1 1 1"
                    class="clickable"
                    gltf-model="#model"
                    animation-mixer
                    htmlembed
                    ></a-entity>
            </a-marker>

            <a-entity camera></a-entity>
        </a-scene>
    </body>

```


- [a-scene](https://aframe.io/docs/1.4.0/core/scene.html)：a-scene要素でシーン(ARのコンテンツ)が表示されます。グローバールなルートオブジェクトであり、すべてのエンティティはa-sceneの内に含まれます。
  - キャンバス、レンダラー、レンダーループの設定
  - デフォルトのカメラとライト
  - webvr-polyfill、VREffectの設定
  - WebVR APIを呼び出すEnter VRのUIの追加
  - WebXRシステムを介してWebXRデバイスを構成する
- [a-assets](https://aframe.io/docs/1.4.0/core/asset-management-system.html) 
   - パフォーマンス向上のためのアセット管理システム
   - アセットを一か所に配置する
   - アセットをプリロードおよびキャッシュできる 
- [a-marker](https://ar-js-org.github.io/AR.js-Docs/marker-based/) 
   - マーカーを認識し、ARのコンテンツを発動
   - マーカーの種類は「hiro」、「pattern」、「barcode」があります。preset="hiro"で、hiroのマーカーを認識することになります。
  
ここまででは、ARの映像を表示することができました。もし表示されない場合は、scaleの値が大きすぎる可能性があります。私自身はこの像を表示するために、scale="0.02 0.02 0.02"を使用しました。gltfファイルは[Sketchfab](https://sketchfab.com/features/gltf)でダウンロードできます。


# 他のARのコンテンツを追加
１つのコンテンツだけ表示できるのはちょっとつまらないので、複数のコンテンツを加えました。そのため、index.htmlを修正し、コンテンツを選択する機能を追加しました。

document.querySelector("a-entity").setAttribute()メソッドを使えば、 エンティティの属性値を変えることができます。ここでは、a-sceneコードの修正は必要ありません。


```html
<!--> index.html <--->

<head>
...
   <script>
    function changeModel(modelParam) {
        let modelName = modelParam.value.toLowerCase();
        console.log(modelName);
        console.log(document.querySelector("a-entity").getAttribute("gltf-model"))
        if (modelName==="elephant") {
            console.log("debug")
            document.querySelector("a-entity").setAttribute("gltf-model", "public/elephant/scene.gltf");
            document.querySelector("a-entity").setAttribute("scale", "0.02 0.02 0.02");
        }

        if (modelName==="shiba") {
            document.querySelector("a-entity").setAttribute("gltf-model", "public/shiba/scene.gltf");
            document.querySelector("a-entity").setAttribute("scale", "1 1 1");
        }

        if (modelName==="horse") {
            document.querySelector("a-entity").setAttribute("gltf-model", "public/horse/scene.gltf");
            document.querySelector("a-entity").setAttribute("scale", "1 1 1");
            
        }

        if (modelName==="tokyo") {
            document.querySelector("a-entity").setAttribute("gltf-model", "public/littlest_tokyo/scene.gltf");
            document.querySelector("a-entity").setAttribute("scale", "0.005 0.005 0.005");

        }
    };
   </script>

</head>
<body>
    <div class="option-list">
         <select onchange="changeModel(this);" class="model" id="rgb">
            <option id="shiba" value="shiba">Shiba</option>
            <option id="elephant" value="elephant">Elephant</option>
            <option id="horse" value="horse">Horse</option>
            <option id="tokyo" value="tokyo">Littlest Tokyo</option>
        </select>
    </div>
    <!--> <a-scene>コード <--->
    ....
</body>

```




以上のコードを入れると、表示するコンテンツを選択できます。

![Screen Shot 2023-04-28 at 11.36.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921051/507a4f70-e964-8d56-5ea7-e712db64e613.png)

# コンテンツをズームや回転
AR.jsの最近バージョンで、コンテンツをズームや回転するためのタッチジェスチャーを実現することができるようになりました。スマホで簡単にコンテンツを回転し、拡大・縮小できます。この機能を作るために、以下の２つのファイルを使用します。

- gesture-detector.js
```js
// gesture-detector.js
// Component that detects and emits events for touch gestures

AFRAME.registerComponent("gesture-detector", {
    schema: {
      element: { default: "" }
    },
  
    init: function() {
      this.targetElement =
        this.data.element && document.querySelector(this.data.element);
  
      if (!this.targetElement) {
        this.targetElement = this.el;
      }
  
      this.internalState = {
        previousState: null
      };
  
      this.emitGestureEvent = this.emitGestureEvent.bind(this);
  
      this.targetElement.addEventListener("touchstart", this.emitGestureEvent);
  
      this.targetElement.addEventListener("touchend", this.emitGestureEvent);
  
      this.targetElement.addEventListener("touchmove", this.emitGestureEvent);
    },
  
    remove: function() {
      this.targetElement.removeEventListener("touchstart", this.emitGestureEvent);
  
      this.targetElement.removeEventListener("touchend", this.emitGestureEvent);
  
      this.targetElement.removeEventListener("touchmove", this.emitGestureEvent);
    },
  
    emitGestureEvent(event) {
      const currentState = this.getTouchState(event);
  
      const previousState = this.internalState.previousState;
  
      const gestureContinues =
        previousState &&
        currentState &&
        currentState.touchCount == previousState.touchCount;
  
      const gestureEnded = previousState && !gestureContinues;
  
      const gestureStarted = currentState && !gestureContinues;
  
      if (gestureEnded) {
        const eventName =
          this.getEventPrefix(previousState.touchCount) + "fingerend";
  
        this.el.emit(eventName, previousState);
  
        this.internalState.previousState = null;
      }
  
      if (gestureStarted) {
        currentState.startTime = performance.now();
  
        currentState.startPosition = currentState.position;
  
        currentState.startSpread = currentState.spread;
  
        const eventName =
          this.getEventPrefix(currentState.touchCount) + "fingerstart";
  
        this.el.emit(eventName, currentState);
  
        this.internalState.previousState = currentState;
      }
  
      if (gestureContinues) {
        const eventDetail = {
          positionChange: {
            x: currentState.position.x - previousState.position.x,
  
            y: currentState.position.y - previousState.position.y
          }
        };
  
        if (currentState.spread) {
          eventDetail.spreadChange = currentState.spread - previousState.spread;
        }
  
        // Update state with new data
  
        Object.assign(previousState, currentState);
  
        // Add state data to event detail
  
        Object.assign(eventDetail, previousState);
  
        const eventName =
          this.getEventPrefix(currentState.touchCount) + "fingermove";
  
        this.el.emit(eventName, eventDetail);
      }
    },
  
    getTouchState: function(event) {
      if (event.touches.length === 0) {
        return null;
      }
  
      // Convert event.touches to an array so we can use reduce
  
      const touchList = [];
  
      for (let i = 0; i < event.touches.length; i++) {
        touchList.push(event.touches[i]);
      }
  
      const touchState = {
        touchCount: touchList.length
      };
  
      // Calculate center of all current touches
  
      const centerPositionRawX =
        touchList.reduce((sum, touch) => sum + touch.clientX, 0) /
        touchList.length;
  
      const centerPositionRawY =
        touchList.reduce((sum, touch) => sum + touch.clientY, 0) /
        touchList.length;
  
      touchState.positionRaw = { x: centerPositionRawX, y: centerPositionRawY };
  
      // Scale touch position and spread by average of window dimensions
  
      const screenScale = 2 / (window.innerWidth + window.innerHeight);
  
      touchState.position = {
        x: centerPositionRawX * screenScale,
        y: centerPositionRawY * screenScale
      };
  
      // Calculate average spread of touches from the center point
  
      if (touchList.length >= 2) {
        const spread =
          touchList.reduce((sum, touch) => {
            return (
              sum +
              Math.sqrt(
                Math.pow(centerPositionRawX - touch.clientX, 2) +
                  Math.pow(centerPositionRawY - touch.clientY, 2)
              )
            );
          }, 0) / touchList.length;
  
        touchState.spread = spread * screenScale;
      }
  
      return touchState;
    },
  
    getEventPrefix(touchCount) {
      const numberNames = ["one", "two", "three", "many"];
  
      return numberNames[Math.min(touchCount, 4) - 1];
    }
  });

```
- gesture-handler.js
```js
// gesture-handler.js
AFRAME.registerComponent("gesture-handler", {
    schema: {
      enabled: { default: true },
      rotationFactor: { default: 5 },
      minScale: { default: 0.3 },
      maxScale: { default: 8 },
    },
  
    init: function () {
      this.handleScale = this.handleScale.bind(this);
      this.handleRotation = this.handleRotation.bind(this);
  
      this.isVisible = false;
      this.initialScale = this.el.object3D.scale.clone();
      this.scaleFactor = 1;
  
      this.el.sceneEl.addEventListener("markerFound", (e) => {
        this.isVisible = true;
      });
  
      this.el.sceneEl.addEventListener("markerLost", (e) => {
        this.isVisible = false;
      });
    },
  
    update: function () {
      if (this.data.enabled) {
        this.el.sceneEl.addEventListener("onefingermove", this.handleRotation);
        this.el.sceneEl.addEventListener("twofingermove", this.handleScale);
      } else {
        this.el.sceneEl.removeEventListener("onefingermove", this.handleRotation);
        this.el.sceneEl.removeEventListener("twofingermove", this.handleScale);
      }
    },
  
    remove: function () {
      this.el.sceneEl.removeEventListener("onefingermove", this.handleRotation);
      this.el.sceneEl.removeEventListener("twofingermove", this.handleScale);
    },
  
    handleRotation: function (event) {
      if (this.isVisible) {
        this.el.object3D.rotation.y +=
          event.detail.positionChange.x * this.data.rotationFactor;
        this.el.object3D.rotation.x +=
          event.detail.positionChange.y * this.data.rotationFactor;
      }
    },
  
    handleScale: function (event) {
      if (this.isVisible) {
        this.scaleFactor *=
          1 + event.detail.spreadChange / event.detail.startSpread;
  
        this.scaleFactor = Math.min(
          Math.max(this.scaleFactor, this.data.minScale),
          this.data.maxScale
        );
  
        this.el.object3D.scale.x = this.scaleFactor * this.initialScale.x;
        this.el.object3D.scale.y = this.scaleFactor * this.initialScale.y;
        this.el.object3D.scale.z = this.scaleFactor * this.initialScale.z;
      }
    },
  });
```

以上の２つのファイルを作れたら、gesture-detectorをa-sceneに、gesture-handlerをa-entityに埋め込みます。

```html
<!--> index.html <--->
<head>
...
<script src="scripts/gesture-detector.js"></script>
<script src="scripts/gesture-handler.js"></script>
</head>
<body>
...
<a-scene 
    arjs
    embedded
    renderer="logarithmicDepthBuffer: true;"
    vr-mode-ui="enabled: false"
    gesture-detector
    id="scene"
>
...
<a-entity 
    position="0 0 0"
    scale="1 1 1"
    class="clickable"
    gesture-handler
    gltf-model="#model"
    animation-mixer
    htmlembed
></a-entity>
...
</a-scene>
</body>

```

# Soundを加える
像や馬や犬が出現するとき、吠える音を聴きたいですね。私は音を出す機能を追加します。以下のsoundhandler.jsを使います。

```js
// soundhandler.js

AFRAME.registerComponent('soundhandler', {
    tick: function () {
        var entity = document.querySelector('[sound]');
        if (document.querySelector('a-marker').object3D.visible === true) {
            console.log()
            if(document.getElementById('voice').style.display !== 'none'){
                entity.components.sound.playSound();
            }
        } else {
            entity.components.sound.pauseSound();
        }

    }
});
```

次に、index.htmlを以下のように修正します。
```html
<!--> index.html <--->

<head>
...
<script src="scripts/soundhandler.js"></script>
<script>
function changeModel(modelParam) {
    let modelName = modelParam.value.toLowerCase();
    console.log(modelName);
    console.log(document.querySelector("a-entity").getAttribute("gltf-model"))
    if (modelName==="elephant") {
        console.log("debug")
        document.querySelector("a-entity").setAttribute("gltf-model", "public/elephant/scene.gltf");
        document.querySelector("a-entity").setAttribute("scale", "0.02 0.02 0.02");
        document.querySelector("a-entity").setAttribute("sound", "src: public/sound/elephant.mp3");
    }

    if (modelName==="shiba") {
        document.querySelector("a-entity").setAttribute("gltf-model", "public/shiba/scene.gltf");
        document.querySelector("a-entity").setAttribute("scale", "1 1 1");
        document.querySelector("a-entity").setAttribute("sound", "src: public/sound/dog.wav");
    }

    if (modelName==="horse") {
        document.querySelector("a-entity").setAttribute("gltf-model", "public/horse/scene.gltf");
        document.querySelector("a-entity").setAttribute("scale", "1 1 1");
        document.querySelector("a-entity").setAttribute("sound", "src: public/sound/horse.wav");
        
    }

    if (modelName==="tokyo") {
        document.querySelector("a-entity").setAttribute("gltf-model", "public/littlest_tokyo/scene.gltf");
        document.querySelector("a-entity").setAttribute("scale", "0.005 0.005 0.005");
        document.querySelector("a-entity").setAttribute("sound", "src: public/sound/tokyo-metro.mp3");
        
    }
};

function muteSound() {
    var soundBtn = document.getElementById('voice');
    soundBtn.style.display = 'none';

    var muteBtn = document.getElementById('mute');
    muteBtn.style.display = 'block';

}

function turnSoundOn () {
    var soundBtn = document.getElementById('voice');
    soundBtn.style.display = 'block';

    var muteBtn = document.getElementById('mute');
    muteBtn.style.display = 'none';
}
        
</script>

</head>
<body>
    <div class="option-list">
        <div class="btn">
            <button id="voice" class="voice" onclick="muteSound()"><i class="fa-solid fa-volume-high" style="color: #c0c0c0;"></i></button>
            <button id="mute" class="mute-voice" onclick="turnSoundOn()"  style="display:none"><i class="fa-solid fa-volume-xmark" style="color: #ff2600;"></i></button>
         </div>
         <select onchange="changeModel(this);" class="model" id="rgb">
            <option id="shiba" value="shiba">Shiba</option>
            <option id="elephant" value="elephant">Elephant</option>
            <option id="horse" value="horse">Horse</option>
            <option id="tokyo" value="tokyo">Littlest Tokyo</option>
        </select>
    </div>
    <!--> <a-scene>コード <--->
    ....
     <a-entity 
        position="0 0 0"
        scale="1 1 1"
        class="clickable"
        gesture-handler
        gltf-model="#model"
        animation-mixer
        htmlembed
        sound="src: public/sound/dog.wav;"
        soundhandler
        ></a-entity>
    ....

</body>

```

# 成果
最後の成果はこのようになります！ 試したい方は[vr-dog.onrender.com](https://vr-dog.onrender.com)で使ってください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/wtW4uiA8sAk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

# ソースコード

https://github.com/btt-17/VR-dog


# 参考文献
[1] https://www.japancv.co.jp/column/4190/
[2][AR.js の世界へようこそ！ ３歩でわかる お手軽 拡張現実](https://qiita.com/dsudo/items/58718e9e3c870e6b92e6)
[3] https://ar-js-org.github.io/AR.js-Docs/