6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

AR.jsでAR(拡張現実)アプリを作成

Posted at

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

マーカー型のAR

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

hiro.jpg

IMG_3053.jpg
(本稿のアプリで表示されたAR像)

AR.jsでマーカー型のAR機能を開発

今回は、JavascriptのライブラリであるAR.jsを使用して、ARを実現しました。AR.jsは、Web ARの開発に使用されるライブラリです。。このライブラリは@dsudoさんが「AR.js の世界へようこそ! 3歩でわかる お手軽 拡張現実」で紹介されています。

ディレクトリ構成

.
├── 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ライブラリを使うことができます。

<!--> 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に以下のコードを入れます。

<!--> 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:a-scene要素でシーン(ARのコンテンツ)が表示されます。グローバールなルートオブジェクトであり、すべてのエンティティはa-sceneの内に含まれます。
    • キャンバス、レンダラー、レンダーループの設定
    • デフォルトのカメラとライト
    • webvr-polyfill、VREffectの設定
    • WebVR APIを呼び出すEnter VRのUIの追加
    • WebXRシステムを介してWebXRデバイスを構成する
  • a-assets
    • パフォーマンス向上のためのアセット管理システム
    • アセットを一か所に配置する
    • アセットをプリロードおよびキャッシュできる
  • a-marker
    • マーカーを認識し、ARのコンテンツを発動
    • マーカーの種類は「hiro」、「pattern」、「barcode」があります。preset="hiro"で、hiroのマーカーを認識することになります。

ここまででは、ARの映像を表示することができました。もし表示されない場合は、scaleの値が大きすぎる可能性があります。私自身はこの像を表示するために、scale="0.02 0.02 0.02"を使用しました。gltfファイルはSketchfabでダウンロードできます。

他のARのコンテンツを追加

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

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

<!--> 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

コンテンツをズームや回転

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

  • gesture-detector.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
// 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;
      }
    },
  });

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

<!--> 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を使います。

// 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を以下のように修正します。

<!--> 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で使ってください。

ソースコード

参考文献

[1] https://www.japancv.co.jp/column/4190/
[2]AR.js の世界へようこそ! 3歩でわかる お手軽 拡張現実
[3] https://ar-js-org.github.io/AR.js-Docs/

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?