Revision control

Copy as Markdown

/*
XRExampleBase holds all of the common XR setup, rendering, and teardown code for a THREE.js based app
It also holds a list of THREE nodes and XRAnchorOffsets which it uses to update the nodes' poses
Extending classes should be able to focus mainly on rendering their scene and handling user input
Parameters:
domElement: an element used to show error messages
createVirtualReality: if true, create a new empty reality for this app
WebVR 1.1 displays require that the call to requestPresent be a direct result of an input event like a click.
If you're trying to use a WebVR 1.1 display then you'll need to pass false in the shouldStartPresenting parameter
of the constructor and then call this.startPresenting() inside an input event handler.
*/
class XRExampleBase {
constructor(domElement, createVirtualReality=true, shouldStartPresenting=true){
this.el = domElement
this.createVirtualReality = createVirtualReality
this.shouldStartPresenting = shouldStartPresenting
this._boundHandleFrame = this._handleFrame.bind(this) // Useful for setting up the requestAnimationFrame callback
// Set during the XR.getDisplays call below
this.displays = null
// Set during this.startSession below
this.display = null
this.session = null
this.scene = new THREE.Scene() // The scene will be rotated and oriented around the camera using the head pose
this.camera = new THREE.PerspectiveCamera(70, 1024, 1024, 0.1, 1000) // These values will be overwritten by the projection matrix from ARKit or ARCore
this.scene.add(this.camera)
// Create a canvas and context for the session layer
this.glCanvas = document.createElement('canvas')
this.glContext = this.glCanvas.getContext('webgl')
if(this.glContext === null){
this.showMessage('Could not create a WebGL canvas')
throw new Error('Could not create GL context')
}
// Set up the THREE renderer with the session's layer's glContext
this.renderer = new THREE.WebGLRenderer({
canvas: this.glCanvas,
context: this.glContext,
antialias: false,
alpha: true
})
this.renderer.setPixelRatio(1)
this.renderer.autoClear = false
this.renderer.setClearColor('#000', 0)
this.requestedFloor = false
this.floorGroup = new THREE.Group() // This group will eventually be be anchored to the floor (see findFloorAnchor below)
// an array of info that we'll use in _handleFrame to update the nodes using anchors
this.anchoredNodes = [] // { XRAnchorOffset, Three.js Object3D }
// Give extending classes the opportunity to initially populate the scene
this.initializeScene()
if(typeof navigator.XR === 'undefined'){
this.showMessage('No WebXR API found, usually because the WebXR polyfill has not loaded')
return
}
// Get displays and then request a session
navigator.XR.getDisplays().then(displays => {
if(displays.length == 0) {
this.showMessage('No displays are available')
return
}
this.displays = displays
this._startSession()
}).catch(err => {
console.error('Error getting XR displays', err)
this.showMessage('Could not get XR displays')
})
}
_startSession(){
let sessionInitParamers = {
exclusive: this.createVirtualReality,
type: this.createVirtualReality ? XRSession.REALITY : XRSession.AUGMENTATION
}
for(let display of this.displays){
if(display.supportsSession(sessionInitParamers)){
this.display = display
break
}
}
if(this.display === null){
this.showMessage('Could not find a display for this type of session')
return
}
this.display.requestSession(sessionInitParamers).then(session => {
this.session = session
this.session.depthNear = 0.1
this.session.depthFar = 1000.0
// Handle session lifecycle events
this.session.addEventListener('focus', ev => { this.handleSessionFocus(ev) })
this.session.addEventListener('blur', ev => { this.handleSessionBlur(ev) })
this.session.addEventListener('end', ev => { this.handleSessionEnded(ev) })
if(this.shouldStartPresenting){
// VR Displays need startPresenting called due to input events like a click
this.startPresenting()
}
}).catch(err => {
console.error('Error requesting session', err)
this.showMessage('Could not initiate the session')
})
}
/*
Empties this.el, adds a div with the message text, and shows a button to test rendering the scene to this.el
*/
showMessage(messageText){
let messages = document.getElementsByClassName('common-message')
if(messages.length > 0){
var message = messages[0]
} else {
var message = document.createElement('div')
message.setAttribute('class', 'common-message')
this.el.append(message)
}
let div = document.createElement('div')
div.innerHTML = messageText
message.appendChild(div)
}
/*
WebVR 1.1 displays require that the call to requestPresent be a direct result of an input event like a click.
If you're trying to set up a VR display, you'll need to pass false in the shouldStartPresenting parameter of the constructor
and then call this.startPresenting() inside an input event handler.
*/
startPresenting(){
if(this.session === null){
this.showMessage('Can not start presenting without a session')
throw new Error('Can not start presenting without a session')
}
// Set the session's base layer into which the app will render
this.session.baseLayer = new XRWebGLLayer(this.session, this.glContext)
// Handle layer focus events
this.session.baseLayer.addEventListener('focus', ev => { this.handleLayerFocus(ev) })
this.session.baseLayer.addEventListener('blur', ev => { this.handleLayerBlur(ev) })
this.session.requestFrame(this._boundHandleFrame)
}
// Extending classes can react to these events
handleSessionFocus(ev){}
handleSessionBlur(ev){}
handleSessionEnded(ev){}
handleLayerFocus(ev){}
handleLayerBlur(ev){}
/*
Extending classes should override this to set up the scene during class construction
*/
initializeScene(){}
/*
Extending classes that need to update the layer during each frame should override this method
*/
updateScene(frame){}
_handleFrame(frame){
const nextFrameRequest = this.session.requestFrame(this._boundHandleFrame)
const headPose = frame.getViewPose(frame.getCoordinateSystem(XRCoordinateSystem.HEAD_MODEL))
// If we haven't already, request the floor anchor offset
if(this.requestedFloor === false){
this.requestedFloor = true
frame.findFloorAnchor('first-floor-anchor').then(anchorOffset => {
if(anchorOffset === null){
console.error('could not find the floor anchor')
return
}
this.addAnchoredNode(anchorOffset, this.floorGroup)
}).catch(err => {
console.error('error finding the floor anchor', err)
})
}
// Update anchored node positions in the scene graph
for(let anchoredNode of this.anchoredNodes){
this.updateNodeFromAnchorOffset(frame, anchoredNode.node, anchoredNode.anchorOffset)
}
// Let the extending class update the scene before each render
this.updateScene(frame)
// Prep THREE.js for the render of each XRView
this.renderer.autoClear = false
this.renderer.setSize(this.session.baseLayer.framebufferWidth, this.session.baseLayer.framebufferHeight, false)
this.renderer.clear()
this.camera.matrixAutoUpdate = false
// Render each view into this.session.baseLayer.context
for(const view of frame.views){
// Each XRView has its own projection matrix, so set the camera to use that
this.camera.projectionMatrix.fromArray(view.projectionMatrix)
this.camera.matrix.fromArray(headPose.poseModelMatrix)
this.camera.updateMatrixWorld(true)
// Set up the renderer to the XRView's viewport and then render
this.renderer.clearDepth()
const viewport = view.getViewport(this.session.baseLayer)
this.renderer.setViewport(viewport.x, viewport.y, viewport.width, viewport.height)
this.doRender()
}
}
doRender(){
this.renderer.render(this.scene, this.camera)
}
/*
Add a node to the scene and keep its pose updated using the anchorOffset
*/
addAnchoredNode(anchorOffset, node){
this.anchoredNodes.push({
anchorOffset: anchorOffset,
node: node
})
this.scene.add(node)
}
/*
Get the anchor data from the frame and use it and the anchor offset to update the pose of the node, this must be an Object3D
*/
updateNodeFromAnchorOffset(frame, node, anchorOffset){
const anchor = frame.getAnchor(anchorOffset.anchorUID)
if(anchor === null){
throttledConsoleLog('Unknown anchor uid', anchorOffset.anchorUID)
return
}
node.matrixAutoUpdate = false
const offsetCoordinates = anchorOffset.getTransformedCoordinates(anchor)
if(offsetCoordinates.coordinateSystem.type === XRCoordinateSystem.TRACKER){
node.matrix.fromArray(offsetCoordinates.poseMatrix)
} else {
node.matrix.fromArray(
offsetCoordinates.getTransformedCoordinates(frame.getCoordinateSystem(XRCoordinateSystem.TRACKER)).poseMatrix
)
}
node.updateMatrixWorld(true)
}
}
/*
If you want to just put virtual things on surfaces, extend this app and override `createSceneGraphNode`
*/
class ThingsOnSurfacesApp extends XRExampleBase {
constructor(domElement){
super(domElement, false)
this._tapEventData = null // Will be filled in on touch start and used in updateScene
this.el.addEventListener('touchstart', this._onTouchStart.bind(this), false)
}
// Return a THREE.Object3D of some sort to be placed when a surface is found
createSceneGraphNode(){
throw new Error('Extending classes should implement createSceneGraphNode')
/*
For example:
let geometry = new THREE.BoxBufferGeometry(0.1, 0.1, 0.1)
let material = new THREE.MeshPhongMaterial({ color: '#99FF99' })
return new THREE.Mesh(geometry, material)
*/
}
// Called once per frame, before render, to give the app a chance to update this.scene
updateScene(frame){
// If we have tap data, attempt a hit test for a surface
if(this._tapEventData !== null){
const x = this._tapEventData[0]
const y = this._tapEventData[1]
this._tapEventData = null
// Attempt a hit test using the normalized screen coordinates
frame.findAnchor(x, y).then(anchorOffset => {
if(anchorOffset === null){
console.log('miss')
return
}
console.log('hit', anchorOffset)
this.addAnchoredNode(anchorOffset, this.createSceneGraphNode())
}).catch(err => {
console.error('Error in hit test', err)
})
}
}
// Save screen taps as normalized coordinates for use in this.updateScene
_onTouchStart(ev){
if (!ev.touches || ev.touches.length === 0) {
console.error('No touches on touch event', ev)
return
}
//save screen coordinates normalized to -1..1 (0,0 is at center and 1,1 is at top right)
this._tapEventData = [
ev.touches[0].clientX / window.innerWidth,
ev.touches[0].clientY / window.innerHeight
]
}
}
function fillInGLTFScene(path, scene, position=[0, 0, -2], scale=[1, 1, 1]){
let ambientLight = new THREE.AmbientLight('#FFF', 1)
scene.add(ambientLight)
let directionalLight = new THREE.DirectionalLight('#FFF', 0.6)
scene.add(directionalLight)
loadGLTF(path).then(gltf => {
gltf.scene.scale.set(...scale)
gltf.scene.position.set(...position)
//gltf.scene.quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / -2)
scene.add(gltf.scene)
}).catch((...params) =>{
console.error('could not load gltf', ...params)
})
}
function loadGLTF(url){
return new Promise((resolve, reject) => {
let loader = new THREE.GLTFLoader()
loader.load(url, (gltf) => {
if(gltf === null){
reject()
}
if(gltf.animations && gltf.animations.length){
let mixer = new THREE.AnimationMixer(gltf.scene)
for(let animation of gltf.animations){
mixer.clipAction(animation).play()
}
}
resolve(gltf)
})
})
}
function loadObj(baseURL, geometry){
return new Promise(function(resolve, reject){
const mtlLoader = new THREE.MTLLoader()
mtlLoader.setPath(baseURL)
const mtlName = geometry.split('.')[geometry.split(':').length - 1] + '.mtl'
mtlLoader.load(mtlName, (materials) => {
materials.preload()
let objLoader = new THREE.OBJLoader()
objLoader.setMaterials(materials)
objLoader.setPath(baseURL)
objLoader.load(geometry, (obj) => {
resolve(obj)
}, () => {} , (...params) => {
console.error('Failed to load obj', ...params)
reject(...params)
})
})
})
}
function requestFullScreen(){
if (document.body.requestFullscreen) {
document.body.requestFullscreen()
} else if (document.body.msRequestFullscreen) {
document.body.msRequestFullscreen()
} else if (document.body.mozRequestFullScreen) {
document.body.mozRequestFullScreen()
} else if (document.body.webkitRequestFullscreen) {
document.body.webkitRequestFullscreen()
}
}
function exitFullScreen(){
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
}
/*
Rate limit a function call. Wait is the minimum number of milliseconds between calls.
If leading is true, the first call to the throttled function is immediately called.
If trailing is true, once the wait time has passed the function is called.
This code is cribbed from https://github.com/jashkenas/underscore
*/
window.throttle = function(func, wait, leading=true, trailing=true) {
var timeout, context, args, result
var previous = 0
var later = function() {
previous = leading === false ? 0 : Date.now()
timeout = null
result = func.apply(context, args)
if (!timeout) context = args = null
}
var throttled = function() {
var now = Date.now()
if (!previous && leading === false) previous = now
var remaining = wait - (now - previous)
context = this
args = arguments
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
result = func.apply(context, args)
if (!timeout) context = args = null
} else if (!timeout && trailing !== false) {
timeout = setTimeout(later, remaining)
}
return result
}
throttled.cancel = function() {
clearTimeout(timeout)
previous = 0
timeout = context = args = null
}
return throttled
}
window.throttledConsoleLog = throttle((...params) => {
console.log(...params)
}, 1000)