本記事は、 Three.js Advent Calendar 2019 の15日目の記事です。
たいへん鮮度が落ちやすい記事です。もし古い情報がありましたら、コメントからご連絡いただけますと幸いです。
あんどうさんによるWebXR AR Paintの解説記事を先に読んでおくと理解が深まるかもしれません:
概要
上にも紹介したように、AR Paintのexampleは、Three.js r111で追加されたexamplesの中でもひときわ目立っていますね。
本記事では、このAR Paintのコードを読みながら、実際にモデルデータをARで床の上に表示する実例を作っていこうと思います。
道中では、以下のような技術を必要としました:
- GLTFLoaderと@pixiv/three-vrmを利用したVRMモデルデータの読み込み
- AR Paintでも使われているARButton
- WebXR Plane Detectionを利用した床認識
- コントローラを利用したレイキャスト
今回の記事で紹介するコードにおいても、実行するためには、例によってARCoreに対応したスマートフォン(Pixel 3とか)とChromeの80+(現在はまだCanary相当)が必要になります。
また、chrome://flagsより、WebXR Device APIおよびWebXR AR Moduleに加えて、WebXR Plane Detectionのフラグを有効化する必要があります。
コードはGlitch.comの上で記述しましたので、開発環境はブラウザのみで大丈夫です。
完成品はこちらです: https://glitch.com/~three-vrm-ar
注意
Plane Detection APIについては、まだW3CのWorking Draftにも全く記述がない技術のため、今後頻繁に仕様の変更が為されるかもしれません……
WebXRそれ自体以上に、まだまだプロダクションレベルでの使用は避けたほうが良いと思われます。
WebGLRendererの初期化
Three.jsでWebXRを使うには、 renderer.vr
の有効化が必要なので、忘れずに有効にします。
ここで気づいたのですが、Three.jsの現在の dev
ブランチでは、 renderer.xr
を使っていますが、r111の時点ではまだ renderer.vr
を使っているようでした( xr
だと動かない)。
本サンプルでもまだ vr
を使っていますが、次のThree.jsのリリースからは xr
を使うことになりそうなので、要注意です。
// 🔥 It must be changed to `renderer.xr` after r112 comes
renderer.vr.enabled = true;
VRMの読み込み
本サンプルでは、*VRM*と呼ばれるアバターフォーマットで記述されたモデルデータを読み込み表示します。
VRMの読み込みには@pixiv/three-vrmを使います。
今回は、ひとまず床の上にARでキャラクターが出せればそれで良いので、VRMの機能はほとんど使いません。
キャラクターに正面を向かせるため、Hipsボーンを取得してy軸回りに180度回転させる処理のみ行っています。
// will be used to update the VRM in render loop
let currentVRM = undefined;
// load the VRM from the given URL
function initVRM( url ) {
// Procedure after the loader completed
const onLoad = ( gltf ) => {
THREE.VRM.from( gltf ).then( ( vrm ) => { // convert the GLTFLoader result into VRM
scene.add( vrm.scene ); // add the VRM root into the scene
currentVRM = vrm; // set the currentVRM
// make the avatar look toward Z+
const hips = vrm.humanoid.getBoneNode( THREE.VRMSchema.HumanoidBoneName.Hips );
hips.rotation.y = Math.PI;
} );
};
// Procedure while the loader is doing his job
const onProgress = ( progress ) => {
console.info( ( 100.0 * progress.loaded / progress.total ).toFixed( 2 ) + '% loaded' );
};
// Procedure when the loader made a mess
const onError = ( error ) => {
console.error( error );
}
// load the VRM from the given URL, as a GLTF
const loader = new THREE.GLTFLoader();
loader.load( url, onLoad, onProgress, onError );
}
// load the VRM from the given URL
initVRM('https://cdn.glitch.com/e9accf7e-65be-4792-8903-f44e1fc88d68%2Fthree-vrm-girl.vrm?v=1568881824654');
ARButton
ARButtonで何をしているかは、あんどうさんが4日目の記事で説明していたとおりです。
*XRSession*を新たに生成して、Three.jsにそのXRSessionを登録している、というコードですね。
XRSessionは、WebXRにおける1セッションを表すインタフェースです。ARButtonによって隠蔽されていますが、Three.jsでWebXRを扱う上でも、ちゃんとXRSessionを作る必要があります。
コードとしては、ARButtonを作ってDOMに追加するだけです。簡単ですね。
// ARButton, does an initialize stuff
const arButton = ARButton.createButton( renderer );
document.body.appendChild( arButton );
Plane Detectionの有効化とReference Spaceの取得
今回はキャラクターを床の上に立たせたいため、床が検出できないといけません。
WebXRで床を検出するには、まず*Plane Detection API*を有効化しなければいけません。
Three.js自体には、このPlane Detection APIを有効化するための機能は存在しないため、XRSessionが生成されたタイミングでこれを有効化します。
Plane Detection APIの有効化には、 XRSession.updateWorldTrackingState
というメソッドを使います。
オプションとして planeDetectionState: { enabled: true }
を渡すことで、Plane Detectionが利用できるようになります。
また、Plane DetectionにはReference Spaceというものも必要なので、ここで一緒に取得をします。
Reference Spaceは XRSession.requestReferenceSpace
というメソッドで取得可能です。
このとき、ReferenceSpaceTypeというものを一緒に渡さなければなりませんが、ARButtonがこれに 'local'
を指定しているので、同じ 'local'
を指定します。
// will be used to initialize our xr stuff
let hasXRInitialized = false;
let xrRefSpace = null;
// initialize our xr stuff
function initXR( session ) {
session.updateWorldTrackingState( {
planeDetectionState: { enabled: true }
} );
session.requestReferenceSpace( 'local' ).then( ( refSpace ) => {
xrRefSpace = refSpace;
} )
}
関数名が initXR
となっていますが、Three.jsおよびARButton側でWebXRにまつわる初期化処理はほとんど終了していますので、お間違いなく!
コントローラの準備
WebXRでARを扱うとき、コントローラとは即ちタッチ操作を表すようです。不思議ですね。
Three.jsでは、VRにおいてコントローラの位置や回転が取得できるように、タッチ位置を3Dオブジェクトとして取得することができます。
ただし、コントローラだからといって別に、ジオメトリが存在しているわけではなさそうです。
今回は、タッチ操作でモデルの配置をできるようにしたいため、このコントローラを使います。
'selectstart'
, 'selectend'
というイベントを用いて、タッチの開始・終了を取得できるため、これで shouldPlace
というフラグを切り替えるようにします。
これは、タッチしている間だけモデルをタッチ位置に配置できるようにするものです。
// whether it should place the object in current frame or not
let shouldPlace = false;
// prepare a controller (...known as touch controls)
const xrController = renderer.vr.getController( 0 );
xrController.addEventListener( 'selectstart', () => { shouldPlace = true; } );
xrController.addEventListener( 'selectend', () => { shouldPlace = false; } );
scene.add( xrController );
Raycasterの準備
床にキャラクターを配置するため、RaycasterとPlaneを準備します。
Plane Detectionを使って床の位置にPlaneを配置し、スマートフォンのカメラからRaycasterを撃ち、床との交差点にキャラクターを配置しようという企みですね。
Three.jsのPlaneBufferGeometryはデフォルトではZ+方向を向いているため、+Y方向に向いてくれるように90度回転をします。
Materialには、ワイヤフレームのMeshBasicMaterialを指定します。床が見えなくても良い場合は、単に visible
を false
にしてしまっても良いでしょう。
side
に THREE.DoubleSide
を指定すると、レイキャストの衝突判定が表裏の両方に対して発生するのでオススメです。
// prepare a raycaster
const raycaster = new THREE.Raycaster();
// prepare a plane, will be used to do the raycasting
const xrPlaneGeometry = new THREE.PlaneBufferGeometry( 100, 100, 100, 100 );
xrPlaneGeometry.rotateX( -0.5 * Math.PI );
const xrPlaneMaterial = new THREE.MeshBasicMaterial( { wireframe: true, side: THREE.DoubleSide } );
const xrPlaneObject = new THREE.Mesh( xrPlaneGeometry, xrPlaneMaterial );
scene.add( xrPlaneObject );
Plane Detectionの結果をThree.jsで使う
Plane Detectionの結果を、先程作ったThree.jsのPlaneに当てはめるコードを書きます。
ここでは*XRFrame*という、WebXRからもらえるインタフェースを利用します。
XRFrameは、WebXRの世界のある瞬間のステートが格納されたインタフェースです。
この中に、Plane Detectionで検出した床の情報も入っていますので、それを取り出してThree.jsのオブジェクトに適用します。
xrFrame.worldInformation.detectedPlanes
には、nullもしくはXRPlaneSetが入ります。
XRPlaneSetはSetLikeでIterableなようなので、とりあえず Array.from
で配列にしてしまいましょう。
通常、この detectedPlanes
には0もしくは1個のXRPlaneが入っているようです。
このXRPlaneの XRPlane.planeSpace
と先程用意しておいた xrRefSpace
を XRFrame.getPose
という関数に食わせると、*XRPose*として床の姿勢を取ることができます。
XRPose.transform
には位置や回転の情報が入っているため、それをそのままThree.jsのPlaneに対して適用させればOKです。
ここで得られる位置や回転の情報は*XRRigidTransform*というインタフェースらしく、中に入っている位置や回転は配列ではなく .x
, .y
, .z
, .w
でしか取得できないことに注意してください。筆者はこれで1時間を溶かしました。
また、この直後にレイキャストを行うので、最後に updateMatrix
を叩くのを忘れずに。
この辺りのコードは、W3Cに全くリファレンスが無かったため、先にも紹介したPlane Detection APIの説明およびChrome上での実際の挙動だけを頼りにコードを書いています……
// apply the result of plane detection to the Three.js plane
function updatePlane( xrFrame ) {
const detectedPlanes = Array.from( xrFrame.worldInformation.detectedPlanes || [] ); // xrFrame.worldInformation.detectedPlanes can be null
if ( xrRefSpace && detectedPlanes.length > 0 ) {
const plane = detectedPlanes[ 0 ]; // get the first plane
const planePose = xrFrame.getPose( plane.planeSpace, xrRefSpace ); // acquire the current plane state
xrPlaneObject.position.set( // update the position
planePose.transform.position.x,
planePose.transform.position.y,
planePose.transform.position.z
);
xrPlaneObject.quaternion.set( // update the quaternion
planePose.transform.orientation.x,
planePose.transform.orientation.y,
planePose.transform.orientation.z,
planePose.transform.orientation.w
);
xrPlaneObject.updateMatrix(); // don't forget to update the matrix
}
}
レイキャストしてモデルを配置する
レイキャストを行い、キャラクターを配置するコードを書きます。
まず、カメラとコントローラの情報をもとに、カメラからタッチ位置に向かって飛ぶRaycasterを準備します。
ここで気をつけなきゃいけないのが、Three.jsのXRにおいて、どうやらカメラ姿勢の情報は camera.matrixWorld
に対して直接適用されているようで、 camera.position
の値は変化しないままでした。
そのため、 decompose
を叩いて matrixWorld
からカメラの位置を抜き出さないといけません。筆者はここでもう1時間を溶かしました。
一方、コントローラの位置は普通に position
を見ればわかるようです。
この「コントローラの位置」というのが具体的に何を表しているかよくわからなかったのですが(タッチ位置ですからね)、試しにカメラからコントローラの向きへ飛ぶRaycasterを作ったら、それらしく動作しました。ヨシ!
レイが生成できたら、普通にPlaneに向けて射出し、交差していたらその交点にモデルを配置します。
// setup the raycaster out of the camera and the controller
function setupRaycaster() {
camera.matrixWorld.decompose( _v3A, _quatA, _v3B ); // ray origin
_v3B.copy( xrController.position ).sub( _v3A ).normalize(); // ray direction
raycaster.set( _v3A, _v3B );
}
// shoot a ray and move the VRM model
function placeModel() {
if ( currentVRM ) {
setupRaycaster(); // setup the raycaster
const isects = raycaster.intersectObject( xrPlaneObject ); // shoot a ray, get an intersect with the plane
if ( 0 !== isects.length ) { // if the raycast works
currentVRM.scene.position.copy( isects[ 0 ].point ); // set the intersection point as the position of the model
}
}
}
組み込む
ここまでで必要なものは全て出来上がったので、これらを実際にRenderループに組み込みましょう。
XRFrameの取得
まず、XRFrameを得るためには、 XRSession.requestAnimationFrame
という関数を叩く必要があります。
引数としてコールバック関数を渡すと、その関数にXRFrameが渡ってくるという寸法です。
// will be used as a callback of XRSession.requestAnimationFrame
function onXRFrame( time, frame ) {
updatePlane( frame );
if ( shouldPlace ) { // if the screen is held
placeModel();
}
}
余談:
Three.jsでは本来、この requestAnimationFrame
を叩かなくても、内部でこれを勝手に叩いてオブジェクトなどにWebXRの情報を適用してくれます。
が、Plane Detectionなどを使って遊ぼうとすると、エンドデベロッパー側もXRFrameが必要になってしまい、自前でも別に叩かなければいけなくなってしまいます……
これについては、Three.jsがXRFrameを渡してくれれば解決する話なので、今後改善すればよいですね。
Renderループ
最後に、Three.js自体のRenderループの中身を見ていきましょう。
まず、XRSessionを手に入れるには、 renderer.vr.getSession
を叩きます。
ユーザがARの利用を承認して実際にXRが動き始めるまでは、この値は null
となりますので注意です。
XRSessionが手に入った最初のフレームで、先に実装した initXR
を叩きます。
毎フレームに対して、先程説明した XRSession.requestAnimationFrame
を onXRFrame
を渡して叩きます。
最後に気をつけなきゃいけないのが、Three.jsでWebXRを扱う場合、自前で requestAnimationFrame
を使ってRenderループを作るのではなく、 renderer.setAnimationLoop
という関数を使ってRenderループを走らせなければいけないようです。
const clock = new THREE.Clock();
clock.start();
function render() {
const session = renderer.vr.getSession();
if ( session ) {
if ( !hasXRInitialized ) {
initXR( session );
hasXRInitialized = true;
}
session.requestAnimationFrame( onXRFrame );
}
const delta = clock.getDelta();
if ( currentVRM ) {
currentVRM.update( delta );
}
renderer.render( scene, camera );
};
renderer.setAnimationLoop( render );
完成
以上で解説は終了です。女の子をAR空間に立たせることができました!
想像以上に辛かったのか、意外と楽だったのか……わからないです。
Three.js上でXRを扱っているつもりが、Plane Detectionを使わなければいけなかったこともあり、結構WebXRのAPIを直接バシバシ叩いていました。
ただ、WebXRのAPIは(まだ仕様が固まっていない部分もあるとはいえ)非常にわかりやすく、かんたんに叩けるような印象を改めて持ちました。
まだ実用段階までは少し遠い気がしますが、WebXRの未来には期待できるのではないでしょうか。
WebXR HitTest API
WebXRで空間を認識するには、Plane Detection APIを使って床を認識する他にも、空間を認識してHitTestを実行できる*Hit Test API*というものもあります。
Google CodelabsによるWalkthroughもあったりして(あんどうさんによる日本語訳です!)、実はPlane Detectionよりも資料がしっかりしているのではないか?!という感覚です。
これからThree.jsでWebXRを始める方は、こちらも選択肢に入れてみてもよいのではないでしょうか。