Skip to content

Commit

Permalink
feature(view): add WebXR support.
Browse files Browse the repository at this point in the history
  • Loading branch information
gchoqueux authored and Desplandis committed Nov 17, 2023
1 parent 2428d56 commit ec64d2b
Show file tree
Hide file tree
Showing 8 changed files with 246 additions and 4 deletions.
3 changes: 2 additions & 1 deletion config/threeExamples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default {
'./utils/WorkerPool.js',
'./capabilities/WebGL.js',
'./libs/ktx-parse.module.js',
'./libs/zstddec.module.js'
'./libs/zstddec.module.js',
'./webxr/VRButton.js',
],
};
1 change: 1 addition & 0 deletions examples/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"view_3d_map": "3D map",
"view_25d_map": "2.5D map",
"view_2d_map": "2D map",
"view_3d_map_webxr": "3D map WebXR",
"view_multiglobe": "Multiple globes",
"view_multi_25d": "Multiple 2.5D maps",
"view_immersive": "Immersive view",
Expand Down
75 changes: 75 additions & 0 deletions examples/view_3d_map_webxr.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<html>
<head>
<title>Itowns - WebXR Example</title>

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<link rel="stylesheet" type="text/css" href="css/example.css">
<link rel="stylesheet" type="text/css" href="css/LoadingScreen.css">
<link rel="stylesheet" type="text/css" href="css/widgets.css">

<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.min.js"></script>
</head>
<body>
<div id="viewerDiv"></div>

<!-- Import iTowns source code -->
<script src="../dist/itowns.js"></script>
<script src="../dist/debug.js"></script>
<!-- Import iTowns Widgets plugin -->
<script src="../dist/itowns_widgets.js"></script>
<!-- Import iTowns LoadingScreen and GuiTools plugins -->
<script src="js/GUI/GuiTools.js"></script>
<script src="js/GUI/LoadingScreen.js"></script>

<script type="text/javascript">
// ---------- CREATE A GlobeView FOR SUPPORTING DATA VISUALIZATION : ----------

// Define camera initial position
const placement = {
coord: new itowns.Coordinates('EPSG:4326', 6.227, 45.167),
range: 15000,
tilt: 5,
heading: 62,
}

// `viewerDiv` will contain iTowns' rendering area (`<canvas>`)
const viewerDiv = document.getElementById('viewerDiv');

// Create a GlobeView
const view = new itowns.GlobeView(viewerDiv, placement, {
webXR: { scale: 0.005 },
});

// Setup loading screen and debug menu
setupLoadingScreen(viewerDiv, view);

// ---------- DISPLAY ORTHO-IMAGES : ----------

// Add one imagery layer to the scene. This layer's properties are
// defined in a json file, but it could be defined as a plain js
// object. See `Layer` documentation for more info.
itowns.Fetcher.json('./layers/JSONLayers/Ortho.json').then((config) => {
config.source = new itowns.WMTSSource(config.source);
view.addLayer(new itowns.ColorLayer('Ortho', config),
);
});

// ---------- DISPLAY A DIGITAL ELEVATION MODEL : ----------

// Add two elevation layers, each with a different level of detail.
// Here again, each layer's properties are defined in a json file.
function addElevationLayerFromConfig(config) {
config.source = new itowns.WMTSSource(config.source);
view.addLayer(
new itowns.ElevationLayer(config.id, config),
);
}
itowns.Fetcher.json('./layers/JSONLayers/IGN_MNT_HIGHRES.json')
.then(addElevationLayerFromConfig);
itowns.Fetcher.json('./layers/JSONLayers/WORLD_DTM.json')
.then(addElevationLayerFromConfig);
</script>
</body>
</html>
9 changes: 7 additions & 2 deletions src/Core/MainLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,12 @@ class MainLoop extends EventDispatcher {
document.title += ' ⌛';
}

requestAnimationFrame((timestamp) => { this.#step(view, timestamp); });
// TODO Fix asynchronization between xr and MainLoop render loops.
// WebGLRenderer#setAnimationLoop must be used for WebXR projects.
// (see WebXR#initializeWebXR).
if (!this.gfxEngine.renderer.xr.isPresenting) {
requestAnimationFrame((timestamp) => { this.step(view, timestamp); });
}
}
}

Expand Down Expand Up @@ -163,7 +168,7 @@ class MainLoop extends EventDispatcher {
}
}

#step(view, timestamp) {
step(view, timestamp) {
const dt = timestamp - this.#lastTimestamp;
view._executeFrameRequestersRemovals();

Expand Down
11 changes: 10 additions & 1 deletion src/Core/View.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as THREE from 'three';
import Camera from 'Renderer/Camera';
import initializeWebXR from 'Renderer/WebXR';
import MainLoop, { MAIN_LOOP_EVENTS, RENDERING_PAUSED } from 'Core/MainLoop';
import Capabilities from 'Core/System/Capabilities';
import { COLOR_LAYERS_ORDER_CHANGED } from 'Renderer/ColorLayersOrdering';
Expand Down Expand Up @@ -149,6 +150,9 @@ class View extends THREE.EventDispatcher {
* a default one will be constructed. In this case, if options.renderer is an object, it will be used to
* configure the renderer (see {@link c3DEngine}. If not present, a new &lt;canvas> will be created and
* added to viewerDiv (mutually exclusive with mainLoop)
* @param {boolean} [options.renderer.isWebGL2=true] - enable webgl 2.0 for THREE.js.
* @param {boolean|Object} [options.webXR=false] - enable webxr button to switch on VR visualization.
* @param {number} [options.webXR.scale=1.0] - apply webxr scale tranformation.
* @param {?Scene} [options.scene3D] - [THREE.Scene](https://threejs.org/docs/#api/en/scenes/Scene) instance to use, otherwise a default one will be constructed
* @param {?Color} options.diffuse - [THREE.Color](https://threejs.org/docs/?q=color#api/en/math/Color) Diffuse color terrain material.
* This color is applied to terrain if there isn't color layer on terrain extent (by example on pole).
Expand Down Expand Up @@ -248,6 +252,10 @@ class View extends THREE.EventDispatcher {

// push all viewer to keep source.cache
viewers.push(this);

if (options.webXR) {
initializeWebXR(this, options.webXR);
}
}

/**
Expand Down Expand Up @@ -453,7 +461,8 @@ class View extends THREE.EventDispatcher {
notifyChange(changeSource = undefined, needsRedraw = true) {
if (changeSource) {
this._changeSources.add(changeSource);
if ((changeSource.isTileMesh || changeSource.isCamera)) {
if (!this.mainLoop.gfxEngine.renderer.xr.isPresenting
&& (changeSource.isTileMesh || changeSource.isCamera)) {
this.#fullSizeDepthBuffer.needsUpdate = true;
}
}
Expand Down
73 changes: 73 additions & 0 deletions src/Renderer/WebXR.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* global XRRigidTransform */

import * as THREE from 'three';
import { VRButton } from 'ThreeExtended/webxr/VRButton';

async function shutdownXR(session) {
if (session) {
await session.end();
}
}

const initializeWebXR = (view, options) => {
const scale = options.scale || 1.0;

const renderer = view.mainLoop.gfxEngine.renderer;
const xr = renderer.xr;

view.domElement.appendChild(VRButton.createButton(renderer));

xr.addEventListener('sessionstart', () => {
const camera = view.camera.camera3D;

const exitXRSession = (event) => {
if (event.key === 'Escape') {
document.removeEventListener('keydown', exitXRSession);
xr.enabled = false;
view.camera.camera3D = camera;

view.scene.scale.multiplyScalar(1 / scale);
view.scene.updateMatrixWorld();

shutdownXR(xr.getSession());
view.notifyChange(view.camera.camera3D, true);
}
};
view.scene.scale.multiplyScalar(scale);
view.scene.updateMatrixWorld();
xr.enabled = true;
xr.getReferenceSpace('local');

const position = view.camera.position();
const geodesicNormal = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), position.geodesicNormal).invert();

const quat = new THREE.Quaternion(-1, 0, 0, 1).normalize().multiply(geodesicNormal);
const trans = camera.position.clone().multiplyScalar(-scale).applyQuaternion(quat);
const transform = new XRRigidTransform(trans, quat);

const baseReferenceSpace = xr.getReferenceSpace();
const teleportSpaceOffset = baseReferenceSpace.getOffsetReferenceSpace(transform);
xr.setReferenceSpace(teleportSpaceOffset);

view.camera.camera3D = xr.getCamera();
view.camera.resize(view.camera.width, view.camera.height);

document.addEventListener('keydown', exitXRSession, false);

// TODO Fix asynchronization between xr and MainLoop render loops.
// (see MainLoop#scheduleViewUpdate).
xr.setAnimationLoop((timestamp) => {
if (xr.isPresenting && view.camera.camera3D.cameras[0]) {
view.camera.camera3D.updateMatrix();
view.camera.camera3D.updateMatrixWorld(true);
view.notifyChange(view.camera.camera3D, true);
}

view.mainLoop.step(view, timestamp);
});
});
};

export default initializeWebXR;


37 changes: 37 additions & 0 deletions test/unit/bootstrap.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */
import fetch from 'node-fetch';
import { Camera } from 'three';

global.window = {
addEventListener: () => {},
Expand Down Expand Up @@ -165,9 +166,21 @@ global.document = {
},
createElementNS: (_, type) => (global.document.createElement(type)),
getElementsByTagName: () => [new DOMElement()],
events: new Map(),
};

global.document.addEventListener = (event, cb) => { global.document.events.set(event, cb); };
global.document.removeEventListener = () => {};
global.document.emitEvent = (event, params) => {
const callback = global.document.events.get(event);
if (callback) {
return callback(params);
}
};
global.document.documentElement = global.document.createElement();
global.document.body = new DOMElement();

global.XRRigidTransform = () => {};

class Path2D {
moveTo() {}
Expand All @@ -176,12 +189,36 @@ class Path2D {

global.Path2D = Path2D;

class EventDispatcher {
constructor() {
this.events = new Map();
}

addEventListener(type, listener) {
this.events.set(type, listener);
}

dispatchEvent(event) {
this.events.get(event.type).call(this, event);
}
}

class Renderer {
constructor() {
this.domElement = new DOMElement();
this.domElement.parentElement = new DOMElement();
this.domElement.parentElement.appendChild(this.domElement);

this.xr = new EventDispatcher();
this.xr.isPresenting = false;
this.xr.getReferenceSpace = () => ({
getOffsetReferenceSpace: () => {},
});
this.xr.setReferenceSpace = () => {};
this.xr.getCamera = () => new Camera();
this.xr.setAnimationLoop = () => {};
this.xr.getSession = () => {};

this.context = {
getParameter: () => 16,
createProgram: () => { },
Expand Down
41 changes: 41 additions & 0 deletions test/unit/webxr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from 'assert';
import View from 'Core/View';
import Renderer from './bootstrap';

describe('WebXR', function () {
let viewer;
before(function () {
const renderer = new Renderer();

viewer = new View('EPSG:4326', renderer.domElement, {
renderer,
webXR: { scale: 0.005 },
});
});

it('should initialize webXr', function () {
// VR button added to viewer div
// Children shall be the canvas and the VR button
assert.ok(viewer.domElement.children.length === 2);
const webXRManager = viewer.mainLoop.gfxEngine.renderer.xr;

const sessionEvent = webXRManager.events.get('sessionstart');

assert.ok(typeof sessionEvent === 'function');
});

it('should initialize webXr session', function () {
const webXRManager = viewer.mainLoop.gfxEngine.renderer.xr;
assert.ok(webXRManager.enabled === undefined);
webXRManager.dispatchEvent({ type: 'sessionstart' });
assert.ok(webXRManager.enabled);
});

it('should close webXr session', function () {
const webXRManager = viewer.mainLoop.gfxEngine.renderer.xr;
assert.ok(webXRManager.enabled);
document.emitEvent('keydown', { key: 'Escape' });
assert.ok(webXRManager.enabled === false);
assert.ok(viewer);
});
});

0 comments on commit ec64d2b

Please sign in to comment.