WebXR MRのmesh detectionをQuest3で試してみる

:::note 
この記事は [WebXR ( WebVR/WebAR ) Advent Calendar 2023](https://adventar.org/calendars/8568) の3日目になります。
:::



# WebXRでMRをするためのAPI

WebXR Device API はブラウザでXRするためのさまざまなモジュールが含まれています。

その中でも、いわゆるMRに関係するものとして、リアルの部屋のメッシュをスタティックに取得するAPIが２種類用意されています。

 -  WebXR Plane Detection Module
 -  WebXR Mesh Detection Module

Plane Detectionは部屋の壁や床を平面ポリゴンとして取得できるもので、おおざっぱな部屋の状況がわかります。Mesh Detectionはさらに細かい部屋のメッシュが取得できるものです。用途によってPaneとMeshを使い分けることが想定されているようです。

## HMDデバイスでの実装状況

Plane detectionについては、昨年のWebXR Advent Calenderで書いたように、Meta Quest2でもサポートされていました。

https://qiita.com/wakufactory/items/602bf4bc1ce075e7a279

Plane detectionについては、去年の段階で仕様になかった semanticLabel 情報が追加され、壁や天井・床などの種別を取得できるようになっています。

Mesh detectionはQuest3の発表に合わせてQuestブラウザで実装されました。

Quest3の設定画面の「スペースの設定」であらかじめ部屋をスキャンして得られたメッシュを取得することができます。

Quest2やQuestProでは部屋のPlaneは設定できますが、Meshのスキャンはできないので、これはQuest3のみの機能ということになります。

2023年12月現在で、PICO4のブラウザではPlaneもまだ対応されていません。

## Mesh Detection API

Mesh detection APIの仕様はこちら

https://immersive-web.github.io/real-world-meshing/


APIを利用する準備として、VRSessionの初期化時にオプションとして機能要求をしておく必要があります。

具体的には requestSessionでrequiredFeaturesのフラグに"mesh-detection"を加えます。

```js 
const session = await navigator.xr.requestSession("immersive-ar", {
  requiredFeatures: ["mesh-detection"]
});
```

この状態でVRセッションが開始されると、描画ループの XRFrame
中に detectedMeshes という属性が取得できるようになります。detectedMeshes は XRMesh のcollectionになっているので、この中に部屋をスキャンしたメッシュが含まれることになります。

```js
const meshes = frame.detectedMeshes
```
XRMeshには、メッシュの座標情報のvertices,indicesと、ワールド座標に変換するための meshSpace が含まれます。
さらに、更新時間であるlastChangedTimeと、メッシュが何を表しているかのsemanticLabel情報が含まれます。

```js
 XRMesh {
    readonly attribute XRSpace meshSpace;
    readonly attribute FrozenArray<Float32Array> vertices;
    readonly attribute Uint32Array indices;
    readonly attribute DOMHighResTimeStamp lastChangedTime;
    readonly attribute DOMString? semanticLabel;
};
```

detectedMeshes は毎フレーム取れますが、更新時間lastChangedTimeが新しくなっていたら、メッシュが更新されたものと解釈する必要があるようです。

実際にQuest3の実装では、meshはリアルタイムに取得されたものではなく、予め部屋のスキャンで得られたものが帰ってくるのですが、この時間が結構頻繁に更新されています。

これはおそらく常時行っているトラッキングの更新によってメッシュの位置が微妙に更新されるためと思われます。また、Metaボタン長押しによるリセンターの操作をすると、ワールド座標上のメッシュの位置がかわります。

ということで、更新時間が新しくなったら、少なくともメッシュの位置は更新する必要があります。


XRMeshをワールド座標に変換するには、referenceSpaceを使ってXRPoseを取得します。この辺りはヘッドやコントローラの座標を取得する方法と同じです。

```js
meshes.forEach(p=>{
    const pose = frame.getPose(p.meshSpace, xrReferenceSpace)
    // p.vertices p.indices のメッシュをposeのmatrixで変換して描画
    // 
    ...
})
```

semanticLabel になにが入って来るか実装依存だと思いますが、Quest3では、部屋のスキャンで得られた全体のメッシュは"global mesh"というラベルがついています。その他、手動で設定した机や椅子などの家具類は、現状は直方体のメッシュとして、"table"のようなラベルが付いてくるようです。

## 実際のサンプル

以下がA-Frameのコンポーネントとして実装した実働サンプルです。

https://polybaas.com/pbe/?yDM1WquiLh5n2pu34Eqm

[OculusQuest で開く](https://www.oculus.com/open_url/?url=https%3A%2F%2Fpolybaas.com%2Fpbe%2F%3FyDM1WquiLh5n2pu34Eqm)
(このリンクをMataにログインした状態で開くと、Quest のデバイスのブラウザで自動的にリンクを開くことができます)


:::note
上のリンク先のサンプルは、ブラウザIDE Polybaasで作成されています。左のソースペインにはA-Frameのタグが表示されていますが、タブをcomponentに切り替えるとcomponentのソースを見ることができます。
:::

componentは、メッシュを検出する roomdetect と、実際にメッシュを描画する roomdraw に分かれています。

roomdetectでは、毎フレームごとにframeからdetectedMeshesを取り出し、更新時間をチェックして、更新されたmeshを描画するために、roomdrawコンポーネントにメッセージを投げています。

roomdrawコンポーネントでは、meshの vertices と indices を利用して、メッシュとワイヤフレームの描画をしています。


上のサンプルでは、planeとmeshを両方取得するようになっていますが、以下はcomponentの定義部分からmeshのみを抜き出したものです。

```js
// WebXR Mesh Detection Module sample component
AFRAME.registerComponent('roomdetect',{
	init:function() {
        // arモードに入ったところでmesh情報を初期化
		scene.addEventListener("enter-vr", ev=>{
			this.meshes = []
			this.tgtel = this.el.querySelectorAll("[roomdraw]")
		})
		const sc =  this.el.sceneEl
        // webxr の初期化にmesh-detectionを追加
		const of = sc.getAttribute("webxr")
		of.optionalFeatures = of.optionalFeatures.concat(["plane-detection","mesh-detection"])
		sc.setAttribute("webxr",of)
	},
	update:function(old) {
	},
	tick:function(time,dur) {
        // a-frameでは sceneEl.framでsession frameが取得できる
		const fr =  this.el.sceneEl.frame
		if(!fr) return 
		const ref =  this.el.sceneEl.renderer.xr.getReferenceSpace()
		// 検知されたmesh
		const m = (fr.detectedMeshes)	
		if(m && m.size>0) { 
			let idx = 0
			let ctime = 0
			let uf = false 
			m.forEach(o=>{
				if(!this.meshes[idx] || o.lastChangedTime>this.meshes[idx].time) {
					uf = true 
					ctime = o.lastChangedTime
					this.meshes[idx] = {
						vertices:o.vertices,
						indices:o.indices,
						pose: fr.getPose(o.meshSpace, ref),
						label:o.semanticLabel,
						time:o.lastChangedTime,
						uf:true 
					}
				} else this.meshes[idx].uf = false 
				idx++
			})
            if(uf) { //更新があった場合にeventを送る
				this.tgtel.forEach(el=>{
					el.emit("updatemesh",this.meshes)
				})
			}
		}
	},
})

//meshの描画
AFRAME.registerComponent('roomdraw',{
	schema: {
    	meshes:{default:3},//描画パラメータ
		mesh_color:{default:"#4f4"},
		mesh_opacity:{default:0.5},
		mesh_pass:{default:false}
	},
	entervr:function() {// immersive-arに入ったところで初期化
		this.el.object3D.children = []
		this.meshes = [] 
		this.meshes_w = [] 		
	},
	init:function() {
		this.material = null 
		this.entervr = this.entervr.bind(this) 
		scene.addEventListener("enter-vr",this.entervr) 
		//roomdetect からのメッセージを受けてメッシュを描画する
		this.el.addEventListener("updatemesh",ev=>{
			if(this.data.meshes==0) return
			const meshes = ev.detail
			this.setmeshes(meshes)
		})
	},
	remove:function() {
		scene.remveEventListener("enter-vr",this.entervr)
	},
	setmeshes:function(meshes) {
		const to = this.el.object3D
		for(let i=0;i<meshes.length;i++) {
			const p = meshes[i] 
			if(!p.uf) continue 
			let mesh
			let geometry
			if(this.meshes[i]) {
                //すでにあるメッシュは使いまわし
				mesh = this.meshes[i] 
				geometry = mesh.geometry
			} else {
                //bufferGeometryを作る
				geometry = new THREE.BufferGeometry();
				if(this.material != null) {
					mesh = new THREE.Mesh( geometry, this.material );
				} else {
					const material = new THREE.MeshStandardMaterial( { 
						color: p.label=="global mesh"?this.data.mesh_color:this.data.mesh_color, 
						transparent:this.data.mesh_opacity!=1,opacity:this.data.mesh_opacity,
						side:THREE.FrontSide,flatShading:true } );
					mesh = new THREE.Mesh( geometry, material );
				}
				this.meshes[i] = mesh
				if(this.data.meshes & 1) to.add(mesh)
			}
            //mesh座標のセット
			mesh.geometry.setIndex(  new THREE.BufferAttribute(p.indices,1) );
			mesh.geometry.setAttribute( 'position', new THREE.BufferAttribute( p.vertices, 3 ) );
			mesh.matrix.elements = p.pose.transform.matrix
    	    mesh.matrix.decompose(mesh.position, mesh.rotation, mesh.scale);
         
			if(this.data.meshes & 2) {//ワイヤフレームの描画
				let line
				if(this.meshes_w[i]) {
					line = this.meshes_w[i] 
				} else {
					const wireframe = new THREE.WireframeGeometry( geometry )
					const wire_material = new THREE.LineBasicMaterial( {
							color: new THREE.Color( "#4f4") ,linewidth: 1} )
					line = new THREE.LineSegments( wireframe,wire_material )
					this.meshes_w[i] = line
					to.add(line)
				}
				line.matrix.elements = p.pose.transform.matrix
		    line.matrix.decompose(line.position, line.rotation, line.scale);
			}
		}
	},
	update:function(old) {
	},
	tick:function(time,dur) {
	}
})
```

上のサンプルを実際にQuest3で動かしてメッシュを表示させた結果です。
赤い部分がplane 緑がmeshで取得されたmeshです。

![bca016afe1c0c7012b654da82dc58bdc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74838/91c3a761-b438-3e35-7519-2f9af95026cc.png)

![608a71848b87a07edc7921357ad36e24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74838/6b3b4c5b-9c1b-ac59-c0c9-12b3b1a69bb9.png)