cursor-style-manager-wle
Version:
Shared cursor styles for Wonderland Engine
531 lines • 21.5 kB
JavaScript
import { Property, Emitter } from '@wonderlandengine/api';
import { HitTestLocation } from '@wonderlandengine/components';
import { vec3, mat4 } from 'gl-matrix';
import { CSMComponent } from './CSMComponent';
const tempVec = new Float32Array(3);
/** Global target for Cursor */
class CursorTargetEmitters {
constructor() {
/** Emitter for events when the target is hovered */
this.onHover = new Emitter();
/** Emitter for events when the target is unhovered */
this.onUnhover = new Emitter();
/** Emitter for events when the target is clicked */
this.onClick = new Emitter();
/** Emitter for events when the cursor moves on the target */
this.onMove = new Emitter();
/** Emitter for events when the user pressed the select button on the target */
this.onDown = new Emitter();
/** Emitter for events when the user unpressed the select button on the target */
this.onUp = new Emitter();
/** Emitter for events when the user scrolls on the target */
this.onScroll = new Emitter();
}
}
/**
* 3D cursor for desktop/mobile/VR.
*
* Implements a ray-casting cursor into the scene. To react to
* clicking/hover/unhover/cursor down/cursor up/move use a
* [cursor-target](#cursor-target).
*
* For VR, the ray is cast in direction of
* [this.object.getForward()](/jsapi/object/#getforward). For desktop and mobile, the
* forward vector is inverse-projected to account for where on screen the user clicked.
*
* `.globalTarget` can be used to call callbacks for all objects, even those that
* do not have a cursor target attached, but match the collision group.
*
* `.hitTestTarget` can be used to call callbacks WebXR hit test results,
*
* See [Animation Example](/showcase/animation).
*/
class CSMCursor extends CSMComponent {
constructor() {
super(...arguments);
this._collisionMask = 0;
this._onDeactivateCallbacks = [];
this._input = null;
this._origin = new Float32Array(3);
this._cursorObjScale = new Float32Array(3);
this._direction = new Float32Array(3);
this._projectionMatrix = new Float32Array(16);
this._viewComponent = null;
this._isDown = false;
this._lastIsDown = false;
this._arTouchDown = false;
this._lastPointerPos = new Float32Array(2);
this._lastCursorPosOnTarget = new Float32Array(3);
this._cursorRayScale = new Float32Array(3);
this._hitTestLocation = null;
this._hitTestObject = null;
this._onSessionStartCallback = null;
/**
* Whether the cursor (and cursorObject) is visible, i.e. pointing at an object
* that matches the collision group
*/
this.visible = true;
/** Maximum distance for the cursor's ray cast */
this.maxDistance = 100;
/** Currently hovered object */
this.hoveringObject = null;
/** CursorTarget component of the currently hovered object */
this.hoveringObjectTarget = null;
/** Whether the cursor is hovering reality via hit-test */
this.hoveringReality = false;
/**
* Global target lets you receive global cursor events on any object.
*/
this.globalTarget = new CursorTargetEmitters();
/**
* Hit test target lets you receive cursor events for "reality", if
* `useWebXRHitTest` is set to `true`.
*
* @example
* ```js
* cursor.hitTestTarget.onClick.add((hit, cursor) => {
* // User clicked on reality
* });
* ```
*/
this.hitTestTarget = new CursorTargetEmitters();
/** World position of the cursor */
this.cursorPos = new Float32Array(3);
this._onViewportResize = () => {
if (!this._viewComponent) {
return;
}
/* Projection matrix will change if the viewport is resized, which will affect the
* projection matrix because of the aspect ratio. */
mat4.invert(this._projectionMatrix, this._viewComponent.projectionMatrix);
};
}
static onRegister(engine) {
engine.registerComponent(HitTestLocation);
}
start() {
var _a;
this._collisionMask = 1 << this.collisionGroup;
if (this.handedness == 0) {
const inputComp = this.object.getComponent('input');
if (!inputComp) {
console.warn('cursor component on object', this.object.name, 'was configured with handedness "input component", ' +
'but object has no input component.');
}
else {
this.handedness = inputComp.handedness || 'none';
this._input = inputComp;
}
}
else {
this.handedness = ['left', 'right', 'none'][this.handedness - 1];
}
if (this.viewObject) {
this._viewComponent = this.viewObject.getComponent('view');
}
else {
this._viewComponent = this.object.getComponent('view');
}
if (this.useWebXRHitTest) {
this._hitTestObject = this.engine.scene.addObject(this.object);
this._hitTestLocation =
(_a = this._hitTestObject.addComponent(HitTestLocation, {
scaleObject: false,
})) !== null && _a !== void 0 ? _a : null;
}
this._onSessionStartCallback = this.setupVREvents.bind(this);
}
onActivate() {
this.engine.onXRSessionStart.add(this._onSessionStartCallback);
this.engine.onResize.add(this._onViewportResize);
this._setCursorVisibility(true);
/* If this object also has a view component, we will enable inverse-projected mouse clicks,
* otherwise just use the objects transformation */
if (this._viewComponent != null) {
const canvas = this.engine.canvas;
const onClick = this.onClick.bind(this);
const onPointerMove = this.onPointerMove.bind(this);
const onPointerDown = this.onPointerDown.bind(this);
const onPointerUp = this.onPointerUp.bind(this);
canvas.addEventListener('click', onClick);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointerup', onPointerUp);
this._onDeactivateCallbacks.push(() => {
canvas.removeEventListener('click', onClick);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointerup', onPointerUp);
});
}
this._onViewportResize();
}
_setCursorRayTransform(hitPosition) {
if (!this.cursorRayObject) {
return;
}
const dist = vec3.dist(this._origin, hitPosition);
this.cursorRayObject.setPositionLocal([0.0, 0.0, -dist / 2]);
if (this.cursorRayScalingAxis != 4) {
this.cursorRayObject.resetScaling();
this._cursorRayScale[this.cursorRayScalingAxis] = dist / 2;
this.cursorRayObject.scaleLocal(this._cursorRayScale);
}
}
_setCursorVisibility(visible) {
if (this.visible == visible) {
return;
}
this.visible = visible;
if (!this.cursorObject) {
return;
}
if (visible) {
this.cursorObject.setScalingWorld(this._cursorObjScale);
}
else {
this.cursorObject.getScalingLocal(this._cursorObjScale);
this.cursorObject.scaleLocal([0, 0, 0]);
}
}
update() {
var _a;
/* If in VR, set the cursor ray based on object transform */
/* Since Google Cardboard tap is registered as arTouchDown without a gamepad, we need to check for gamepad presence */
if (this.engine.xr &&
this._arTouchDown &&
this._input &&
this.engine.xr.session.inputSources[0].handedness === 'none' &&
this.engine.xr.session.inputSources[0].gamepad) {
/* WebXR AR input */
const p = this.engine.xr.session.inputSources[0].gamepad.axes;
/* Screenspace Y is inverted */
this._direction[0] = p[0];
this._direction[1] = -p[1];
this._direction[2] = -1.0;
this.applyTransformAndProjectDirection();
}
else if (this.engine.xr && this._input && this._input.xrInputSource) {
/* WebXR VR input */
this._direction[0] = 0;
this._direction[1] = 0;
this._direction[2] = -1.0;
this.applyTransformToDirection();
}
else if (this._viewComponent) {
/* Apply potentially changed transform to last stored pointer
* position */
this.updateDirection();
}
this.rayCast(null, (_a = this.engine.xr) === null || _a === void 0 ? void 0 : _a.frame);
if (this.cursorObject) {
if (this.hoveringObject &&
(this.cursorPos[0] != 0 || this.cursorPos[1] != 0 || this.cursorPos[2] != 0)) {
this._setCursorVisibility(true);
this.cursorObject.setPositionWorld(this.cursorPos);
this._setCursorRayTransform(this.cursorPos);
}
else {
this._setCursorVisibility(false);
}
}
}
/* Returns the hovered cursor target, if available */
notify(event, originalEvent) {
const target = this.hoveringObject;
if (target) {
const cursorTarget = this.hoveringObjectTarget;
if (cursorTarget) {
cursorTarget[event].notify(target, this, originalEvent !== null && originalEvent !== void 0 ? originalEvent : undefined);
}
this.globalTarget[event].notify(target, this, originalEvent !== null && originalEvent !== void 0 ? originalEvent : undefined);
}
}
hoverBehaviour(rayHit, hitTestResult, doClick, originalEvent) {
/* Old API version does not return null for objects[0] if no hit */
const hit = !this.hoveringReality && rayHit.hitCount > 0 ? rayHit.objects[0] : null;
if (hit) {
if (!this.hoveringObject || !this.hoveringObject.equals(hit)) {
/* Unhover previous, if exists */
if (this.hoveringObject) {
this.notify('onUnhover', originalEvent);
}
/* Hover new object */
this.hoveringObject = hit;
this.hoveringObjectTarget = this.hoveringObject.getComponent('cursor-target');
if (this.styleCursor) {
this.setCursorStyle('pointer');
}
this.notify('onHover', originalEvent);
}
}
else if (this.hoveringObject) {
/* Previously hovering object, now hovering nothing */
this.notify('onUnhover', originalEvent);
this.hoveringObject = null;
this.hoveringObjectTarget = null;
if (this.styleCursor) {
this.setCursorStyle(null);
}
}
if (this.hoveringObject) {
/* onDown/onUp for object */
if (this._isDown !== this._lastIsDown) {
this.notify(this._isDown ? 'onDown' : 'onUp', originalEvent);
}
/* onClick for object */
if (doClick) {
this.notify('onClick', originalEvent);
}
}
else if (this.hoveringReality) {
/* onDown/onUp for hit test */
if (this._isDown !== this._lastIsDown) {
(this._isDown ? this.hitTestTarget.onDown : this.hitTestTarget.onUp).notify(hitTestResult, this, originalEvent !== null && originalEvent !== void 0 ? originalEvent : undefined);
}
/* onClick for hit test */
if (doClick) {
this.hitTestTarget.onClick.notify(hitTestResult, this, originalEvent !== null && originalEvent !== void 0 ? originalEvent : undefined);
}
}
/* onMove */
if (hit) {
if (this.hoveringObject) {
this.hoveringObject.transformPointInverseWorld(tempVec, this.cursorPos);
}
else {
tempVec.set(this.cursorPos);
}
if (!vec3.equals(this._lastCursorPosOnTarget, tempVec)) {
this.notify('onMove', originalEvent);
this._lastCursorPosOnTarget.set(tempVec);
}
}
else if (this.hoveringReality) {
if (!vec3.equals(this._lastCursorPosOnTarget, this.cursorPos)) {
this.hitTestTarget.onMove.notify(hitTestResult, this, originalEvent !== null && originalEvent !== void 0 ? originalEvent : undefined);
this._lastCursorPosOnTarget.set(this.cursorPos);
}
}
else {
this._lastCursorPosOnTarget.set(this.cursorPos);
}
this._lastIsDown = this._isDown;
}
/**
* Setup event listeners on session object
* @param s - WebXR session
*
* Sets up 'select' and 'end' events.
*/
setupVREvents(s) {
if (!s) {
console.error('setupVREvents called without a valid session');
return;
}
/* If in VR, one-time bind the listener */
const onSelect = this.onSelect.bind(this);
s.addEventListener('select', onSelect);
const onSelectStart = this.onSelectStart.bind(this);
s.addEventListener('selectstart', onSelectStart);
const onSelectEnd = this.onSelectEnd.bind(this);
s.addEventListener('selectend', onSelectEnd);
this._onDeactivateCallbacks.push(() => {
var _a;
if (!((_a = this.engine.xr) === null || _a === void 0 ? void 0 : _a.session)) {
return;
}
s.removeEventListener('select', onSelect);
s.removeEventListener('selectstart', onSelectStart);
s.removeEventListener('selectend', onSelectEnd);
});
/* After AR session was entered, the projection matrix changed */
this._onViewportResize();
}
onDeactivate() {
super.onDeactivate();
this.engine.onXRSessionStart.remove(this._onSessionStartCallback);
this.engine.onResize.remove(this._onViewportResize);
this._setCursorVisibility(false);
if (this.hoveringObject) {
this.notify('onUnhover', null);
}
if (this.cursorRayObject) {
this.cursorRayObject.scaleLocal([0, 0, 0]);
}
/* Ensure all event listeners are removed */
for (const f of this._onDeactivateCallbacks) {
f();
}
this._onDeactivateCallbacks.length = 0;
}
onDestroy() {
var _a;
(_a = this._hitTestObject) === null || _a === void 0 ? void 0 : _a.destroy();
}
/** 'select' event listener */
onSelect(e) {
if (e.inputSource.handedness != this.handedness) {
return;
}
this.rayCast(e, e.frame, true);
}
/** 'selectstart' event listener */
onSelectStart(e) {
this._arTouchDown = true;
if (e.inputSource.handedness == this.handedness) {
this._isDown = true;
this.rayCast(e, e.frame);
}
}
/** 'selectend' event listener */
onSelectEnd(e) {
this._arTouchDown = false;
if (e.inputSource.handedness == this.handedness) {
this._isDown = false;
this.rayCast(e, e.frame);
}
}
/** 'pointermove' event listener */
onPointerMove(e) {
/* Don't care about secondary pointers */
if (!e.isPrimary) {
return;
}
this.updateMousePos(e);
this.rayCast(e, null);
}
/** 'click' event listener */
onClick(e) {
this.updateMousePos(e);
this.rayCast(e, null, true);
}
/** 'pointerdown' event listener */
onPointerDown(e) {
/* Don't care about secondary pointers or non-left clicks */
if (!e.isPrimary || e.button !== 0) {
return;
}
this.updateMousePos(e);
this._isDown = true;
this.rayCast(e);
}
/** 'pointerup' event listener */
onPointerUp(e) {
/* Don't care about secondary pointers or non-left clicks */
if (!e.isPrimary || e.button !== 0) {
return;
}
this.updateMousePos(e);
this._isDown = false;
this.rayCast(e);
}
/**
* Update mouse position in non-VR mode and raycast for new position
* @returns @ref WL.RayHit for new position.
*/
updateMousePos(e) {
this._lastPointerPos[0] = e.clientX;
this._lastPointerPos[1] = e.clientY;
this.updateDirection();
}
updateDirection() {
const bounds = this.engine.canvas.getBoundingClientRect();
/* Get direction in normalized device coordinate space from mouse position */
const left = this._lastPointerPos[0] / bounds.width;
const top = this._lastPointerPos[1] / bounds.height;
this._direction[0] = left * 2 - 1;
this._direction[1] = -top * 2 + 1;
this._direction[2] = -1.0;
this.applyTransformAndProjectDirection();
}
applyTransformAndProjectDirection() {
/* Reverse-project the direction into view space */
vec3.transformMat4(this._direction, this._direction, this._projectionMatrix);
vec3.normalize(this._direction, this._direction);
this.applyTransformToDirection();
}
applyTransformToDirection() {
vec3.transformQuat(this._direction, this._direction, this.object.getTransformWorld());
this.object.getPositionWorld(this._origin);
}
rayCast(originalEvent, frame = null, doClick = false) {
var _a, _b;
const rayHit = this.rayCastMode == 0
? this.engine.scene.rayCast(this._origin, this._direction, this._collisionMask)
: this.engine.physics.rayCast(this._origin, this._direction, this._collisionMask, this.maxDistance);
let hitResultDistance = Infinity;
let hitTestResult = null;
if ((_a = this._hitTestLocation) === null || _a === void 0 ? void 0 : _a.visible) {
this._hitTestObject.getPositionWorld(this.cursorPos);
hitResultDistance = vec3.distance(this.object.getPositionWorld(tempVec), this.cursorPos);
hitTestResult = (_b = this._hitTestLocation) === null || _b === void 0 ? void 0 : _b.getHitTestResults(frame)[0];
}
let hoveringReality = false;
if (rayHit.hitCount > 0) {
const d = rayHit.distances[0];
if (hitResultDistance >= d) {
/* Override cursorPos set by hit test location */
this.cursorPos.set(rayHit.locations[0]);
}
else {
hoveringReality = true;
}
}
else if (hitResultDistance < Infinity) {
/* cursorPos already set */
}
else {
this.cursorPos.fill(0);
}
if (hoveringReality && !this.hoveringReality) {
this.hitTestTarget.onHover.notify(hitTestResult, this);
}
else if (!hoveringReality && this.hoveringReality) {
this.hitTestTarget.onUnhover.notify(hitTestResult, this);
}
this.hoveringReality = hoveringReality;
this.hoverBehaviour(rayHit, hitTestResult, doClick, originalEvent);
return rayHit;
}
}
CSMCursor.TypeName = 'csm-cursor';
CSMCursor.Properties = Object.assign(Object.assign({}, CSMComponent.Properties), {
/**
* Collision group for the ray cast. Only objects in this group will be
* affected by this cursor.
*/
collisionGroup: Property.int(1),
/** (optional) Object that visualizes the cursor's ray. */
cursorRayObject: Property.object(),
/** Axis along which to scale the `cursorRayObject`. */
cursorRayScalingAxis: Property.enum(['x', 'y', 'z', 'none'], 'z'),
/** (optional) Object that visualizes the cursor's hit location. */
cursorObject: Property.object(),
/**
* Handedness for VR cursors to accept trigger events only from
* respective controller.
*/
handedness: Property.enum(['input component', 'left', 'right', 'none'], 'input component'),
/**
* Mode for raycasting, whether to use PhysX or simple collision
* components
*/
rayCastMode: Property.enum(['collision', 'physx'], 'collision'),
/** Whether to set the CSS style of the mouse cursor on desktop */
styleCursor: Property.bool(true),
/**
* Use WebXR hit-test if available.
*
* Attaches a hit-test-location component to the cursorObject, which
* will be used by the cursor to send events to the hitTestTarget with
* HitTestResult.
*/
useWebXRHitTest: Property.bool(false),
/**
* Object with view component. If not set, then the object of this
* component is used
*/
viewObject: Property.object() });
export { CSMCursor };
//# sourceMappingURL=CSMCursor.js.map