playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
1,140 lines (969 loc) • 35.5 kB
JavaScript
import { BUTTON_TRANSITION_MODE_TINT, Color, Entity, Quat, Script, Vec2, Vec3, Vec4 } from 'playcanvas';
/** @import { Asset, XrInputSource } from 'playcanvas' */
// Pre-allocated vectors for performance
const tmpVec3A = new Vec3();
const tmpVec3B = new Vec3();
const tmpVec3C = new Vec3();
const tmpQuat = new Quat();
// Finger joint IDs for extension detection (pre-allocated to avoid GC pressure)
const FINGER_JOINTS = [
{ tip: 'index-finger-tip', meta: 'index-finger-metacarpal' },
{ tip: 'middle-finger-tip', meta: 'middle-finger-metacarpal' },
{ tip: 'ring-finger-tip', meta: 'ring-finger-metacarpal' },
{ tip: 'pinky-finger-tip', meta: 'pinky-finger-metacarpal' }
];
/**
* Provides a hybrid WebXR menu system that works with both Hand Tracking ("Palm Up" gesture)
* and Controllers (Button Toggle). The menu automatically detects the input mode and switches
* between hand-anchored and controller-anchored positioning.
*
* This script uses PlayCanvas' UI system (Screen, Element, Button components) for rendering
* the menu, providing proper text rendering and familiar button interaction patterns.
*
* This script should be attached to an entity in your scene. It creates menu buttons dynamically
* based on the `menuItems` configuration. When a button is activated, it fires the corresponding
* app event.
*
* Features:
* - Hand Tracking: Detects "looking at open palm" gesture to show menu anchored to palm
* - Controller Mode: Toggle menu visibility with a configurable button, anchored to controller
* - Uses PlayCanvas UI system for proper text rendering
* - Smooth following with configurable dampening
* - Finger touch and button click interaction support
* - Fires 'xr:menu:active' app event when menu visibility changes (for coordination with other scripts)
*
* @example
* // Configure menu items via script attributes:
* menuItems: [
* { label: 'Teleport', eventName: 'menu:teleport' },
* { label: 'Settings', eventName: 'menu:settings' },
* { label: 'Exit', eventName: 'xr:end' }
* ]
*/
class XrMenu extends Script {
static scriptName = 'xrMenu';
/**
* Array of menu item definitions. Each item should have a `label` (display text) and
* `eventName` (app event to fire when activated).
*
* @type {Array<{label: string, eventName: string}>}
* @attribute
*/
menuItems = [];
/**
* Audio asset for button click sound.
*
* @type {Asset|null}
* @attribute
*/
clickSound = null;
/**
* Font asset for button text. Required for text rendering.
*
* @type {Asset|null}
* @attribute
*/
fontAsset = null;
/**
* Offset from the anchor point where the menu appears.
* For hand tracking: Z is distance from palm center along the palm normal.
* For controllers: Applied in controller-local space.
*
* @type {Vec3}
* @attribute
*/
menuOffset = new Vec3(0, 0, 0.06);
/**
* Vertical spacing between menu buttons in meters.
*
* @type {number}
* @attribute
* @range [0.001, 0.05]
* @precision 0.001
*/
buttonSpacing = 0.0025;
/**
* Width of each button in meters.
*
* @type {number}
* @attribute
* @range [0.02, 0.3]
* @precision 0.01
*/
buttonWidth = 0.075;
/**
* Height of each button in meters.
*
* @type {number}
* @attribute
* @range [0.01, 0.1]
* @precision 0.001
*/
buttonHeight = 0.015;
/**
* Font size for button text in UI pixels.
*
* @type {number}
* @attribute
* @range [4, 48]
*/
fontSize = 8;
/**
* Overall scale multiplier for the entire menu.
*
* @type {number}
* @attribute
* @range [0.5, 2]
* @precision 0.1
*/
menuScale = 1.0;
/**
* How quickly the menu follows the anchor point. Higher values = snappier movement.
*
* @type {number}
* @attribute
* @range [1, 30]
*/
followSpeed = 25;
/**
* Dot product threshold for detecting palm-up gesture. Higher values require the palm
* to face more directly toward the camera.
*
* @type {number}
* @attribute
* @range [0.3, 0.95]
* @precision 0.05
*/
palmUpThreshold = 0.6;
/**
* Gamepad button index used to toggle the menu in controller mode.
* Default is 4 (typically Y button on left controller, B on right).
*
* @type {number}
* @attribute
* @range [0, 10]
*/
toggleButtonIndex = 4;
/**
* Which hand the menu should be attached to ('left' or 'right').
*
* @type {string}
* @attribute
*/
preferredHand = 'left';
/**
* Distance threshold for finger touch hover in meters.
*
* @type {number}
* @attribute
* @range [0.01, 0.1]
* @precision 0.01
*/
touchDistance = 0.05;
/**
* Cooldown time after button press before another press is allowed (seconds).
*
* @type {number}
* @attribute
* @range [0.1, 1.0]
* @precision 0.1
*/
pressCooldown = 0.3;
/**
* Background color of menu buttons.
*
* @type {Color}
* @attribute
*/
buttonColor = new Color(0.85, 0.85, 0.85, 0.9);
/**
* Color of menu buttons when hovered.
*
* @type {Color}
* @attribute
*/
hoverColor = new Color(1, 1, 1, 1);
/**
* Color of menu buttons when pressed/activated.
*
* @type {Color}
* @attribute
*/
pressColor = new Color(0.7, 0.7, 0.7, 1);
/**
* Text color for button labels.
*
* @type {Color}
* @attribute
*/
textColor = new Color(1, 1, 1);
/**
* Optional texture asset for button backgrounds.
*
* @type {Asset|null}
* @attribute
*/
buttonTexture = null;
/**
* Duration of fade in/out animation in seconds.
*
* @type {number}
* @attribute
* @range [0, 1]
* @precision 0.05
*/
fadeDuration = 0.15;
// Internal state
/** @type {Entity|null} */
_menuContainer = null;
/** @type {Entity|null} */
_screenEntity = null;
/** @type {Entity[]} */
_buttons = [];
/** @type {Set<XrInputSource>} */
_inputSources = new Set();
/** @type {boolean} */
_menuVisible = false;
/** @type {boolean} */
_toggleButtonWasPressed = false;
/** @type {Vec3} */
_targetPosition = new Vec3();
/** @type {Quat} */
_targetRotation = new Quat();
/** @type {Entity|null} */
_hoveredButton = null;
/** @type {Entity|null} */
_pressedButton = null;
/** @type {number} */
_lastPressTime = 0;
/** @type {XrInputSource|null} */
_activeInputSource = null;
/** @type {Entity|null} */
_cameraEntity = null;
/** @type {number} */
_uiScale = 0.001; // Convert UI pixels to meters
/** @type {number} */
_currentOpacity = 0;
/** @type {number} */
_targetOpacity = 0;
initialize() {
if (!this.app.xr) {
console.warn('XrMenu: XR is not available on this application');
return;
}
// Find camera entity for palm detection
this._cameraEntity = this.entity.findComponent('camera')?.entity || null;
if (!this._cameraEntity) {
// Try to find any camera in the scene
this._cameraEntity = this.app.root.findComponent('camera')?.entity || null;
}
// Set up click sound (non-positional for UI feedback)
if (this.clickSound) {
this.entity.addComponent('sound', {
positional: false
});
this.entity.sound?.addSlot('click', {
asset: this.clickSound.id,
volume: 0.5
});
}
// Create menu container and UI
this._createMenu();
// Hide menu initially
this._setMenuVisible(false);
// Listen for XR input sources
this.app.xr.input.on('add', this._onInputSourceAdd, this);
this.app.xr.input.on('remove', this._onInputSourceRemove, this);
// Listen for XR session end to clean up
this.app.xr.on('end', this._onXrEnd, this);
this.on('destroy', () => {
this._onDestroy();
});
}
_onDestroy() {
if (this.app.xr) {
this.app.xr.input.off('add', this._onInputSourceAdd, this);
this.app.xr.input.off('remove', this._onInputSourceRemove, this);
this.app.xr.off('end', this._onXrEnd, this);
}
// Destroy menu container
if (this._menuContainer) {
this._menuContainer.destroy();
this._menuContainer = null;
}
this._buttons = [];
this._inputSources.clear();
}
_onXrEnd() {
this._setMenuVisible(false);
this._inputSources.clear();
this._activeInputSource = null;
}
/**
* @param {XrInputSource} inputSource - The input source that was added.
* @private
*/
_onInputSourceAdd(inputSource) {
this._inputSources.add(inputSource);
}
/**
* @param {XrInputSource} inputSource - The input source that was removed.
* @private
*/
_onInputSourceRemove(inputSource) {
this._inputSources.delete(inputSource);
if (this._activeInputSource === inputSource) {
this._activeInputSource = null;
this._setMenuVisible(false);
}
}
/**
* Creates the menu with PlayCanvas UI system.
*
* @private
*/
_createMenu() {
// Create a container entity for positioning
this._menuContainer = new Entity('XrMenuContainer');
this.app.root.addChild(this._menuContainer);
// Create a world-space screen for UI
this._screenEntity = new Entity('XrMenuScreen');
this._screenEntity.addComponent('screen', {
referenceResolution: new Vec2(1000, 1000),
screenSpace: false,
scaleBlend: 1
});
// Scale the screen to convert pixels to meters
const scale = this._uiScale * this.menuScale;
this._screenEntity.setLocalScale(scale, scale, scale);
this._menuContainer.addChild(this._screenEntity);
// Generate buttons from menuItems
this._generateButtons();
}
/**
* Generates menu buttons from the menuItems configuration.
*
* @private
*/
_generateButtons() {
if (!this._screenEntity) return;
// Clear existing buttons
for (const button of this._buttons) {
button.destroy();
}
this._buttons = [];
// Convert meter sizes to UI pixels
const widthPx = this.buttonWidth / this._uiScale;
const heightPx = this.buttonHeight / this._uiScale;
const spacingPx = this.buttonSpacing / this._uiScale;
// Create buttons from menuItems
for (let i = 0; i < this.menuItems.length; i++) {
const item = this.menuItems[i];
if (!item || typeof item !== 'object') continue;
const label = item.label || `Button ${i}`;
const eventName = item.eventName || '';
const button = this._createButton(label, eventName, i, widthPx, heightPx);
if (button) {
this._screenEntity.addChild(button);
this._buttons.push(button);
}
}
// Layout buttons vertically
this._layoutButtons(heightPx, spacingPx);
}
/**
* Creates a single menu button using PlayCanvas UI.
*
* @param {string} label - Display text for the button.
* @param {string} eventName - Event to fire when button is activated.
* @param {number} index - Index of the button in the menu.
* @param {number} widthPx - Button width in pixels.
* @param {number} heightPx - Button height in pixels.
* @returns {Entity} The created button entity.
* @private
*/
_createButton(label, eventName, index, widthPx, heightPx) {
const button = new Entity(`MenuButton_${index}`);
// Add button component for interactivity
button.addComponent('button', {
active: true,
transitionMode: BUTTON_TRANSITION_MODE_TINT,
hoverTint: this.hoverColor,
pressedTint: this.pressColor,
inactiveTint: this.buttonColor
});
// Add element component for visual appearance (image type for button background)
/** @type {Object} */
const elementConfig = {
type: 'image',
anchor: new Vec4(0.5, 0.5, 0.5, 0.5),
pivot: new Vec2(0.5, 0.5),
width: widthPx,
height: heightPx,
color: this.buttonColor,
opacity: this.buttonColor.a,
useInput: true,
layers: [this.app.scene.layers.getLayerByName('UI')?.id ?? 0]
};
// Use texture if provided
if (this.buttonTexture?.resource) {
elementConfig.textureAsset = this.buttonTexture.id;
elementConfig.color = new Color(1, 1, 1, this.buttonColor.a); // Tint white to show texture
}
button.addComponent('element', elementConfig);
// Store metadata
// @ts-ignore - Adding custom property
button.menuData = {
label: label,
eventName: eventName,
index: index
};
// Handle button click
if (button.button) {
button.button.on('click', () => {
this._onButtonClick(button);
});
// Handle hover events for finger touch visual feedback
button.button.on('hoverstart', () => {
this._hoveredButton = button;
});
button.button.on('hoverend', () => {
if (this._hoveredButton === button) {
this._hoveredButton = null;
}
});
}
// Create text label as child
const textEntity = new Entity('ButtonText');
textEntity.addComponent('element', {
type: 'text',
text: label.toUpperCase(),
anchor: new Vec4(0, 0, 1, 1),
pivot: new Vec2(0.5, 0.5),
margin: new Vec4(4, 4, 4, 4),
fontSize: this.fontSize,
color: this.textColor,
fontAsset: this.fontAsset?.id ?? this._getDefaultFontAsset()?.id,
autoWidth: false,
autoHeight: false,
wrapLines: false,
alignment: new Vec2(0.5, 0.5)
});
button.addChild(textEntity);
return button;
}
/**
* Gets or creates a default font asset.
*
* @returns {Asset|null} The default font asset.
* @private
*/
_getDefaultFontAsset() {
// Try to find an existing font in the asset registry
const fonts = this.app.assets.filter(asset => asset.type === 'font');
if (fonts.length > 0) {
return fonts[0];
}
return null;
}
/**
* Handles button click with visual feedback.
*
* @param {Entity} button - The clicked button.
* @private
*/
_onButtonClick(button) {
// @ts-ignore
const menuData = button.menuData;
if (!menuData) return;
// Play click sound
if (this.entity.sound) {
this.entity.sound.play('click');
}
// Visual feedback - flash press color and scale
this._setButtonPress(button, true);
// Reset visual after short delay
setTimeout(() => {
this._setButtonPress(button, false);
}, 150);
// Fire the event
if (menuData.eventName) {
this.app.fire(menuData.eventName);
}
}
/**
* Sets press visual state on a button.
*
* @param {Entity} button - The button.
* @param {boolean} pressed - Whether the button is pressed.
* @private
*/
_setButtonPress(button, pressed) {
if (!button.element) return;
// @ts-ignore
button._isPressed = pressed;
if (pressed) {
button.element.color = this.pressColor;
button.setLocalScale(0.95, 0.95, 1);
} else {
// Restore based on current hover state
const isHovered = this._hoveredButton === button;
if (isHovered) {
button.element.color = this.hoverColor;
button.setLocalScale(1.05, 1.05, 1);
} else {
button.element.color = this.buttonColor;
button.setLocalScale(1, 1, 1);
}
}
}
/**
* Sets hover visual state on a button.
*
* @param {Entity} button - The button to set hover state on.
* @param {boolean} hovered - Whether the button is hovered.
* @private
*/
_setButtonHover(button, hovered) {
if (!button.element) return;
// Don't change visuals if button is currently pressed
// @ts-ignore
if (button._isPressed) return;
if (hovered) {
button.element.color = this.hoverColor;
button.setLocalScale(1.05, 1.05, 1);
} else {
button.element.color = this.buttonColor;
button.setLocalScale(1, 1, 1);
}
}
/**
* Lays out buttons vertically in the menu.
*
* @param {number} heightPx - Button height in pixels.
* @param {number} spacingPx - Spacing between buttons in pixels.
* @private
*/
_layoutButtons(heightPx, spacingPx) {
const totalHeight = (this._buttons.length - 1) * (heightPx + spacingPx) + heightPx;
const startY = totalHeight / 2 - heightPx / 2;
for (let i = 0; i < this._buttons.length; i++) {
const button = this._buttons[i];
button.setLocalPosition(0, startY - i * (heightPx + spacingPx), 0);
}
}
/**
* Sets menu visibility and fires the appropriate event.
*
* @param {boolean} visible - Whether the menu should be visible.
* @private
*/
_setMenuVisible(visible) {
if (this._menuVisible === visible) return;
this._menuVisible = visible;
this._targetOpacity = visible ? 1 : 0;
// Enable container immediately when showing (opacity will fade in)
if (visible && this._menuContainer) {
this._menuContainer.enabled = true;
// Snap to current anchor position immediately (don't lerp from old position)
if (this._activeInputSource) {
const anchor = this._activeInputSource.hand ?
this._getPalmAnchor(this._activeInputSource) :
this._getControllerAnchor(this._activeInputSource);
if (anchor) {
this._menuContainer.setPosition(anchor.position);
this._menuContainer.setRotation(anchor.rotation);
}
}
}
// Fire event for other scripts to coordinate (e.g., disable navigation while menu is open)
this.app.fire('xr:menu:active', visible);
// Reset hover state when hiding
if (!visible) {
if (this._hoveredButton) {
this._setButtonHover(this._hoveredButton, false);
}
this._hoveredButton = null;
this._pressedButton = null;
}
}
/**
* Updates the opacity of all menu elements.
*
* @param {number} opacity - Opacity value from 0 to 1.
* @private
*/
_updateMenuOpacity(opacity) {
for (const button of this._buttons) {
if (button.element) {
button.element.opacity = opacity * this.buttonColor.a;
}
// Also update text opacity
const textChild = /** @type {Entity|undefined} */ (button.children[0]);
if (textChild?.element) {
textChild.element.opacity = opacity;
}
}
}
/**
* Toggles menu visibility.
*
* @private
*/
_toggleMenuVisibility() {
this._setMenuVisible(!this._menuVisible);
}
/**
* Finds the preferred input source based on handedness setting.
*
* @returns {XrInputSource|null} The preferred input source or null.
* @private
*/
_findPreferredInput() {
for (const inputSource of this._inputSources) {
if (inputSource.handedness === this.preferredHand) {
return inputSource;
}
}
// Fallback to any available input
for (const inputSource of this._inputSources) {
if (inputSource.handedness !== 'none') {
return inputSource;
}
}
return null;
}
/**
* Checks if the fingers are extended (open hand).
* Measures the distance from fingertip to metacarpal (knuckle) -
* when extended this is large (~8-10cm), when curled it's small (~3-5cm).
*
* @param {XrInputSource} inputSource - The hand input source.
* @returns {boolean} True if fingers are extended.
* @private
*/
_areFingersExtended(inputSource) {
const hand = inputSource.hand;
if (!hand || !hand.tracking) return false;
let extendedCount = 0;
for (const finger of FINGER_JOINTS) {
const tip = hand.getJointById(finger.tip);
const meta = hand.getJointById(finger.meta);
if (!tip || !meta) continue;
// Distance from metacarpal (knuckle) to fingertip
// Extended finger: ~8-10cm (0.08-0.10m)
// Curled finger: ~3-5cm (0.03-0.05m)
const tipToMeta = tip.getPosition().distance(meta.getPosition());
// Threshold: finger is extended if tip is more than 6cm from knuckle
if (tipToMeta > 0.06) {
extendedCount++;
}
}
// Require at least 3 fingers extended for "open hand"
return extendedCount >= 3;
}
/**
* Checks if the palm is facing the camera with an open hand gesture.
*
* @param {XrInputSource} inputSource - The hand input source.
* @returns {boolean} True if palm is facing camera with open hand.
* @private
*/
_isPalmFacingCamera(inputSource) {
// First check if fingers are extended (open hand)
if (!this._areFingersExtended(inputSource)) {
return false;
}
// Get palm normal using shared calculation
const palmNormal = this._getPalmNormal(inputSource);
if (!palmNormal) return false;
// Get camera forward direction
if (!this._cameraEntity) return false;
const cameraForward = this._cameraEntity.forward;
// Check if palm normal faces roughly toward camera (negative dot product)
// We want the palm facing the user, so the normal should point toward the camera
const dot = palmNormal.dot(cameraForward);
// Negative dot means palm is facing camera
return dot < -this.palmUpThreshold;
}
/**
* Calculates the palm normal vector (pointing away from palm surface).
*
* @param {XrInputSource} inputSource - The hand input source.
* @returns {Vec3|null} The palm normal or null.
* @private
*/
_getPalmNormal(inputSource) {
const hand = inputSource.hand;
if (!hand || !hand.tracking) return null;
const wrist = hand.wrist;
const middleMeta = hand.getJointById('middle-finger-metacarpal');
const indexMeta = hand.getJointById('index-finger-metacarpal');
const pinkyMeta = hand.getJointById('pinky-finger-metacarpal');
if (!wrist || !middleMeta || !indexMeta || !pinkyMeta) return null;
const wristPos = wrist.getPosition();
const middlePos = middleMeta.getPosition();
const indexPos = indexMeta.getPosition();
const pinkyPos = pinkyMeta.getPosition();
// Vector from wrist to middle finger base
tmpVec3A.sub2(middlePos, wristPos);
// Vector from index to pinky (across the palm)
tmpVec3B.sub2(pinkyPos, indexPos);
// Cross product gives palm normal
tmpVec3C.cross(tmpVec3A, tmpVec3B).normalize();
// Flip normal for left hand so it always points away from palm surface
if (inputSource.handedness === 'left') {
tmpVec3C.mulScalar(-1);
}
return tmpVec3C;
}
/**
* Gets the palm anchor position and rotation for menu placement.
*
* Note: Returns references to reused internal Vec3/Quat objects for performance.
* Callers must use the values immediately or copy them - do not store the references.
*
* @param {XrInputSource} inputSource - The hand input source.
* @returns {{position: Vec3, rotation: Quat}|null} Anchor transform or null.
* @private
*/
_getPalmAnchor(inputSource) {
const hand = inputSource.hand;
if (!hand || !hand.tracking) return null;
// Use middle-finger-phalanx-proximal (first knuckle) for positioning closer to palm center
const middleProximal = hand.getJointById('middle-finger-phalanx-proximal');
const middleMeta = hand.getJointById('middle-finger-metacarpal');
if (!middleProximal || !middleMeta) return null;
// Get palm normal (pointing away from palm surface, toward camera when palm is up)
const palmNormal = this._getPalmNormal(inputSource);
if (!palmNormal) return null;
// Position at center of palm (halfway between metacarpal and proximal)
this._targetPosition.lerp(middleMeta.getPosition(), middleProximal.getPosition(), 0.5);
// Offset the menu along the palm normal (in front of palm)
tmpVec3A.copy(palmNormal).mulScalar(this.menuOffset.z);
this._targetPosition.add(tmpVec3A);
// Menu should face the camera (full look-at, not just Y rotation)
if (this._cameraEntity) {
const cameraPos = this._cameraEntity.getPosition();
tmpVec3A.sub2(cameraPos, this._targetPosition);
if (tmpVec3A.lengthSq() > 0.001) {
tmpVec3A.normalize();
// Calculate yaw (Y rotation)
const yaw = Math.atan2(tmpVec3A.x, tmpVec3A.z) * (180 / Math.PI);
// Calculate pitch (X rotation) - tilt to face camera
const pitch = -Math.asin(tmpVec3A.y) * (180 / Math.PI);
this._targetRotation.setFromEulerAngles(pitch, yaw, 0);
}
}
return {
position: this._targetPosition,
rotation: this._targetRotation
};
}
/**
* Gets the controller anchor position and rotation for menu placement.
*
* Note: Returns references to reused internal Vec3/Quat objects for performance.
* Callers must use the values immediately or copy them - do not store the references.
*
* @param {XrInputSource} inputSource - The controller input source.
* @returns {{position: Vec3, rotation: Quat}|null} Anchor transform or null.
* @private
*/
_getControllerAnchor(inputSource) {
if (!inputSource.grip) return null;
const position = inputSource.getPosition();
const rotation = inputSource.getRotation();
if (!position || !rotation) return null;
// Apply offset in controller-local space
this._targetPosition.copy(position);
tmpVec3A.copy(this.menuOffset);
rotation.transformVector(tmpVec3A, tmpVec3A);
this._targetPosition.add(tmpVec3A);
// Menu faces outward from controller
this._targetRotation.copy(rotation);
return {
position: this._targetPosition,
rotation: this._targetRotation
};
}
/**
* Checks for finger touch interaction with buttons.
*
* @param {XrInputSource} inputSource - The hand input source.
* @private
*/
_checkFingerTouch(inputSource) {
const hand = inputSource.hand;
if (!hand || !hand.tracking) return;
// Get index finger tip position (using the opposite hand for interaction)
// Find the other hand to use for touching
let touchHand = null;
for (const source of this._inputSources) {
if (source !== inputSource && source.hand && source.hand.tracking) {
touchHand = source.hand;
break;
}
}
if (!touchHand) return;
const indexTip = touchHand.getJointById('index-finger-tip');
if (!indexTip) return;
const fingerPos = indexTip.getPosition();
let closestButton = null;
let closestDist = this.touchDistance;
for (const button of this._buttons) {
const buttonPos = button.getPosition();
const dist = fingerPos.distance(buttonPos);
if (dist < closestDist) {
closestDist = dist;
closestButton = button;
}
}
const now = Date.now() / 1000; // Current time in seconds
const pressDist = this.touchDistance * 0.6; // Press threshold
if (closestButton) {
// Set hover state if this is a new hover
if (this._hoveredButton !== closestButton) {
// Clear previous hover
if (this._hoveredButton) {
this._setButtonHover(this._hoveredButton, false);
}
this._hoveredButton = closestButton;
this._setButtonHover(closestButton, true);
}
// Check for press (finger moving into button)
// Only allow press if: within press distance, not already pressed, and cooldown elapsed
if (closestDist < pressDist) {
const cooldownElapsed = (now - this._lastPressTime) > this.pressCooldown;
if (!this._pressedButton && cooldownElapsed) {
this._pressedButton = closestButton;
this._lastPressTime = now;
this._onButtonClick(closestButton);
}
} else if (this._pressedButton === closestButton && closestDist >= pressDist) {
// Finger moved out of press threshold but is still hovering - clear pressed state
this._pressedButton = null;
}
} else {
// Finger fully exited hover zone - clear states and allow re-press
if (this._hoveredButton) {
this._setButtonHover(this._hoveredButton, false);
}
this._hoveredButton = null;
this._pressedButton = null;
}
}
/**
* Updates hand tracking mode.
*
* @param {XrInputSource} inputSource - The hand input source.
* @param {number} dt - Delta time.
* @private
*/
_updateHandMode(inputSource, dt) {
// Check for palm-up gesture
const palmFacing = this._isPalmFacingCamera(inputSource);
if (palmFacing) {
if (!this._menuVisible) {
this._setMenuVisible(true);
}
// Check for finger touch interaction
this._checkFingerTouch(inputSource);
} else {
if (this._menuVisible) {
this._setMenuVisible(false);
}
}
// Update anchor position while menu is visible OR still fading out
if ((this._menuVisible || this._currentOpacity > 0) && this._menuContainer) {
const anchor = this._getPalmAnchor(inputSource);
if (anchor) {
// Smooth interpolation
tmpVec3A.lerp(
this._menuContainer.getPosition(),
anchor.position,
Math.min(1, this.followSpeed * dt)
);
this._menuContainer.setPosition(tmpVec3A);
tmpQuat.slerp(
this._menuContainer.getRotation(),
anchor.rotation,
Math.min(1, this.followSpeed * dt)
);
this._menuContainer.setRotation(tmpQuat);
}
}
}
/**
* Updates controller mode.
*
* @param {XrInputSource} inputSource - The controller input source.
* @param {number} dt - Delta time.
* @private
*/
_updateControllerMode(inputSource, dt) {
// Check for menu toggle button
const gamepad = inputSource.gamepad;
if (gamepad?.buttons?.[this.toggleButtonIndex]) {
const pressed = gamepad.buttons[this.toggleButtonIndex].pressed;
if (pressed && !this._toggleButtonWasPressed) {
this._toggleMenuVisibility();
}
this._toggleButtonWasPressed = pressed;
} else {
// Reset toggle state if the gamepad or button is unavailable
this._toggleButtonWasPressed = false;
}
// Update menu position while visible OR still fading out
if ((this._menuVisible || this._currentOpacity > 0) && this._menuContainer) {
const anchor = this._getControllerAnchor(inputSource);
if (anchor) {
// Smooth interpolation
tmpVec3A.lerp(
this._menuContainer.getPosition(),
anchor.position,
Math.min(1, this.followSpeed * dt)
);
this._menuContainer.setPosition(tmpVec3A);
tmpQuat.slerp(
this._menuContainer.getRotation(),
anchor.rotation,
Math.min(1, this.followSpeed * dt)
);
this._menuContainer.setRotation(tmpQuat);
}
}
}
update(dt) {
if (!this.app.xr?.active) return;
// Animate opacity fade
if (this._currentOpacity !== this._targetOpacity) {
const fadeSpeed = this.fadeDuration > 0 ? 1 / this.fadeDuration : 100;
if (this._targetOpacity > this._currentOpacity) {
this._currentOpacity = Math.min(this._targetOpacity, this._currentOpacity + fadeSpeed * dt);
} else {
this._currentOpacity = Math.max(this._targetOpacity, this._currentOpacity - fadeSpeed * dt);
}
this._updateMenuOpacity(this._currentOpacity);
// Disable container when fully faded out
if (this._currentOpacity <= 0 && this._menuContainer) {
this._menuContainer.enabled = false;
}
}
// Find the preferred input source
const inputSource = this._findPreferredInput();
if (!inputSource) return;
// Reset controller toggle state when input source changes
if (this._activeInputSource !== inputSource) {
this._toggleButtonWasPressed = false;
}
this._activeInputSource = inputSource;
// Determine input mode and update accordingly
if (inputSource.hand) {
this._updateHandMode(inputSource, dt);
} else if (inputSource.grip) {
this._updateControllerMode(inputSource, dt);
}
}
}
export { XrMenu };