2
0
Эх сурвалжийг харах

Merge pull request #18450 from toji/xr-controller-model-loader

Examples: Add XRControllerModelLoader class
Mr.doob 5 жил өмнө
parent
commit
205e345b4f

+ 397 - 0
examples/jsm/libs/motion-controllers.module.js

@@ -0,0 +1,397 @@
+/**
+ * @webxr-input-profiles/motion-controllers 1.0.0 https://github.com/immersive-web/webxr-input-profiles
+ */
+
+const Constants = {
+  Handedness: Object.freeze({
+    NONE: 'none',
+    LEFT: 'left',
+    RIGHT: 'right'
+  }),
+
+  ComponentState: Object.freeze({
+    DEFAULT: 'default',
+    TOUCHED: 'touched',
+    PRESSED: 'pressed'
+  }),
+
+  ComponentProperty: Object.freeze({
+    BUTTON: 'button',
+    X_AXIS: 'xAxis',
+    Y_AXIS: 'yAxis',
+    STATE: 'state'
+  }),
+
+  ComponentType: Object.freeze({
+    TRIGGER: 'trigger',
+    SQUEEZE: 'squeeze',
+    TOUCHPAD: 'touchpad',
+    THUMBSTICK: 'thumbstick',
+    BUTTON: 'button'
+  }),
+
+  ButtonTouchThreshold: 0.05,
+
+  AxisTouchThreshold: 0.1,
+
+  VisualResponseProperty: Object.freeze({
+    TRANSFORM: 'transform',
+    VISIBILITY: 'visibility'
+  })
+};
+
+/**
+ * @description Static helper function to fetch a JSON file and turn it into a JS object
+ * @param {string} path - Path to JSON file to be fetched
+ */
+async function fetchJsonFile(path) {
+  const response = await fetch(path);
+  if (!response.ok) {
+    throw new Error(response.statusText);
+  } else {
+    return response.json();
+  }
+}
+
+async function fetchProfilesList(basePath) {
+  if (!basePath) {
+    throw new Error('No basePath supplied');
+  }
+
+  const profileListFileName = 'profilesList.json';
+  const profilesList = await fetchJsonFile(`${basePath}/${profileListFileName}`);
+  return profilesList;
+}
+
+async function fetchProfile(xrInputSource, basePath, defaultProfile = null, getAssetPath = true) {
+  if (!xrInputSource) {
+    throw new Error('No xrInputSource supplied');
+  }
+
+  if (!basePath) {
+    throw new Error('No basePath supplied');
+  }
+
+  // Get the list of profiles
+  const supportedProfilesList = await fetchProfilesList(basePath);
+
+  // Find the relative path to the first requested profile that is recognized
+  let match;
+  xrInputSource.profiles.some((profileId) => {
+    const supportedProfile = supportedProfilesList[profileId];
+    if (supportedProfile) {
+      match = {
+        profileId,
+        profilePath: `${basePath}/${supportedProfile.path}`,
+        deprecated: !!supportedProfile.deprecated
+      };
+    }
+    return !!match;
+  });
+
+  if (!match) {
+    if (!defaultProfile) {
+      throw new Error('No matching profile name found');
+    }
+
+    const supportedProfile = supportedProfilesList[defaultProfile];
+    if (!supportedProfile) {
+      throw new Error(`No matching profile name found and default profile "${defaultProfile}" missing.`);
+    }
+
+    match = {
+      profileId: defaultProfile,
+      profilePath: `${basePath}/${supportedProfile.path}`,
+      deprecated: !!supportedProfile.deprecated
+    };
+  }
+
+  const profile = await fetchJsonFile(match.profilePath);
+
+  let assetPath;
+  if (getAssetPath) {
+    let layout;
+    if (xrInputSource.handedness === 'any') {
+      layout = profile.layouts[Object.keys(profile.layouts)[0]];
+    } else {
+      layout = profile.layouts[xrInputSource.handedness];
+    }
+    if (!layout) {
+      throw new Error(
+        `No matching handedness, ${xrInputSource.handedness}, in profile ${match.profileId}`
+      );
+    }
+
+    if (layout.assetPath) {
+      assetPath = match.profilePath.replace('profile.json', layout.assetPath);
+    }
+  }
+
+  return { profile, assetPath };
+}
+
+/** @constant {Object} */
+const defaultComponentValues = {
+  xAxis: 0,
+  yAxis: 0,
+  button: 0,
+  state: Constants.ComponentState.DEFAULT
+};
+
+/**
+ * @description Converts an X, Y coordinate from the range -1 to 1 (as reported by the Gamepad
+ * API) to the range 0 to 1 (for interpolation). Also caps the X, Y values to be bounded within
+ * a circle. This ensures that thumbsticks are not animated outside the bounds of their physical
+ * range of motion and touchpads do not report touch locations off their physical bounds.
+ * @param {number} x The original x coordinate in the range -1 to 1
+ * @param {number} y The original y coordinate in the range -1 to 1
+ */
+function normalizeAxes(x = 0, y = 0) {
+  let xAxis = x;
+  let yAxis = y;
+
+  // Determine if the point is outside the bounds of the circle
+  // and, if so, place it on the edge of the circle
+  const hypotenuse = Math.sqrt((x * x) + (y * y));
+  if (hypotenuse > 1) {
+    const theta = Math.atan2(y, x);
+    xAxis = Math.cos(theta);
+    yAxis = Math.sin(theta);
+  }
+
+  // Scale and move the circle so values are in the interpolation range.  The circle's origin moves
+  // from (0, 0) to (0.5, 0.5). The circle's radius scales from 1 to be 0.5.
+  const result = {
+    normalizedXAxis: (xAxis * 0.5) + 0.5,
+    normalizedYAxis: (yAxis * 0.5) + 0.5
+  };
+  return result;
+}
+
+/**
+ * Contains the description of how the 3D model should visually respond to a specific user input.
+ * This is accomplished by initializing the object with the name of a node in the 3D model and
+ * property that need to be modified in response to user input, the name of the nodes representing
+ * the allowable range of motion, and the name of the input which triggers the change. In response
+ * to the named input changing, this object computes the appropriate weighting to use for
+ * interpolating between the range of motion nodes.
+ */
+class VisualResponse {
+  constructor(visualResponseDescription) {
+    this.componentProperty = visualResponseDescription.componentProperty;
+    this.states = visualResponseDescription.states;
+    this.valueNodeName = visualResponseDescription.valueNodeName;
+    this.valueNodeProperty = visualResponseDescription.valueNodeProperty;
+
+    if (this.valueNodeProperty === Constants.VisualResponseProperty.TRANSFORM) {
+      this.minNodeName = visualResponseDescription.minNodeName;
+      this.maxNodeName = visualResponseDescription.maxNodeName;
+    }
+
+    // Initializes the response's current value based on default data
+    this.value = 0;
+    this.updateFromComponent(defaultComponentValues);
+  }
+
+  /**
+   * Computes the visual response's interpolation weight based on component state
+   * @param {Object} componentValues - The component from which to update
+   * @param {number} xAxis - The reported X axis value of the component
+   * @param {number} yAxis - The reported Y axis value of the component
+   * @param {number} button - The reported value of the component's button
+   * @param {string} state - The component's active state
+   */
+  updateFromComponent({
+    xAxis, yAxis, button, state
+  }) {
+    const { normalizedXAxis, normalizedYAxis } = normalizeAxes(xAxis, yAxis);
+    switch (this.componentProperty) {
+      case Constants.ComponentProperty.X_AXIS:
+        this.value = (this.states.includes(state)) ? normalizedXAxis : 0.5;
+        break;
+      case Constants.ComponentProperty.Y_AXIS:
+        this.value = (this.states.includes(state)) ? normalizedYAxis : 0.5;
+        break;
+      case Constants.ComponentProperty.BUTTON:
+        this.value = (this.states.includes(state)) ? button : 0;
+        break;
+      case Constants.ComponentProperty.STATE:
+        if (this.valueNodeProperty === Constants.VisualResponseProperty.VISIBILITY) {
+          this.value = (this.states.includes(state));
+        } else {
+          this.value = this.states.includes(state) ? 1.0 : 0.0;
+        }
+        break;
+      default:
+        throw new Error(`Unexpected visualResponse componentProperty ${this.componentProperty}`);
+    }
+  }
+}
+
+class Component {
+  /**
+   * @param {Object} componentId - Id of the component
+   * @param {Object} componentDescription - Description of the component to be created
+   */
+  constructor(componentId, componentDescription) {
+    if (!componentId
+     || !componentDescription
+     || !componentDescription.visualResponses
+     || !componentDescription.gamepadIndices
+     || Object.keys(componentDescription.gamepadIndices).length === 0) {
+      throw new Error('Invalid arguments supplied');
+    }
+
+    this.id = componentId;
+    this.type = componentDescription.type;
+    this.rootNodeName = componentDescription.rootNodeName;
+    this.touchPointNodeName = componentDescription.touchPointNodeName;
+
+    // Build all the visual responses for this component
+    this.visualResponses = {};
+    Object.keys(componentDescription.visualResponses).forEach((responseName) => {
+      const visualResponse = new VisualResponse(componentDescription.visualResponses[responseName]);
+      this.visualResponses[responseName] = visualResponse;
+    });
+
+    // Set default values
+    this.gamepadIndices = Object.assign({}, componentDescription.gamepadIndices);
+
+    this.values = {
+      state: Constants.ComponentState.DEFAULT,
+      button: (this.gamepadIndices.button !== undefined) ? 0 : undefined,
+      xAxis: (this.gamepadIndices.xAxis !== undefined) ? 0 : undefined,
+      yAxis: (this.gamepadIndices.yAxis !== undefined) ? 0 : undefined
+    };
+  }
+
+  get data() {
+    const data = { id: this.id, ...this.values };
+    return data;
+  }
+
+  /**
+   * @description Poll for updated data based on current gamepad state
+   * @param {Object} gamepad - The gamepad object from which the component data should be polled
+   */
+  updateFromGamepad(gamepad) {
+    // Set the state to default before processing other data sources
+    this.values.state = Constants.ComponentState.DEFAULT;
+
+    // Get and normalize button
+    if (this.gamepadIndices.button !== undefined
+        && gamepad.buttons.length > this.gamepadIndices.button) {
+      const gamepadButton = gamepad.buttons[this.gamepadIndices.button];
+      this.values.button = gamepadButton.value;
+      this.values.button = (this.values.button < 0) ? 0 : this.values.button;
+      this.values.button = (this.values.button > 1) ? 1 : this.values.button;
+
+      // Set the state based on the button
+      if (gamepadButton.pressed || this.values.button === 1) {
+        this.values.state = Constants.ComponentState.PRESSED;
+      } else if (gamepadButton.touched || this.values.button > Constants.ButtonTouchThreshold) {
+        this.values.state = Constants.ComponentState.TOUCHED;
+      }
+    }
+
+    // Get and normalize x axis value
+    if (this.gamepadIndices.xAxis !== undefined
+        && gamepad.axes.length > this.gamepadIndices.xAxis) {
+      this.values.xAxis = gamepad.axes[this.gamepadIndices.xAxis];
+      this.values.xAxis = (this.values.xAxis < -1) ? -1 : this.values.xAxis;
+      this.values.xAxis = (this.values.xAxis > 1) ? 1 : this.values.xAxis;
+
+      // If the state is still default, check if the xAxis makes it touched
+      if (this.values.state === Constants.ComponentState.DEFAULT
+        && Math.abs(this.values.xAxis) > Constants.AxisTouchThreshold) {
+        this.values.state = Constants.ComponentState.TOUCHED;
+      }
+    }
+
+    // Get and normalize Y axis value
+    if (this.gamepadIndices.yAxis !== undefined
+        && gamepad.axes.length > this.gamepadIndices.yAxis) {
+      this.values.yAxis = gamepad.axes[this.gamepadIndices.yAxis];
+      this.values.yAxis = (this.values.yAxis < -1) ? -1 : this.values.yAxis;
+      this.values.yAxis = (this.values.yAxis > 1) ? 1 : this.values.yAxis;
+
+      // If the state is still default, check if the yAxis makes it touched
+      if (this.values.state === Constants.ComponentState.DEFAULT
+        && Math.abs(this.values.yAxis) > Constants.AxisTouchThreshold) {
+        this.values.state = Constants.ComponentState.TOUCHED;
+      }
+    }
+
+    // Update the visual response weights based on the current component data
+    Object.values(this.visualResponses).forEach((visualResponse) => {
+      visualResponse.updateFromComponent(this.values);
+    });
+  }
+}
+
+/**
+  * @description Builds a motion controller with components and visual responses based on the
+  * supplied profile description. Data is polled from the xrInputSource's gamepad.
+  * @author Nell Waliczek / https://github.com/NellWaliczek
+*/
+class MotionController {
+  /**
+   * @param {Object} xrInputSource - The XRInputSource to build the MotionController around
+   * @param {Object} profile - The best matched profile description for the supplied xrInputSource
+   * @param {Object} assetUrl
+   */
+  constructor(xrInputSource, profile, assetUrl) {
+    if (!xrInputSource) {
+      throw new Error('No xrInputSource supplied');
+    }
+
+    if (!profile) {
+      throw new Error('No profile supplied');
+    }
+
+    this.xrInputSource = xrInputSource;
+    this.assetUrl = assetUrl;
+    this.id = profile.profileId;
+
+    // Build child components as described in the profile description
+    this.layoutDescription = profile.layouts[xrInputSource.handedness];
+    this.components = {};
+    Object.keys(this.layoutDescription.components).forEach((componentId) => {
+      const componentDescription = this.layoutDescription.components[componentId];
+      this.components[componentId] = new Component(componentId, componentDescription);
+    });
+
+    // Initialize components based on current gamepad state
+    this.updateFromGamepad();
+  }
+
+  get gripSpace() {
+    return this.xrInputSource.gripSpace;
+  }
+
+  get targetRaySpace() {
+    return this.xrInputSource.targetRaySpace;
+  }
+
+  /**
+   * @description Returns a subset of component data for simplified debugging
+   */
+  get data() {
+    const data = [];
+    Object.values(this.components).forEach((component) => {
+      data.push(component.data);
+    });
+    return data;
+  }
+
+  /**
+   * @description Poll for updated data based on current gamepad state
+   */
+  updateFromGamepad() {
+    Object.values(this.components).forEach((component) => {
+      component.updateFromGamepad(this.xrInputSource.gamepad);
+    });
+  }
+}
+
+export { Constants, MotionController, fetchProfile, fetchProfilesList };

+ 23 - 0
examples/jsm/webxr/XRControllerModelFactory.d.ts

@@ -0,0 +1,23 @@
+import {
+	Group,
+	Object3D,
+	Texture
+} from '../../../src/Three';
+
+import { GLTFLoader } from '../loaders/GLTFLoader';
+
+export class XRControllerModel extends Object3D {
+	constructor( );
+
+	motionController: any;
+
+	setEnvironmentMap( envMap: Texture ): XRControllerModel;
+}
+
+export class XRControllerModelFactory {
+	constructor( gltfLoader?: GLTFLoader );
+	gltfLoader: GLTFLoader | null;
+	path: string;
+
+	createControllerModel( controller: Group ): XRControllerModel;
+}

+ 310 - 0
examples/jsm/webxr/XRControllerModelFactory.js

@@ -0,0 +1,310 @@
+/**
+ * @author Nell Waliczek / https://github.com/NellWaliczek
+ * @author Brandon Jones / https://github.com/toji
+ */
+
+import {
+	Mesh,
+	MeshBasicMaterial,
+	Object3D,
+	Quaternion,
+	SphereGeometry,
+} from "../../../build/three.module.js";
+
+import { GLTFLoader } from '../loaders/GLTFLoader.js';
+
+import {
+	Constants as MotionControllerConstants,
+	fetchProfile,
+	MotionController
+} 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-trigger';
+
+function XRControllerModel( ) {
+
+	Object3D.call( this );
+
+	this.motionController = null;
+	this.envMap = null;
+
+}
+
+XRControllerModel.prototype = Object.assign( Object.create( Object3D.prototype ), {
+
+	constructor: XRControllerModel,
+
+	setEnvironmentMap: function ( envMap ) {
+
+		if ( this.envMap == envMap ) {
+
+			return this;
+
+		}
+
+		this.envMap = envMap;
+		this.traverse(( child ) => {
+
+			if ( child.isMesh ) {
+
+				child.material.envMap = this.envMap;
+				child.material.needsUpdate = true;
+
+			}
+
+		});
+
+		return this;
+
+	},
+
+	/**
+	 * Polls data from the XRInputSource and updates the model's components to match
+	 * the real world data
+	 */
+	updateMatrixWorld: function ( force ) {
+
+		Object3D.prototype.updateMatrixWorld.call( this, force );
+
+		if ( !this.motionController ) return;
+
+		// Cause the MotionController to poll the Gamepad for data
+		this.motionController.updateFromGamepad();
+
+		// Update the 3D model to reflect the button, thumbstick, and touchpad state
+		Object.values( this.motionController.components ).forEach(( component ) => {
+
+			// Update node data based on the visual responses' current states
+			Object.values( component.visualResponses ).forEach(( visualResponse ) => {
+
+				const { valueNode, minNode, maxNode, value, valueNodeProperty } = visualResponse;
+
+				// Skip if the visual response node is not found. No error is needed,
+				// because it will have been reported at load time.
+				if ( !valueNode ) return;
+
+				// Calculate the new properties based on the weight supplied
+				if ( valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY ) {
+
+					valueNode.visible = value;
+
+				} else if ( valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM ) {
+
+					Quaternion.slerp(
+						minNode.quaternion,
+						maxNode.quaternion,
+						valueNode.quaternion,
+						value
+					);
+
+					valueNode.position.lerpVectors(
+						minNode.position,
+						maxNode.position,
+						value
+					);
+
+				}
+
+			});
+
+		});
+
+	}
+
+} );
+
+/**
+ * Walks the model's tree to find the nodes needed to animate the components and
+ * saves them to the motionContoller components for use in the frame loop. When
+ * touchpads are found, attaches a touch dot to them.
+ */
+function findNodes( motionController, scene ) {
+
+	// Loop through the components and find the nodes needed for each components' visual responses
+	Object.values( motionController.components ).forEach(( component ) => {
+
+		const { type, touchPointNodeName, visualResponses } = component;
+
+		if (type === MotionControllerConstants.ComponentType.TOUCHPAD) {
+
+			component.touchPointNode = scene.getObjectByName( touchPointNodeName );
+			if ( component.touchPointNode ) {
+
+				// Attach a touch dot to the touchpad.
+				const sphereGeometry = new SphereGeometry( 0.001 );
+				const material = new MeshBasicMaterial({ color: 0x0000FF });
+				const sphere = new Mesh( sphereGeometry, material );
+				component.touchPointNode.add( sphere );
+
+			} else {
+
+				console.warn(`Could not find touch dot, ${component.touchPointNodeName}, in touchpad component ${componentId}`);
+
+			}
+
+		}
+
+		// Loop through all the visual responses to be applied to this component
+		Object.values( visualResponses ).forEach(( visualResponse ) => {
+
+			const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse;
+
+			// If animating a transform, find the two nodes to be interpolated between.
+			if ( valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM ) {
+
+				visualResponse.minNode = scene.getObjectByName(minNodeName);
+				visualResponse.maxNode = scene.getObjectByName(maxNodeName);
+
+				// If the extents cannot be found, skip this animation
+				if ( !visualResponse.minNode ) {
+
+					console.warn(`Could not find ${minNodeName} in the model`);
+					return;
+
+				}
+
+				if ( !visualResponse.maxNode ) {
+
+					console.warn(`Could not find ${maxNodeName} in the model`);
+					return;
+
+				}
+			}
+
+			// If the target node cannot be found, skip this animation
+			visualResponse.valueNode = scene.getObjectByName(valueNodeName);
+			if ( !visualResponse.valueNode ) {
+
+				console.warn(`Could not find ${valueNodeName} in the model`);
+
+			}
+		});
+	});
+}
+
+function addAssetSceneToControllerModel( controllerModel, scene ) {
+	// Find the nodes needed for animation and cache them on the motionController.
+	findNodes( controllerModel.motionController, scene );
+
+	// Apply any environment map that the mesh already has set.
+	if ( controllerModel.envMap ) {
+
+		scene.traverse(( child ) => {
+
+			if ( child.isMesh ) {
+
+				child.material.envMap = controllerModel.envMap;
+				child.material.needsUpdate = true;
+
+			}
+
+		});
+
+	}
+
+	// Add the glTF scene to the controllerModel.
+	controllerModel.add( scene );
+}
+
+var XRControllerModelFactory = ( function () {
+
+	function XRControllerModelFactory( gltfLoader = null ) {
+
+		this.gltfLoader = gltfLoader;
+		this.path = DEFAULT_PROFILES_PATH;
+		this._assetCache = {};
+
+		// If a GLTFLoader wasn't supplied to the constructor create a new one.
+		if ( !this.gltfLoader ) {
+
+			this.gltfLoader = new GLTFLoader();
+
+		}
+
+	}
+
+	XRControllerModelFactory.prototype = {
+
+		constructor: XRControllerModelFactory,
+
+		createControllerModel: function ( controller ) {
+
+			const controllerModel = new XRControllerModel();
+			let scene = null;
+
+			controller.addEventListener( 'connected', ( event ) => {
+
+				const xrInputSource = event.data;
+
+				if (xrInputSource.targetRayMode !== 'tracked-pointer' || !xrInputSource.gamepad ) return;
+
+				fetchProfile( xrInputSource, this.path, DEFAULT_PROFILE ).then(({ profile, assetPath }) => {
+
+					controllerModel.motionController = new MotionController(
+						xrInputSource,
+						profile,
+						assetPath
+					);
+
+					let cachedAsset = this._assetCache[controllerModel.motionController.assetUrl];
+					if (cachedAsset) {
+
+						scene = cachedAsset.scene.clone();
+
+						addAssetSceneToControllerModel(controllerModel, scene);
+
+					} else {
+
+						if ( !this.gltfLoader ) {
+
+							throw new Error(`GLTFLoader not set.`);
+
+						}
+
+						this.gltfLoader.setPath('');
+						this.gltfLoader.load( controllerModel.motionController.assetUrl, ( asset ) => {
+
+							this._assetCache[controllerModel.motionController.assetUrl] = asset;
+
+							scene = asset.scene.clone();
+
+							addAssetSceneToControllerModel(controllerModel, scene);
+
+						},
+						null,
+						() => {
+
+							throw new Error(`Asset ${motionController.assetUrl} missing or malformed.`);
+
+						});
+
+					}
+
+				}).catch((err) => {
+
+					console.warn(err);
+
+				});
+
+			});
+
+			controller.addEventListener( 'disconnected', () => {
+
+				controllerModel.motionController = null;
+				controllerModel.remove( scene );
+				scene = null;
+
+			});
+
+			return controllerModel;
+
+		}
+
+	};
+
+	return XRControllerModelFactory;
+
+} )();
+
+export { XRControllerModelFactory };

+ 22 - 2
examples/webxr_vr_ballshooter.html

@@ -20,9 +20,11 @@
 
 			import { BoxLineGeometry } from './jsm/geometries/BoxLineGeometry.js';
 			import { VRButton } from './jsm/webxr/VRButton.js';
+			import { XRControllerModelFactory } from './jsm/webxr/XRControllerModelFactory.js';
 
 			var camera, scene, renderer;
 			var controller1, controller2;
+			var controllerGrip1, controllerGrip2;
 
 			var room;
 
@@ -51,8 +53,10 @@
 				room.geometry.translate( 0, 3, 0 );
 				scene.add( room );
 
-				var light = new THREE.HemisphereLight( 0xffffff, 0x444444 );
-				light.position.set( 1, 1, 1 );
+				scene.add( new THREE.HemisphereLight( 0x606060, 0x404040 ) );
+
+				var light = new THREE.DirectionalLight( 0xffffff );
+				light.position.set( 1, 1, 1 ).normalize();
 				scene.add( light );
 
 				var geometry = new THREE.IcosahedronBufferGeometry( radius, 2 );
@@ -79,6 +83,7 @@
 				renderer = new THREE.WebGLRenderer( { antialias: true } );
 				renderer.setPixelRatio( window.devicePixelRatio );
 				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.outputEncoding = THREE.sRGBEncoding;
 				renderer.xr.enabled = true;
 				document.body.appendChild( renderer.domElement );
 
@@ -130,6 +135,21 @@
 				} );
 				scene.add( controller2 );
 
+				// The XRControllerModelFactory will automatically fetch controller models
+				// that match what the user is holding as closely as possible. The models
+				// should be attached to the object returned from getControllerGrip in
+				// order to match the orientation of the held device.
+
+				var controllerModelFactory = new XRControllerModelFactory();
+
+				controllerGrip1 = renderer.xr.getControllerGrip( 0 );
+				controllerGrip1.add( controllerModelFactory.createControllerModel( controllerGrip1 ) );
+				scene.add( controllerGrip1 );
+
+				controllerGrip2 = renderer.xr.getControllerGrip( 1 );
+				controllerGrip2.add( controllerModelFactory.createControllerModel( controllerGrip2 ) );
+				scene.add( controllerGrip2 );
+
 				//
 
 				window.addEventListener( 'resize', onWindowResize, false );

+ 9 - 1
examples/webxr_vr_cubes.html

@@ -20,6 +20,7 @@
 
 			import { BoxLineGeometry } from './jsm/geometries/BoxLineGeometry.js';
 			import { VRButton } from './jsm/webxr/VRButton.js';
+			import { XRControllerModelFactory } from './jsm/webxr/XRControllerModelFactory.js';
 
 			var clock = new THREE.Clock();
 
@@ -28,7 +29,7 @@
 
 			var room;
 
-			var controller, tempMatrix = new THREE.Matrix4();
+			var controller, controllerGrip, tempMatrix = new THREE.Matrix4();
 			var INTERSECTED;
 
 			init();
@@ -90,6 +91,7 @@
 				renderer = new THREE.WebGLRenderer( { antialias: true } );
 				renderer.setPixelRatio( window.devicePixelRatio );
 				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.outputEncoding = THREE.sRGBEncoding;
 				renderer.xr.enabled = true;
 				container.appendChild( renderer.domElement );
 
@@ -122,6 +124,12 @@
 				} );
 				scene.add( controller );
 
+				var controllerModelFactory = new XRControllerModelFactory();
+
+				controllerGrip = renderer.xr.getControllerGrip( 0 );
+				controllerGrip.add( controllerModelFactory.createControllerModel( controllerGrip ) );
+				scene.add( controllerGrip );
+
 				window.addEventListener( 'resize', onWindowResize, false );
 
 				//

+ 12 - 0
examples/webxr_vr_dragging.html

@@ -19,10 +19,12 @@
 			import * as THREE from '../build/three.module.js';
 			import { OrbitControls } from './jsm/controls/OrbitControls.js';
 			import { VRButton } from './jsm/webxr/VRButton.js';
+			import { XRControllerModelFactory } from './jsm/webxr/XRControllerModelFactory.js';
 
 			var container;
 			var camera, scene, renderer;
 			var controller1, controller2;
+			var controllerGrip1, controllerGrip2;
 
 			var raycaster, intersected = [];
 			var tempMatrix = new THREE.Matrix4();
@@ -133,6 +135,16 @@
 				controller2.addEventListener( 'selectend', onSelectEnd );
 				scene.add( controller2 );
 
+				var controllerModelFactory = new XRControllerModelFactory();
+
+				controllerGrip1 = renderer.xr.getControllerGrip( 0 );
+				controllerGrip1.add( controllerModelFactory.createControllerModel( controllerGrip1 ) );
+				scene.add( controllerGrip1 );
+
+				controllerGrip2 = renderer.xr.getControllerGrip( 1 );
+				controllerGrip2.add( controllerModelFactory.createControllerModel( controllerGrip2 ) );
+				scene.add( controllerGrip2 );
+
 				//
 
 				var geometry = new THREE.BufferGeometry().setFromPoints( [ new THREE.Vector3( 0, 0, 0 ), new THREE.Vector3( 0, 0, - 1 ) ] );