| <!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> |