338 строки
11 KiB
JavaScript
338 строки
11 KiB
JavaScript
/*
|
|
XRExampleBase holds all of the common XR setup, rendering, and teardown code for a THREE.js based app
|
|
Extending classes should be able to focus on rendering their scene
|
|
|
|
Parameters:
|
|
domElement: an element used to show error messages
|
|
createVirtualReality: if true, create a new empty reality for this app
|
|
*/
|
|
class XRExampleBase {
|
|
constructor(domElement, createVirtualReality=true){
|
|
this.el = domElement
|
|
this.createVirtualReality = createVirtualReality
|
|
|
|
this.hasWebkit = typeof window.webkit !== 'undefined'
|
|
if(this.hasWebkit) this.setupWebkitUI()
|
|
|
|
// Set during the XR.getDisplays call below
|
|
this.display = null
|
|
this.session = null
|
|
|
|
// Create a simple THREE test scene for the layer
|
|
this.scene = new THREE.Scene() // The scene will be rotated and oriented around the camera using the head pose
|
|
this.stageGroup = new THREE.Group() // The group that stays on the "stage", which is at foot level relative to the head
|
|
this.scene.add(this.stageGroup)
|
|
this.camera = new THREE.PerspectiveCamera(70, 1024, 1024, 1, 1000) // These values will be overwritten by the projection matrix from ARKit or ARCore
|
|
this.renderer = null // Set in this.handleNewSession
|
|
|
|
// Give extending classes the opportunity to initially populate the stage group
|
|
this.initializeStageGroup(this.stageGroup)
|
|
|
|
if(typeof navigator.XR === 'undefined'){
|
|
this.showMessage('No WebXR API found, usually because the WebXR polyfill has not loaded')
|
|
return
|
|
}
|
|
|
|
// Get a display and then request a session
|
|
navigator.XR.getDisplays().then(displays => {
|
|
if(displays.length == 0) {
|
|
this.showMessage('No displays are available')
|
|
return
|
|
}
|
|
this.display = displays[0] // production code would allow the user to choose, this code assumes that this is a FlatDisplay
|
|
this.display.requestSession({
|
|
exclusive: this.createVirtualReality,
|
|
type: this.createVirtualReality ? XRSession.REALITY : XRSession.AUGMENTATION
|
|
}).then(session => {
|
|
this.handleNewSession(session)
|
|
}).catch(err => {
|
|
console.error('Error requesting session', err)
|
|
this.showMessage('Could not initiate the session')
|
|
})
|
|
}).catch(err => {
|
|
console.error('Error getting XR displays', err)
|
|
this.showMessage('Could not get XR displays')
|
|
})
|
|
}
|
|
|
|
/*
|
|
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 message = document.createElement('div')
|
|
message.innerHTML = messageText
|
|
this.el.append(message)
|
|
}
|
|
|
|
handleNewSession(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) })
|
|
|
|
// Create a canvas and context for the layer
|
|
let glCanvas = document.createElement('canvas')
|
|
let glContext = glCanvas.getContext('webgl')
|
|
if(glContext === null){
|
|
throw 'Could not create GL context'
|
|
}
|
|
|
|
// Set the session's base layer into which the app will render
|
|
this.session.baseLayer = new XRWebGLLayer(this.session, glContext)
|
|
|
|
// Handle layer focus events
|
|
this.session.baseLayer.addEventListener('focus', ev => { this.handleLayerFocus(ev) })
|
|
this.session.baseLayer.addEventListener('blur', ev => { this.handleLayerBlur(ev) })
|
|
|
|
// Set up the THREE renderer with the session's layer's glContext
|
|
this.renderer = new THREE.WebGLRenderer({
|
|
canvas: glCanvas,
|
|
context: glContext,
|
|
antialias: false,
|
|
alpha: true
|
|
})
|
|
this.renderer.setPixelRatio(1)
|
|
this.renderer.autoClear = false
|
|
this.renderer.setClearColor('#000', 0)
|
|
|
|
/*
|
|
This part is a bit bogus and relies on the polyfill only returning a FlatDisplay
|
|
*/
|
|
const width = parseInt(window.getComputedStyle(document.body).width)
|
|
const height = parseInt(window.getComputedStyle(document.body).height)
|
|
this.camera.aspect = width / height
|
|
this.camera.updateProjectionMatrix()
|
|
this.renderer.setSize(width, height)
|
|
|
|
if(this.createVirtualReality){
|
|
const reality = this.session.createVirtualReality('VR Example', false)
|
|
|
|
// Reqest the Reality change and then set up its XRLayer
|
|
this.session.requestRealityChange(reality).then(() => {
|
|
this.session.requestFrame(frame => { this.handleFrame(frame) })
|
|
}).error(err => {
|
|
console.error('Could not change realities')
|
|
})
|
|
} else {
|
|
// The session's reality defaults to the most recently used shared reality
|
|
this.session.requestFrame(frame => { this.handleFrame(frame) })
|
|
}
|
|
}
|
|
|
|
// 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 stageGroup during class construction
|
|
*/
|
|
initializeStageGroup(){}
|
|
|
|
/*
|
|
Extending classes that need to update the layer during each frame should override this method
|
|
*/
|
|
updateStageGroup(frame, stageCoordinateSystem, stagePose){}
|
|
|
|
handleFrame(frame){
|
|
const nextFrameRequest = this.session.requestFrame(frame => { this.handleFrame(frame) })
|
|
let stageCoordinateSystem = frame.getCoordinateSystem('stage')
|
|
if(stageCoordinateSystem === null){
|
|
this.showMessage('Could not get a usable stage coordinate system')
|
|
this.session.cancelFrame(nextFrameRequest)
|
|
this.session.endSession()
|
|
// Production apps could render a 'waiting' message and keep checking for an acceptable coordinate system
|
|
return
|
|
}
|
|
|
|
// Get the two poses we care about: the foot level stage and head pose which is updated by ARKit, ARCore, or orientation events
|
|
let stagePose = frame.getViewPose(stageCoordinateSystem)
|
|
let headPose = frame.getViewPose(frame.getCoordinateSystem(XRCoordinateSystem.HEAD_MODEL))
|
|
|
|
// Let the extending class update the stageGroup before each render
|
|
this.updateStageGroup(frame, stageCoordinateSystem, stagePose)
|
|
|
|
// Update the stage group relative to the current head pose
|
|
this.stageGroup.matrixAutoUpdate = false
|
|
this.stageGroup.matrix.fromArray(stagePose.poseModelMatrix)
|
|
this.stageGroup.matrix.elements[12] -= headPose.poseModelMatrix[12]
|
|
this.stageGroup.matrix.elements[13] -= headPose.poseModelMatrix[13]
|
|
this.stageGroup.matrix.elements[14] -= headPose.poseModelMatrix[14]
|
|
this.stageGroup.updateMatrixWorld(true)
|
|
|
|
// Prep THREE.js for the render of each XRView
|
|
this.renderer.resetGLState()
|
|
this.scene.matrixAutoUpdate = false
|
|
this.renderer.autoClear = false
|
|
this.renderer.setSize(this.session.baseLayer.framebufferWidth, this.session.baseLayer.framebufferHeight)
|
|
//this.renderer.clear()
|
|
|
|
//this.session.baseLayer.context.bindFramebuffer(this.session.baseLayer.context.FRAMEBUFFER, this.session.baseLayer.framebuffer)
|
|
|
|
// 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)
|
|
|
|
// Set the scene's view matrix using the head pose
|
|
this.scene.matrix.fromArray(headPose.getViewMatrix(view))
|
|
this.scene.updateMatrixWorld(true)
|
|
|
|
// Set up the renderer to the XRView's viewport and then render
|
|
const viewport = view.getViewport(this.session.baseLayer)
|
|
this.renderer.setViewport(viewport.x, viewport.y, viewport.width, viewport.height)
|
|
this.renderer.render(this.scene, this.camera)
|
|
}
|
|
}
|
|
|
|
/*
|
|
A temporary UI to show on the iOS app until the app itself provides a URL bar
|
|
*/
|
|
setupWebkitUI(){
|
|
this.webkitControlEl = document.createElement('div')
|
|
this.el.appendChild(this.webkitControlEl)
|
|
this.webkitControlEl.setAttribute('class', 'webkit-control')
|
|
this.locationInput = document.createElement('input')
|
|
this.locationInput.style.width = '50%'
|
|
this.locationInput.value = '' + document.location.href
|
|
this.webkitControlEl.appendChild(this.locationInput)
|
|
this.locationButton = document.createElement('button')
|
|
this.locationButton.innerHTML = 'load'
|
|
this.webkitControlEl.appendChild(this.locationButton)
|
|
|
|
this.locationButton.addEventListener('click', ev => {
|
|
window.webkit.messageHandlers.loadUrl.postMessage({
|
|
url: this.locationInput.value
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
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 fillInBoxScene(scene){
|
|
let geometry = new THREE.BoxBufferGeometry(0.2, 0.2, 0.2)
|
|
let material = new THREE.MeshPhongMaterial({ color: '#DDFFDD' })
|
|
let mesh = new THREE.Mesh(geometry, material)
|
|
mesh.position.set(0, 0, -0.8)
|
|
scene.add(mesh)
|
|
|
|
let ambientLight = new THREE.AmbientLight('#FFF', 1)
|
|
scene.add(ambientLight)
|
|
|
|
let directionalLight = new THREE.DirectionalLight('#FFF', 0.6)
|
|
scene.add(directionalLight)
|
|
|
|
return scene
|
|
}
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
/*
|
|
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)
|