diff --git a/config/threeExamples.mjs b/config/threeExamples.mjs index 9c1107d8a7..9254c2f035 100644 --- a/config/threeExamples.mjs +++ b/config/threeExamples.mjs @@ -9,6 +9,8 @@ export default { './utils/WorkerPool.js', './capabilities/WebGL.js', './libs/ktx-parse.module.js', - './libs/zstddec.module.js' + './libs/zstddec.module.js', + './libs/motion-controllers.module.js', + './webxr/XRControllerModelFactory.js', ], }; diff --git a/examples/js/XR/Controllers.js b/examples/js/XR/Controllers.js new file mode 100644 index 0000000000..aaa5f4b63b --- /dev/null +++ b/examples/js/XR/Controllers.js @@ -0,0 +1,513 @@ +const Controllers = {}; + +let renderer; + +// move clipped to a fixed altitude +let clipToground = false; + +Controllers.MIN_DELTA_ALTITUDE = 1.8; + +let deltaRotation = 0; + +let startedPressButton; + +let actionElevationPerformed = false; + +// hack mode switch between navigation Mode +let rightCtrChangeNavMode = false; +let leftCtrChangeNavMode = false; +let alreadySwitched = false; +const navigationMode = []; +let currentNavigationModeIndex = 0; + +let view; +let contextXR; +// TODO cache geodesicQuat + +/** + * Controller.userData { + * isSelecting + * lockedTeleportPosition + * } + * requires a contextXR variable. + * @param {*} _view itowns view object + * @param {*} _contextXR itowns WebXR context object + */ +Controllers.addControllers = (_view, _contextXR) => { + view = _view; + contextXR = _contextXR; + // eslint-disable-next-line no-use-before-define + navigationMode.push(Mode1, Mode2); + renderer = view.mainLoop.gfxEngine.renderer; + const controller1 = bindListeners(0); + const controller2 = bindListeners(1); + controller1.addEventListener('itowns-xr-axes-changed', onLeftAxisChanged); + controller2.addEventListener('itowns-xr-axes-changed', onRightAxisChanged); + controller2.addEventListener('itowns-xr-axes-stop', onRightAxisStop); + controller1.addEventListener('itowns-xr-axes-stop', onLeftAxisStop); + controller2.addEventListener('itowns-xr-button-pressed', onRightButtonPressed); + controller1.addEventListener('itowns-xr-button-pressed', onLeftButtonPressed); + controller1.addEventListener('itowns-xr-button-released', onLeftButtonReleased); + controller2.addEventListener('itowns-xr-button-released', onRightButtonReleased); + controller1.addEventListener('selectstart', onSelectLeftStart); + controller1.addEventListener('selectend', onSelectLeftEnd); + controller2.addEventListener('selectstart', onSelectRightStart); + controller2.addEventListener('selectend', onSelectRightEnd); + + const cameraRightCtrl = new itowns.THREE.PerspectiveCamera(view.camera.camera3D.fov); + cameraRightCtrl.position.copy(view.camera.camera3D.position); + const cameraRighthelper = new itowns.THREE.CameraHelper(cameraRightCtrl); + + XRUtils.addToScene(cameraRighthelper, true); + + + contextXR.cameraRightGrp = { camera: cameraRightCtrl, cameraHelper: cameraRighthelper }; + + contextXR.controller1 = controller1; + contextXR.controller2 = controller2; +}; + +Controllers.getGeodesicalQuaternion = () => { + // TODO can be optimized with better cache + const position = view.controls.getCameraCoordinate().clone().as(view.referenceCrs); + const geodesicNormal = new itowns.THREE.Quaternion().setFromUnitVectors(new itowns.THREE.Vector3(0, 0, 1), position.geodesicNormal).invert(); + return new itowns.THREE.Quaternion(-1, 0, 0, 1).normalize().multiply(geodesicNormal); +}; + +function bindListeners(index) { + return renderer.xr.getController(index); +} + +function clampAndApplyTransformationToXR(trans, offsetRotation) { + const transClamped = clampToGround(trans); + applyTransformationToXR(transClamped, offsetRotation); +} + + +function applyTransformationToXR(trans, offsetRotation) { + if (!offsetRotation) { + console.error('missing rotation quaternion'); + return; + } + if (!trans) { + console.error('missing translation vector'); + return; + } + const finalTransformation = trans.multiplyScalar(-1).applyQuaternion(offsetRotation); + const transform = new XRRigidTransform(finalTransformation, offsetRotation); + const teleportSpaceOffset = contextXR.baseReferenceSpace.getOffsetReferenceSpace(transform); + renderer.xr.setReferenceSpace(teleportSpaceOffset); +} + +/** + * Clamp camera to ground if option {clipToground} is active + * @param {Vector3} trans + * @returns {Vector3} coordinates clamped to ground + */ +function clampToGround(trans) { + const transCoordinate = new itowns.Coordinates(view.referenceCrs, trans.x, trans.y, trans.z); + const terrainElevation = itowns.DEMUtils.getElevationValueAt(view.tileLayer, transCoordinate, itowns.DEMUtils.PRECISE_READ_Z); + if (!terrainElevation) { + console.error('no elevation intersection possible'); + return; + } + const coordsProjected = transCoordinate.as(view.controls.getCameraCoordinate().crs); + if (clipToground || (coordsProjected.altitude - terrainElevation) - Controllers.MIN_DELTA_ALTITUDE <= 0) { + clipToground = true; + coordsProjected.altitude = terrainElevation + Controllers.MIN_DELTA_ALTITUDE; + } + return coordsProjected.as(view.referenceCrs).toVector3(); +} + +function onSelectRightStart() { + navigationMode[currentNavigationModeIndex].onSelectRightStart(this); +} + +function onSelectLeftStart() { + navigationMode[currentNavigationModeIndex].onSelectLeftStart(this); +} + +function onSelectRightEnd() { + navigationMode[currentNavigationModeIndex].onSelectRightEnd(this); +} + +function onSelectLeftEnd() { + navigationMode[currentNavigationModeIndex].onSelectLeftEnd(this); +} + +function onRightButtonPressed(data) { + if (data.target.name !== 'rightController') { + return; + } + navigationMode[currentNavigationModeIndex].onRightButtonPressed(data); + if (data.message.buttonIndex === 3) { + // hack mode, for testing many stick interaction + rightCtrChangeNavMode = true; + if (leftCtrChangeNavMode) { + switchNavigationMode(); + } + } +} + +function onLeftButtonPressed(data) { + if (data.target.name !== 'leftController') { + return; + } + navigationMode[currentNavigationModeIndex].onLeftButtonPressed(data); + if (data.message.buttonIndex === 3) { + // hack mode, for testing many stick interaction + leftCtrChangeNavMode = true; + if (rightCtrChangeNavMode) { + switchNavigationMode(); + } + } +} + +function onRightAxisChanged(data) { + if (data.target.name !== 'rightController') { + return; + } + navigationMode[currentNavigationModeIndex].onRightAxisChanged(data); +} + +function onLeftAxisChanged(data) { + if (data.target.name !== 'leftController') { + return; + } + navigationMode[currentNavigationModeIndex].onLeftAxisChanged(data); +} + +function onRightAxisStop(data) { + // camera fly reset + data.message.controller.flyDirectionQuat = undefined; + navigationMode[currentNavigationModeIndex].onRightAxisStop(data); +} + +function onLeftAxisStop(data) { + navigationMode[currentNavigationModeIndex].onLeftAxisStop(data); +} + +function onLeftButtonReleased(data) { + if (data.target.name !== 'leftController') { + return; + } + leftCtrChangeNavMode = false; + alreadySwitched = false; + navigationMode[currentNavigationModeIndex].onLeftButtonReleased(data); + if (data.message.buttonIndex === 4) { + switchDebugMode(); + } +} + +function onRightButtonReleased(data) { + if (data.target.name !== 'rightController') { + return; + } + rightCtrChangeNavMode = false; + alreadySwitched = false; + navigationMode[currentNavigationModeIndex].onRightButtonReleased(data); +} + +function switchNavigationMode() { + if (alreadySwitched) { + return; + } + alreadySwitched = true; + if (currentNavigationModeIndex >= navigationMode.length - 1) { + currentNavigationModeIndex = 0; + } else { + currentNavigationModeIndex++; + } + console.log('switching nav mode: ', currentNavigationModeIndex); +} + +function switchDebugMode() { + contextXR.showDebug = !contextXR.showDebug; + XRUtils.updateDebugVisibilities(contextXR.showDebug); + console.log('debug is: ', contextXR.showDebug); +} + + +function applyTeleportation(ctrl) { + // if locked, should I do a second click to validate as we are locked ? + if (!ctrl.userData.isSelecting) { + // if has been aborted + return; + } + ctrl.userData.isSelecting = false; + ctrl.userData.lockedTeleportPosition = false; + if (contextXR.coordOnCamera) { + const offsetRotation = Controllers.getGeodesicalQuaternion(); + const projectedCoordinate = contextXR.coordOnCamera.as(view.referenceCrs); + XRUtils.showPosition('intersect', projectedCoordinate, 0x0000ff, 50, true); + // reset continuous translation applied to headSet parent. + contextXR.xrHeadSet.position.copy(new itowns.THREE.Vector3()); + // compute targeted position relative to the origine camera. + const trans = new itowns.THREE.Vector3(projectedCoordinate.x, projectedCoordinate.y, projectedCoordinate.z); + applyTransformationToXR(trans, offsetRotation); + } +} + +function getSpeedFactor() { + const speedFactor = Math.min(Math.max(view.camera.elevationToGround / 10, 5), 2000); + return speedFactor; +} + +function getTranslationZ(axisValue, speedFactor) { + // flying following the locked camera look at + const speed = axisValue * speedFactor; + const matrixHeadset = new itowns.THREE.Matrix4(); + matrixHeadset.identity().extractRotation(view.camera.camera3D.matrixWorld); + const directionY = new itowns.THREE.Vector3(0, 0, 1).applyMatrix4(matrixHeadset).multiplyScalar(speed); + return directionY; +} + + +// ////////////////////////////////// MODE 1 + +function getRotationYaw(axisValue) { + if (axisValue === 0) { + return; + } + deltaRotation += Math.PI / (160 * axisValue); + const offsetRotation = Controllers.getGeodesicalQuaternion(); + const thetaRotMatrix = new itowns.THREE.Matrix4().identity().makeRotationY(deltaRotation); + const rotationQuartenion = new itowns.THREE.Quaternion().setFromRotationMatrix(thetaRotMatrix).normalize(); + offsetRotation.premultiply(rotationQuartenion); + return offsetRotation; +} + +function getTranslationElevation(axisValue, speedFactor) { + const speed = axisValue * speedFactor; + const direction = view.controls.getCameraCoordinate().geodesicNormal.clone(); + direction.multiplyScalar(-speed); + return direction; +} + +/** + * FIXME flying back and forth cause a permanent shift to up. + * @param {*} ctrl + * @returns + */ +function cameraOnFly(ctrl) { + if (!ctrl.flyDirectionQuat) { + // locking camera look at + // FIXME using {view.camera.camera3D.matrixWorld} or normalized quaternion produces the same effect and shift to the up direction. + ctrl.flyDirectionQuat = view.camera.camera3D.quaternion.clone().normalize(); + } + if (ctrl.gamepad.axes[2] === 0 && ctrl.gamepad.axes[3] === 0) { + return; + } + let directionX = new itowns.THREE.Vector3(); + let directionY = new itowns.THREE.Vector3(); + const speedFactor = getSpeedFactor(); + if (ctrl.gamepad.axes[3] !== 0) { + // flying following the locked camera look at + const speed = ctrl.gamepad.axes[3] * speedFactor; + directionY = new itowns.THREE.Vector3(0, 0, 1).applyQuaternion(ctrl.flyDirectionQuat).multiplyScalar(speed); + } + if (ctrl.gamepad.axes[2] !== 0) { + const speed = ctrl.gamepad.axes[2] * speedFactor; + directionX = new itowns.THREE.Vector3(1, 0, 0).applyQuaternion(ctrl.flyDirectionQuat).multiplyScalar(speed); + } + + const offsetRotation = Controllers.getGeodesicalQuaternion(); + const trans = view.camera.camera3D.position.clone().add(directionX.add(directionY)); + clampAndApplyTransformationToXR(trans, offsetRotation); +} + +const Mode1 = { + onSelectRightEnd: (ctrl) => { + applyTeleportation(ctrl); + }, + onSelectRightStart: (ctrl) => { + ctrl.userData.isSelecting = true; + }, + onSelectLeftStart: (ctrl) => { + // nothing yet needed + }, + onSelectLeftEnd: (ctrl) => { + // first left click while right selecting locks the teleportation target + // Second left click cancels teleportation target. + if (contextXR.controller2.userData.lockedTeleportPosition) { + contextXR.controller2.userData.isSelecting = false; + } + if (contextXR.controller2.userData.isSelecting) { + contextXR.controller2.userData.lockedTeleportPosition = true; + } + }, + onRightButtonPressed: (data) => { + const ctrl = data.message.controller; + if (data.message.buttonIndex === 1) { + // activate vertical adjustment + if (ctrl.gamepad.axes[3] === 0) { + return; + } + // disable clip to ground + clipToground = false; + const offsetRotation = Controllers.getGeodesicalQuaternion(); + const speedFactor = getSpeedFactor(); + const deltaTransl = getTranslationElevation(ctrl.gamepad.axes[3], speedFactor); + const trans = view.camera.camera3D.position.clone().add(deltaTransl); + clampAndApplyTransformationToXR(trans, offsetRotation); + } + }, + onLeftButtonPressed: (data) => { + if (data.message.buttonIndex === 1) { + // activate vertical adjustment + // setCameraTocontroller(); + } + }, + onRightAxisChanged: (data) => { + const ctrl = data.message.controller; + // translation controls + if (ctrl.lockButtonIndex) { + return; + } + if (contextXR.INTERSECTION) { + // updating elevation at intersection destination + contextXR.deltaAltitude -= ctrl.gamepad.axes[3] * 100; + } else { + cameraOnFly(ctrl); + } + }, + onLeftAxisChanged: (data) => { + const ctrl = data.message.controller; + // rotation controls + if (contextXR.INTERSECTION) { + // inop + } else { + const trans = view.camera.camera3D.position.clone(); + const quat = getRotationYaw(ctrl.gamepad.axes[2]); + applyTransformationToXR(trans, quat); + } + }, + onRightAxisStop: (data) => { + // inop + }, + onLeftAxisStop: (data) => { + // inop + }, + onRightButtonReleased: (data) => { + // inop + }, + onLeftButtonReleased: (data) => { + // inop + }, +}; + + +// ////////////////////////////////// MODE 2 + +const Mode2 = { + onSelectRightEnd: (ctrl) => { + applyTeleportation(ctrl); + }, + onSelectRightStart: (ctrl) => { + ctrl.userData.isSelecting = true; + }, + onSelectLeftStart: (ctrl) => { + // nothing yet needed + }, + /** + * first left click while right selecting locks the teleportation target + * Second left click cancels teleportation target. + * @param {*} ctrl + */ + onSelectLeftEnd: (ctrl) => { + if (contextXR.controller2.userData.lockedTeleportPosition) { + contextXR.controller2.userData.isSelecting = false; + } + if (contextXR.controller2.userData.isSelecting) { + contextXR.controller2.userData.lockedTeleportPosition = true; + } + }, + onRightButtonPressed: (data) => { + if (data.message.buttonIndex === 4 || data.message.buttonIndex === 5) { + if (!startedPressButton) { + startedPressButton = Date.now(); + } + // disable clip to ground + clipToground = false; + } + + const deltaTimePressed = Date.now() - startedPressButton; + if (deltaTimePressed > 2000 && !actionElevationPerformed) { + const offsetRotation = Controllers.getGeodesicalQuaternion(); + let deltaTransl; + const speedFactor = 1; + if (data.message.buttonIndex === 4) { + // activate vertical adjustment down : clamp to ground + deltaTransl = getTranslationElevation(1000000, speedFactor); + } else if (data.message.buttonIndex === 5) { + // activate vertical adjustment up : bird view + deltaTransl = getTranslationElevation(-10000, speedFactor); + } + const trans = view.camera.camera3D.position.clone().add(deltaTransl); + clampAndApplyTransformationToXR(trans, offsetRotation); + actionElevationPerformed = true; + } + }, + onLeftButtonPressed: (data) => { + // inop + }, + onRightAxisChanged: (data) => { + // translation controls + const ctrl = data.message.controller; + if (ctrl.lockButtonIndex) { + return; + } + if (contextXR.INTERSECTION) { + // updating elevation at intersection destination + contextXR.deltaAltitude -= ctrl.gamepad.axes[3] * 100; + } else { + const trans = view.camera.camera3D.position.clone(); + let quat = Controllers.getGeodesicalQuaternion(); + if (ctrl.gamepad.axes[3] !== 0) { + const deltaZ = getTranslationZ(ctrl.gamepad.axes[3], getSpeedFactor()); + trans.add(deltaZ); + } + if (ctrl.gamepad.axes[2] !== 0) { + quat = getRotationYaw(ctrl.gamepad.axes[2]); + } + clampAndApplyTransformationToXR(trans, quat); + } + }, + onLeftAxisChanged: (data) => { + // inop + }, + onRightAxisStop: (data) => { + // inop + }, + onLeftAxisStop: (data) => { + // inop + }, + onRightButtonReleased: (data) => { + let deltaTransl = new itowns.THREE.Vector3(); + startedPressButton = undefined; + + const offsetRotation = Controllers.getGeodesicalQuaternion(); + + if (!actionElevationPerformed) { + const speedFactor = getSpeedFactor(); + // lower button + if (data.message.buttonIndex === 4) { + // activate vertical adjustment down + deltaTransl = getTranslationElevation(5, speedFactor); + + // upper button + } else if (data.message.buttonIndex === 5) { + // activate vertical adjustment up + deltaTransl = getTranslationElevation(-5, speedFactor); + } + const trans = view.camera.camera3D.position.clone().add(deltaTransl); + clampAndApplyTransformationToXR(trans, offsetRotation); + } else { + actionElevationPerformed = false; + } + + }, + onLeftButtonReleased: (data) => { + // inop + }, +}; diff --git a/examples/js/XR/README.txt b/examples/js/XR/README.txt new file mode 100644 index 0000000000..8b4ad855a8 --- /dev/null +++ b/examples/js/XR/README.txt @@ -0,0 +1,77 @@ +Manettes PICO4: + +switch pour tester différents set d’interaction déplacement / rotation. +* faire une pression sur les 2 joysticks en même temps. + +## MODE 1 : + +thumbstick droit : +* X : translation gauche droite +* Y : translation avant arrière + +thumbstick gauche : +* X : rotation yaw +* Y : + +gachette#0 index droit : +* téléportation au relachement. +* + stick droit pendant pression : ajustement de la hauteur de téléportation. + +gachette#1 majeure droit : +* + stick ajustement de la hauteur de la caméra. + +bouton#4 bas (A) droit +* switch de scène prédéfinie au relachement. + +bouton#5 haut (B) droit +* + +gachette#0 index gauche : +* (si gachette index droit actif) : 1 clic verrou de la position de téléportation, 2 clic annulation de la téléportation. + +gachette#1 majeure gauche : +* afficher les coordonnées caméra actuelles. + +bouton#4 bas (X) gauche +* switch le mode debug + +bouton#5 haut (Y) gauche +* changer le mode du 3D tiles + + +## MODE 2 : + +thumbstick droit : +* X : rotation yaw +* Y : translation avant arrière + +thumbstick gauche : +* X : +* Y : + +gachette#0 index droit : +* téléportation au relachement. +* + stick droit pendant pression : ajustement de la hauteur de téléportation. + +gachette#1 majeure droit : +* + +bouton#4 bas (A) droit +* relachement courte : petit ajustement vertical bas +* pression longue (2sec) : ajustement max vertical sol + +bouton#5 haut (B) droit +* relachement courte : petit ajustement vertical haut +* pression longue (2sec) : ajustement max vertical (ciel) + +gachette#0 index gauche : +* (si gachette index droit actif) : 1 clic verrou de la position de téléportation, 2 clic annulation de la téléportation. + +gachette#1 majeure gauche : + + +bouton#4 bas (X) gauche +* + +bouton#5 haut (Y) gauche +* \ No newline at end of file diff --git a/examples/js/XR/Utils.js b/examples/js/XR/Utils.js new file mode 100644 index 0000000000..abd6d18bdb --- /dev/null +++ b/examples/js/XR/Utils.js @@ -0,0 +1,200 @@ +/** + * Reads parameter contextXR.showDebug + */ +const XRUtils = {}; + +XRUtils.objects = []; + +XRUtils.updateDebugVisibilities = (showDebugValue) => { + XRUtils.objects.forEach((obj) => { obj.visible = showDebugValue; }); + if (contextXR.visibleBbox) { + contextXR.visibleBbox.visible = showDebugValue; + } + view.notifyChange(); +}; + +XRUtils.showPosition = (name, coordinates, color, radius = 50, isDebug = false) => { + let existingChild = findExistingRef(name); + + if (existingChild) { + existingChild.position.copy(coordinates); + existingChild.scale.copy(new itowns.THREE.Vector3(1, 1, 1)).multiplyScalar(radius); + } else { + const previousPos = new itowns.THREE.Mesh( + new itowns.THREE.SphereGeometry(1, 16, 8), + new itowns.THREE.MeshBasicMaterial({ color: color, wireframe: true }), + ); + previousPos.name = name; + previousPos.position.copy(coordinates); + previousPos.scale.multiplyScalar(radius); + XRUtils.addToScene(previousPos, isDebug); + existingChild = previousPos; + } + return existingChild; +}; + +XRUtils.removeReference = (name) => { + const existingChild = findExistingRef(name); + if (existingChild) { + view.scene.remove(existingChild); + let indexToRemove = null; + XRUtils.objects.forEach((child, index) => { if (child.name === name) { indexToRemove = index; } }); + XRUtils.objects.splice(indexToRemove, 1); + } +}; + +/** + * + * @param {*} name + * @param {*} coordinates + * @param {*} color hexa color + * @param {*} size + * @param {*} isDebug + */ +XRUtils.addPositionPoints = (name, coordinates, color, size, isDebug = false) => { + const existingChild = findExistingRef(name); + if (existingChild) { + const verticesUpdated = existingChild.geometry.attributes.position.array.values().toArray(); + verticesUpdated.push(coordinates.x, coordinates.y, coordinates.z); + existingChild.geometry.setAttribute('position', new itowns.THREE.Float32BufferAttribute(verticesUpdated, 3)); + } else { + const geometry = new itowns.THREE.BufferGeometry(); + const vertices = []; + vertices.push(coordinates.x, coordinates.y, coordinates.z); + const material = new itowns.THREE.PointsMaterial({ size: size, color: color }); + geometry.setAttribute('position', new itowns.THREE.Float32BufferAttribute(vertices, 3)); + const particle = new itowns.THREE.Points(geometry, material); + particle.name = name; + XRUtils.addToScene(particle, isDebug); + } +}; + +/** + * + * @param {*} name + * @param {*} coordinates + * @param {*} color hexa color + * @param {*} upSize + * @param {*} isDebug + */ +XRUtils.showPositionVerticalLine = (name, coordinates, color, upSize, isDebug = false) => { + const existingChild = findExistingRef(name); + if (existingChild) { + existingChild.position.copy(coordinates); + existingChild.lookAt(new itowns.THREE.Vector3(0, 0, 1)); + } else { + const points = []; + points.push(new itowns.THREE.Vector3(0, 0, 0)); + // upward direction + points.push(new itowns.THREE.Vector3(0, 0, -upSize)); + const line = new itowns.THREE.Line( + new itowns.THREE.BufferGeometry().setFromPoints(points), + new itowns.THREE.LineBasicMaterial({ color: color })); + line.position.copy(coordinates); + // necessary to "look" vertically + line.lookAt(new itowns.THREE.Vector3(0, 0, 1)); + line.name = name; + XRUtils.addToScene(line, isDebug); + } +}; + +/** + * + * @param {*} name + * @param {*} originVector3 + * @param {*} directionVector3 + * @param {*} scale + * @param {*} color hexa color + * @param {*} isDebug + */ +XRUtils.renderdirectionArrow = (name, originVector3, directionVector3, scale, color, isDebug = false) => { + const existingChild = findExistingRef(name); + if (existingChild) { + existingChild.setDirection(directionVector3); + existingChild.position.copy(originVector3); + } else { + const arrow = new itowns.THREE.ArrowHelper(directionVector3, originVector3, scale, color); + arrow.name = name; + XRUtils.addToScene(arrow, isDebug); + } +}; + +/** + * + * @param {Object3D} object + * @returns {Object3D} + */ +XRUtils.generateVRBox = (object) => { + const objectBbox = new itowns.THREE.Box3(); + // better than object.geometry.computeBoundingBox(); as it copy parent position. + objectBbox.setFromObject(object); + object.VRBbox = objectBbox; + object.VRBbox = new itowns.THREE.Box3Helper(object.VRBbox, 0xffff00); + + object.VRBbox.name = (object.name || object.uuid) + '_VRBbox'; + // console.log('adding VRBbox to scene : ', object.VRBbox.name); + // no need to add each bbox to the Utils memory + view.scene.add(object.VRBbox); + object.VRBbox.visible = false; + return object.VRBbox; +}; + +/** + * + * @param {Object3D} object + * @returns + */ +XRUtils.updateBboxVisibility = (object) => { + if (!contextXR.showDebug) { + return; + } + if (contextXR.visibleBbox && contextXR.visibleBbox === object.VRBbox) { + return; + } + // proper to box3Helper + if (object.box) { + if (!object.visible) { + resetPreviousVisibleeBbox(); + contextXR.visibleBbox = object; + object.visible = true; + } + } else if (object.geometry) { + if (!object.VRBbox) { + XRUtils.generateVRBox(object); + } + if (!object.VRBbox.visible) { + resetPreviousVisibleeBbox(); + contextXR.visibleBbox = object.VRBbox; + object.VRBbox.visible = true; + } + } else if (contextXR.visibleBbox) { + resetPreviousVisibleeBbox(); + } + + function resetPreviousVisibleeBbox() { + if (contextXR.visibleBbox) { + contextXR.visibleBbox.visible = false; + contextXR.visibleBbox = undefined; + } + } +}; + +/** + * + * @param {Object3D} object + * @param {boolean} isDebug + */ +XRUtils.addToScene = (object, isDebug) => { + console.log('adding object to scene : ', object.name); + object.visible = !isDebug || (isDebug && contextXR.showDebug); + view.scene.add(object); + XRUtils.objects.push(object); +}; + +function findExistingRef(name) { + let existingChild; + view.scene.children.forEach((child) => { if (child.name === name) { existingChild = child; } }); + return existingChild; +} + + diff --git a/examples/js/XR/mode1.png b/examples/js/XR/mode1.png new file mode 100644 index 0000000000..0c76db842f Binary files /dev/null and b/examples/js/XR/mode1.png differ diff --git a/examples/js/XR/mode2.png b/examples/js/XR/mode2.png new file mode 100644 index 0000000000..3c9657311c Binary files /dev/null and b/examples/js/XR/mode2.png differ diff --git a/examples/misc_clamp_ground.html b/examples/misc_clamp_ground.html index 1e5317d0c8..383d9c0601 100644 --- a/examples/misc_clamp_ground.html +++ b/examples/misc_clamp_ground.html @@ -65,12 +65,12 @@ // position of the mesh var meshCoord = cameraTargetPosition; - meshCoord.altitude += 30; + // meshCoord.altitude += 30; // position and orientation of the mesh mesh.position.copy(meshCoord.as(view.referenceCrs)); - mesh.lookAt(new THREE.Vector3(0, 0, 0)); - mesh.rotateX(Math.PI / 2); + //mesh.lookAt(new THREE.Vector3(0, 0, 0)); + // mesh.rotateX(Math.PI / 2); // update coordinate of the mesh mesh.updateMatrixWorld(); diff --git a/examples/view_3d_map_webxr.html b/examples/view_3d_map_webxr.html index 56bfee0a60..a28e13a917 100644 --- a/examples/view_3d_map_webxr.html +++ b/examples/view_3d_map_webxr.html @@ -1,16 +1,21 @@ - - Itowns - WebXR Example + + Itowns - WebXR Example + + + +
+ + + + + + + + + + - - + if (!contextXR.controller2.userData.lockedTeleportPosition) { + contextXR.INTERSECTION = undefined; + const intersects = intersectInteractiveLayers(); + if (intersects && intersects.length > 0) { + XRUtils.updateBboxVisibility(intersects[0].object); + updateIntersectionMarker(intersects[0]); + if (contextXR.controller2.userData.isSelecting === true) { + contextXR.INTERSECTION = intersects[0].point; + if (view.meshPointer) { + view.meshPointer.visible = false; + } + } + } else if (contextXR.visibleBbox) { + contextXR.visibleBbox.visible = false; + contextXR.visibleBbox = undefined; + } + } else if (contextXR.visibleBbox) { + contextXR.visibleBbox.visible = false; + contextXR.visibleBbox = undefined; + } + + if (contextXR.INTERSECTION) { + var intersectionCoord = new itowns.Coordinates(view.referenceCrs, contextXR.INTERSECTION.x, contextXR.INTERSECTION.y, contextXR.INTERSECTION.z); + var justElevationAt = itowns.DEMUtils.getElevationValueAt(view.tileLayer, view.controls.getCameraCoordinate().clone(), itowns.DEMUtils.PRECISE_READ_Z); + getJumpCoordinate(intersectionCoord); + + var cameraFloorIntersect = view.controls.getCameraCoordinate().clone(); + // FIXME result is not stricly the camera position as expected, maybe due to the intersection position? + // FIXME getCameraCoordinate doesn't update on webxr camera move, maybe test it in "unbounded" / "bounded" context + var cameraTargetPosition = view.controls.getCameraCoordinate().clone(); + /* the staircase effect find explanation in regard of view.camera.camera3D.position z evolution + for some reason, position is highly rounded when not to initial vr pos. + but camera matrices seems like in the same level of definition... + */ + cameraFloorIntersect.altitude = justElevationAt; + XRUtils.showPosition('findMeCamera', cameraFloorIntersect.as(view.referenceCrs), 0x44aaff, 100, true); + + // showing camera floor intersection + contextXR.marker.position.copy(cameraFloorIntersect.as(view.referenceCrs)); + } else { + // if no more intersection, reset coords. + XRUtils.removeReference('positionJumpDebug') + XRUtils.removeReference('positionJump'); + XRUtils.removeReference('findMeCamera'); + XRUtils.removeReference('verticalAxisJump'); + contextXR.coordOnCamera = undefined; + } + contextXR.marker.visible = contextXR.INTERSECTION !== undefined; + } + + function updateIntersectionMarker(intersectionObject) { + var intersectionVector = intersectionObject.point; + if (intersectionObject.object.type === 'Points') { + if (view.meshPointer) { + view.meshPointer.visible = false; + } + + XRUtils.showPosition('pointsIntersections', intersectionVector, 0xfeff00, view.camera.camera3D.position.distanceTo(intersectionVector) / 100, false); + } + else { + XRUtils.removeReference('pointsIntersections'); + // update marker on terrain position + var intersectPosition = new itowns.Coordinates(view.referenceCrs); + intersectPosition.setFromVector3(intersectionVector); + var coordIntersectedMappedOnLayer = itowns.DEMUtils.getTerrainObjectAt(view.tileLayer, intersectPosition, itowns.DEMUtils.PRECISE_READ_Z); + if (view.meshPointer && coordIntersectedMappedOnLayer) { + view.meshPointer.visible = true; + view.meshPointer.position.copy(coordIntersectedMappedOnLayer.coord.as(view.referenceCrs)); + } + } + + } + + function getJumpCoordinate(intersectionCoord) { + // not a {itowns.Coordinates} + var coordOnCamera = itowns.DEMUtils.getTerrainObjectAt(view.tileLayer, intersectionCoord, itowns.DEMUtils.PRECISE_READ_Z); + var coordIntersectItowns = coordOnCamera.coord.clone().as('EPSG:4326'); + // update jump target altitude. + if (contextXR.coordOnCamera && contextXR.deltaAltitude) { + if (coordOnCamera.coord.altitude + contextXR.deltaAltitude < coordOnCamera.coord.altitude + Controllers.MIN_DELTA_ALTITUDE) { + contextXR.deltaAltitude = Controllers.MIN_DELTA_ALTITUDE; + } + coordOnCamera.coord.altitude += contextXR.deltaAltitude; + } else { + // by default set altitude to the current camera elevation from terrain + let cameraCoordOnTerrain = itowns.DEMUtils.getTerrainObjectAt(view.tileLayer, view.controls.getCameraCoordinate().clone(), itowns.DEMUtils.PRECISE_READ_Z); + coordOnCamera.coord.altitude += cameraCoordOnTerrain.coord.z; + contextXR.deltaAltitude = cameraCoordOnTerrain.coord.z; + } + + // must clone Coordinate to avoid side effect, maybe with DEMUtils, but not sure. + contextXR.coordOnCamera = new itowns.Coordinates(coordOnCamera.coord.crs); + contextXR.coordOnCamera.setFromVector3(coordOnCamera.coord); + XRUtils.showPosition('positionJump', contextXR.coordOnCamera.as(view.referenceCrs), 0xfe0c00, 30, false); + XRUtils.showPositionVerticalLine('verticalAxisJump', intersectionCoord, 0xfe0c00, 10000, false); + XRUtils.showPosition('positionJumpDebug', intersectionCoord, 0xfeff00, 5500, true); + } + + //--------- add fake to ground + + function addMeshToScene() { + // creation of the new mesh (a cylinder) + var THREE = itowns.THREE; + var geometry = new THREE.CylinderGeometry(0, 10, 60, 8); + var material = new THREE.MeshBasicMaterial({ color: 0xff0000 }); + var meshPointer = new THREE.Mesh(geometry, material); + + // get the position on the globe, from the camera + var cameraTargetPosition = view.controls.getLookAtCoordinate(); + + // position of the mesh + var meshCoord = cameraTargetPosition.clone(); + meshCoord.altitude += 30; + + // position and orientation of the mesh + meshPointer.position.copy(meshCoord.as(view.referenceCrs)); + meshPointer.lookAt(new THREE.Vector3(0, 0, 0)); + meshPointer.rotateX(Math.PI / 2); + + // update coordinate of the mesh + meshPointer.updateMatrixWorld(); + + // add the mesh to the scene + view.scene.add(meshPointer); + + // make the object usable from outside of the function + view.meshPointer = meshPointer; + view.notifyChange(); + } + + // Listen for globe full initialisation event + view.addEventListener(itowns.GLOBE_VIEW_EVENTS.GLOBE_INITIALIZED, function globeInitialized() { + // eslint-disable-next-line no-console + console.info('Globe initialized'); + + + addMeshToScene(); + }); + + // Add the UI Debug + var d = new debug.Debug(view, debugMenu.gui); + debug.createTileDebugUI(debugMenu.gui, view, view.tileLayer, d); + + d.switch = function () { + Controllers.change3DTileRepresentation(); + } + debugMenu.gui.add(d, 'switch').name('Mode Switch'); + + // ---------- 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); + var elevationLayer = new itowns.ElevationLayer(config.id, config); + view.addLayer(elevationLayer).then(debugMenu.addLayerGUI.bind(debugMenu)); + } + itowns.Fetcher.json('./layers/JSONLayers/IGN_MNT_HIGHRES.json').then(addElevationLayerFromConfig); + // must keep it, Failed to execute 'updateRenderState' on 'XRSession': Failed to read the 'depthFar' property from 'XRRenderStateInit': The provided double value is non-finite. + itowns.Fetcher.json('./layers/JSONLayers/WORLD_DTM.json').then(addElevationLayerFromConfig); + + + + \ No newline at end of file diff --git a/src/Controls/GlobeControls.js b/src/Controls/GlobeControls.js index d707fc2d29..9758e5323c 100644 --- a/src/Controls/GlobeControls.js +++ b/src/Controls/GlobeControls.js @@ -1009,6 +1009,7 @@ class GlobeControls extends THREE.EventDispatcher { /** * Returns the camera location projected on the ground in lat,lon. See {@linkcode Coordinates} for conversion. + * TODO : is never updated in XR scene, add a log or fix the in WebXR file; * @return {Coordinates} position */ diff --git a/src/Core/View.js b/src/Core/View.js index 4377182119..99b715477e 100644 --- a/src/Core/View.js +++ b/src/Core/View.js @@ -153,6 +153,7 @@ class View extends THREE.EventDispatcher { * @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 {number} [options.webXR.callback] - rendering callback. * @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). @@ -1065,7 +1066,8 @@ class View extends THREE.EventDispatcher { const length = orthoZ / Math.cos(angle); target.addVectors(this.camera3D.position, ray.direction.setLength(length)); } else { - const gl_FragCoord_Z = g.depthBufferRGBAValueToOrthoZ(buffer, this.camera3D); + // FIXME picking doesn't work with arrayCamera + const gl_FragCoord_Z = g.depthBufferRGBAValueToOrthoZ(buffer, this.camera.camera3D); target.set(screen.x, screen.y, gl_FragCoord_Z); target.unproject(this.camera3D); diff --git a/src/Main.js b/src/Main.js index 3e849a88d5..0fc415546e 100644 --- a/src/Main.js +++ b/src/Main.js @@ -104,3 +104,5 @@ export { default as C3DTExtensions } from './Core/3DTiles/C3DTExtensions'; export { default as C3DTilesTypes } from './Core/3DTiles/C3DTilesTypes'; export { default as C3DTBatchTableHierarchyExtension } from './Core/3DTiles/C3DTBatchTableHierarchyExtension'; export { process3dTilesNode, $3dTilesCulling, $3dTilesSubdivisionControl } from 'Process/3dTilesProcessing'; + +export { XRControllerModelFactory } from 'ThreeExtended/webxr/XRControllerModelFactory'; diff --git a/src/Renderer/WebXR.js b/src/Renderer/WebXR.js index 57137730be..63946574f6 100644 --- a/src/Renderer/WebXR.js +++ b/src/Renderer/WebXR.js @@ -1,6 +1,9 @@ /* global XRRigidTransform */ import * as THREE from 'three'; +import { XRControllerModelFactory } from 'ThreeExtended/webxr/XRControllerModelFactory'; +import Coordinates from 'Core/Geographic/Coordinates'; +import DEMUtils from 'Utils/DEMUtils'; async function shutdownXR(session) { if (session) { @@ -8,6 +11,11 @@ async function shutdownXR(session) { } } +/** + * + * @param {*} view dsfsdf + * @param {*} options webXR, callback + */ const initializeWebXR = (view, options) => { const scale = options.scale || 1.0; @@ -29,24 +37,44 @@ const initializeWebXR = (view, options) => { view.notifyChange(view.camera.camera3D, true); } }; + + const vrHeadSet = new THREE.Object3D(); + vrHeadSet.name = 'xrHeadset'; + view.scene.scale.multiplyScalar(scale); view.scene.updateMatrixWorld(); + + const xrControllers = initControllers(xr, vrHeadSet); + + const position = view.controls.getCameraCoordinate().as(view.referenceCrs); + // To avoid controllers precision issues, headset should handle camera position and camera should be reset to origin + view.scene.add(vrHeadSet); + 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); + // https://github.com/immersive-web/webxr/issues/1236 for high position value const trans = camera.position.clone().multiplyScalar(-scale).applyQuaternion(quat); const transform = new XRRigidTransform(trans, quat); - + // here position seems ok {x: 4485948.637198923, y: 476198.0416370128, z: 4497216.056600053, w: 1} const baseReferenceSpace = xr.getReferenceSpace(); const teleportSpaceOffset = baseReferenceSpace.getOffsetReferenceSpace(transform); - xr.setReferenceSpace(teleportSpaceOffset); + // there it is not anymore : originOffset Matrix is : 4485948.5, 476198.03125, 4497216 + + // Must delay replacement to allow user listening to sessionstart to get original ReferenceSpace + setTimeout(() => { + xr.setReferenceSpace(teleportSpaceOffset); + // does a regression over controller matrixWorld update... + }); + view.notifyChange(); view.camera.camera3D = xr.getCamera(); + view.camera.camera3D.far = 100; view.camera.resize(view.camera.width, view.camera.height); + vrHeadSet.add(view.camera.camera3D); document.addEventListener('keydown', exitXRSession, false); @@ -54,14 +82,164 @@ const initializeWebXR = (view, options) => { // (see MainLoop#scheduleViewUpdate). xr.setAnimationLoop((timestamp) => { if (xr.isPresenting && view.camera.camera3D.cameras[0]) { + + if (xrControllers.left) { + listenGamepad(xrControllers.left); + } + if (xrControllers.right) { + listenGamepad(xrControllers.right); + } + view.camera.camera3D.updateMatrix(); view.camera.camera3D.updateMatrixWorld(true); + resyncControlCamera(); + + if (view.scene.matrixWorldAutoUpdate === true) { + view.scene.updateMatrixWorld(); + } + + computeDistanceToGround(); + updateFarDistance(); + if (options.callback) { + options.callback(); + } + view.notifyChange(view.camera.camera3D, true); } view.mainLoop.step(view, timestamp); + }); }); + + function resyncControlCamera() { + // search for other this.camera in Itowns code for perfs issues + view.controls.camera.position.copy(view.camera.camera3D.position); + view.controls.camera.rotation.copy(view.camera.camera3D.rotation); + view.controls.camera.updateMatrix(); + // view.controls.camera.rotation. + } + + function computeDistanceToGround() { + view.camera.elevationToGround = view.controls.getCameraCoordinate().altitude; + } + + function updateFarDistance() { + view.camera.camera3D.far = Math.min(Math.max(view.camera.elevationToGround * 1000, 10000), 100000); + view.camera.camera3D.updateProjectionMatrix(); + } + + /* + Listening {XRInputSource} and emit changes for convenience user binding + Adding a few internal states for reactivity + - controller.lockButtonIndex {number} when a button is pressed, gives its index + - controller.isStickActive {boolean} true when a controller stick is not on initial state. + - + */ + function listenGamepad(controller) { + if (controller.gamepad) { + // gamepad.axes = [0, 0, x, y]; + const gamepad = controller.gamepad; + const activeValue = gamepad.axes.find(value => value !== 0); + if (controller.isStickActive && !activeValue && controller.gamepad.endGamePadtrackEmit) { + controller.dispatchEvent({ type: 'itowns-xr-axes-stop', message: { controller } }); + controller.isStickActive = false; + return; + } else if (!controller.isStickActive && activeValue) { + controller.gamepad.endGamePadtrackEmit = false; + controller.isStickActive = true; + } else if (controller.isStickActive && !activeValue) { + controller.gamepad.endGamePadtrackEmit = true; + } + + if (activeValue) { + controller.dispatchEvent({ type: 'itowns-xr-axes-changed', message: { controller } }); + } + + for (const [index, button] of gamepad.buttons.entries()) { + if (button.pressed) { + // 0 - gachette index + // 1 - gachette majeur + // 3 - stick pressed + // 4 - bottom button + // 5 - upper button + controller.dispatchEvent({ type: 'itowns-xr-button-pressed', message: { controller, buttonIndex: index, button } }); + controller.lastButtonItem = button; + } else if (controller.lastButtonItem && controller.lastButtonItem === button) { + controller.dispatchEvent({ type: 'itowns-xr-button-released', message: { controller, buttonIndex: index, button } }); + controller.lastButtonItem = undefined; + } + + if (button.touched) { + // triggered really often + } + } + } + } + + function initControllers(webXRManager, vrHeadSet) { + const controllerModelFactory = new XRControllerModelFactory(); + const leftController = webXRManager.getController(0); + leftController.name = 'leftController'; + const rightController = webXRManager.getController(1); + rightController.name = 'rightController'; + bindControllerListeners(leftController, vrHeadSet); + bindControllerListeners(rightController, vrHeadSet); + const leftGripController = webXRManager.getControllerGrip(0); + leftGripController.name = 'leftGripController'; + const rightGripController = webXRManager.getControllerGrip(1); + rightGripController.name = 'rightGripController'; + bindGripController(controllerModelFactory, leftGripController, vrHeadSet); + bindGripController(controllerModelFactory, rightGripController, vrHeadSet); + vrHeadSet.add(new THREE.HemisphereLight(0xa5a5a5, 0x898989, 3)); + return { left: leftController, right: rightController }; + } + + function bindControllerListeners(controller, vrHeadSet) { + controller.addEventListener('disconnected', function removeCtrl() { + this.remove(this.children[0]); + }); + controller.addEventListener('connected', function addCtrl(event) { + this.add(buildController(event.data)); + // {XRInputSource} event.data + controller.gamepad = event.data.gamepad; + }); + controller.addEventListener('itowns-xr-button-released', (event) => { + const ctrl = event.message.controller; + ctrl.lockButtonIndex = undefined; + }); + controller.addEventListener('itowns-xr-button-pressed', (event) => { + const ctrl = event.message.controller; + ctrl.lockButtonIndex = event.message.buttonIndex; + }); + vrHeadSet.add(controller); + } + + function bindGripController(controllerModelFactory, gripController, vrHeadSet) { + gripController.add(controllerModelFactory.createControllerModel(gripController)); + vrHeadSet.add(gripController); + } + + function buildController(data) { + const params = { geometry: {}, material: {} }; + switch (data.targetRayMode) { + case 'tracked-pointer': + params.geometry = new THREE.BufferGeometry(); + + params.geometry.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, -view.camera.camera3D.far], 3)); + params.geometry.setAttribute('color', new THREE.Float32BufferAttribute([1, 1, 1], 3)); + + params.material = new THREE.LineBasicMaterial({ vertexColors: true, blending: THREE.AdditiveBlending }); + return new THREE.Line(params.geometry, params.material); + + case 'gaze': + params.geometry = new THREE.RingGeometry(0.02, 0.04, 32).translate(0, 0, -1); + params.material = new THREE.MeshBasicMaterial({ opacity: 0.5, transparent: true }); + return new THREE.Mesh(params.geometry, params.material); + default: + break; + } + } }; export default initializeWebXR;