🔰

WebXRでかんたんAR体験

2021/11/24に公開

WebXRとは

WebXRは、仮想空間を3D空間として表示するVR(Virtual Reality)や、現実世界に3Dオブジェクトを表示するAR(Augumented Reality)をまとめた総称です。(ざっくり)
細かいことはさておき、今回はAndroidデバイスを利用してWebXR(AR)をあっさり体験してみましょう。

WebXR Device APIを利用する

"WebXR Device API"は、WebXRの機能を実装しデバイスの管理と3Dシーンの描画を管理します。

WebXR Device API

WebXRへの対応状況は、次のサイトで確認する事ができます。
(いよいよ広がってきました!!)

WebXR Device API

プロジェクトを作る

では早速作っていきましょう。
サンプルプロジェクトを、次のサイトから一式ダウンロードします。

Immersive Web at W3C

ダウンロードした圧縮ファイルにある"webxr-samples/js/"フォルダをまるごとコピーし、
次の様にプロジェクトを作ります。

MyProject01/
 ├ assets
 | └ gltf/ (GLTF形式のモデリングデータを保存するフォルダです)
 ├ libs
 | └ threejs/ ("webxr-samples/js/"の中身をコピーします)
 |  ま├ render/
 |  る├ third-party/
 |  ご├ third-party/
 |  と├ util/
 |  コ├ wglu/
 |  ピ├ hittest.js
 |  |├ stereo-util.js
 |  !├ webxr-sample-app.js
 ├ custom.css (スタイルシートを記述するファイルです)
 ├ index.html (プログラムを起動するファイルです)
 └ main.js (メインのプログラムを記述するファイルです)

HTMLを編集する

"index.html"ファイルを次の様に編集します。

index.html
<!DOCTYPE html>
<html lang="en">
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<link href="custom.css" rel="stylesheet"></link>
</head>
<body>
	<div id="log"><ul></ul></div>
	<div id="outer">
		<div id="inner"></div>
	</div>
	<div id="xrview"></div>
	<script type="module" src="./main.js" defer></script>
</body>
</html>

idが"xrview"となっているタグに、カメラの映像を描画します。

CSSファイルを編集する

"custom.css"ファイルを次の様に編集します。

custom.css
/* CSS */

html{
	width: 100%; height: 100%;
}

body{
	width: 100%; height: 100%;
	margin: 0px; padding: 0px;
}

#log{
	width: auto; height: auto;
	margin: 0px; padding: 5px;
	position: absolute;
}

#log ul{
	margin: 0px; padding: 0px;
}

#log li{
	list-style-type:none;
}

#outer{
	width: 100%; height: 100%;
	margin: 0px; padding: 0px;
	display: flex;
	justify-content: center;
	align-items: center;
}

#inner{
	width: auto; height: auto;
	margin: 0px; padding: 20px;
	text-align: center;
}

こちらは特に説明することはありませんね。

JavaScriptファイルを編集する

いよいよメインの処理です。
次の流れで処理が進行します。

  1. Sceneに3Dオブジェクトを配置する
  2. WebXRに対応しているかどうか判定する
  3. VR/ARいずれかをリクエストする
  4. WebXRを描画する準備をする
  5. WebXRの描画を開始する

1,Sceneに3Dオブジェクトを配置する

Three.jsを利用して3Dオブジェクトを配置します。

同ライブラリについては、かじる程度に解説をした記事も御座います。
Three.jsをかじる本

Three.js

次に示すコードは、3Dオブジェクトを作り"scene"(3D空間だと思ってください)に追加するものです。

main.js
// 省略
// GLTF
let model = new Gltf2Node({url: location.gltf});
model.scale = [0.1, 0.1, 0.1];
scene.addNode(model);
// 省略

2,WebXRに対応しているか判定する

実行中の端末がWebXRに対応しているかどうかを判定します。

"navigator.xr.isSessionSupported"関数の第一引数に、
VRの場合は"immersive-vr"、ARの場合は"immersive-ar"のいずれかを指定します。
(今回はARですので、"immersive-ar"を指定します)

実行後に続く"then"の仮引数"supprted"に利用の可否が格納されます。

main.js
// 省略
if(navigator.xr){
	navigator.xr.isSessionSupported(XR_SESSION_STRING).then((supported)=>{
		isXRAvailable = supported;
		xrButton.enabled = supported;
		appendLog("isXRAvailable:" + supported);
	});
	navigator.xr.requestSession("inline").then(onSessionStarted);
}
// 省略

3,VR/ARいずれかをリクエストする

"navigator.xr.requestSession"関数で、WebXRセッションをリクエストします。

こちらの第一引数には、判定で利用した内容と同じ文字列を指定します。
(今回は先程と同じく"immersive-ar"ですね)

main.js
// 省略
navigator.xr.requestSession(XR_SESSION_STRING, {requiredFeatures: ["anchors"]}).then((session)=>{
	session.isImmersive = true;
	xrButton.setSession(session);
	onSessionStarted(session);
});
// 省略

4,WebXRを描画する準備をする

セッションが開始されたタイミングで、実際にWebXRを描画する準備をします。

main.js
// 省略
if(gl) return;
gl = createWebGLContext({xrCompatible: true});
document.querySelector("#xrview").appendChild(gl.canvas);

function onResize(){
	gl.canvas.width = gl.canvas.clientWidth * window.devicePixelRatio;
	gl.canvas.height = gl.canvas.clientHeight * window.devicePixelRatio;
}
window.addEventListener("resize", onResize);
onResize();
renderer = new Renderer(gl);
scene.setRenderer(renderer);
// 省略

5,WebXRの描画を開始する

最後は簡単です。
"session.requestAnimationFrame"で、描画を開始するだけです。

main.js
// 省略
scene.inputRenderer.useProfileControllerMeshes(session);
session.updateRenderState({baseLayer: new XRWebGLLayer(session, gl)});
session.requestReferenceSpace("local").then((refSpace)=>{
	if(session.isImmersive) xrImmersiveRefSpace = refSpace;
	session.requestAnimationFrame(onXRFrame);
});
// 省略

"https"環境でのサーバーにプログラム一式をUploadし、
Android端末からアクセスする事でWebXRを体験する事ができます。(やりました!!)

全体のコード

最後に、全体のコードを載せておきますね。
複数の関数が用意してありますが、それぞれ次の処理が実装されています。

関数名 処理内容
initXR() WebXRのスタートボタン、WebXR実行環境の判定
onRequestSession() WebXRのセッションをリクエストします
onSessionStarted() WebXRが開始されたタイミングで実行されます
onSessionEnded() WebXRが終了したタイミングで実行されます
initGL() onSessionStartedで、画面全体にVR/AR空間を描画する準備をします
onXRFrame() WebXR空間を実際に描画します
main.js
console.log("Hello WebXR!!");

// Modules
import {Gltf2Node} from "./libs/threejs/render/nodes/gltf2.js";
import {InlineViewerHelper} from "./libs/threejs/util/inline-viewer-helper.js";
import {QueryArgs} from "./libs/threejs/util/query-args.js";
import {Renderer, createWebGLContext} from "./libs/threejs/render/core/renderer.js";
import {Scene} from "./libs/threejs/render/scenes/scene.js";
import {SkyboxNode} from "./libs/threejs/render/nodes/skybox.js";
import {WebXRButton} from "./libs/threejs/util/webxr-button.js";

const XR_SESSION_STRING = "immersive-ar";

window.onload = (e)=>{
	appendLog("Onload");
	startWebXR("./assets/gltf/space/space.gltf");// Start WebXR!!
}

function startWebXR(location){
	appendLog("startWebXR");

	// XR
	let xrButton            = null;
	let xrImmersiveRefSpace = null;
	let isXRAvailable       = false;

	// WebGL
	let gl                  = null;
	let renderer            = null;
	let scene               = new Scene();
	
	// GLTF
	let model               = new Gltf2Node({url: location.gltf});
	model.scale             = [0.1, 0.1, 0.1];
	scene.addNode(model);

	let all_previous_anchors = new Set();

	initXR();// Start the XR application.

	function initXR(){
		appendLog("initXR");

		// Button
		xrButton = new WebXRButton({
			onRequestSession: onRequestSession,
			onEndSession: onEndSession,
			textXRNotFoundTitle: "XR NOT FOUND",
			textEnterXRTitle: "START XR",
			textExitXRTitle: "EXIT  XR"
		});
		document.querySelector("#inner").appendChild(xrButton.domElement);

		if(navigator.xr){

			navigator.xr.isSessionSupported(XR_SESSION_STRING).then((supported)=>{
				isXRAvailable = supported;
				xrButton.enabled = supported;
				appendLog("isXRAvailable:" + supported);
			});

			navigator.xr.requestSession("inline").then(onSessionStarted);
		}
	}

	function onRequestSession(){
		appendLog("onRequestSession");
		return navigator.xr.requestSession(XR_SESSION_STRING, {requiredFeatures: ["anchors"]}).then((session)=>{
			session.isImmersive = true;
			xrButton.setSession(session);
			onSessionStarted(session);
		});
	}

	function onSessionStarted(session){
		appendLog("onSessionStarted");
		session.addEventListener("end", onSessionEnded);

		if(session.isImmersive) initGL();

		scene.inputRenderer.useProfileControllerMeshes(session);
		session.updateRenderState({baseLayer: new XRWebGLLayer(session, gl)});
		session.requestReferenceSpace("local").then((refSpace)=>{
			if(session.isImmersive) xrImmersiveRefSpace = refSpace;
			session.requestAnimationFrame(onXRFrame);
		});
	}

	function onEndSession(session){
		appendLog("onEndSession");
		session.end();
	}

	function onSessionEnded(event){
		appendLog("onSessionEnded");
		document.querySelector("#xrview").innerHTML = "";
		if(event.session.isImmersive) xrButton.setSession(null);
	}

	function initGL(){
		appendLog("initGL");

		if(gl) return;
		gl = createWebGLContext({xrCompatible: true});
		document.querySelector("#xrview").appendChild(gl.canvas);

		function onResize(){
			gl.canvas.width = gl.canvas.clientWidth * window.devicePixelRatio;
			gl.canvas.height = gl.canvas.clientHeight * window.devicePixelRatio;
		}
		window.addEventListener("resize", onResize);
		onResize();
		renderer = new Renderer(gl);
		scene.setRenderer(renderer);
	}

	function onXRFrame(t, frame){
		let session = frame.session;
		let xrRefSpace = xrImmersiveRefSpace;
		let pose = frame.getViewerPose(xrRefSpace);

		const tracked_anchors = frame.trackedAnchors;
		if(tracked_anchors){

			all_previous_anchors.forEach((anchor)=>{
				if(!tracked_anchors.has(anchor)){
					scene.removeNode(anchor.sceneObject);
				}
			});

			tracked_anchors.forEach((anchor)=>{
				const anchorPose = frame.getPose(anchor.anchorSpace, xrRefSpace);
				if(anchorPose){
					anchor.context.sceneObject.matrix = anchorPose.transform.matrix;
					anchor.context.sceneObject.visible = true;
				}else{
					anchor.context.sceneObject.visible = false;
				}
			});
			all_previous_anchors = tracked_anchors;

		}else{
			all_previous_anchors.forEach((anchor)=>{
				scene.removeNode(anchor.sceneObject);
			});
			all_previous_anchors = new Set();
		}
		
		scene.updateInputSources(frame, xrRefSpace);
		scene.startFrame();
		session.requestAnimationFrame(onXRFrame);
		scene.drawXRFrame(frame, pose);
		scene.endFrame();
	}  
}

function appendLog(str){
	let elem = document.createElement("li");
	elem.innerText = str;
	document.querySelector("#log ul").appendChild(elem);
}

最後に

駆け足ではありましたが、思ったより簡単にWebXRを体験する事ができる事が伝われば幸いです。
数年前にも試していたのですが、実行可能環境を含めとても扱いやすくなっていると感じました。
Webでの取り扱いもシンプルなので、色々試して遊べそうですね。
ここまで読んでいただき有難うございました。

Discussion