Three.js　AR　女の子　出す　方法

本記事は、 [Three.js Advent Calendar 2019](https://qiita.com/advent-calendar/2019/threejs) の15日目の記事です。

たいへん鮮度が落ちやすい記事です。もし古い情報がありましたら、コメントからご連絡いただけますと幸いです。

あんどうさんによるWebXR AR Paintの解説記事を先に読んでおくと理解が深まるかもしれません:

- [WebXR AR Paint その１](https://qiita.com/technohippy@github/items/33b926d5b451c932b315)
- [WebXR AR Paint その２](https://www.kabuku.co.jp/developers/webxrarpaint)

## 概要

上にも紹介したように、[AR Paintのexample](https://threejs.org/examples/#webxr_ar_paint)は、Three.js r111で追加されたexamplesの中でもひときわ目立っていますね。
本記事では、このAR Paintのコードを読みながら、実際にモデルデータをARで床の上に表示する実例を作っていこうと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58446/803ffacf-ddce-19e5-ed71-b93833acc345.png)

道中では、以下のような技術を必要としました:

- GLTFLoaderと[@pixiv/three-vrm](https://github.com/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**のフラグを有効化する必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58446/dedfbefb-6ae7-06af-d2a3-014c3e1415be.png)

コードは[Glitch.com](https://glitch.com/)の上で記述しましたので、開発環境はブラウザのみで大丈夫です。

**完成品はこちらです: https://glitch.com/~three-vrm-ar**

## 注意

**Plane Detection APIについては、まだ[W3CのWorking Draft](https://www.w3.org/TR/webxr/)にも全く記述がない**技術のため、今後頻繁に仕様の変更が為されるかもしれません……
WebXRそれ自体以上に、まだまだプロダクションレベルでの使用は避けたほうが良いと思われます。

## WebGLRendererの初期化

Three.jsでWebXRを使うには、 `renderer.vr` の有効化が必要なので、忘れずに有効にします。

ここで気づいたのですが、Three.jsの現在の `dev` ブランチでは、 `renderer.xr` を使っていますが、**r111の時点ではまだ `renderer.vr` を使っている**ようでした（ `xr` だと動かない）。
本サンプルでもまだ `vr` を使っていますが、**次のThree.jsのリリースからは `xr` を使うことになりそう**なので、要注意です。

```js
// 🔥 It must be changed to `renderer.xr` after r112 comes
renderer.vr.enabled = true;
```

## VRMの読み込み

本サンプルでは、*[VRM](https://vrm.dev/)*と呼ばれるアバターフォーマットで記述されたモデルデータを読み込み表示します。
VRMの読み込みには[@pixiv/three-vrm](https://github.com/pixiv/three-vrm/)を使います。

今回は、ひとまず床の上にARでキャラクターが出せればそれで良いので、VRMの機能はほとんど使いません。
キャラクターに正面を向かせるため、Hipsボーンを取得してy軸回りに180度回転させる処理のみ行っています。

```js
// 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日目の記事](https://www.kabuku.co.jp/developers/webxrarpaint)で説明していたとおりです。
*[XRSession](https://www.w3.org/TR/webxr/#xrsession-interface)*を新たに生成して、Three.jsにそのXRSessionを登録している、というコードですね。
XRSessionは、WebXRにおける1セッションを表すインタフェースです。ARButtonによって隠蔽されていますが、Three.jsでWebXRを扱う上でも、ちゃんとXRSessionを作る必要があります。

コードとしては、ARButtonを作ってDOMに追加するだけです。簡単ですね。

```js
// ARButton, does an initialize stuff
const arButton = ARButton.createButton( renderer );
document.body.appendChild( arButton );
```

## Plane Detectionの有効化とReference Spaceの取得

今回はキャラクターを床の上に立たせたいため、床が検出できないといけません。
WebXRで床を検出するには、まず*[Plane Detection API](https://github.com/immersive-web/real-world-geometry/blob/master/plane-detection-explainer.md)*を有効化しなければいけません。
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'` を指定します。

```js
// 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` というフラグを切り替えるようにします。
これは、タッチしている間だけモデルをタッチ位置に配置できるようにするものです。

```js
// 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` を指定すると、レイキャストの衝突判定が表裏の両方に対して発生するのでオススメです。

```js
// 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](https://www.w3.org/TR/webxr/#xrframe-interface)*という、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](https://developer.mozilla.org/en-US/docs/Web/API/XRPose)*として床の姿勢を取ることができます。

`XRPose.transform` には位置や回転の情報が入っているため、それをそのままThree.jsのPlaneに対して適用させればOKです。
ここで得られる位置や回転の情報は*[XRRigidTransform](https://developer.mozilla.org/en-US/docs/Web/API/XRRigidTransform)*というインタフェースらしく、**中に入っている位置や回転は配列ではなく `.x` , `.y` , `.z` , `.w` でしか取得できない**ことに注意してください。筆者はこれで1時間を溶かしました。
また、この直後にレイキャストを行うので、最後に `updateMatrix` を叩くのを忘れずに。

この辺りのコードは、W3Cに全くリファレンスが無かったため、先にも紹介した[Plane Detection APIの説明](https://github.com/immersive-web/real-world-geometry/blob/master/plane-detection-explainer.md)およびChrome上での実際の挙動だけを頼りにコードを書いています……

```js
// 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に向けて射出し、交差していたらその交点にモデルを配置します。

```js
// 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が渡ってくるという寸法です。

```js
// 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ループを走らせなければいけないようです。

```js
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空間に立たせることができました！

![Screenshot_20191209-200014.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58446/15d64785-edd5-375a-6463-c799fc6bdbd5.png)

想像以上に辛かったのか、意外と楽だったのか……わからないです。
Three.js上でXRを扱っているつもりが、Plane Detectionを使わなければいけなかったこともあり、結構WebXRのAPIを直接バシバシ叩いていました。
ただ、WebXRのAPIは（まだ仕様が固まっていない部分もあるとはいえ）非常にわかりやすく、かんたんに叩けるような印象を改めて持ちました。
まだ実用段階までは少し遠い気がしますが、WebXRの未来には期待できるのではないでしょうか。

## WebXR HitTest API

WebXRで空間を認識するには、Plane Detection APIを使って床を認識する他にも、空間を認識してHitTestを実行できる*[Hit Test API](https://github.com/immersive-web/hit-test)*というものもあります。
[Google CodelabsによるWalkthrough](https://codelabs.developers.google.com/codelabs/ar-with-webxr-ja/index.html#0)もあったりして（あんどうさんによる日本語訳です！）、実はPlane Detectionよりも資料がしっかりしているのではないか？！という感覚です。
これからThree.jsでWebXRを始める方は、こちらも選択肢に入れてみてもよいのではないでしょうか。