three.ar.jsを使ったスマホAR入門
2018/01/09
  • このエントリーをはてなブックマークに追加

残念ながらdemo2がiOSでうまく動いていないようです。おいおいなんとかしますが取り急ぎはAndroidを買ってください。

あけましておめでとうございます。株式会社カブクのAR/VR担当代理補佐のあんどうです。

さてAR。

AR用のAPIとしてAndroidではARCore、iOSではARKitが利用できるようになり、モバイル系開発者にとってARはぐっと身近なものになりました。しかしそれはあくまでネイティブアプリ開発者の場合。われわれウェブ系エンジニアはこのような状況を前に唇を噛むしかないのでしょうか?いいえ、そんなことはありません。私たちにはthree.ar.jsがあります。

three.ar.js


three.ar.jsを使用すればWebARonARCoreWebARonARKitを利用して、three.jsのインターフェースでARアプリを開発できます。唐突にWebARonARCoreとWebARonARKitが出てきましたが、これらはそれぞれAndroidとiOSのAR APIをJavaScript APIを通じて利用できる実験的なブラウザです。このWebARonAR(Core|Kit)上であればJavaScript(three.ar.js)を使用してARアプリケーションが開発できます。

three.ar.jsを試す


サンプルコードの話に進む前に、まずはthree.ar.jsに付属するサンプルアプリケーションを試してみましょう。ここではAnrdoidを使用する場合の手順についてのみ紹介しますが、iOSの場合も基本的な手順は(おそらく)同様です。心の中のジョブズと相談してよしなにやってください。

まず、開発に使用するAnroid端末でブラウザを開き、以下のリンクをクリックしてWebARonARCoreをインストールします。




インストールが完了したらWebARCoreを立ち上げます。動画撮影、メディアアクセスなどの許可が求められるので、全て「許可」してください。無事にブラウザが立ち上がるとサ
ンプル一覧が表示されます。




どのサンプルでも構いませんが、今回はGraffitiを試してみます。



画面をタッチしながらスマホを動かすと3D空間上にグラフィティが描かれるはずです。そのままグラフィティの周りを歩いてみると、まるで空中にイラストが浮かんでいるように見えるでしょう。

これでWebARonARCoreとthree.ar.jsの動作確認は完了です。それではこのthree.ar.jsを使って実際に何かARアプリケーションを作ってみましょう。

サンプルアプリ



みなさんはHoloLenz Gateというアプリをご存知でしょうか?百聞は一見にしかず、下の動画を見てください。


Twitterで見かけたときからずっとやってみたかったんですが、名前からわかるとおりHoloLenz専用。今回はこのHoloLenz Gate気分を少しでも味わえる庶民的サンプルアプリを作ります。最終的にはこうなります。


ボイラープレート


three.ar.jsのexamplesディレクトリにboilerplate.htmlというファイルがあります。three.ar.jsを使って新しくARアプリを開発する場合はこのファイルを元に進めるのがいいでしょう。

簡単にコードを説明します。

アプリケーション開始


THREE.ARUtils.getARDisplay().then(function (display) {
  if (display) {
    vrFrameData = new VRFrameData();
    vrDisplay = display;
    init();
  } else {
    THREE.ARUtils.displayUnsupportedMessage();
  }
});

  1. THREE.ARUtils.getARDisplayを使用してARアプリに使用できるディスプレイを取得します。内部的にはWebVR APIのnavigator.getVRDisplaysを使用してVRディスプレイを取得した後で、ディスプレイ名に'tango'または'arkit'が含まれていたらARディスプレイとみなすようです。

  2. ARディスプレイが取得できた場合はARアプリを実行可能だと判断し、各オブジェクトを準備した後でアプリの初期化関数init()を呼び出します。なお、ここでインスタンス化しているVRFrameDataは後ほど端末の位置や向きを取得する際に使用されます。

  3. ARディスプレイが取得できなかった場合(ARCode/ARKit非対応のブラウザで開いた場合)はTHREE.ARUtils.displayUnsupportedMessageで画面に警告を表示して終了します。

初期化


function init() {
  // デバッグ表示用パネル追加
  var arDebug = new THREE.ARDebug(vrDisplay);
  document.body.appendChild(arDebug.getElement());

  // three.jsの準備
  renderer = new THREE.WebGLRenderer({ alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.autoClear = false;
  canvas = renderer.domElement;
  document.body.appendChild(canvas);
  scene = new THREE.Scene();

  // 背景として使用するカメラ映像を準備
  arView = new THREE.ARView(vrDisplay, renderer);

  // 視界を設定
  camera = new THREE.ARPerspectiveCamera(
    vrDisplay,
    60,
    window.innerWidth / window.innerHeight,
    vrDisplay.depthNear,
    vrDisplay.depthFar
  );

  // 端末の位置・向きに応じて視界を設定するコントロールを準備
  vrControls = new THREE.VRControls(camera);

  // イベントハンドラ
  window.addEventListener('resize', onWindowResize, false);

  // 表示開始
  update();
}

  1. 最初に作成しているTHREE.ARDebugは端末の位置や向きをデバッグ表示するパネルを画面に追加するものです。自分で表示内容を追加することもできるようですが、そこまでしなくてもこれがあるだけで「なんか動いている」ことは確認できるので追加しておくことをお勧めします。

  2. three.jsの準備はいつもどおりですが、WebGLRendererのインスタンス化時に{alpha:true}を設定しておくことは忘れないようにしましょう。

  3. THREE.ARViewは後ほどカメラ映像を背景として表示するために使用します。

  4. THREE.ARPerspectiveCameraTHREE.PerspectiveCameraを継承して、VRディスプレイ用の機能を追加したものです。

  5. THREE.VRControlsは端末の位置・向きに合わせてAR空間内のカメラの位置と向きを設定するコントロールです。

  6. 初期化が完了するとupdate()を呼び出して、表示を開始します。

    1. 表示


      function update() {
        camera.updateProjectionMatrix();
        vrDisplay.getFrameData(vrFrameData);
      
        vrControls.update();
      
        arView.render();
        renderer.clearDepth();
        renderer.render(scene, camera);
      
        vrDisplay.requestAnimationFrame(update);
      }

      ARっぽい処理はこのメソッドに集約されています。

      1. ARPerspectiveCamera#updateProjectionMatrixは継承元であるPerspectiveCameraには存在しないメソッドで、VRディスプレイの状態に合わせて投影行列を更新するものだと思われます。私の環境では消しても問題はありませんでしたが、端末によっては何かあるかもしれないので残しておいたほうがいいでしょう。

      2. vrDisplay.getFrameData(vrFrameData)はVRディスプレイの状態(端末の位置や向き)をvrFrameDataオブジェクトに格納します。格納された情報は後でオブジェクトを3D空間に追加する際に使用します。

      3. vrControls.update()はAR空間内のカメラの位置や向きを端末の位置や向きと合わせます。

      4. arView.render()は背景にカメラ映像を表示します。

      5. renderer.clearDepth()はデプスバッファをクリアします。このメソッドを呼び出し忘れるとシーンが描画されずにカメラ映像だけが表示されて、途方に暮れることになるので注意が必要です。

      6. デプスバッファをクリアした後でrenderer.render(scene, camera)でシーンを描画します。

      7. vrDisplay.requestAnimationFrame(update)はWebVR APIで追加されたVRディスプレイ用のrequestAnimationFrameです。リフレッシュレートがVRディスプレイに合わせて調整されています。

      以上でthree.ar.jsの準備は終わりです。とりあえず表示を確認してみます。



      シーンに何も追加していないのでカメラ映像が表示されるだけですが、右上のARDebugパネルに数字が表示されていることから端末の位置と向きを正しく取得できていることが確認できます。

      ユーザー操作


      このままではARと言ってもなにも拡張されていないただの現実です。画面をタップすることで端末の位置にオブジェクトを追加できるようにします。

      イベントハンドラ設定


      var maskGeometry = new THREE.CircleGeometry(0.05, 32);
      var maskMaterial = new THREE.MeshBasicMaterial( { color: 0xffffff, side: THREE.DoubleSide } );
      hole = new THREE.Mesh( maskGeometry, maskMaterial );
      
      canvas.addEventListener('touchstart', onClick, false);

      1. init()メソッドの中でタッチイベントで追加されることになるオブジェクトの雛形、holeを作成します。今回は最終的にHoloLenz Gateのように穴が空いたように見せかけるためTHREE.CircleGeometryを使用します。

      2. init()メソッドの中で、WebGLRendererが作成したcanvasにタッチイベントハンドラonClickを追加します。

        1. イベントハンドラ


          function onClick () {
            // 端末の向きと位置を取得
            var pose = vrFrameData.pose;
            var ori = new THREE.Quaternion(
              pose.orientation[0],
              pose.orientation[1],
              pose.orientation[2],
              pose.orientation[3]
            );
            var pos = new THREE.Vector3(
              pose.position[0],
              pose.position[1],
              pose.position[2]
            );
          
            // 位置を視線の少し先に移動
            var dirMtx = new THREE.Matrix4();
            dirMtx.makeRotationFromQuaternion(ori);
            var push = new THREE.Vector3(0, 0, -1.0);
            push.transformDirection(dirMtx);
            pos.addScaledVector(push, 0.325);
          
            // オブジェクトをシーンに追加
            var clone = hole.clone();
            clone.position.copy(pos);
            clone.quaternion.copy(ori);
            scene.add(clone);
          }

          1. vrFrameDataには端末の位置と向きが保持されています。この位置と向きをそれぞれposoriとして取り出します。

          2. オブジェクトを端末の位置にそのまま追加すると場合によってはオブジェクトの内側に入ってしまい何も表示されないので、oriの情報を使用してposをすこし前方に移動します。

          3. 最後にhole.clone()でオブジェクトをクローンして、端末の位置(の少し前方)と向きに揃え、シーンに追加します。

          これで画面をタップすると円が表示されるようになりました。表示を確認してみます。



          無事に視線前方に白丸が表示されるようになりました。なお、白丸はタップした位置ではなく画面中央に表示される仕様なので注意してください。

          ここまでの結果は以下で確認できます。


          窓の外を見る


          three.ar.jsの説明としてはここまです。後は普通にthree.jsでがんばってこの白丸を宙に浮いた窓と置き換えます。基本的な方針は以下の通り。

          1. シーンにSkyboxを追加(このSkyboxが「窓の外の風景」になります)
          2. 白丸をカラーバッファではなくステンシルバッファに描画
          3. ステンシルバッファを有効にしてシーンをカラーバッファに描画

          ステンシルバッファは描画領域をクリッピングするためのバッファです。先ほどの白丸をステンシルバッファに描画してからシーンを描画することで、白丸の領域だけにSkyboxが表示されて宙に浮いた丸窓のように見えます。

          シーンにSkyboxを追加


          function init() {
            ...snip...
          
            scene.background = new THREE.CubeTextureLoader()
              .setPath( 'textures/cube/Park3Med/' )
              .load( [ 'px.jpg', 'nx.jpg', 'py.jpg', 'ny.jpg', 'pz.jpg', 'nz.jpg' ] );
            maskScene = new THREE.Scene();
          
            ...snip...
          }

          特に説明することはありません。init()関数内でTHREE.Sceneオブジェクトのbackgruondプロパティにキューブマップテクスチャを設定しています。なお、ここで使用しているキューブマップテクスチャはThree.jsの以下のexampleで使用されているものです。


          ステンシルバッファに使用するmaskSceneシーンもここで作成しておきます。

          白丸をステンシルバッファに設定してシーンを描画


          function onClick () {
            ...snip...
            //scene.add(clone);
            maskScene.add(clone);
          }

          白丸をsceneオブジェクトではなくmaskSceneオブジェクトに追加するように変更します。

          function init() {
            ...snip...
          
            renderer = new THREE.WebGLRenderer({ alpha: true });
            gl = renderer.context;
          
            ...snip...
          }

          ステンシルバッファを使用するにはglオブジェクトを生で使う必要があるのでレンダラ作成後に変数に保持しておきます。

          function update() {
            ...snip...
          
            renderer.clear();
            renderer.clearDepth();
            maskAndRender();
          
            vrDisplay.requestAnimationFrame(update);
          }
          
          function maskAndRender() {
            // 背景にカメラ映像を表示
            arView.render();
          
            // マスク作成
            gl.clearStencil(0);
            gl.clear(gl.STENCIL_BUFFER_BIT);
            gl.stencilFunc(gl.ALWAYS, 1, ~0);
            gl.stencilOp(gl.KEEP, gl.REPLACE, gl.REPLACE);
            gl.colorMask(false, false, false, false);
            gl.enable(gl.STENCIL_TEST);
            renderer.render(maskScene, camera);
          
            // マスクした領域にシーンを描画
            gl.stencilFunc(gl.EQUAL, 1, ~0);
            gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
            renderer.clearDepth();
            gl.colorMask(true, true, true, true);
            renderer.render(scene, camera);
          
            gl.disable(gl.STENCIL_TEST);
            gl.flush();
          }

          以下のサイトを参考にステンシルバッファを使用しました。


          マスクの描画にはrenderer.render(maskScene, camera)、シーンの描画にはrenderer.render(scene, camera)と、シーンを使い分けていることに注意してください。白丸はmaskSceneに描画されていて、背景のSkyboxはsceneに描画されています。

          不具合修正


          これでうまくいくはずだったんですが、カメラ映像が表示されるはずの部分が真っ白になってしまいます。glオブジェクトを直接使用しているので状態の管理がthree.jsと競合してうまく行っていないんでしょうか?



          設定を変えたりいろいろ頑張ってみましたがどうにもならなかったので諦めて次のように対応しました。

          function init() {
            vrRenderer = new THREE.WebGLRenderer({ alpha: true });
            vrRenderer.setPixelRatio(window.devicePixelRatio);
            vrRenderer.setSize(window.innerWidth, window.innerHeight);
            vrRenderer.autoClear = false;
            vrRenderer.setClearColor(0x000000, 0);
            document.body.appendChild(vrRenderer.domElement);
            arView = new THREE.ARView(vrDisplay, vrRenderer);
          
            ...snip...
          }

          canvasタグを二重にして、裏側のcanvasにカメラ映像を、表側のcanvasに窓を表示しています。

          若干の敗北感はありますが、これで期待通りの表示になりました。

          窓の外に手が届く


          最後に窓から外に出られるように、窓に一定以上近づいたら全面にSkyboxを表示します。

          function update() {
            camera.updateProjectionMatrix();
            vrDisplay.getFrameData(vrFrameData);
          
            // 端末の位置がいずれかの穴の近くにあるかどうかを確認
            var pose = vrFrameData.pose;
            var pos = new THREE.Vector3(
              pose.position[0],
              pose.position[1],
              pose.position[2]
            );
            maskScene.children.forEach(function(child) {
              if (child.position.distanceTo(pos) < 0.05) {
                inHole = true;
              }
            });
          
            vrControls.update();
          
            renderer.clear();
            renderer.clearDepth();
            if (inHole) {
              // 穴の近くにあればシーンをそのまま表示
              renderer.render(scene, camera);
            }
            else {
              maskAndRender();
            }
          
            vrDisplay.requestAnimationFrame(update);
          }

          以上で完成です。最終結果は以下で確認できます。


          ソースコードは以下で確認できます。



          まとめ


          思っていたより長くなったのでthree.ar.jsを使う上でこの辺だけ抑えておけばなんとかなるんじゃないかと思われる最低限をまとめます。

          1. THREE.ARViewを使うと背景にカメラ映像を設定できます。
          2. THREE.VRControlsを使うとシーン内のカメラの座標と向きを端末のそれと合わせることができます。
          3. AR空間内にオブジェクトを配置するには、VRFrameDataを使用して端末の現在の位置や向きを取得して使用します。

          とりあえずこのくらい踏まえておけば後は調べながらなんとかできるはずです。

          注意


          先に書くと記事を読んでもらえなくなりそうで、うっかりわざと書き忘れていたんですが、ARCoreを利用できるのは現在のところ以下の端末だけです。対応端末を持っていない場合はiOS(未確認)を使用するかHololenzを買って本家アプリを楽しんでください。

          • Google Pixel
          • Google Pixel XL
          • Google Pixel 2
          • Google Pixel 2 XL
          • Samsung Galaxy S8

          株式会社カブクではAR/VRというか3Dに興味のあるエンジニアを募集しています。