<!DOCTYPE html> <html lang="en"> <head> <title>three.js vr - handinput - point and click</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <link type="text/css" rel="stylesheet" href="main.css"> </head> <body> <div id="info"> <a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> vr - handinput - point and click<br /> (Oculus Browser 15.1+) </div> <!-- Import maps polyfill --> <!-- Remove this when import maps will be widely supported --> <script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script> <script type="importmap"> { "imports": { "three": "../build/three.module.js", "three/addons/": "./jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { VRButton } from 'three/addons/webxr/VRButton.js'; import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js'; import { OculusHandModel } from 'three/addons/webxr/OculusHandModel.js'; import { OculusHandPointerModel } from 'three/addons/webxr/OculusHandPointerModel.js'; import { createText } from 'three/addons/webxr/Text2D.js'; import { World, System, Component, TagComponent, Types } from 'three/addons/libs/ecsy.module.js'; class Object3D extends Component { } Object3D.schema = { object: { type: Types.Ref } }; class Button extends Component { } Button.schema = { // button states: [none, hovered, pressed] currState: { type: Types.String, default: 'none' }, prevState: { type: Types.String, default: 'none' }, action: { type: Types.Ref, default: () => { } } }; class ButtonSystem extends System { execute( /* delta, time */ ) { this.queries.buttons.results.forEach( entity => { const button = entity.getMutableComponent( Button ); const buttonMesh = entity.getComponent( Object3D ).object; if ( button.currState == 'none' ) { buttonMesh.scale.set( 1, 1, 1 ); } else { buttonMesh.scale.set( 1.1, 1.1, 1.1 ); } if ( button.currState == 'pressed' && button.prevState != 'pressed' ) { button.action(); } // preserve prevState, clear currState // HandRaySystem will update currState button.prevState = button.currState; button.currState = 'none'; } ); } } ButtonSystem.queries = { buttons: { components: [ Button ] } }; class Intersectable extends TagComponent { } class HandRaySystem extends System { init( attributes ) { this.handPointers = attributes.handPointers; } execute( /* delta, time */ ) { this.handPointers.forEach( hp => { let distance = null; let intersectingEntity = null; this.queries.intersectable.results.forEach( entity => { const object = entity.getComponent( Object3D ).object; const intersections = hp.intersectObject( object, false ); if ( intersections && intersections.length > 0 ) { if ( distance == null || intersections[ 0 ].distance < distance ) { distance = intersections[ 0 ].distance; intersectingEntity = entity; } } } ); if ( distance ) { hp.setCursor( distance ); if ( intersectingEntity.hasComponent( Button ) ) { const button = intersectingEntity.getMutableComponent( Button ); if ( hp.isPinched() ) { button.currState = 'pressed'; } else if ( button.currState != 'pressed' ) { button.currState = 'hovered'; } } } else { hp.setCursor( 1.5 ); } } ); } } HandRaySystem.queries = { intersectable: { components: [ Intersectable ] } }; class Rotating extends TagComponent { } class RotatingSystem extends System { execute( delta/*, time*/ ) { this.queries.rotatingObjects.results.forEach( entity => { const object = entity.getComponent( Object3D ).object; object.rotation.x += 0.4 * delta; object.rotation.y += 0.4 * delta; } ); } } RotatingSystem.queries = { rotatingObjects: { components: [ Rotating ] } }; class HandsInstructionText extends TagComponent { } class InstructionSystem extends System { init( attributes ) { this.controllers = attributes.controllers; } execute( /* delta, time */ ) { let visible = false; this.controllers.forEach( controller => { if ( controller.visible ) { visible = true; } } ); this.queries.instructionTexts.results.forEach( entity => { const object = entity.getComponent( Object3D ).object; object.visible = visible; } ); } } InstructionSystem.queries = { instructionTexts: { components: [ HandsInstructionText ] } }; class OffsetFromCamera extends Component { } OffsetFromCamera.schema = { x: { type: Types.Number, default: 0 }, y: { type: Types.Number, default: 0 }, z: { type: Types.Number, default: 0 }, }; class NeedCalibration extends TagComponent { } class CalibrationSystem extends System { init( attributes ) { this.camera = attributes.camera; this.renderer = attributes.renderer; } execute( /* delta, time */ ) { this.queries.needCalibration.results.forEach( entity => { if ( this.renderer.xr.getSession() ) { const offset = entity.getComponent( OffsetFromCamera ); const object = entity.getComponent( Object3D ).object; const xrCamera = this.renderer.xr.getCamera(); object.position.x = xrCamera.position.x + offset.x; object.position.y = xrCamera.position.y + offset.y; object.position.z = xrCamera.position.z + offset.z; entity.removeComponent( NeedCalibration ); } } ); } } CalibrationSystem.queries = { needCalibration: { components: [ NeedCalibration ] } }; const world = new World(); const clock = new THREE.Clock(); let camera, scene, renderer; init(); animate(); function makeButtonMesh( x, y, z, color ) { const geometry = new THREE.BoxGeometry( x, y, z ); const material = new THREE.MeshPhongMaterial( { color: color } ); const buttonMesh = new THREE.Mesh( geometry, material ); buttonMesh.castShadow = true; buttonMesh.receiveShadow = true; return buttonMesh; } function init() { const container = document.createElement( 'div' ); document.body.appendChild( container ); scene = new THREE.Scene(); scene.background = new THREE.Color( 0x444444 ); camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 10 ); camera.position.set( 0, 1.2, 0.3 ); scene.add( new THREE.HemisphereLight( 0x808080, 0x606060 ) ); const light = new THREE.DirectionalLight( 0xffffff ); light.position.set( 0, 6, 0 ); light.castShadow = true; light.shadow.camera.top = 2; light.shadow.camera.bottom = - 2; light.shadow.camera.right = 2; light.shadow.camera.left = - 2; light.shadow.mapSize.set( 4096, 4096 ); scene.add( light ); renderer = new THREE.WebGLRenderer( { antialias: true } ); renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.outputEncoding = THREE.sRGBEncoding; renderer.shadowMap.enabled = true; renderer.xr.enabled = true; renderer.xr.cameraAutoUpdate = false; container.appendChild( renderer.domElement ); document.body.appendChild( VRButton.createButton( renderer ) ); // controllers const controller1 = renderer.xr.getController( 0 ); scene.add( controller1 ); const controller2 = renderer.xr.getController( 1 ); scene.add( controller2 ); const controllerModelFactory = new XRControllerModelFactory(); // Hand 1 const controllerGrip1 = renderer.xr.getControllerGrip( 0 ); controllerGrip1.add( controllerModelFactory.createControllerModel( controllerGrip1 ) ); scene.add( controllerGrip1 ); const hand1 = renderer.xr.getHand( 0 ); hand1.add( new OculusHandModel( hand1 ) ); const handPointer1 = new OculusHandPointerModel( hand1, controller1 ); hand1.add( handPointer1 ); scene.add( hand1 ); // Hand 2 const controllerGrip2 = renderer.xr.getControllerGrip( 1 ); controllerGrip2.add( controllerModelFactory.createControllerModel( controllerGrip2 ) ); scene.add( controllerGrip2 ); const hand2 = renderer.xr.getHand( 1 ); hand2.add( new OculusHandModel( hand2 ) ); const handPointer2 = new OculusHandPointerModel( hand2, controller2 ); hand2.add( handPointer2 ); scene.add( hand2 ); // setup objects in scene and entities const floorGeometry = new THREE.PlaneGeometry( 4, 4 ); const floorMaterial = new THREE.MeshPhongMaterial( { color: 0x222222 } ); const floor = new THREE.Mesh( floorGeometry, floorMaterial ); floor.rotation.x = - Math.PI / 2; floor.receiveShadow = true; scene.add( floor ); const menuGeometry = new THREE.PlaneGeometry( 0.24, 0.5 ); const menuMaterial = new THREE.MeshPhongMaterial( { opacity: 0, transparent: true, } ); const menuMesh = new THREE.Mesh( menuGeometry, menuMaterial ); menuMesh.position.set( 0.4, 1, - 1 ); menuMesh.rotation.y = - Math.PI / 12; scene.add( menuMesh ); const orangeButton = makeButtonMesh( 0.2, 0.1, 0.01, 0xffd3b5 ); orangeButton.position.set( 0, 0.18, 0 ); menuMesh.add( orangeButton ); const pinkButton = makeButtonMesh( 0.2, 0.1, 0.01, 0xe84a5f ); pinkButton.position.set( 0, 0.06, 0 ); menuMesh.add( pinkButton ); const resetButton = makeButtonMesh( 0.2, 0.1, 0.01, 0x355c7d ); const resetButtonText = createText( 'reset', 0.06 ); resetButton.add( resetButtonText ); resetButtonText.position.set( 0, 0, 0.0051 ); resetButton.position.set( 0, - 0.06, 0 ); menuMesh.add( resetButton ); const exitButton = makeButtonMesh( 0.2, 0.1, 0.01, 0xff0000 ); const exitButtonText = createText( 'exit', 0.06 ); exitButton.add( exitButtonText ); exitButtonText.position.set( 0, 0, 0.0051 ); exitButton.position.set( 0, - 0.18, 0 ); menuMesh.add( exitButton ); const tkGeometry = new THREE.TorusKnotGeometry( 0.5, 0.2, 200, 32 ); const tkMaterial = new THREE.MeshPhongMaterial( { color: 0xffffff } ); tkMaterial.metalness = 0.8; const torusKnot = new THREE.Mesh( tkGeometry, tkMaterial ); torusKnot.position.set( 0, 1, - 5 ); scene.add( torusKnot ); const instructionText = createText( 'This is a WebXR Hands demo, please explore with hands.', 0.04 ); instructionText.position.set( 0, 1.6, - 0.6 ); scene.add( instructionText ); const exitText = createText( 'Exiting session...', 0.04 ); exitText.position.set( 0, 1.5, - 0.6 ); exitText.visible = false; scene.add( exitText ); world .registerComponent( Object3D ) .registerComponent( Button ) .registerComponent( Intersectable ) .registerComponent( Rotating ) .registerComponent( HandsInstructionText ) .registerComponent( OffsetFromCamera ) .registerComponent( NeedCalibration ); world .registerSystem( RotatingSystem ) .registerSystem( InstructionSystem, { controllers: [ controllerGrip1, controllerGrip2 ] } ) .registerSystem( CalibrationSystem, { renderer: renderer, camera: camera } ) .registerSystem( ButtonSystem ) .registerSystem( HandRaySystem, { handPointers: [ handPointer1, handPointer2 ] } ); const menuEntity = world.createEntity(); menuEntity.addComponent( Intersectable ); menuEntity.addComponent( OffsetFromCamera, { x: 0.4, y: 0, z: - 1 } ); menuEntity.addComponent( NeedCalibration ); menuEntity.addComponent( Object3D, { object: menuMesh } ); const obEntity = world.createEntity(); obEntity.addComponent( Intersectable ); obEntity.addComponent( Object3D, { object: orangeButton } ); const obAction = function () { torusKnot.material.color.setHex( 0xffd3b5 ); }; obEntity.addComponent( Button, { action: obAction } ); const pbEntity = world.createEntity(); pbEntity.addComponent( Intersectable ); pbEntity.addComponent( Object3D, { object: pinkButton } ); const pbAction = function () { torusKnot.material.color.setHex( 0xe84a5f ); }; pbEntity.addComponent( Button, { action: pbAction } ); const rbEntity = world.createEntity(); rbEntity.addComponent( Intersectable ); rbEntity.addComponent( Object3D, { object: resetButton } ); const rbAction = function () { torusKnot.material.color.setHex( 0xffffff ); }; rbEntity.addComponent( Button, { action: rbAction } ); const ebEntity = world.createEntity(); ebEntity.addComponent( Intersectable ); ebEntity.addComponent( Object3D, { object: exitButton } ); const ebAction = function () { exitText.visible = true; setTimeout( function () { exitText.visible = false; renderer.xr.getSession().end(); }, 2000 ); }; ebEntity.addComponent( Button, { action: ebAction } ); const tkEntity = world.createEntity(); tkEntity.addComponent( Rotating ); tkEntity.addComponent( Object3D, { object: torusKnot } ); const itEntity = world.createEntity(); itEntity.addComponent( HandsInstructionText ); itEntity.addComponent( Object3D, { object: instructionText } ); window.addEventListener( 'resize', onWindowResize ); } function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); } function animate() { renderer.setAnimationLoop( render ); } function render() { const delta = clock.getDelta(); const elapsedTime = clock.elapsedTime; renderer.xr.updateCamera( camera ); world.execute( delta, elapsedTime ); renderer.render( scene, camera ); } </script> </body> </html>