diff --git a/examples/mixed-reality/watch/button.js b/examples/mixed-reality/watch/button.js new file mode 100644 index 00000000000..cf6087b3dd4 --- /dev/null +++ b/examples/mixed-reality/watch/button.js @@ -0,0 +1,52 @@ +/* global AFRAME */ +AFRAME.registerComponent('button', { + init: function () { + var buttonContainerEl = this.buttonContainerEl = document.createElement('div'); + var buttonWristEl = this.buttonWristEl = document.createElement('button'); + var buttonPalmEl = this.buttonPalmEl = document.createElement('button'); + + var style = document.createElement('style'); + var css = + '.a-button-container {box-sizing: border-box; display: inline-block; height: 34px; padding: 0;;' + + 'bottom: 20px; width: 200px; left: calc(50% - 75px); position: absolute; color: white;' + + 'font-size: 20px; line-height: 20px; border: none;' + + 'border-radius: 5px}' + + '.a-button {cursor: pointer; padding: 0px 10px 0 10px; font-weight: bold; color: #666; border: 3px solid #666; box-sizing: border-box; vertical-align: middle; max-width: 200px; border-radius: 10px; height: 40px; background-color: white; margin: 0; margin-right: 10px;}' + + '.a-button:hover {border-color: #ef2d5e; color: #ef2d5e}' + + '.a-button.selected {color: white; background-color: #ef2d5e; border-color: #ef2d5e}'; + + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + document.getElementsByTagName('head')[0].appendChild(style); + + buttonContainerEl.classList.add('a-button-container'); + + buttonPalmEl.classList.add('a-button'); + buttonPalmEl.classList.add('selected'); + buttonPalmEl.addEventListener('click', this.onClick.bind(this)); + buttonContainerEl.appendChild(buttonPalmEl); + + buttonWristEl.classList.add('a-button'); + buttonWristEl.addEventListener('click', this.onClick.bind(this)); + buttonContainerEl.appendChild(buttonWristEl); + + this.el.sceneEl.appendChild(buttonContainerEl); + buttonWristEl.innerHTML = 'WRIST'; + buttonPalmEl.innerHTML = 'PALM'; + }, + + onClick: function (evt) { + if (evt.target === this.buttonPalmEl) { + this.el.querySelector('[hand-menu]').setAttribute('hand-menu', 'location', 'palm'); + this.buttonPalmEl.classList.add('selected'); + this.buttonWristEl.classList.remove('selected'); + } else { + this.el.querySelector('[hand-menu]').setAttribute('hand-menu', 'location', 'wrist'); + this.buttonPalmEl.classList.remove('selected'); + this.buttonWristEl.classList.add('selected'); + } + } +}); diff --git a/examples/mixed-reality/watch/hand-menu-button.js b/examples/mixed-reality/watch/hand-menu-button.js new file mode 100644 index 00000000000..98cc08fd972 --- /dev/null +++ b/examples/mixed-reality/watch/hand-menu-button.js @@ -0,0 +1,153 @@ +/* global AFRAME, THREE */ +AFRAME.registerComponent('hand-menu-button', { + schema: { + src: {default: ''}, + srcHover: {default: ''}, + mixin: {default: ''} + }, + + init: function () { + this.onWatchButtonHovered = this.onWatchButtonHovered.bind(this); + this.onAnimationComplete = this.onAnimationComplete.bind(this); + this.onCollisionStarted = this.onCollisionStarted.bind(this); + this.onCollisionEnded = this.onCollisionEnded.bind(this); + this.onAnimationBegin = this.onAnimationBegin.bind(this); + this.onPinchEnded = this.onPinchEnded.bind(this); + + this.el.addEventListener('obbcollisionstarted', this.onCollisionStarted); + this.el.addEventListener('obbcollisionended', this.onCollisionEnded); + this.el.object3D.renderOrder = 1000; + + this.menuEl = this.el.parentEl; + this.handMenuEl = this.el.sceneEl.querySelector('[hand-menu]'); + + this.menuEl.addEventListener('animationbegin', this.onAnimationBegin); + this.menuEl.addEventListener('animationcomplete', this.onAnimationComplete); + }, + + onAnimationBegin: function (evt) { + // To prevent menu activations while animation is running. + if (evt.detail.name === 'animation__open') { this.menuOpen = false; } + }, + + onAnimationComplete: function (evt) { + if (evt.detail.name === 'animation__open') { this.menuOpen = true; } + if (evt.detail.name === 'animation__close') { this.menuOpen = false; } + }, + + onCollisionStarted: function (evt) { + var withEl = evt.detail.withEl; + if (this.handMenuEl === withEl || + !withEl.components['hand-tracking-controls']) { return; } + if (!this.menuOpen) { return; } + this.handHoveringEl = withEl; + this.el.emit('watchbuttonhoverstarted'); + }, + + onCollisionEnded: function (evt) { + var withEl = evt.detail.withEl; + if (this.handMenuEl === withEl || + !withEl.components['hand-tracking-controls']) { return; } + this.disableHover(); + this.handHoveringEl = undefined; + this.el.emit('watchbuttonhoverended'); + }, + + enableHover: function () { + this.handHoveringEl.addEventListener('pinchended', this.onPinchEnded); + this.el.setAttribute('material', 'src', this.data.srcHover); + }, + + disableHover: function () { + if (!this.handHoveringEl) { return; } + this.handHoveringEl.removeEventListener('pinchended', this.onPinchEnded); + this.el.setAttribute('material', 'src', this.data.src); + }, + + onPinchEnded: (function () { + var spawnPosition = new THREE.Vector3(0, 1, 0); + return function () { + var cubeEl; + var newEntityEl; + if (!this.menuOpen) { return; } + this.menuOpen = false; + if (!this.handHoveringEl || !this.data.mixin) { return; } + // Spawn shape a little above the menu. + spawnPosition.set(0, 1, 0); + // Apply rotation of the menu. + spawnPosition.applyQuaternion(this.el.parentEl.object3D.quaternion); + // 20cm above the menu. + spawnPosition.normalize().multiplyScalar(0.2); + spawnPosition.add(this.el.parentEl.object3D.position); + + newEntityEl = document.createElement('a-entity'); + newEntityEl.setAttribute('mixin', this.data.mixin); + newEntityEl.setAttribute('position', spawnPosition); + this.el.sceneEl.appendChild(newEntityEl); + this.handHoveringEl.removeEventListener('pinchended', this.onPinchEnded); + }; + })(), + + onWatchButtonHovered: function (evt) { + if (evt.target === this.el || !this.handHoveringEl) { return; } + this.disableHover(); + this.handHoveringEl = undefined; + } +}); + +/* + User's hand can collide with multiple buttons simulatenously but only want one in a hovered state. + This system keeps track of all the collided buttons, keeping just the closest to the hand in a hovered state. +*/ +AFRAME.registerSystem('hand-menu-button', { + init: function () { + this.onWatchButtonHovered = this.onWatchButtonHovered.bind(this); + this.el.parentEl.addEventListener('watchbuttonhoverended', this.onWatchButtonHovered); + this.el.parentEl.addEventListener('watchbuttonhoverstarted', this.onWatchButtonHovered); + this.hoveredButtonEls = []; + }, + + tick: function () { + var buttonWorldPosition = new THREE.Vector3(); + var thumbPosition; + var smallestDistance = 1000000; + var currentDistance; + var closestButtonEl; + if (this.hoveredButtonEls.length < 2) { return; } + thumbPosition = this.hoveredButtonEls[0].components['hand-menu-button'].handHoveringEl.components['obb-collider'].trackedObject3D.position; + for (var i = 0; i < this.hoveredButtonEls.length; ++i) { + this.hoveredButtonEls[i].object3D.getWorldPosition(buttonWorldPosition); + currentDistance = buttonWorldPosition.distanceTo(thumbPosition); + if (currentDistance < smallestDistance) { + closestButtonEl = this.hoveredButtonEls[i]; + smallestDistance = currentDistance; + } + } + + if (this.hoveredButtonEl === closestButtonEl) { return; } + + this.hoveredButtonEl = closestButtonEl; + + for (i = 0; i < this.hoveredButtonEls.length; ++i) { + if (!this.hoveredButtonEls[i].components['hand-menu-button'].handHoveringEl) { continue; } + if (this.hoveredButtonEls[i] === closestButtonEl) { + this.hoveredButtonEls[i].components['hand-menu-button'].enableHover(); + continue; + } + this.hoveredButtonEls[i].components['hand-menu-button'].disableHover(); + } + }, + + onWatchButtonHovered: function (evt) { + this.buttonEls = this.el.sceneEl.querySelectorAll('[hand-menu-button]'); + this.hoveredButtonEls = []; + for (var i = 0; i < this.buttonEls.length; ++i) { + if (!this.buttonEls[i].components['hand-menu-button'].handHoveringEl) { continue; } + this.hoveredButtonEls.push(this.buttonEls[i]); + } + if (this.hoveredButtonEls.length === 1) { + this.hoveredButtonEl = this.hoveredButtonEls[0]; + this.hoveredButtonEls[0].components['hand-menu-button'].enableHover(); + } + } +}); diff --git a/examples/mixed-reality/watch/hand-menu.js b/examples/mixed-reality/watch/hand-menu.js new file mode 100644 index 00000000000..22a93ea33fa --- /dev/null +++ b/examples/mixed-reality/watch/hand-menu.js @@ -0,0 +1,243 @@ +/* global AFRAME, THREE */ +AFRAME.registerComponent('hand-menu', { + schema: { + location: {default: 'palm', oneOf: ['wrist', 'palm']} + }, + + menuHTML: /* syntax: html */ ` + + + + + + + + + + + + + + + + + + `, + + init: function () { + this.onCollisionStarted = this.onCollisionStarted.bind(this); + this.onCollisionEnded = this.onCollisionEnded.bind(this); + this.onModelLoaded = this.onModelLoaded.bind(this); + this.onSceneLoaded = this.onSceneLoaded.bind(this); + this.onEnterVR = this.onEnterVR.bind(this); + + this.throttledOnPinchEvent = AFRAME.utils.throttle(this.throttledOnPinchEvent, 50, this); + + this.el.sceneEl.addEventListener('loaded', this.onSceneLoaded); + this.el.sceneEl.addEventListener('enter-vr', this.onEnterVR); + }, + + onEnterVR: function () { + this.setupMenu(); + }, + + onSceneLoaded: function () { + var handEls = this.el.sceneEl.querySelectorAll('[hand-tracking-controls]'); + for (var i = 0; i < handEls.length; i++) { + if (handEls[i] === this.el) { continue; } + this.handElement = handEls[i]; + } + }, + + setupMenu: function () { + var template = document.createElement('template'); + template.innerHTML = this.menuHTML; + this.menuEl = template.content.children[0]; + this.el.sceneEl.appendChild(this.menuEl); + + if (this.data.location === 'wrist') { + this.setupWatch(); + } else { + this.setupPalm(); + } + }, + + setupWatch: function () { + var el = this.openMenuEl = document.createElement('a-entity'); + el.setAttribute('gltf-model', '#watch'); + el.setAttribute('scale', '0.05 0.05 0.05'); + el.setAttribute('position', '0 0.035 0'); + if (this.el.getAttribute('hand-tracking-controls').hand === 'right') { + el.setAttribute('rotation', '0 180 0'); + } + el.setAttribute('obb-collider', 'centerModel: true'); + + el.addEventListener('model-loaded', this.onModelLoaded); + el.addEventListener('obbcollisionstarted', this.onCollisionStarted); + el.addEventListener('obbcollisionended', this.onCollisionEnded); + + var timeEl = this.timeEl = document.createElement('a-entity'); + timeEl.setAttribute('text', 'align: center'); + timeEl.setAttribute('time', ''); + if (this.el.getAttribute('hand-tracking-controls').hand === 'right') { + timeEl.setAttribute('rotation', '-90 -90 0'); + timeEl.setAttribute('position', '0.024 0.038 -0.001'); + } else { + timeEl.setAttribute('rotation', '-90 -90 180'); + timeEl.setAttribute('position', '-0.024 0.038 0'); + } + timeEl.setAttribute('scale', '0.09 0.09 0.09'); + el.appendChild(timeEl); + + this.el.appendChild(el); + }, + + setupPalm: function () { + var el = this.openMenuEl = document.createElement('a-entity'); + el.setAttribute('geometry', 'primitive: circle; radius: 0.025'); + el.setAttribute('material', 'side: double; src: #palmButton; shader: flat'); + el.setAttribute('rotation', '90 0 180'); + el.setAttribute('position', '0 -0.035 -0.07'); + el.setAttribute('obb-collider', ''); + el.addEventListener('obbcollisionstarted', this.onCollisionStarted); + el.addEventListener('obbcollisionended', this.onCollisionEnded); + this.el.appendChild(el); + }, + + throttledOnPinchEvent: function (evt) { + if (evt.type === 'pinchstarted') { this.onPinchStarted(evt); } + if (evt.type === 'pinchended') { this.onPinchEnded(evt); } + }, + + onModelLoaded: function (evt) { + var textureImage = document.querySelector('#watchDisplayHover'); + this.watchDisplayObject = evt.detail.model.getObjectByName('Object_24'); + this.watchDisplayTextureHover = new THREE.Texture(textureImage); + this.watchDisplayTexture = this.watchDisplayObject.material.map; + }, + + onCollisionStarted: function (evt) { + var withEl = evt.detail.withEl; + if (this.handElement !== withEl) { return; } + withEl.addEventListener('pinchstarted', this.throttledOnPinchEvent); + withEl.addEventListener('pinchended', this.throttledOnPinchEvent); + this.handHoveringEl = withEl; + this.updateUI(); + }, + + onCollisionEnded: function (evt) { + var withEl = evt.detail.withEl; + if (this.handElement !== withEl) { return; } + withEl.removeEventListener('pinchstarted', this.throttledOnPinchEvent); + if (!this.opened) { + withEl.removeEventListener('pinchended', this.throttledOnPinchEvent); + } + this.handHoveringEl = undefined; + this.updateUI(); + }, + + updateUI: function () { + var watchDisplayObject = this.watchDisplayObject; + var palmButtonImage; + if (this.data.location === 'wrist' && watchDisplayObject) { + watchDisplayObject.material.map = this.handHoveringEl ? this.watchDisplayTextureHover : this.watchDisplayTexture; + watchDisplayObject.material.map.needsUpdate = true; + watchDisplayObject.material.needsUpdate = true; + return; + } + if (this.data.location === 'palm') { + palmButtonImage = this.handHoveringEl ? '#palmButtonHover' : '#palmButton'; + this.openMenuEl.setAttribute('material', 'src', palmButtonImage); + return; + } + }, + + onPinchStarted: (function () { + var auxMaxtrix = new THREE.Matrix4(); + var auxQuaternion = new THREE.Quaternion(); + return function (evt) { + if (!this.handHoveringEl || this.opened) { return; } + this.opened = true; + this.menuEl.object3D.position.copy(evt.detail.position); + this.menuEl.emit('open'); + function lookAtVector (sourcePoint, destPoint) { + return auxQuaternion.setFromRotationMatrix( + auxMaxtrix.identity() + .lookAt(sourcePoint, destPoint, new THREE.Vector3(0, 1, 0))); + } + + var cameraEl = this.el.sceneEl.querySelector('[camera]'); + var rotationQuaternion = lookAtVector(this.menuEl.object3D.position, cameraEl.object3D.position); + this.menuEl.object3D.quaternion.copy(rotationQuaternion); + this.pinchedEl = this.handHoveringEl; + if (this.data.location === 'palm') { this.openMenuEl.object3D.visible = false; } + }; + })(), + + onPinchEnded: function (evt) { + if (!this.pinchedEl) { return; } + this.opened = false; + this.menuEl.emit('close'); + this.pinchedEl = undefined; + this.openMenuEl.object3D.visible = true; + }, + + lookAtCamera: (function () { + var auxVector = new THREE.Vector3(); + var auxObject3D = new THREE.Object3D(); + return function (el) { + var cameraEl = this.el.sceneEl.querySelector('[camera]'); + auxVector.subVectors(cameraEl.object3D.position, el.object3D.position).add(el.object3D.position); + el.object3D.lookAt(auxVector); + el.object3D.rotation.z = 0; + }; + })() +}); + +/* + +Watch style UI that work both in VR and AR with @aframevr in one line of + +Try now on @Meta Quest Browser + +https://a-watch.glitch.me/ + +Just 400 lines of code: https://glitch.com/edit/#!/a-watch + +Watch-style intuitive but easy to occlude hands ⌚️ +Palm- style less familiar but more robust ✋ + +Enjoy! Wanna see more of this? sponsor me on @github + +https://github.com/sponsors/dmarcos + +*/ diff --git a/examples/mixed-reality/watch/index.html b/examples/mixed-reality/watch/index.html new file mode 100644 index 00000000000..8b6e28c8d45 --- /dev/null +++ b/examples/mixed-reality/watch/index.html @@ -0,0 +1,112 @@ + + + + + Hand Menu (Mixed Reality) • A-Frame + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/mixed-reality/watch/message.html b/examples/mixed-reality/watch/message.html new file mode 100644 index 00000000000..ae49eb9f65d --- /dev/null +++ b/examples/mixed-reality/watch/message.html @@ -0,0 +1,13 @@ +

+It requires a headset and browser with hand tracking support (e.g Meta browser on Meta headsets). Starting point for hand attached menus. Only 400 lines of code. +

+ +

+Pinch to open the menu and release to close or select an option. A wrist menu more familiar but often one hand to occludes the other resulting in tracking loss. A palm menu is less intuitive but more robust against occlusion. +

+ +

+Grid shader by onirenaud
+Starry Night shader by sneha-belkhale
+Watch model by Socksthecat +

\ No newline at end of file diff --git a/examples/mixed-reality/watch/time.js b/examples/mixed-reality/watch/time.js new file mode 100644 index 00000000000..23fe5322e88 --- /dev/null +++ b/examples/mixed-reality/watch/time.js @@ -0,0 +1,9 @@ +/* global AFRAME */ +AFRAME.registerComponent('time', { + tick: function () { + var date = new Date(); + this.el.setAttribute('text', 'value', + date.getHours().toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping: false}) + + ':' + date.getMinutes().toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping: false})); + } +});