瀏覽代碼

Fixing Oculus hands rendering broken by recent WebXR Hands API change (#21712)

* Adding advanced hand examples

* Update samples with ECSY implementation

* Fix hands examples broken by the recent oculus WebXR Hands API change

* Replace fbx hand models with glb

* Generate screenshots for new examples

* Clean up in Text2D.js

* Clean up in Text2D.js

* Update XRHandModelFactory and OculusHandModel to fetch hand model from webxr input profile cdn
Tianrui "Felix" Zhang 4 年之前
父節點
當前提交
29dd2170cb

+ 3 - 0
examples/files.json

@@ -342,6 +342,9 @@
 		"webxr_vr_handinput",
 		"webxr_vr_handinput_cubes",
 		"webxr_vr_handinput_profiles",
+		"webxr_vr_handinput_pointerclick",
+		"webxr_vr_handinput_pointerdrag",
+		"webxr_vr_handinput_pressbutton",
 		"webxr_vr_haptics",
 		"webxr_vr_lorenzattractor",
 		"webxr_vr_panorama",

+ 82 - 0
examples/jsm/webxr/OculusHandModel.js

@@ -0,0 +1,82 @@
+import { Object3D, Sphere, Box3 } from "../../../build/three.module.js";
+import { fetchProfile } from '../libs/motion-controllers.module.js';
+import { XRHandMeshModel } from "./XRHandMeshModel.js";
+
+const DEFAULT_PROFILES_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/[email protected]/dist/profiles';
+const DEFAULT_PROFILE = 'generic-hand';
+
+class OculusHandModel extends Object3D {
+  constructor(controller) {
+    super();
+
+    this.controller = controller;
+    this.motionController = null;
+    this.envMap = null;
+
+    this.mesh = null;
+
+    controller.addEventListener("connected", (event) => {
+      const xrInputSource = event.data;
+      if (xrInputSource.hand && !this.motionController) {
+        this.visible = true;
+        this.xrInputSource = xrInputSource;
+        fetchProfile(xrInputSource, DEFAULT_PROFILES_PATH, DEFAULT_PROFILE).then(({ profile, assetPath }) => {
+          this.motionController = new XRHandMeshModel(
+            this,
+            controller,
+            assetPath
+          );
+        }).catch((err) => {
+          console.warn(err);
+        });
+      }
+    });
+
+    controller.addEventListener("disconnected", () => {
+      this.clear();
+      this.motionController = null;
+    })
+  }
+
+  updateMatrixWorld(force) {
+    super.updateMatrixWorld(force);
+
+    if (this.motionController) {
+      this.motionController.updateMesh();
+    }
+  }
+
+  getPointerPosition() {
+    let indexFingerTip = this.controller.joints[POINTING_JOINT];
+    if (indexFingerTip) {
+      return indexFingerTip.position;
+    } else {
+      return null;
+    }
+  }
+
+  intersectBoxObject(boxObject) {
+    let pointerPosition = this.getPointerPosition();
+    if (pointerPosition) {
+      let indexSphere = new Sphere(pointerPosition, TOUCH_RADIUS);
+      let box = new Box3().setFromObject(boxObject);
+      return indexSphere.intersectsBox(box);
+    } else {
+      return false;
+    }
+  }
+
+  checkButton(button) {
+    if (this.intersectBoxObject(button)) {
+      button.onPress();
+    } else {
+      button.onClear();
+    }
+
+    if (button.isPressed()) {
+      button.whilePressed();
+    }
+  }
+}
+
+export { OculusHandModel };

+ 314 - 0
examples/jsm/webxr/OculusHandPointerModel.js

@@ -0,0 +1,314 @@
+import * as THREE from "../../../build/three.module.js";
+
+const PINCH_MAX = 0.05;
+const PINCH_THRESHOLD = 0.02;
+const PINCH_MIN = 0.01;
+const POINTER_ADVANCE_MAX = 0.02;
+const POINTER_OPACITY_MAX = 1;
+const POINTER_OPACITY_MIN = 0.4;
+const POINTER_FRONT_RADIUS = 0.002;
+const POINTER_REAR_RADIUS = 0.01;
+const POINTER_REAR_RADIUS_MIN = 0.003;
+const POINTER_LENGTH = 0.035;
+const POINTER_SEGMENTS = 16;
+const POINTER_RINGS = 12;
+const POINTER_HEMISPHERE_ANGLE = 110;
+const YAXIS = new THREE.Vector3(0, 1, 0);
+const ZAXIS = new THREE.Vector3(0, 0, 1);
+
+const CURSOR_RADIUS = 0.02;
+const CURSOR_MAX_DISTANCE = 1.5;
+
+class OculusHandPointerModel extends THREE.Object3D {
+  constructor(hand, controller) {
+    super();
+
+    this.hand = hand;
+    this.controller = controller;
+    this.motionController = null;
+    this.envMap = null;
+
+    this.mesh = null;
+
+    this.pointerGeometry = null;
+    this.pointerMesh = null;
+    this.pointerObject = null;
+
+    this.pinched = false;
+    this.attached = false;
+
+    this.cursorObject = null;
+
+    this.raycaster = null;
+
+    hand.addEventListener("connected", (event) => {
+      const xrInputSource = event.data;
+      if (xrInputSource.hand) {
+        this.visible = true;
+        this.xrInputSource = xrInputSource;
+
+        this.createPointer();
+      }
+    });
+  }
+
+  _drawVerticesRing(vertices, baseVector, ringIndex) {
+    const segmentVector = baseVector.clone();
+    for (var i = 0; i < POINTER_SEGMENTS; i++) {
+      segmentVector.applyAxisAngle(ZAXIS, (Math.PI * 2) / POINTER_SEGMENTS);
+      let vid = ringIndex * POINTER_SEGMENTS + i;
+      vertices[3 * vid] = segmentVector.x;
+      vertices[3 * vid + 1] = segmentVector.y;
+      vertices[3 * vid + 2] = segmentVector.z;
+    }
+  }
+
+  _updatePointerVertices(rearRadius) {
+    const vertices = this.pointerGeometry.attributes.position.array;
+    // first ring for front face
+    const frontFaceBase = new THREE.Vector3(
+      POINTER_FRONT_RADIUS,
+      0,
+      -1 * (POINTER_LENGTH - rearRadius)
+    );
+    this._drawVerticesRing(vertices, frontFaceBase, 0);
+
+    // rings for rear hemisphere
+    const rearBase = new THREE.Vector3(
+      Math.sin((Math.PI * POINTER_HEMISPHERE_ANGLE) / 180) * rearRadius,
+      Math.cos((Math.PI * POINTER_HEMISPHERE_ANGLE) / 180) * rearRadius,
+      0
+    );
+    for (var i = 0; i < POINTER_RINGS; i++) {
+      this._drawVerticesRing(vertices, rearBase, i + 1);
+      rearBase.applyAxisAngle(
+        YAXIS,
+        (Math.PI * POINTER_HEMISPHERE_ANGLE) / 180 / (POINTER_RINGS * -2)
+      );
+    }
+
+    // front and rear face center vertices
+    const frontCenterIndex = POINTER_SEGMENTS * (1 + POINTER_RINGS);
+    const rearCenterIndex = POINTER_SEGMENTS * (1 + POINTER_RINGS) + 1;
+    const frontCenter = new THREE.Vector3(
+      0,
+      0,
+      -1 * (POINTER_LENGTH - rearRadius)
+    );
+    vertices[frontCenterIndex * 3] = frontCenter.x;
+    vertices[frontCenterIndex * 3 + 1] = frontCenter.y;
+    vertices[frontCenterIndex * 3 + 2] = frontCenter.z;
+    const rearCenter = new THREE.Vector3(0, 0, rearRadius);
+    vertices[rearCenterIndex * 3] = rearCenter.x;
+    vertices[rearCenterIndex * 3 + 1] = rearCenter.y;
+    vertices[rearCenterIndex * 3 + 2] = rearCenter.z;
+
+    this.pointerGeometry.setAttribute(
+      "position",
+      new THREE.Float32BufferAttribute(vertices, 3)
+    );
+    // verticesNeedUpdate = true;
+  }
+
+  createPointer() {
+    var i, j;
+    const vertices = new Array(
+      ((POINTER_RINGS + 1) * POINTER_SEGMENTS + 2) * 3
+    ).fill(0);
+    // const vertices = [];
+    const indices = [];
+    this.pointerGeometry = new THREE.BufferGeometry();
+
+    this.pointerGeometry.setAttribute(
+      "position",
+      new THREE.Float32BufferAttribute(vertices, 3)
+    );
+
+    this._updatePointerVertices(POINTER_REAR_RADIUS);
+
+    // construct faces to connect rings
+    for (i = 0; i < POINTER_RINGS; i++) {
+      for (j = 0; j < POINTER_SEGMENTS - 1; j++) {
+        indices.push(
+          i * POINTER_SEGMENTS + j,
+          i * POINTER_SEGMENTS + j + 1,
+          (i + 1) * POINTER_SEGMENTS + j
+        );
+        indices.push(
+          i * POINTER_SEGMENTS + j + 1,
+          (i + 1) * POINTER_SEGMENTS + j + 1,
+          (i + 1) * POINTER_SEGMENTS + j
+        );
+      }
+      indices.push(
+        (i + 1) * POINTER_SEGMENTS - 1,
+        i * POINTER_SEGMENTS,
+        (i + 2) * POINTER_SEGMENTS - 1
+      );
+      indices.push(
+        i * POINTER_SEGMENTS,
+        (i + 1) * POINTER_SEGMENTS,
+        (i + 2) * POINTER_SEGMENTS - 1
+      );
+    }
+
+    // construct front and rear face
+    const frontCenterIndex = POINTER_SEGMENTS * (1 + POINTER_RINGS);
+    const rearCenterIndex = POINTER_SEGMENTS * (1 + POINTER_RINGS) + 1;
+
+    for (i = 0; i < POINTER_SEGMENTS - 1; i++) {
+      indices.push(frontCenterIndex, i + 1, i);
+      indices.push(
+        rearCenterIndex,
+        i + POINTER_SEGMENTS * POINTER_RINGS,
+        i + POINTER_SEGMENTS * POINTER_RINGS + 1
+      );
+    }
+    indices.push(frontCenterIndex, 0, POINTER_SEGMENTS - 1);
+    indices.push(
+      rearCenterIndex,
+      POINTER_SEGMENTS * (POINTER_RINGS + 1) - 1,
+      POINTER_SEGMENTS * POINTER_RINGS
+    );
+
+    const material = new THREE.MeshBasicMaterial();
+    material.transparent = true;
+    material.opacity = POINTER_OPACITY_MIN;
+
+    this.pointerGeometry.setIndex(indices);
+
+    this.pointerMesh = new THREE.Mesh(this.pointerGeometry, material);
+
+    this.pointerMesh.position.set(0, 0, -1 * POINTER_REAR_RADIUS);
+    this.pointerObject = new THREE.Object3D();
+    this.pointerObject.add(this.pointerMesh);
+
+    this.raycaster = new THREE.Raycaster();
+
+    // create cursor
+    const cursorGeometry = new THREE.SphereGeometry(CURSOR_RADIUS, 10, 10);
+    const cursorMaterial = new THREE.MeshBasicMaterial();
+    cursorMaterial.transparent = true;
+    cursorMaterial.opacity = POINTER_OPACITY_MIN;
+
+    this.cursorObject = new THREE.Mesh(cursorGeometry, cursorMaterial);
+    this.pointerObject.add(this.cursorObject);
+
+    this.add(this.pointerObject);
+  }
+
+  _updateRaycaster() {
+    if (this.raycaster) {
+      const pointerMatrix = this.pointerObject.matrixWorld;
+      const tempMatrix = new THREE.Matrix4();
+      tempMatrix.identity().extractRotation(pointerMatrix);
+      this.raycaster.ray.origin.setFromMatrixPosition(pointerMatrix);
+      this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
+    }
+  }
+
+  _updatePointer() {
+    this.pointerObject.visible = this.controller.visible;
+    const indexTip = this.hand.joints["index-finger-tip"];
+    const thumbTip = this.hand.joints["thumb-tip"];
+    const distance = indexTip.position.distanceTo(thumbTip.position);
+    const position = indexTip.position
+      .clone()
+      .add(thumbTip.position)
+      .multiplyScalar(0.5);
+    this.pointerObject.position.copy(position);
+    this.pointerObject.quaternion.copy(this.controller.quaternion);
+
+    this.pinched = distance <= PINCH_THRESHOLD;
+
+    const pinchScale = (distance - PINCH_MIN) / (PINCH_MAX - PINCH_MIN);
+    const focusScale = (distance - PINCH_MIN) / (PINCH_THRESHOLD - PINCH_MIN);
+    if (pinchScale > 1) {
+      this._updatePointerVertices(POINTER_REAR_RADIUS);
+      this.pointerMesh.position.set(0, 0, -1 * POINTER_REAR_RADIUS);
+      this.pointerMesh.material.opacity = POINTER_OPACITY_MIN;
+    } else if (pinchScale > 0) {
+      const rearRadius =
+        (POINTER_REAR_RADIUS - POINTER_REAR_RADIUS_MIN) * pinchScale +
+        POINTER_REAR_RADIUS_MIN;
+      this._updatePointerVertices(rearRadius);
+      if (focusScale < 1) {
+        this.pointerMesh.position.set(
+          0,
+          0,
+          -1 * rearRadius - (1 - focusScale) * POINTER_ADVANCE_MAX
+        );
+        this.pointerMesh.material.opacity =
+          POINTER_OPACITY_MIN +
+          (1 - focusScale) * (POINTER_OPACITY_MAX - POINTER_OPACITY_MIN);
+      } else {
+        this.pointerMesh.position.set(0, 0, -1 * rearRadius);
+        this.pointerMesh.material.opacity = POINTER_OPACITY_MIN;
+      }
+    } else {
+      this._updatePointerVertices(POINTER_REAR_RADIUS_MIN);
+      this.pointerMesh.position.set(
+        0,
+        0,
+        -1 * POINTER_REAR_RADIUS_MIN - POINTER_ADVANCE_MAX
+      );
+      this.pointerMesh.material.opacity = POINTER_OPACITY_MAX;
+    }
+    this.cursorObject.material.opacity = this.pointerMesh.material.opacity;
+  }
+
+  updateMatrixWorld(force) {
+    THREE.Object3D.prototype.updateMatrixWorld.call(this, force);
+    if (this.pointerGeometry) {
+      this._updatePointer();
+      this._updateRaycaster();
+    }
+  }
+
+  isPinched() {
+    return this.pinched;
+  }
+
+  setAttached(attached) {
+    this.attached = attached;
+  }
+
+  isAttached() {
+    return this.attached;
+  }
+
+  intersectObject(object) {
+    if (this.raycaster) {
+      return this.raycaster.intersectObject(object);
+    }
+  }
+
+  intersectObjects(objects) {
+    if (this.raycaster) {
+      return this.raycaster.intersectObjects(objects);
+    }
+  }
+
+  checkIntersections(objects) {
+    if (this.raycaster && !this.attached) {
+      let intersections = this.raycaster.intersectObjects(objects);
+      let direction = new THREE.Vector3(0, 0, -1);
+      if (intersections.length > 0) {
+        let intersection = intersections[0];
+        let distance = intersection.distance;
+        this.cursorObject.position.copy(direction.multiplyScalar(distance));
+      } else {
+        this.cursorObject.position.copy(direction.multiplyScalar(CURSOR_MAX_DISTANCE));
+      }
+    }
+  }
+
+  setCursor(distance) {
+    let direction = new THREE.Vector3(0, 0, -1);
+    if (this.raycaster && !this.attached) {
+      this.cursorObject.position.copy(direction.multiplyScalar(distance));
+    }
+  }
+}
+
+export { OculusHandPointerModel };

+ 36 - 0
examples/jsm/webxr/Text2D.js

@@ -0,0 +1,36 @@
+import * as THREE from "../../../build/three.module.js";
+
+function createText(message, height) {
+  const canvas = document.createElement("canvas");
+  const context = canvas.getContext("2d");
+  let metrics = null,
+    textHeight = 100;
+  context.font = "normal " + textHeight + "px Arial";
+  metrics = context.measureText(message);
+  const textWidth = metrics.width;
+  canvas.width = textWidth;
+  canvas.height = textHeight;
+  context.font = "normal " + textHeight + "px Arial";
+  context.textAlign = "center";
+  context.textBaseline = "middle";
+  context.fillStyle = "#ffffff";
+  context.fillText(message, textWidth / 2, textHeight / 2);
+
+  const texture = new THREE.Texture(canvas);
+  texture.needsUpdate = true;
+  //var spriteAlignment = new THREE.Vector2(0,0) ;
+  const material = new THREE.MeshBasicMaterial({
+    color: 0xffffff,
+    side: THREE.DoubleSide,
+    map: texture,
+    transparent: true,
+  });
+  const geometry = new THREE.PlaneGeometry(
+    (height * textWidth) / textHeight,
+    height
+  );
+  let plane = new THREE.Mesh(geometry, material);
+  return plane;
+}
+
+export { createText };

+ 83 - 0
examples/jsm/webxr/XRHandMeshModel.js

@@ -0,0 +1,83 @@
+import { GLTFLoader } from '../loaders/GLTFLoader.js';
+
+class XRHandMeshModel {
+
+  constructor(handModel, controller, assetUrl) {
+
+    this.controller = controller;
+    this.handModel = handModel;
+
+    this.bones = [];
+    const loader = new GLTFLoader();
+
+    loader.setPath('');
+    loader.load(assetUrl, gltf => {
+      const object = gltf.scene.children[0];
+      this.handModel.add(object);
+
+      const mesh = object.getObjectByProperty('type', 'SkinnedMesh');
+      mesh.frustumCulled = false;
+      mesh.castShadow = true;
+      mesh.receiveShadow = true;
+
+      const joints = [
+        'wrist',
+        'thumb-metacarpal',
+        'thumb-phalanx-proximal',
+        'thumb-phalanx-distal',
+        'thumb-tip',
+        'index-finger-metacarpal',
+        'index-finger-phalanx-proximal',
+        'index-finger-phalanx-intermediate',
+        'index-finger-phalanx-distal',
+        'index-finger-tip',
+        'middle-finger-metacarpal',
+        'middle-finger-phalanx-proximal',
+        'middle-finger-phalanx-intermediate',
+        'middle-finger-phalanx-distal',
+        'middle-finger-tip',
+        'ring-finger-metacarpal',
+        'ring-finger-phalanx-proximal',
+        'ring-finger-phalanx-intermediate',
+        'ring-finger-phalanx-distal',
+        'ring-finger-tip',
+        'pinky-finger-metacarpal',
+        'pinky-finger-phalanx-proximal',
+        'pinky-finger-phalanx-intermediate',
+        'pinky-finger-phalanx-distal',
+        'pinky-finger-tip',
+      ];
+
+      joints.forEach(jointName => {
+        const bone = object.getObjectByName(jointName);
+        if (bone !== undefined) {
+          bone.jointName = jointName;
+        } else {
+          console.warn(`Couldn't find ${jointName} in ${handedness} hand mesh`);
+        }
+        this.bones.push(bone);
+      });
+    });
+  }
+
+  updateMesh() {
+    // XR Joints
+    const XRJoints = this.controller.joints;
+    for (let i = 0; i < this.bones.length; i++) {
+      const bone = this.bones[i];
+      if (bone) {
+        const XRJoint = XRJoints[bone.jointName];
+        if (XRJoint.visible) {
+          const position = XRJoint.position;
+          if (bone) {
+            bone.position.copy(position);
+            bone.quaternion.copy(XRJoint.quaternion);
+            // bone.scale.setScalar( XRJoint.jointRadius || defaultRadius );
+          }
+        }
+      }
+    }
+  }
+}
+
+export { XRHandMeshModel };

+ 19 - 4
examples/jsm/webxr/XRHandModelFactory.js

@@ -6,9 +6,16 @@ import {
 	XRHandPrimitiveModel
 } from './XRHandPrimitiveModel.js';
 
-import {
-	XRHandOculusMeshModel
-} from './XRHandOculusMeshModel.js';
+import { 
+	XRHandMeshModel 
+} from "./XRHandMeshModel.js";
+
+import { 
+	fetchProfile 
+} from '../libs/motion-controllers.module.js';
+
+const DEFAULT_PROFILES_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/[email protected]/dist/profiles';
+const DEFAULT_PROFILE = 'generic-hand';
 
 class XRHandModel extends Object3D {
 
@@ -77,7 +84,15 @@ class XRHandModelFactory {
 
 				} else if ( profile === 'oculus' ) {
 
-					handModel.motionController = new XRHandOculusMeshModel( handModel, controller, this.path, xrInputSource.handedness, options );
+					fetchProfile(xrInputSource, DEFAULT_PROFILES_PATH, DEFAULT_PROFILE).then(({ profile, assetPath }) => {
+	
+						handModel.motionController = new XRHandMeshModel( handModel, controller, assetPath);
+
+					}).catch((err) => {
+
+						console.warn(err);
+
+					});
 
 				}
 

+ 0 - 151
examples/jsm/webxr/XRHandOculusMeshModel.js

@@ -1,151 +0,0 @@
-import { FBXLoader } from '../loaders/FBXLoader.js';
-
-class XRHandOculusMeshModel {
-
-	constructor( handModel, controller, path, handedness, options ) {
-
-		this.controller = controller;
-		this.handModel = handModel;
-
-		this.bones = [];
-		const loader = new FBXLoader();
-		const low = options && options.model === 'lowpoly' ? '_low' : '';
-
-		loader.setPath( path );
-		loader.load( `OculusHand_${handedness === 'right' ? 'R' : 'L'}${low}.fbx`, object => {
-
-			this.handModel.add( object );
-			// Hack because of the scale of the skinnedmesh
-			object.scale.setScalar( 0.01 );
-
-			const mesh = object.getObjectByProperty( 'type', 'SkinnedMesh' );
-			mesh.frustumCulled = false;
-			mesh.castShadow = true;
-			mesh.receiveShadow = true;
-
-			const bonesMapping = [
-				'b_%_wrist', // XRHand.WRIST,
-
-				'b_%_thumb1', // XRHand.THUMB_METACARPAL,
-				'b_%_thumb2', // XRHand.THUMB_PHALANX_PROXIMAL,
-				'b_%_thumb3', // XRHand.THUMB_PHALANX_DISTAL,
-				'b_%_thumb_null', // XRHand.THUMB_PHALANX_TIP,
-
-				null, //'b_%_index1', // XRHand.INDEX_METACARPAL,
-				'b_%_index1', // XRHand.INDEX_PHALANX_PROXIMAL,
-				'b_%_index2', // XRHand.INDEX_PHALANX_INTERMEDIATE,
-				'b_%_index3', // XRHand.INDEX_PHALANX_DISTAL,
-				'b_%_index_null', // XRHand.INDEX_PHALANX_TIP,
-
-				null, //'b_%_middle1', // XRHand.MIDDLE_METACARPAL,
-				'b_%_middle1', // XRHand.MIDDLE_PHALANX_PROXIMAL,
-				'b_%_middle2', // XRHand.MIDDLE_PHALANX_INTERMEDIATE,
-				'b_%_middle3', // XRHand.MIDDLE_PHALANX_DISTAL,
-				'b_%_middlenull', // XRHand.MIDDLE_PHALANX_TIP,
-
-				null, //'b_%_ring1', // XRHand.RING_METACARPAL,
-				'b_%_ring1', // XRHand.RING_PHALANX_PROXIMAL,
-				'b_%_ring2', // XRHand.RING_PHALANX_INTERMEDIATE,
-				'b_%_ring3', // XRHand.RING_PHALANX_DISTAL,
-				'b_%_ring_inull', // XRHand.RING_PHALANX_TIP,
-
-				'b_%_pinky0', // XRHand.LITTLE_METACARPAL,
-				'b_%_pinky1', // XRHand.LITTLE_PHALANX_PROXIMAL,
-				'b_%_pinky2', // XRHand.LITTLE_PHALANX_INTERMEDIATE,
-				'b_%_pinky3', // XRHand.LITTLE_PHALANX_DISTAL,
-				'b_%_pinkynull', // XRHand.LITTLE_PHALANX_TIP
-			];
-
-			const joints = [
-				'wrist',
-				'thumb-metacarpal',
-				'thumb-phalanx-proximal',
-				'thumb-phalanx-distal',
-				'thumb-tip',
-				'index-finger-metacarpal',
-				'index-finger-phalanx-proximal',
-				'index-finger-phalanx-intermediate',
-				'index-finger-phalanx-distal',
-				'index-finger-tip',
-				'middle-finger-metacarpal',
-				'middle-finger-phalanx-proximal',
-				'middle-finger-phalanx-intermediate',
-				'middle-finger-phalanx-distal',
-				'middle-finger-tip',
-				'ring-finger-metacarpal',
-				'ring-finger-phalanx-proximal',
-				'ring-finger-phalanx-intermediate',
-				'ring-finger-phalanx-distal',
-				'ring-finger-tip',
-				'pinky-finger-metacarpal',
-				'pinky-finger-phalanx-proximal',
-				'pinky-finger-phalanx-intermediate',
-				'pinky-finger-phalanx-distal',
-				'pinky-finger-tip',
-			];
-
-			let i = 0;
-
-			bonesMapping.forEach( boneName => {
-
-				if ( boneName ) {
-
-					const bone = object.getObjectByName( boneName.replace( /%/g, handedness === 'right' ? 'r' : 'l' ) );
-
-					if ( bone !== undefined ) {
-
-						bone.jointName = joints[ i ];
-
-					}
-
-					this.bones.push( bone );
-
-				} else {
-
-					this.bones.push( null );
-
-				}
-
-				i ++;
-
-			} );
-
-		} );
-
-	}
-
-	updateMesh() {
-
-		// XR Joints
-		const XRJoints = this.controller.joints;
-		for ( let i = 0; i < this.bones.length; i ++ ) {
-
-			const bone = this.bones[ i ];
-
-			if ( bone ) {
-
-				const XRJoint = XRJoints[ bone.jointName ];
-
-				if ( XRJoint.visible ) {
-
-					const position = XRJoint.position;
-
-					if ( bone ) {
-
-						bone.position.copy( position.clone().multiplyScalar( 100 ) );
-						bone.quaternion.copy( XRJoint.quaternion );
-						// bone.scale.setScalar( XRJoint.jointRadius || defaultRadius );
-
-					}
-
-				}
-
-			}
-
-		}
-
-	}
-
-}
-
-export { XRHandOculusMeshModel };

二進制
examples/models/fbx/OculusHand_L.fbx


二進制
examples/models/fbx/OculusHand_L_low.fbx


二進制
examples/models/fbx/OculusHand_R.fbx


二進制
examples/models/fbx/OculusHand_R_low.fbx


二進制
examples/screenshots/webxr_vr_handinput_pointerclick.jpg


二進制
examples/screenshots/webxr_vr_handinput_pointerdrag.jpg


二進制
examples/screenshots/webxr_vr_handinput_pressbutton.jpg


二進制
examples/sounds/button-press.ogg


二進制
examples/sounds/button-release.ogg


+ 1 - 1
examples/webxr_vr_handinput.html

@@ -87,7 +87,7 @@
 				scene.add( controller2 );
 
 				const controllerModelFactory = new XRControllerModelFactory();
-				const handModelFactory = new XRHandModelFactory().setPath( "./models/fbx/" );
+				const handModelFactory = new XRHandModelFactory().setPath( "./models/gltf/" );
 
 				// Hand 1
 				controllerGrip1 = renderer.xr.getControllerGrip( 0 );

+ 1 - 1
examples/webxr_vr_handinput_cubes.html

@@ -100,7 +100,7 @@
 				scene.add( controller2 );
 
 				const controllerModelFactory = new XRControllerModelFactory();
-				const handModelFactory = new XRHandModelFactory().setPath( "./models/fbx/" );
+				const handModelFactory = new XRHandModelFactory().setPath( "./models/gltf/" );
 
 				// Hand 1
 				controllerGrip1 = renderer.xr.getControllerGrip( 0 );

+ 419 - 0
examples/webxr_vr_handinput_pointerclick.html

@@ -0,0 +1,419 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <title>three.js webxr hands - 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 with #webxr-hands flag enabled)
+  </div>
+
+  <script type="module">
+
+    import * as THREE from '../build/three.module.js';
+    import { VRButton } from './jsm/webxr/VRButton.js';
+    import { XRControllerModelFactory } from './jsm/webxr/XRControllerModelFactory.js';
+    import { OculusHandModel } from './jsm/webxr/OculusHandModel.js';
+    import { OculusHandPointerModel } from './jsm/webxr/OculusHandPointerModel.js';
+    import { createText } from './jsm/webxr/Text2D.js';
+    
+    import { World, System, Component, TagComponent, Types } from "https://ecsy.io/build/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 => {
+          var button = entity.getMutableComponent(Button);
+          var 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 => {
+          var distance = null;
+          var intersectingEntity = null;
+          this.queries.intersectable.results.forEach(entity => {
+            var object = entity.getComponent(Object3D).object;
+            let intersections = hp.intersectObject(object);
+            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)) {
+              let 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 => {
+          var 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 => {
+          var 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()) {
+            let offset = entity.getComponent(OffsetFromCamera);
+            let object = entity.getComponent(Object3D).object;
+            let xrCamera = renderer.xr.getCamera(this.camera);
+            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]
+      }
+    }
+
+    let world = new World();
+    var 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);
+      return buttonMesh;
+    }
+
+
+    function init() {
+
+      let 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;
+
+      container.appendChild(renderer.domElement);
+
+      document.body.appendChild(VRButton.createButton(renderer));
+
+      // controllers
+      let controller1 = renderer.xr.getController(0);
+      scene.add(controller1);
+
+      let controller2 = renderer.xr.getController(1);
+      scene.add(controller2);
+
+      const controllerModelFactory = new XRControllerModelFactory();
+
+      // Hand 1
+      let controllerGrip1 = renderer.xr.getControllerGrip(0);
+      controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1));
+      scene.add(controllerGrip1);
+
+      let hand1 = renderer.xr.getHand(0);
+      hand1.add(new OculusHandModel(hand1));
+      let handPointer1 = new OculusHandPointerModel(hand1, controller1);
+      hand1.add(handPointer1);
+
+      scene.add(hand1);
+
+      // Hand 2
+      let controllerGrip2 = renderer.xr.getControllerGrip(1);
+      controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2));
+      scene.add(controllerGrip2);
+
+      let hand2 = renderer.xr.getHand(1);
+      hand2.add(new OculusHandModel(hand2));
+      let 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;
+      scene.add(floor);
+
+      const menuGeometry = new THREE.PlaneGeometry(0.24, 0.5);
+      const menuMaterial = new THREE.MeshPhongMaterial({
+        opacity: 0,
+        transparent: true,
+      });
+      let menuMesh = new THREE.Mesh(menuGeometry, menuMaterial);
+      menuMesh.position.set(0.4, 1, -1);
+      menuMesh.rotation.y = - Math.PI / 12;
+      scene.add(menuMesh);
+
+      let orangeButton = makeButtonMesh(0.2, 0.1, 0.01, 0xffd3b5);
+      orangeButton.position.set(0, 0.18, 0);
+      menuMesh.add(orangeButton);
+
+      let pinkButton = makeButtonMesh(0.2, 0.1, 0.01, 0xe84a5f);
+      pinkButton.position.set(0, 0.06, 0);
+      menuMesh.add(pinkButton);
+
+      let resetButton = makeButtonMesh(0.2, 0.1, 0.01, 0x355c7d);
+      let 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);
+
+      let exitButton = makeButtonMesh(0.2, 0.1, 0.01, 0xff0000);
+      let 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);
+
+      let tkGeometry = new THREE.TorusKnotGeometry(0.5, 0.2, 200, 32);
+      let tkMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff });
+      tkMaterial.metalness = 0.8;
+      let torusKnot = new THREE.Mesh(tkGeometry, tkMaterial);
+      torusKnot.position.set(0, 1, -5);
+      scene.add(torusKnot);
+
+      let 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);
+
+      let 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] });
+
+      var menuEntity = world.createEntity();
+      menuEntity.addComponent(Intersectable);
+      menuEntity.addComponent(OffsetFromCamera, { x: 0.4, y: 0, z: -1 });
+      menuEntity.addComponent(NeedCalibration);
+      menuEntity.addComponent(Object3D, { object: menuMesh });
+
+      var obEntity = world.createEntity();
+      obEntity.addComponent(Intersectable);
+      obEntity.addComponent(Object3D, { object: orangeButton });
+      let obAction = function () { torusKnot.material.color.setHex(0xffd3b5); };
+      obEntity.addComponent(Button, { action: obAction });
+
+      var pbEntity = world.createEntity();
+      pbEntity.addComponent(Intersectable);
+      pbEntity.addComponent(Object3D, { object: pinkButton });
+      let pbAction = function () { torusKnot.material.color.setHex(0xe84a5f); };
+      pbEntity.addComponent(Button, { action: pbAction });
+
+      var rbEntity = world.createEntity();
+      rbEntity.addComponent(Intersectable);
+      rbEntity.addComponent(Object3D, { object: resetButton });
+      let rbAction = function () { torusKnot.material.color.setHex(0xffffff); };
+      rbEntity.addComponent(Button, { action: rbAction });
+
+      var ebEntity = world.createEntity();
+      ebEntity.addComponent(Intersectable);
+      ebEntity.addComponent(Object3D, { object: exitButton });
+      let ebAction = function () {
+        exitText.visible = true;
+        setTimeout(function () { exitText.visible = false; renderer.xr.getSession().end(); }, 2000);
+      };
+      ebEntity.addComponent(Button, { action: ebAction });
+
+      var tkEntity = world.createEntity();
+      tkEntity.addComponent(Rotating);
+      tkEntity.addComponent(Object3D, { object: torusKnot });
+
+      var 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() {
+      var delta = clock.getDelta();
+      var elapsedTime = clock.elapsedTime;
+      world.execute(delta, elapsedTime);
+      renderer.render(scene, camera);
+    }
+
+  </script>
+</body>
+
+</html>

+ 479 - 0
examples/webxr_vr_handinput_pointerdrag.html

@@ -0,0 +1,479 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <title>three.js webxr hands - point and drag</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 drag<br />
+    (Oculus Browser with #webxr-hands flag enabled)
+  </div>
+
+  <script type="module">
+
+    import * as THREE from '../build/three.module.js';
+    import { VRButton } from './jsm/webxr/VRButton.js';
+    import { XRControllerModelFactory } from './jsm/webxr/XRControllerModelFactory.js';
+    import { OculusHandModel } from './jsm/webxr/OculusHandModel.js';
+    import { OculusHandPointerModel } from './jsm/webxr/OculusHandPointerModel.js';
+    import { createText } from './jsm/webxr/Text2D.js';
+
+    import { World, System, Component, TagComponent, Types } from "https://ecsy.io/build/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 => {
+          var button = entity.getMutableComponent(Button);
+          var 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 Draggable extends Component { }
+
+    Draggable.schema = {
+      // draggable states: [detached, hovered, to-be-attached, attached, to-be-detached]
+      state: { type: Types.String, default: 'none' },
+      originalParent: { type: Types.Ref, default: null },
+      attachedPointer: { type: Types.Ref, default: null }
+    }
+
+    class DraggableSystem extends System {
+      execute(delta, time) {
+        this.queries.draggable.results.forEach(entity => {
+          let draggable = entity.getMutableComponent(Draggable);
+          let object = entity.getComponent(Object3D).object;
+          if (draggable.originalParent == null) {
+            draggable.originalParent = object.parent;
+          }
+          switch (draggable.state) {
+            case 'to-be-attached':
+              draggable.attachedPointer.children[0].attach(object);
+              draggable.state = 'attached';
+              break;
+            case 'to-be-detached':
+              draggable.originalParent.attach(object);
+              draggable.state = 'detached';
+              break;
+            default:
+              object.scale.set(1, 1, 1);
+          }
+        });
+      }
+    }
+
+    DraggableSystem.queries = {
+      draggable: {
+        components: [Draggable]
+      }
+    }
+
+    class Intersectable extends TagComponent { }
+
+    class HandRaySystem extends System {
+      init(attributes) {
+        this.handPointers = attributes.handPointers;
+      }
+
+      execute(delta, time) {
+        this.handPointers.forEach(hp => {
+          var distance = null;
+          var intersectingEntity = null;
+          this.queries.intersectable.results.forEach(entity => {
+            let object = entity.getComponent(Object3D).object;
+            let intersections = hp.intersectObject(object);
+            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)) {
+              let button = intersectingEntity.getMutableComponent(Button);
+              if (hp.isPinched()) {
+                button.currState = 'pressed';
+              } else if (button.currState != 'pressed') {
+                button.currState = 'hovered';
+              }
+            }
+            if (intersectingEntity.hasComponent(Draggable)) {
+              let draggable = intersectingEntity.getMutableComponent(Draggable);
+              let object = intersectingEntity.getComponent(Object3D).object;
+              object.scale.set(1.1, 1.1, 1.1);
+              if (hp.isPinched()) {
+                if (!hp.isAttached() && draggable.state != 'attached') {
+                  draggable.state = 'to-be-attached';
+                  draggable.attachedPointer = hp;
+                  hp.setAttached(true);
+                }
+              } else {
+                if (hp.isAttached() && draggable.state == 'attached') {
+                  console.log('hello');
+                  draggable.state = 'to-be-detached';
+                  draggable.attachedPointer = null;
+                  hp.setAttached(false);
+                }
+              }
+            }
+          } else {
+            hp.setCursor(1.5);
+          }
+        });
+      }
+    }
+
+    HandRaySystem.queries = {
+      intersectable: {
+        components: [Intersectable]
+      }
+    };
+
+    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 => {
+          var 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()) {
+            let offset = entity.getComponent(OffsetFromCamera);
+            let object = entity.getComponent(Object3D).object;
+            let xrCamera = renderer.xr.getCamera(this.camera);
+            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]
+      }
+    }
+
+    class Randomizable extends TagComponent { }
+
+    class RandomizerSystem extends System {
+      init(attributes) {
+        this.needRandomizing = true;
+      }
+
+      execute(delta, time) {
+        if (!this.needRandomizing) { return; }
+        this.queries.randomizable.results.forEach(entity => {
+          let object = entity.getComponent(Object3D).object;
+
+          object.material.color.setHex(Math.random() * 0xffffff);
+
+          object.position.x = Math.random() * 2 - 1;
+          object.position.y = Math.random() * 2;
+          object.position.z = Math.random() * 2 - 1;
+
+          object.rotation.x = Math.random() * 2 * Math.PI;
+          object.rotation.y = Math.random() * 2 * Math.PI;
+          object.rotation.z = Math.random() * 2 * Math.PI;
+
+          object.scale.x = Math.random() + 0.5;
+          object.scale.y = Math.random() + 0.5;
+          object.scale.z = Math.random() + 0.5;
+          this.needRandomizing = false;
+        });
+      }
+    }
+
+    RandomizerSystem.queries = {
+      randomizable: {
+        components: [Randomizable]
+      }
+    }
+
+    let world = new World();
+    var 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);
+      return buttonMesh;
+    }
+
+
+    function init() {
+
+      let 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;
+
+      container.appendChild(renderer.domElement);
+
+      document.body.appendChild(VRButton.createButton(renderer));
+
+      // controllers
+      let controller1 = renderer.xr.getController(0);
+      scene.add(controller1);
+
+      let controller2 = renderer.xr.getController(1);
+      scene.add(controller2);
+
+      const controllerModelFactory = new XRControllerModelFactory();
+
+      // Hand 1
+      let controllerGrip1 = renderer.xr.getControllerGrip(0);
+      controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1));
+      scene.add(controllerGrip1);
+
+      let hand1 = renderer.xr.getHand(0);
+      hand1.add(new OculusHandModel(hand1));
+      let handPointer1 = new OculusHandPointerModel(hand1, controller1);
+      hand1.add(handPointer1);
+
+      scene.add(hand1);
+
+      // Hand 2
+      let controllerGrip2 = renderer.xr.getControllerGrip(1);
+      controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2));
+      scene.add(controllerGrip2);
+
+      let hand2 = renderer.xr.getHand(1);
+      hand2.add(new OculusHandModel(hand2));
+      let 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 });
+      let floor = new THREE.Mesh(floorGeometry, floorMaterial);
+      floor.rotation.x = - Math.PI / 2;
+      scene.add(floor);
+
+      const menuGeometry = new THREE.PlaneGeometry(0.24, 0.5);
+      const menuMaterial = new THREE.MeshPhongMaterial({
+        opacity: 0,
+        transparent: true,
+      });
+      let menuMesh = new THREE.Mesh(menuGeometry, menuMaterial);
+      menuMesh.position.set(0.4, 1, -1);
+      menuMesh.rotation.y = - Math.PI / 12;
+      scene.add(menuMesh);
+
+      let resetButton = makeButtonMesh(0.2, 0.1, 0.01, 0x355c7d);
+      let 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);
+
+      let exitButton = makeButtonMesh(0.2, 0.1, 0.01, 0xff0000);
+      let 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);
+
+      let 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);
+
+      let 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(HandsInstructionText)
+        .registerComponent(OffsetFromCamera)
+        .registerComponent(NeedCalibration)
+        .registerComponent(Randomizable)
+        .registerComponent(Draggable);
+
+      world
+        .registerSystem(RandomizerSystem)
+        .registerSystem(InstructionSystem, { controllers: [controllerGrip1, controllerGrip2] })
+        .registerSystem(CalibrationSystem, { renderer: renderer, camera: camera })
+        .registerSystem(ButtonSystem)
+        .registerSystem(DraggableSystem)
+        .registerSystem(HandRaySystem, { handPointers: [handPointer1, handPointer2] });
+
+      for (let i = 0; i < 20; i++) {
+        const object = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.15, 0.15), new THREE.MeshLambertMaterial({ color: 0xffffff }));
+        scene.add(object);
+
+        let entity = world.createEntity();
+        entity.addComponent(Intersectable);
+        entity.addComponent(Randomizable);
+        entity.addComponent(Object3D, { object: object });
+        entity.addComponent(Draggable);
+      }
+
+      var menuEntity = world.createEntity();
+      menuEntity.addComponent(Intersectable);
+      menuEntity.addComponent(OffsetFromCamera, { x: 0.4, y: 0, z: -1 });
+      menuEntity.addComponent(NeedCalibration);
+      menuEntity.addComponent(Object3D, { object: menuMesh });
+
+      var rbEntity = world.createEntity();
+      rbEntity.addComponent(Intersectable);
+      rbEntity.addComponent(Object3D, { object: resetButton });
+      let rbAction = function () { world.getSystem(RandomizerSystem).needRandomizing = true; };
+      rbEntity.addComponent(Button, { action: rbAction });
+
+      var ebEntity = world.createEntity();
+      ebEntity.addComponent(Intersectable);
+      ebEntity.addComponent(Object3D, { object: exitButton });
+      let ebAction = function () {
+        exitText.visible = true;
+        setTimeout(function () { exitText.visible = false; renderer.xr.getSession().end(); }, 2000);
+      };
+      ebEntity.addComponent(Button, { action: ebAction });
+
+      var 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() {
+      var delta = clock.getDelta();
+      var elapsedTime = clock.elapsedTime;
+      world.execute(delta, elapsedTime);
+      renderer.render(scene, camera);
+    }
+
+  </script>
+</body>
+
+</html>

+ 460 - 0
examples/webxr_vr_handinput_pressbutton.html

@@ -0,0 +1,460 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <title>three.js webxr hands - press button</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 - press button<br />
+    (Oculus Browser with #webxr-hands flag enabled)
+  </div>
+
+  <script type="module">
+
+    import * as THREE from '../build/three.module.js';
+    import { VRButton } from './jsm/webxr/VRButton.js';
+    import { XRControllerModelFactory } from './jsm/webxr/XRControllerModelFactory.js';
+    import { OculusHandModel } from './jsm/webxr/OculusHandModel.js';
+    import { createText } from './jsm/webxr/Text2D.js';
+
+    import { World, System, Component, TagComponent, Types } from "https://ecsy.io/build/ecsy.module.js";
+
+    class Object3D extends Component { }
+
+    Object3D.schema = {
+      object: { type: Types.Ref }
+    };
+
+    class Button extends Component { }
+
+    Button.schema = {
+      // button states: [resting, pressed, fully_pressed, recovering]
+      currState: { type: Types.String, default: 'resting' },
+      prevState: { type: Types.String, default: 'resting' },
+      pressSound: { type: Types.Ref, default: null },
+      releaseSound: { type: Types.Ref, default: null },
+      restingY: { type: Types.Number, default: null },
+      surfaceY: { type: Types.Number, default: null },
+      recoverySpeed: { type: Types.Number, default: 0.4 },
+      fullPressDistance: { type: Types.Number, default: null },
+      action: { type: Types.Ref, default: () => { } }
+    }
+
+    class ButtonSystem extends System {
+      init(attributes) {
+        this.renderer = attributes.renderer;
+        this.soundAdded = false;
+      }
+
+      execute(delta, time) {
+        let buttonPressSound, buttonReleaseSound;
+        if (this.renderer.xr.getSession() && !this.soundAdded) {
+          let xrCamera = this.renderer.xr.getCamera(camera);
+
+          const listener = new THREE.AudioListener();
+          xrCamera.add(listener);
+
+          // create a global audio source
+          buttonPressSound = new THREE.Audio(listener);
+          buttonReleaseSound = new THREE.Audio(listener);
+
+          // load a sound and set it as the Audio object's buffer
+          const audioLoader = new THREE.AudioLoader();
+          audioLoader.load('sounds/button-press.ogg', function (buffer) {
+            buttonPressSound.setBuffer(buffer);
+          });
+          audioLoader.load('sounds/button-release.ogg', function (buffer) {
+            buttonReleaseSound.setBuffer(buffer);
+          });
+          this.soundAdded = true;
+        }
+
+        this.queries.buttons.results.forEach(entity => {
+          var button = entity.getMutableComponent(Button);
+          var buttonMesh = entity.getComponent(Object3D).object;
+          // populate restingY
+          if (button.restingY == null) {
+            button.restingY = buttonMesh.position.y;
+          }
+          if (buttonPressSound) {
+            button.pressSound = buttonPressSound;
+          }
+          if (buttonReleaseSound) {
+            button.releaseSound = buttonReleaseSound;
+          }
+
+          if (button.currState == 'fully_pressed' && button.prevState != 'fully_pressed') {
+            button.pressSound?.play();
+            button.action();
+          }
+          if (button.currState == 'recovering' && button.prevState != 'recovering') {
+            button.releaseSound?.play();
+          }
+
+          // preserve prevState, clear currState
+          // FingerInputSystem will update currState
+          button.prevState = button.currState;
+          button.currState = 'resting';
+        });
+      }
+    }
+
+    ButtonSystem.queries = {
+      buttons: {
+        components: [Button]
+      }
+    }
+
+    class Pressable extends TagComponent { }
+
+    class FingerInputSystem extends System {
+      init(attributes) {
+        this.hands = attributes.hands;
+      }
+
+      execute(delta, time) {
+        this.queries.pressable.results.forEach(entity => {
+          var button = entity.getMutableComponent(Button);
+          let object = entity.getComponent(Object3D).object;
+          let pressingDistances = [];
+          this.hands.forEach(hand => {
+            if (hand && hand.intersectBoxObject(object)) {
+              let pressingPosition = hand.getPointerPosition();
+              pressingDistances.push(button.surfaceY - object.worldToLocal(pressingPosition).y);
+            }
+          });
+          if (pressingDistances.length == 0) { // not pressed this frame
+            if (object.position.y < button.restingY) {
+              object.position.y += button.recoverySpeed * delta;
+              button.currState = "recovering";
+            } else {
+              object.position.y = button.restingY;
+              button.currState = "resting";
+            }
+          } else {
+            button.currState = "pressed";
+            let pressingDistance = Math.max(pressingDistances);
+            if (pressingDistance > 0) {
+              object.position.y -= pressingDistance;
+            }
+            if (object.position.y <= button.restingY - button.fullPressDistance) {
+              button.currState = "fully_pressed";
+              object.position.y = button.restingY - button.fullPressDistance;
+            }
+          }
+        });
+      }
+    }
+
+    FingerInputSystem.queries = {
+      pressable: {
+        components: [Pressable]
+      }
+    };
+
+    class Rotating extends TagComponent { }
+
+    class RotatingSystem extends System {
+      execute(delta, time) {
+        this.queries.rotatingObjects.results.forEach(entity => {
+          var 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 => {
+          var 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()) {
+            let offset = entity.getComponent(OffsetFromCamera);
+            let object = entity.getComponent(Object3D).object;
+            let xrCamera = renderer.xr.getCamera(this.camera);
+            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]
+      }
+    }
+
+    let world = new World();
+    var 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);
+      return buttonMesh;
+    }
+
+
+    function init() {
+
+      let 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;
+
+      container.appendChild(renderer.domElement);
+
+      document.body.appendChild(VRButton.createButton(renderer));
+
+      // controllers
+      let controller1 = renderer.xr.getController(0);
+      scene.add(controller1);
+
+      let controller2 = renderer.xr.getController(1);
+      scene.add(controller2);
+
+      const controllerModelFactory = new XRControllerModelFactory();
+
+      // Hand 1
+      let controllerGrip1 = renderer.xr.getControllerGrip(0);
+      controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1));
+      scene.add(controllerGrip1);
+
+      let hand1 = renderer.xr.getHand(0);
+      let handModel1 = new OculusHandModel(hand1)
+      hand1.add(handModel1);
+      scene.add(hand1);
+
+      // Hand 2
+      let controllerGrip2 = renderer.xr.getControllerGrip(1);
+      controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2));
+      scene.add(controllerGrip2);
+
+      let hand2 = renderer.xr.getHand(1);
+      let handModel2 = new OculusHandModel(hand2);
+      hand2.add(handModel2);
+      scene.add(hand2);
+
+
+      // buttons
+      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;
+      scene.add(floor);
+
+      const consoleGeometry = new THREE.BoxGeometry(0.5, 0.12, 0.15);
+      const consoleMaterial = new THREE.MeshPhongMaterial({ color: 0x595959 });
+      let consoleMesh = new THREE.Mesh(consoleGeometry, consoleMaterial);
+      consoleMesh.position.set(0, 1, -0.3);
+      scene.add(consoleMesh);
+
+      let orangeButton = makeButtonMesh(0.08, 0.1, 0.08, 0xffd3b5);
+      orangeButton.position.set(-0.15, 0.04, 0);
+      consoleMesh.add(orangeButton);
+
+      let pinkButton = makeButtonMesh(0.08, 0.1, 0.08, 0xe84a5f);
+      pinkButton.position.set(-0.05, 0.04, 0);
+      consoleMesh.add(pinkButton);
+
+      let resetButton = makeButtonMesh(0.08, 0.1, 0.08, 0x355c7d);
+      let resetButtonText = createText("reset", 0.03);
+      resetButton.add(resetButtonText);
+      resetButtonText.rotation.x = - Math.PI / 2;
+      resetButtonText.position.set(0, 0.051, 0);
+      resetButton.position.set(0.05, 0.04, 0);
+      consoleMesh.add(resetButton);
+
+      let exitButton = makeButtonMesh(0.08, 0.1, 0.08, 0xff0000);
+      let exitButtonText = createText("exit", 0.03);
+      exitButton.add(exitButtonText);
+      exitButtonText.rotation.x = - Math.PI / 2;
+      exitButtonText.position.set(0, 0.051, 0);
+      exitButton.position.set(0.15, 0.04, 0);
+      consoleMesh.add(exitButton);
+
+      let tkGeometry = new THREE.TorusKnotGeometry(0.5, 0.2, 200, 32);
+      let tkMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff });
+      tkMaterial.metalness = 0.8;
+      let torusKnot = new THREE.Mesh(tkGeometry, tkMaterial);
+      torusKnot.position.set(0, 1, -5);
+      scene.add(torusKnot);
+
+      let 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);
+
+      let 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(Pressable)
+        .registerComponent(Rotating)
+        .registerComponent(HandsInstructionText)
+        .registerComponent(OffsetFromCamera)
+        .registerComponent(NeedCalibration);
+
+      world
+        .registerSystem(RotatingSystem)
+        .registerSystem(InstructionSystem, { controllers: [controllerGrip1, controllerGrip2] })
+        .registerSystem(CalibrationSystem, { renderer: renderer, camera: camera })
+        .registerSystem(ButtonSystem, { renderer: renderer, camera: camera })
+        .registerSystem(FingerInputSystem, { hands: [handModel1, handModel2] });
+
+      var csEntity = world.createEntity();
+      csEntity.addComponent(OffsetFromCamera, { x: 0, y: -0.4, z: -0.3 });
+      csEntity.addComponent(NeedCalibration);
+      csEntity.addComponent(Object3D, { object: consoleMesh });
+
+      var obEntity = world.createEntity();
+      obEntity.addComponent(Pressable);
+      obEntity.addComponent(Object3D, { object: orangeButton });
+      let obAction = function () { torusKnot.material.color.setHex(0xffd3b5); };
+      obEntity.addComponent(Button, { action: obAction, surfaceY: 0.05, fullPressDistance: 0.02 });
+
+      var pbEntity = world.createEntity();
+      pbEntity.addComponent(Pressable);
+      pbEntity.addComponent(Object3D, { object: pinkButton });
+      let pbAction = function () { torusKnot.material.color.setHex(0xe84a5f); };
+      pbEntity.addComponent(Button, { action: pbAction, surfaceY: 0.05, fullPressDistance: 0.02 });
+
+      var rbEntity = world.createEntity();
+      rbEntity.addComponent(Pressable);
+      rbEntity.addComponent(Object3D, { object: resetButton });
+      let rbAction = function () { torusKnot.material.color.setHex(0xffffff); };
+      rbEntity.addComponent(Button, { action: rbAction, surfaceY: 0.05, fullPressDistance: 0.02 });
+
+      var ebEntity = world.createEntity();
+      ebEntity.addComponent(Pressable);
+      ebEntity.addComponent(Object3D, { object: exitButton });
+      let ebAction = function () {
+        exitText.visible = true;
+        setTimeout(function () { exitText.visible = false; renderer.xr.getSession().end(); }, 2000);
+      };
+      ebEntity.addComponent(Button, { action: ebAction, surfaceY: 0.05, recoverySpeed: 0.2, fullPressDistance: 0.03 });
+
+      var tkEntity = world.createEntity();
+      tkEntity.addComponent(Rotating);
+      tkEntity.addComponent(Object3D, { object: torusKnot });
+
+      var 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() {
+      var delta = clock.getDelta();
+      var elapsedTime = clock.elapsedTime;
+      world.execute(delta, elapsedTime);
+      renderer.render(scene, camera);
+    }
+
+  </script>
+</body>
+
+</html>

+ 5 - 3
examples/webxr_vr_handinput_profiles.html

@@ -99,7 +99,7 @@
 				scene.add( controller2 );
 
 				const controllerModelFactory = new XRControllerModelFactory();
-				const handModelFactory = new XRHandModelFactory().setPath( "./models/fbx/" );
+				const handModelFactory = new XRHandModelFactory().setPath( "./models/gltf/" );
 
 				// Hand 1
 
@@ -113,7 +113,8 @@
 				handModels.left = [
 					handModelFactory.createHandModel( hand1, "boxes" ),
 					handModelFactory.createHandModel( hand1, "spheres" ),
-					handModelFactory.createHandModel( hand1, "oculus", { model: "lowpoly" } ),
+					// low poly option disabled until low poly hands model is fixed
+					// handModelFactory.createHandModel( hand1, "oculus", { model: "lowpoly" } ),
 					handModelFactory.createHandModel( hand1, "oculus" )
 				];
 
@@ -152,7 +153,8 @@
 				handModels.right = [
 					handModelFactory.createHandModel( hand2, "boxes" ),
 					handModelFactory.createHandModel( hand2, "spheres" ),
-					handModelFactory.createHandModel( hand2, "oculus", { model: "lowpoly" } ),
+					// low poly option disabled until low poly hands model is fixed
+					// handModelFactory.createHandModel( hand2, "oculus", { model: "lowpoly" } ),
 					handModelFactory.createHandModel( hand2, "oculus" )
 				];
 				handModels.right.forEach( model => {