<!doctype html> | |
<!-- | |
Copyright 2019 The Immersive Web Community Group | |
Permission is hereby granted, free of charge, to any person obtaining a copy of | |
this software and associated documentation files (the "Software"), to deal in | |
the Software without restriction, including without limitation the rights to | |
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of | |
the Software, and to permit persons to whom the Software is furnished to do so, | |
subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS | |
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR | |
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER | |
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN | |
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
--> | |
<html> | |
<head> | |
<meta charset='utf-8'> | |
<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'> | |
<meta name='mobile-web-app-capable' content='yes'> | |
<meta name='apple-mobile-web-app-capable' content='yes'> | |
<link rel='icon' type='image/png' sizes='32x32' href='favicon-32x32.png'> | |
<link rel='icon' type='image/png' sizes='96x96' href='favicon-96x96.png'> | |
<link rel='stylesheet' href='css/common.css'> | |
<title>Teleportation</title> | |
</head> | |
<body> | |
<header> | |
<details open> | |
<summary>Teleportation</summary> | |
<p> | |
This sample demonstrates teleporting the viewer by updating the | |
XRSession reference space. Select a point on the floor with a | |
controller to teleport to it. Select the leftmost box to rotate to the | |
left by 30 degrees. Selecting the rightmost box rotates the viewer by | |
30 degrees to the right. Select the middle box to reset the | |
viewer orientation. | |
<a class="back" href="./index.html">Back</a> | |
</p> | |
</details> | |
</header> | |
<script type="module"> | |
import {WebXRButton} from './js/util/webxr-button.js'; | |
import {Scene} from './js/render/scenes/scene.js'; | |
import {Renderer, createWebGLContext} from './js/render/core/renderer.js'; | |
import {Node} from './js/render/core/node.js'; | |
import {Gltf2Node} from './js/render/nodes/gltf2.js'; | |
import {SkyboxNode} from './js/render/nodes/skybox.js'; | |
import {BoxBuilder} from './js/render/geometry/box-builder.js'; | |
import {PbrMaterial} from './js/render/materials/pbr.js'; | |
import {mat4, vec3, quat} from './js/render/math/gl-matrix.js'; | |
import {QueryArgs} from './js/util/query-args.js'; | |
// If requested, use the polyfill to provide support for mobile devices | |
// and devices which only support WebVR. | |
import WebXRPolyfill from './js/third-party/webxr-polyfill/build/webxr-polyfill.module.js'; | |
if (QueryArgs.getBool('usePolyfill', true)) { | |
let polyfill = new WebXRPolyfill(); | |
} | |
// If requested, don't display the frame rate info. | |
let hideStats = QueryArgs.getBool('hideStats', false); | |
// XR globals. Several additional reference spaces are required because of | |
// how the teleportation mechanic in onSelect works. | |
let xrButton = null; | |
let xrImmersiveRefSpaceBase = null; | |
let xrImmersiveRefSpaceOffset = null; | |
let xrInlineRefSpaceBase = null; | |
let xrInlineRefSpaceOffset = null; | |
let xrViewerSpaces = {}; | |
let trackingSpaceOriginInWorldSpace = vec3.create(); | |
let trackingSpaceHeadingDegrees = 0; // around +Y axis, positive angles rotate left | |
let floorSize = 10; | |
let floorPosition = [0, -floorSize / 2 + 0.01, 0]; | |
let floorNode = null; | |
// WebGL scene globals. | |
let gl = null; | |
let renderer = null; | |
let scene = new Scene(); | |
if (hideStats) { | |
scene.enableStats(false); | |
} | |
scene.addNode(new Gltf2Node({url: 'media/gltf/cube-room/cube-room.gltf'})); | |
scene.standingStats(true); | |
let boxes = []; | |
function initXR() { | |
xrButton = new WebXRButton({ | |
onRequestSession: onRequestSession, | |
onEndSession: onEndSession | |
}); | |
document.querySelector('header').appendChild(xrButton.domElement); | |
if (navigator.xr) { | |
navigator.xr.isSessionSupported('immersive-vr').then((supported) => { | |
xrButton.enabled = supported; | |
}); | |
navigator.xr.requestSession('inline').then(onSessionStarted); | |
} | |
} | |
function addBox(x, y, z, r, g, b, box_list) { | |
let boxBuilder = new BoxBuilder(); | |
boxBuilder.pushCube([0, 0, 0], 0.4); | |
let boxPrimitive = boxBuilder.finishPrimitive(renderer); | |
let boxMaterial = new PbrMaterial(); | |
boxMaterial.baseColorFactor.value = [r, g, b, 1.0]; | |
let boxRenderPrimitive = renderer.createRenderPrimitive(boxPrimitive, boxMaterial); | |
let boxNode = new Node(); | |
boxNode.addRenderPrimitive(boxRenderPrimitive); | |
// Marks the node as one that needs to be checked when hit testing. | |
boxNode.selectable = true; | |
box_list.push({ | |
node: boxNode, | |
renderPrimitive: boxRenderPrimitive, | |
position: [x, y, z] | |
}); | |
scene.addNode(boxNode); | |
} | |
function addFloorBox() { | |
let boxBuilder = new BoxBuilder(); | |
boxBuilder.pushCube([0, 0, 0], floorSize); | |
let boxPrimitive = boxBuilder.finishPrimitive(renderer); | |
let boxMaterial = new PbrMaterial(); | |
boxMaterial.baseColorFactor.value = [0.3, 0.3, 0.3, 1.0]; | |
let boxRenderPrimitive = renderer.createRenderPrimitive(boxPrimitive, boxMaterial); | |
floorNode = new Node(); | |
floorNode.addRenderPrimitive(boxRenderPrimitive); | |
floorNode.selectable = true; | |
scene.addNode(floorNode); | |
mat4.identity(floorNode.matrix); | |
mat4.translate(floorNode.matrix, floorNode.matrix, floorPosition); | |
} | |
function initGL() { | |
if (gl) | |
return; | |
gl = createWebGLContext({ | |
xrCompatible: true | |
}); | |
document.body.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); | |
// Create several boxes to use for hit testing. | |
addBox(-1.0, 1.6, -1.3, 1.0, 0.0, 0.0, boxes); | |
addBox(0.0, 1.7, -1.5, 0.0, 1.0, 0.0, boxes); | |
addBox(1.0, 1.6, -1.3, 0.0, 0.0, 1.0, boxes); | |
// Represent the floor as a box so that we can perform a hit test | |
// against it onSelect so that we can teleport the user to that | |
// particular location. | |
addFloorBox(); | |
} | |
function onRequestSession() { | |
return navigator.xr.requestSession('immersive-vr', { | |
requiredFeatures: ['local-floor'] | |
}).then((session) => { | |
xrButton.setSession(session); | |
session.isImmersive = true; | |
onSessionStarted(session); | |
}); | |
} | |
function onSessionStarted(session) { | |
session.addEventListener('end', onSessionEnded); | |
// By listening for the 'select' event we can find out when the user has | |
// performed some sort of primary input action and respond to it. | |
session.addEventListener('select', onSelect); | |
initGL(); | |
scene.inputRenderer.useProfileControllerMeshes(session); | |
let glLayer = new XRWebGLLayer(session, gl); | |
session.updateRenderState({ baseLayer: glLayer }); | |
session.requestReferenceSpace('local-floor').then((refSpace) => { | |
console.log("created local-floor reference space"); | |
return refSpace; | |
}, (e) => { | |
if (!session.isImmersive) { | |
// If we're in inline mode, our underlying platform may not support | |
// the local-floor reference space, but a viewer space is guaranteed. | |
console.log("falling back to viewer reference space"); | |
return session.requestReferenceSpace('viewer').then((viewerRefSpace) => { | |
// Adjust the viewer space for an estimated user height. Otherwise, | |
// the poses queried with this space will originate from the floor. | |
let xform = new XRRigidTransform({x: 0, y: -1.5, z: 0}); | |
return viewerRefSpace.getOffsetReferenceSpace(xform); | |
}); | |
} else { | |
throw e; | |
} | |
}).then((refSpace) => { | |
// Save the session-specific base reference space, and apply the current | |
// player orientation/position as originOffset. This reference space | |
// won't change for the duration of the session and is used when | |
// updating the player position and/or orientation in onSelect. | |
setRefSpace(session, refSpace, false); | |
updateOriginOffset(session); | |
session.requestReferenceSpace('viewer').then(function(viewerSpace){ | |
// Save a separate reference space that represents the tracking space | |
// origin, which does not change for the duration of the session. | |
// This is used when updating the player position and/or orientation | |
// in onSelect. | |
xrViewerSpaces[session.mode] = viewerSpace; | |
session.requestAnimationFrame(onXRFrame); | |
}); | |
}); | |
} | |
// Used for updating the origin offset. | |
let playerInWorldSpaceOld = vec3.create(); | |
let playerInWorldSpaceNew = vec3.create(); | |
let playerOffsetInWorldSpaceOld = vec3.create(); | |
let playerOffsetInWorldSpaceNew = vec3.create(); | |
let rotationDeltaQuat = quat.create(); | |
let invPosition = vec3.create(); | |
let invOrientation = quat.create(); | |
// If the user selected a point on the floor, teleport them to that | |
// position while keeping their orientation the same. | |
// Otherwise, check if one of the boxes was selected and update the | |
// user's orientation accordingly: | |
// left box: turn left by 30 degrees | |
// center box: reset orientation | |
// right box: turn right by 30 degrees | |
function onSelect(ev) { | |
let session = ev.frame.session; | |
let refSpace = getRefSpace(session, true); | |
let headPose = ev.frame.getPose(xrViewerSpaces[session.mode], refSpace); | |
if (!headPose) return; | |
// Get the position offset in world space from the tracking space origin | |
// to the player's feet. The headPose position is the head position in world space. | |
// Subtract the tracking space origin position in world space to get a relative world space vector. | |
vec3.set(playerInWorldSpaceOld, headPose.transform.position.x, 0, headPose.transform.position.z); | |
vec3.sub( | |
playerOffsetInWorldSpaceOld, | |
playerInWorldSpaceOld, | |
trackingSpaceOriginInWorldSpace); | |
// based on https://github.com/immersive-web/webxr/blob/master/input-explainer.md#targeting-ray-pose | |
let inputSourcePose = ev.frame.getPose(ev.inputSource.targetRaySpace, refSpace); | |
if (!inputSourcePose) { | |
return; | |
} | |
vec3.copy(playerInWorldSpaceNew, playerInWorldSpaceOld); | |
let rotationDelta = 0; | |
// Hit test results can change teleport position and orientation. | |
let hitResult = scene.hitTest(inputSourcePose.transform); | |
if (hitResult) { | |
// Check to see if the hit result was one of our boxes. | |
for (let i = 0; i < boxes.length; ++i) { | |
let box = boxes[i]; | |
if (hitResult.node == box.node) { | |
// Change the box color to something random. | |
let uniforms = box.renderPrimitive.uniforms; | |
uniforms.baseColorFactor.value = [Math.random(), Math.random(), Math.random(), 1.0]; | |
if (i == 0) { | |
// turn left | |
rotationDelta = 30; | |
} else if (i == 1) { | |
// reset heading by undoing the current rotation | |
rotationDelta = -trackingSpaceHeadingDegrees; | |
} else if (i == 2) { | |
// turn right | |
rotationDelta = -30; | |
} | |
console.log('rotate by', rotationDelta); | |
} | |
} | |
if (hitResult.node == floorNode) { | |
// New position uses x/z values of the hit test result, keeping y at 0 (floor level) | |
playerInWorldSpaceNew[0] = hitResult.intersection[0]; | |
playerInWorldSpaceNew[1] = 0; | |
playerInWorldSpaceNew[2] = hitResult.intersection[2]; | |
console.log('teleport to', playerInWorldSpaceNew); | |
} | |
} | |
// Get the new world space offset vector from tracking space origin | |
// to the player's feet, for the updated tracking space rotation. | |
// Formally, this is the old world-space player offset transformed | |
// into tracking space using the old originOffset's rotation component, | |
// then transformed back into world space using the inverse of the | |
// new originOffset. This simplifies to a rotation of the old player | |
// offset by (new angle - old angle): | |
// worldOffsetNew = inv(rot_of(originOffsetNew)) * rot_of(originOffsetOld) * worldOffsetOld | |
// = inv(rotY(-angleNew)) * rotY(-angleOld) * worldOffsetOld | |
// = rotY(angleNew) * rotY(-angleOld) * worldOffsetOld | |
// = rotY(angleNew - angleOld) * worldOffsetOld | |
quat.identity(rotationDeltaQuat); | |
quat.rotateY(rotationDeltaQuat, rotationDeltaQuat, rotationDelta * Math.PI / 180); | |
vec3.transformQuat(playerOffsetInWorldSpaceNew, playerOffsetInWorldSpaceOld, rotationDeltaQuat); | |
trackingSpaceHeadingDegrees += rotationDelta; | |
// Update tracking space origin so that origin + playerOffset == player location in world space | |
vec3.sub( | |
trackingSpaceOriginInWorldSpace, | |
playerInWorldSpaceNew, | |
playerOffsetInWorldSpaceNew); | |
updateOriginOffset(session); | |
} | |
function updateOriginOffset(session) { | |
// Compute the origin offset based on player position/orientation. | |
quat.identity(invOrientation); | |
quat.rotateY(invOrientation, invOrientation, -trackingSpaceHeadingDegrees * Math.PI / 180); | |
vec3.negate(invPosition, trackingSpaceOriginInWorldSpace); | |
vec3.transformQuat(invPosition, invPosition, invOrientation); | |
let xform = new XRRigidTransform( | |
{x: invPosition[0], y: invPosition[1], z: invPosition[2]}, | |
{x: invOrientation[0], y: invOrientation[1], z: invOrientation[2], w: invOrientation[3]}); | |
// Update offset reference to use a new originOffset with the teleported | |
// player position and orientation. | |
// This new offset needs to be applied to the base ref space. | |
let refSpace = getRefSpace(session, false).getOffsetReferenceSpace(xform); | |
setRefSpace(session, refSpace, true); | |
console.log('teleport to', trackingSpaceOriginInWorldSpace); | |
} | |
function onEndSession(session) { | |
session.end(); | |
} | |
function onSessionEnded(event) { | |
if (event.session.isImmersive) { | |
xrButton.setSession(null); | |
} | |
} | |
function getRefSpace(session, isOffset) { | |
return session.isImmersive ? | |
(isOffset ? xrImmersiveRefSpaceOffset : xrImmersiveRefSpaceBase) : | |
(isOffset ? xrInlineRefSpaceOffset : xrInlineRefSpaceBase); | |
} | |
function setRefSpace(session, refSpace, isOffset) { | |
if (session.isImmersive) { | |
if (isOffset) { | |
xrImmersiveRefSpaceOffset = refSpace; | |
} else { | |
xrImmersiveRefSpaceBase = refSpace; | |
} | |
} else { | |
if (isOffset) { | |
xrInlineRefSpaceOffset = refSpace; | |
} else { | |
xrInlineRefSpaceBase = refSpace; | |
} | |
} | |
} | |
function onXRFrame(time, frame) { | |
let session = frame.session; | |
let refSpace = getRefSpace(session, true); | |
let pose = frame.getViewerPose(refSpace); | |
scene.startFrame(); | |
session.requestAnimationFrame(onXRFrame); | |
// Update the matrix for each box | |
for (let box of boxes) { | |
let node = box.node; | |
mat4.identity(node.matrix); | |
mat4.translate(node.matrix, node.matrix, box.position); | |
mat4.rotateX(node.matrix, node.matrix, time/1000); | |
mat4.rotateY(node.matrix, node.matrix, time/1500); | |
} | |
scene.updateInputSources(frame, refSpace); | |
scene.drawXRFrame(frame, pose); | |
scene.endFrame(); | |
} | |
// Start the XR application. | |
initXR(); | |
</script> | |
</body> | |
</html> |