(元ネタ) 『檸檬』
えたいの知れない不吉な塊が私の心を始終圧えつけていた。
憂鬱な気持ちを抱える主人公はレモン🍋の美しさに惹きつけられる。気を紛らわすようにレモン🍋を爆弾💣に見立て、丸善の書棚へ残し立ち去る...
WebXRでこっそりレモン🍋を置く
日頃のストレスを放置してしまうと心身ともに悪影響です。
健やかに生き抜くためにこっそりレモン🍋を置いてストレス発散しましょう。
使用技術
- WebXR:専用のデバイスは必要とせずに、ブラウザで拡張現実(AR)や仮想現実(VR)を提供できる。
- Three.js:HTML5で3Dコンテンツを制作できるJavascriptライブラリ。アプリケーションのレンダリング周りを管理する。
WebXR はレンダリング技術ではないので、三次元データを管理したり、ディスプレイにレンダリングしたりする機能は提供しません。これは重要な事実として覚えておいてください。 WebXR はシーンを描画する際に関連するタイミングやスケジューリング、さまざまな視点を管理しますが、モデルをロードして管理する方法や、レンダリングしてテクスチャを貼る方法などはわかりません。
Vite + Vue3 + Three.js 環境構築
Viteによるプロジェクト準備
個人的に慣れているVue.jsを選んでいますがお好みでどうぞ。
ビルドツールにはcreate-vueを使用しています。
npm init vue@3 # オプションが表示されるので好きに設定する。
Three.jsのインストール
npm install three
npm install --save-dev @types/three # TypeScriptでは型もインストール
適当なコンポーネントを作成し動作確認しておきます。
<script setup lang="ts">
import * as THREE from "three";
/* SCENE,CAMERA */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
/* RENDERER */
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
/* 立方体生成 */
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;
/* フレームごとに実行されるアニメーション */
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
</script>
<template>
<h1>Threejs Test</h1>
</template>
Scene
や立方体生成については簡単ですが解説を書いているのでよければ参照ください。
アプリケーションからWebXRがサポートされているか調べる
現時点でWebXR Device API
の対応状況は下記の通り。
また、使用するデバイスでサポートされているかは下記サイトにアクセスすると確認できます。
アプリケーションでもWebXR Device API
やimmersive-ar
がサポートされているか調べる必要がありますが、Three.jsで提供されているARButton
を使用すると簡単に実装できます。
ARButton
がデバイスのWebXRサポート状況を表示してくれます。
import { ARButton } from "three/examples/jsm/webxr/ARButton.js";
/* ARButton */
document.body.appendChild(
ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
);
平面検出
ARコンテンツにおいてユーザーエクスペリエンスを向上させるには、現実空間との融合が重要です。
現実空間の平面を検出し配置することで、実際にレモンが存在するように感じさせることができます。
Three.jsで提供されているサンプルを参考に平面検出を実装してみます。
WebXR Device APIを有効にする
WebGLRendererを利用して、WebXR Device API
を使えるようにします。
また、デバイスのWebXRサポート状況を表示するARButton
も追加しておきます。
/* RENDERER */
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true; // WebXR Device APIを有効化
/* ARButton */
document.body.appendChild(
ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
);
平面上にマーカーを表示する
サンプルでは、RingGeometryをマーカーとして利用しています。
また、毎フレームごとにXRHitTestResultからマーカーの位置を変更しています。
function render(timestamp: number, frame: XRFrame) {
if (frame) {
// ...
if (hitTestSource) {
const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length) {
/* XRHitTestResultがあれば、マーカーの位置を変更 */
const hit = hitTestResults[0];
reticle.visible = true;
reticle.matrix.fromArray(
hit.getPose(referenceSpace)!.transform.matrix
);
} else {
/* XRHitTestResultがなければ、マーカーを非表示に */
reticle.visible = false;
}
}
}
// ...
}
下記は平面検出サンプルを適当にコンポーネント化したものです。
<script setup lang="ts">
import * as THREE from "three";
import { ARButton } from "three/examples/jsm/webxr/ARButton.js";
import { onMounted } from "vue";
onMounted(() => {
init();
});
const init = () => {
let hitTestSource: XRHitTestSource | null = null;
let hitTestSourceRequested = false;
/* Container */
const container = document.getElementById("three-container");
if (!container) {
console.log("sorry cannot get three-container");
return;
}
/* SCENE,CAMERA */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.01,
20
);
/* Light */
const light = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 1);
light.position.set(0.5, 1, 0.25);
scene.add(light);
/* RENDERER */
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
container.appendChild(renderer.domElement);
/* ARButton */
document.body.appendChild(
ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
);
/* Geometry */
const reticle = new THREE.Mesh(
new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial()
);
reticle.matrixAutoUpdate = false;
reticle.visible = false;
scene.add(reticle);
const geometry = new THREE.CylinderGeometry(0.1, 0.1, 0.2, 32).translate(
0,
0.1,
0
);
/* Controller */
const controller = renderer.xr.getController(0);
controller.addEventListener("select", onSelect);
scene.add(controller);
function onSelect() {
if (reticle.visible) {
const material = new THREE.MeshPhongMaterial({
color: 0xffffff * Math.random(),
});
const mesh = new THREE.Mesh(geometry, material);
reticle.matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
mesh.scale.y = Math.random() * 2 + 1;
scene.add(mesh);
}
}
/* ウィンドウリサイズ対応 */
window.addEventListener("resize", onWindowResize);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// フレームごとに実行されるアニメーション
animate();
function animate() {
renderer.setAnimationLoop(render);
}
function render(timestamp: number, frame: XRFrame) {
if (frame) {
const referenceSpace = renderer.xr.getReferenceSpace();
if (!referenceSpace) {
console.log("sorry cannot get renderer referenceSpace");
return;
}
const session = renderer.xr.getSession();
if (!session) {
console.log("sorry cannot get renderer session");
return;
}
if (hitTestSourceRequested === false) {
session.requestReferenceSpace("viewer").then((referenceSpace) => {
session.requestHitTestSource!({ space: referenceSpace })!.then(
function (source) {
hitTestSource = source;
}
);
});
session.addEventListener("end", function () {
hitTestSourceRequested = false;
hitTestSource = null;
});
hitTestSourceRequested = true;
}
if (hitTestSource) {
const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length) {
const hit = hitTestResults[0];
reticle.visible = true;
reticle.matrix.fromArray(
hit.getPose(referenceSpace)!.transform.matrix
);
} else {
reticle.visible = false;
}
}
}
renderer.render(scene, camera);
}
};
</script>
<template>
<header class="header">
<div class="header__inner">
<h1 class="header__title">Hit Test</h1>
</div>
</header>
<div id="three-container"></div>
</template>
<style scoped>
.header {
padding: 20px;
height: 80px;
}
.header__inner {
max-width: 1230px;
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
.header__title {
font-size: 32px;
}
</style>
3Dモデルの読み込みと配置
Three.jsでの3Dモデル読み込みには、モデルの拡張子ごとにローダーを選択して実装します。
three.js provides many loaders
今回は.glb
モデルを使用するので、GLTFLoaderを使用します。
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
function loadModel(){
const loader = new GLTFLoader().setPath("./assets/");
/* モデルのロード */
loader.load('model.glb', function (gltf) {
const mesh = gltf.scene.children[0];
/* モデルのサイズや位置調整 */
const s = 0.35;
mesh.scale.set(s, s, s);
mesh.position.y = 15;
mesh.rotation.y = - 1;
mesh.castShadow = true;
mesh.receiveShadow = true;
/* Sceneへ追加 */
scene.add(mesh);
});
}
3Dモデルの読み込みができたので、検出された平面上に画面タップで配置するようにします。
renderer.xr.getController(0)
を取得することで画面タップ時の処理を登録できます。
/* Controller */
const controller = renderer.xr.getController(0);
controller.addEventListener("select", onSelect);
scene.add(controller);
// 画面タップ時のイベントハンドラー
function onSelect() {
if (reticle.visible) {
model.visible = true;
// マーカーの位置にモデルを配置
model.position.setFromMatrixPosition(reticle.matrix);
}
}
}
下記は3Dモデルの読み込みと配置を適当にコンポーネント化したものです。
<script setup lang="ts">
import * as THREE from "three";
import { ARButton } from "three/examples/jsm/webxr/ARButton.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { onMounted } from "vue";
onMounted(() => {
init();
});
const init = () => {
let hitTestSource: XRHitTestSource | null = null;
let hitTestSourceRequested = false;
let model: THREE.Object3D<THREE.Event> | null = null;
/* Container */
const container = document.getElementById("three-container");
if (!container) {
console.log("sorry cannot get three-container");
return;
}
/* Scene */
const scene = new THREE.Scene();
/* Camera */
const camera = new THREE.PerspectiveCamera(
30,
window.innerWidth / window.innerHeight,
1,
5000
);
camera.position.set(0, 0, 250);
/* Light */
const light = new THREE.DirectionalLight();
light.position.set(0.2, 1, 1);
scene.add(light);
/* Renderer */
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
container.appendChild(renderer.domElement);
/* ARButton */
document.body.appendChild(
ARButton.createButton(renderer, { requiredFeatures: ["hit-test"] })
);
/* Reticle */
const reticle = new THREE.Mesh(
new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial()
);
reticle.matrixAutoUpdate = false;
reticle.visible = false;
scene.add(reticle);
/* Model */
const loader = new GLTFLoader().setPath("./assets/");
loader.load("model.glb", function (gltf) {
const mesh = gltf.scene.children[0];
/* モデルのサイズや位置調整 */
const s = 0.03;
mesh.scale.set(s, s, s);
mesh.position.y = 15;
mesh.rotation.y = -1;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.visible = false;
model = mesh;
scene.add(model);
});
/* Controller */
const controller = renderer.xr.getController(0);
controller.addEventListener("select", onSelect);
scene.add(controller);
function onSelect() {
if (model && reticle.visible) {
if (model.visible) {
model.position.setFromMatrixPosition(reticle.matrix);
} else {
model.visible = true;
model.position.setFromMatrixPosition(reticle.matrix);
}
}
}
/* ウィンドウリサイズ対応 */
window.addEventListener("resize", onWindowResize);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// フレームごとに実行されるアニメーション
animate();
function animate() {
renderer.setAnimationLoop(render);
}
function render(timestamp: number, frame: XRFrame) {
if (frame) {
const referenceSpace = renderer.xr.getReferenceSpace();
if (!referenceSpace) {
console.log("sorry cannot get renderer referenceSpace");
return;
}
const session = renderer.xr.getSession();
if (!session) {
console.log("sorry cannot get renderer session");
return;
}
if (hitTestSourceRequested === false) {
session.requestReferenceSpace("viewer").then((referenceSpace) => {
session.requestHitTestSource!({ space: referenceSpace })!.then(
function (source) {
hitTestSource = source;
}
);
});
session.addEventListener("end", function () {
hitTestSourceRequested = false;
hitTestSource = null;
});
hitTestSourceRequested = true;
}
if (hitTestSource) {
const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length) {
const hit = hitTestResults[0];
reticle.visible = true;
reticle.matrix.fromArray(
hit.getPose(referenceSpace)!.transform.matrix
);
} else {
reticle.visible = false;
}
}
}
renderer.render(scene, camera);
}
};
</script>
<template>
<header class="header">
<div class="header__inner">
<h1 class="header__title">Hit Test</h1>
</div>
</header>
<div id="three-container"></div>
</template>
<style scoped>
.header {
padding: 20px;
height: 80px;
}
.header__inner {
max-width: 1230px;
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
.header__title {
font-size: 32px;
}
</style>
おわり
WebXRを利用して平面検出と3Dモデル配置を試してみました。
専用デバイスを必要としないため様々なユーザーに提供することができる...といいですが各ブラウザのWebXRサポートを待つ状況です。