playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
332 lines (284 loc) • 10.8 kB
JavaScript
import { Script } from 'playcanvas';
/** @import { XrInputSource } from 'playcanvas' */
/**
* Automatically loads and displays WebXR controller models (hands or gamepads) based on the
* WebXR Input Profiles specification. The script fetches controller models from the WebXR
* Input Profiles asset repository and updates their transforms each frame to match the
* tracked input sources.
*
* Features:
* - Automatic controller model loading from WebXR Input Profiles repository
* - Support for both hand tracking and gamepad controllers
* - Automatic cleanup on input source removal or XR session end
* - Visibility control for integration with other XR scripts
* - Fires events for controller lifecycle coordination
*
* This script should be attached to a parent entity (typically the same entity as XrSession).
* Use it in conjunction with the `XrNavigation` and `XrMenu` scripts.
*
* @example
* // Add to camera parent entity
* cameraParent.addComponent('script');
* cameraParent.script.create(XrControllers, {
* properties: {
* basePath: 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets/dist/profiles'
* }
* });
*/
class XrControllers extends Script {
static scriptName = 'xrControllers';
/**
* The base URL for fetching the WebXR input profiles.
*
* @attribute
* @type {string}
*/
basePath = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets/dist/profiles';
/**
* Map of input sources to their controller data (entity, joint mappings, and asset).
*
* @type {Map<XrInputSource, { entity: import('playcanvas').Entity, jointMap: Map, asset: import('playcanvas').Asset }>}
*/
controllers = new Map();
/**
* Set of input sources currently being loaded (to handle race conditions).
*
* @type {Set<XrInputSource>}
* @private
*/
_pendingInputSources = new Set();
/**
* Whether controller models are currently visible.
*
* @type {boolean}
* @private
*/
_visible = true;
/**
* Bound event handlers for proper cleanup.
*
* @type {{ onAdd: (inputSource: XrInputSource) => void, onRemove: (inputSource: XrInputSource) => void, onXrEnd: () => void } | null}
* @private
*/
_handlers = null;
initialize() {
if (!this.app.xr) {
console.error('XrControllers script requires XR to be enabled on the application');
return;
}
// Create bound handlers for proper cleanup
this._handlers = {
onAdd: this._onInputSourceAdd.bind(this),
onRemove: this._onInputSourceRemove.bind(this),
onXrEnd: this._onXrEnd.bind(this)
};
// Listen for input source changes
this.app.xr.input.on('add', this._handlers.onAdd);
this.app.xr.input.on('remove', this._handlers.onRemove);
// Listen for XR session end to clean up all controllers
this.app.xr.on('end', this._handlers.onXrEnd);
// Clean up on script destroy
this.once('destroy', () => {
this._onDestroy();
});
}
/**
* Cleans up all resources when the script is destroyed.
*
* @private
*/
_onDestroy() {
if (this._handlers && this.app.xr) {
this.app.xr.input.off('add', this._handlers.onAdd);
this.app.xr.input.off('remove', this._handlers.onRemove);
this.app.xr.off('end', this._handlers.onXrEnd);
}
// Destroy all controller entities
this._destroyAllControllers();
this._handlers = null;
this._pendingInputSources.clear();
}
/**
* Handles XR session end by cleaning up all controllers.
*
* @private
*/
_onXrEnd() {
this._destroyAllControllers();
this._pendingInputSources.clear();
}
/**
* Destroys a single controller and its associated resources.
*
* @param {XrInputSource} inputSource - The input source to destroy.
* @private
*/
_destroyController(inputSource) {
const controller = this.controllers.get(inputSource);
if (!controller) return;
controller.entity.destroy();
if (controller.asset) {
this.app.assets.remove(controller.asset);
controller.asset.unload();
}
this.controllers.delete(inputSource);
this.app.fire('xr:controller:remove', inputSource);
}
/**
* Destroys all controller entities and clears the map.
*
* @private
*/
_destroyAllControllers() {
for (const inputSource of this.controllers.keys()) {
this._destroyController(inputSource);
}
}
/**
* Tries to load profiles sequentially, returning the first successful result.
*
* @param {XrInputSource} inputSource - The input source.
* @param {string[]} profiles - Array of profile IDs to try.
* @param {number} [index=0] - Current index in the profiles array.
* @returns {Promise<{ profileId: string, asset: import('playcanvas').Asset } | null>} The result or null.
* @private
*/
async _tryLoadProfiles(inputSource, profiles, index = 0) {
if (index >= profiles.length) return null;
if (!this._pendingInputSources.has(inputSource)) return null;
const result = await this._loadProfile(inputSource, profiles[index]);
if (result) return result;
return this._tryLoadProfiles(inputSource, profiles, index + 1);
}
/**
* Called when an input source is added.
*
* @param {XrInputSource} inputSource - The input source that was added.
* @private
*/
async _onInputSourceAdd(inputSource) {
if (!inputSource.profiles?.length) {
console.warn('XrControllers: No profiles available for input source');
return;
}
// Track this input source as pending to handle race conditions
this._pendingInputSources.add(inputSource);
// Load profiles sequentially and stop on first success
const successfulResult = await this._tryLoadProfiles(inputSource, inputSource.profiles);
// Check if input source was removed during loading
if (!this._pendingInputSources.has(inputSource)) {
// Clean up the loaded asset if we got one
if (successfulResult?.asset) {
this.app.assets.remove(successfulResult.asset);
successfulResult.asset.unload();
}
return;
}
// Remove from pending set
this._pendingInputSources.delete(inputSource);
if (successfulResult) {
const { asset } = successfulResult;
const container = asset.resource;
const entity = container.instantiateRenderEntity();
this.app.root.addChild(entity);
// Apply current visibility state
entity.enabled = this._visible;
// Build joint map for hand tracking
const jointMap = new Map();
if (inputSource.hand) {
for (const joint of inputSource.hand.joints) {
const jointEntity = entity.findByName(joint.id);
if (jointEntity) {
jointMap.set(joint, jointEntity);
}
}
}
this.controllers.set(inputSource, { entity, jointMap, asset });
// Fire event for other scripts to coordinate
this.app.fire('xr:controller:add', inputSource, entity);
} else {
console.warn('XrControllers: No compatible profiles found for input source');
}
}
/**
* Loads a single profile and its model.
*
* @param {XrInputSource} inputSource - The input source.
* @param {string} profileId - The profile ID to load.
* @returns {Promise<{ profileId: string, asset: import('playcanvas').Asset } | null>} The result or null on failure.
* @private
*/
async _loadProfile(inputSource, profileId) {
const profileUrl = `${this.basePath}/${profileId}/profile.json`;
try {
const response = await fetch(profileUrl);
if (!response.ok) {
return null;
}
const profile = await response.json();
const layoutPath = profile.layouts[inputSource.handedness]?.assetPath || '';
const assetPath = `${this.basePath}/${profile.profileId}/${inputSource.handedness}${layoutPath.replace(/^\/?(left|right)/, '')}`;
// Load the model
const asset = await new Promise((resolve, reject) => {
this.app.assets.loadFromUrl(assetPath, 'container', (err, asset) => {
if (err) reject(err);
else resolve(asset);
});
});
return { profileId, asset };
} catch (error) {
// Silently fail for individual profiles - we'll try the next one
return null;
}
}
/**
* Called when an input source is removed.
*
* @param {XrInputSource} inputSource - The input source that was removed.
* @private
*/
_onInputSourceRemove(inputSource) {
// Remove from pending set if still loading
this._pendingInputSources.delete(inputSource);
this._destroyController(inputSource);
}
/**
* Sets the visibility state of controller models.
*
* @type {boolean}
*/
set visible(value) {
if (this._visible === value) return;
this._visible = value;
for (const [, controller] of this.controllers) {
controller.entity.enabled = value;
}
}
/**
* Gets the visibility state of controller models.
*
* @type {boolean}
*/
get visible() {
return this._visible;
}
update(dt) {
if (!this.app.xr?.active || !this._visible) return;
for (const [inputSource, { entity, jointMap }] of this.controllers) {
if (inputSource.hand) {
// Update hand joint positions
for (const [joint, jointEntity] of jointMap) {
jointEntity.setPosition(joint.getPosition());
jointEntity.setRotation(joint.getRotation());
}
} else {
// Update controller position
const position = inputSource.getPosition();
const rotation = inputSource.getRotation();
if (position) entity.setPosition(position);
if (rotation) entity.setRotation(rotation);
}
}
}
}
export { XrControllers };