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()を呼び出して、表示を開始します。

表示

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を追加します。

イベントハンドラ

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に興味のあるエンジニアを募集しています。

その他の記事

Other Articles


TypeScript で “radian” と “degree” を間違えないようにする

2019/02/05
Python3でGoogle Cloud ML Engineをローカルで動作する方法

2019/01/18
SIGGRAPH Asia 2018 参加レポート

2019/01/08
お正月だョ!ECMAScript Proposal全員集合!!

2019/01/08
カブクエンジニア開発合宿に行ってきました 2018秋

2018/12/25
OpenAPI 3 ファーストな Web アプリケーション開発(環境編)

2018/12/23
いまMLKitカスタムモデル(TF Lite)は使えるのか

2018/12/21
[IoT] Docker on JetsonでMQTTを使ってCloud IoT Coreと通信する

2018/12/11
TypeScriptで実現する型安全な多言語対応(Angularを例に)

2018/12/05
GASでCompute Engineの時間に応じた自動停止/起動ツールを作成する 〜GASで簡単に好きなGoogle APIを叩く方法〜

2018/12/02
single quotes な Black を vendoring して packaging

2018/11/14
3次元データに2次元データの深層学習の技術(Inception V3, ResNet)を適用

2018/11/04
Node Knockout 2018 に参戦しました

2018/10/24
SIGGRAPH 2018参加レポート-後編(VR/AR)

2018/10/11
Angular 4アプリケーションをAngular 6に移行する

2018/10/05
SIGGRAPH 2018参加レポート-特別編(VR@50)

2018/10/03
Three.jsでVRしたい

2018/10/02
SIGGRAPH 2018参加レポート-前編

2018/09/27
ズーム可能なSVGを実装する方法の解説

2018/09/25
Kerasを用いた複数入力モデル精度向上のためのTips

2018/09/21
競技プログラミングの勉強会を開催している話

2018/09/19
Ladder Netwoksによる半教師あり学習

2018/08/10
「Maker Faire Tokyo 2018」に出展しました

2018/08/02
Kerasを用いた複数時系列データを1つの深層学習モデルで学習させる方法

2018/07/26
Apollo GraphQLでWebサービスを開発してわかったこと

2018/07/19
【深層学習】時系列データに対する1次元畳み込み層の出力を可視化

2018/07/11
きたない requirements.txt から Pipenv への移行

2018/06/26
CSS Houdiniを味見する

2018/06/25
不確実性を考慮した時系列データ予測

2018/06/20
Google Colaboratory を自分のマシンで走らせる

2018/06/18
Go言語でWebAssembly

2018/06/15
カブクエンジニア開発合宿に行ってきました 2018春

2018/06/08
2018 年の tree shaking

2018/06/07
隠れマルコフモデル 入門

2018/05/30
DASKによる探索的データ分析(EDA)

2018/05/10
TensorFlowをソースからビルドする方法とその効果

2018/04/23
EGLとOpenGLを使用するコードのビルド方法〜libGLからlibOpenGLへ

2018/04/23
技術書典4にサークル参加してきました

2018/04/13
Python で Cura をバッチ実行するためには

2018/04/04
ARCoreで3Dプリント風エフェクトを実現する〜呪文による積層造形映像制作の舞台裏〜

2018/04/02
深層学習を用いた時系列データにおける異常検知

2018/04/01
音声ユーザーインターフェースを用いた新方式積層造形装置の提案

2018/03/31
Container builderでコンテナイメージをBuildしてSlackで結果を受け取る開発スタイルが捗る

2018/03/23
ngUpgrade を使って AngularJS から Angular に移行

2018/03/14
Three.jsのパフォーマンスTips

2018/02/14
C++17の新機能を試す〜その1「3次元版hypot」

2018/01/17
時系列データにおける異常検知

2018/01/11
異常検知の基礎

2017/12/17
Python OpenAPIライブラリ bravado-core の発展的な使い方

2017/12/15
WebAssembly(wat)を手書きする

2017/12/14
AngularJS を Angular に移行: ng-annotate 相当の機能を TypeScrpt ファイルに適用

2017/12/08
Android Thingsで4足ロボットを作る ~ Android ThingsとPCA9685でサーボ制御)

2017/12/06
Raspberry PIとDialogflow & Google Cloud Platformを利用した、3Dプリンターボット(仮)の開発 (概要編)

2017/11/20
カブクエンジニア開発合宿に行ってきました 2017秋

2017/10/19
Android Thingsを使って3Dプリント戦車を作ろう ① ハードウェア準備編

2017/10/13
第2回 魁!! GPUクラスタ on GKE ~PodからGPUを使う編~

2017/10/05
第1回 魁!! GPUクラスタ on GKE ~GPUクラスタ構築編~

2017/09/13
「Maker Faire Tokyo 2017」に出展しました。

2017/09/11
PyConJP2017に参加しました

2017/09/08
bravado-coreによるOpenAPIを利用したPythonアプリケーション開発

2017/08/23
OpenAPIのご紹介

2017/08/18
EuroPython2017で2名登壇しました。

2017/07/26
3DプリンターでLチカ

2017/07/03
Three.js r86で何が変わったのか

2017/06/21
3次元データへの深層学習の適用

2017/06/01
カブクエンジニア開発合宿に行ってきました 2017春

2017/05/08
Three.js r85で何が変わったのか

2017/04/10
GCPのGPUインスタンスでレンダリングを高速化

2017/02/07
Three.js r84で何が変わったのか

2017/01/27
Google App EngineのFlexible EnvironmentにTmpfsを導入する

2016/12/21
Three.js r83で何が変わったのか

2016/12/02
Three.jsでのクリッピング平面の利用

2016/11/08
Three.js r82で何が変わったのか

2016/12/17
SIGGRAPH 2016 レポート

2016/11/02
カブクエンジニア開発合宿に行ってきました 2016秋

2016/10/28
PyConJP2016 行きました

2016/10/17
EuroPython2016で登壇しました

2016/10/13
Angular 2.0.0ファイナルへのアップグレード

2016/10/04
Three.js r81で何が変わったのか

2016/09/14
カブクのエンジニアインターンシッププログラムについての詩

2016/09/05
カブクのエンジニアインターンとして3ヶ月でやった事 〜高橋知成の場合〜

2016/08/30
Three.js r80で何が変わったのか

2016/07/15
Three.js r79で何が変わったのか

2016/06/02
Vulkanを試してみた

2016/05/20
MakerGoの作り方

2016/05/08
TensorFlow on DockerでGPUを使えるようにする方法

2016/04/27
Blenderの3DデータをMinecraftに送りこむ

2016/04/20
Tensorflowを使ったDeep LearningにおけるGPU性能調査

→
←

関連職種

Recruit

サーバーサイドエンジニア(Python/Go)

業務内容

カブク自社で開発・運営しているWebサービス(3Dプリンターなどを活用したデジタル製造サービス)のサーバサイド開発。WebサービスのバックエンドやAPIの設計・実装をお任せします。

フロントエンドエンジニア(TypeScript)

業務内容

自社で開発・運営しているWebサービス(3Dプリンターなどを活用したデジタル製造サービス)のフロントエンドの設計や実装をお任せします。 また、新規サービス開発プロジェクトへも参画いただけます。

機械学習エンジニア

業務内容

機械学習を用いた3Dデータや2Dデータからの情報抽出モデルの構築やセンサーデータの分析モデルの調査・研究・開発。 PoCだけでなく、データの前処理や学習、ハイパーパラメータチューニング、獲得モデルの評価、適用、運用のパイプライン構築まで、機械学習をプロダクション適用する全てのお仕事に携われます。

インターン(エンジニア)

業務内容

カブクの社員と肩を並べて、実業務を中心とした知識やスキルを身につけていただく実践型インターンシップ。スタートアップならではのスピードがあり、ダイナミックな就業経験を体験することが可能です。

→
←

お客様のご要望に「Kabuku」はお応えいたします。
ぜひお気軽にご相談ください。

お電話でも受け付けております
03-6380-2750
営業時間:09:30~18:00
※土日祝は除く