three.ar.jsを使ったスマホAR入門
あけましておめでとうございます。株式会社カブクのAR/VR担当代理補佐のあんどうです。
さてAR。
AR用のAPIとしてAndroidではARCore、iOSではARKitが利用できるようになり、モバイル系開発者にとってARはぐっと身近なものになりました。しかしそれはあくまでネイティブアプリ開発者の場合。われわれウェブ系エンジニアはこのような状況を前に唇を噛むしかないのでしょうか?いいえ、そんなことはありません。私たちにはthree.ar.jsがあります。
three.ar.js
three.ar.jsを使用すればWebARonARCore、WebARonARKitを利用して、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というアプリをご存知でしょうか?百聞は一見にしかず、下の動画を見てください。
空間に穴を開けて向こう側へ行き、向こう側から穴を閉じて、別の場所に穴を開けてそこを通ってこちら側へ帰ってくる。まで出来ました。#HoloLens pic.twitter.com/NIa3v2Lcho
— VoxelKei (@VoxelKei) 2017年2月10日
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();
}
});
THREE.ARUtils.getARDisplay
を使用してARアプリに使用できるディスプレイを取得します。内部的にはWebVR APIのnavigator.getVRDisplays
を使用してVRディスプレイを取得した後で、ディスプレイ名に'tango'
または'arkit'
が含まれていたらARディスプレイとみなすようです。
- ARディスプレイが取得できた場合はARアプリを実行可能だと判断し、各オブジェクトを準備した後でアプリの初期化関数
init()
を呼び出します。なお、ここでインスタンス化しているVRFrameData
は後ほど端末の位置や向きを取得する際に使用されます。 - 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();
}
- 最初に作成している
THREE.ARDebug
は端末の位置や向きをデバッグ表示するパネルを画面に追加するものです。自分で表示内容を追加することもできるようですが、そこまでしなくてもこれがあるだけで「なんか動いている」ことは確認できるので追加しておくことをお勧めします。
- three.jsの準備はいつもどおりですが、
WebGLRenderer
のインスタンス化時に{alpha:true}
を設定しておくことは忘れないようにしましょう。
THREE.ARView
は後ほどカメラ映像を背景として表示するために使用します。
THREE.ARPerspectiveCamera
はTHREE.PerspectiveCamera
を継承して、VRディスプレイ用の機能を追加したものです。
THREE.VRControls
は端末の位置・向きに合わせてAR空間内のカメラの位置と向きを設定するコントロールです。
- 初期化が完了すると
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
には端末の位置と向きが保持されています。この位置と向きをそれぞれpos
、ori
として取り出します。
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を使う上でこの辺だけ抑えておけばなんとかなるんじゃないかと思われる最低限をまとめます。
THREE.ARView
を使うと背景にカメラ映像を設定できます。THREE.VRControls
を使うとシーン内のカメラの座標と向きを端末のそれと合わせることができます。- 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
関連職種
Recruit