UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

300 lines • 13.4 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { Ray } from "three"; import { Gizmos } from "../../engine/engine_gizmos.js"; import { serializable } from "../../engine/engine_serialization_decorator.js"; import { getTempVector } from "../../engine/engine_three_utils.js"; import { getParam } from "../../engine/engine_utils.js"; import { Behaviour } from "../Component.js"; const debug = getParam("debugcursor"); /** * [CursorFollow](https://engine.needle.tools/docs/api/CursorFollow) makes an object smoothly follow the cursor or touch position in 3D space. * The component tracks pointer movement and updates the object's position to follow it, with optional damping for smooth motion. * * ![](https://cloud.needle.tools/-/media/GDspQGC_kB85Bc9IyEtr9Q.gif) * * **How It Works:** * The component creates a ray from the camera through the cursor position and places the object along that ray. * By default, it maintains the object's initial distance from the camera, creating a natural cursor-following effect * that works consistently regardless of camera movement. * * **Key Features:** * - Smooth cursor following with configurable damping * - Works with both mouse and touch input * - Can follow cursor across the entire page or just within the canvas * - Maintains consistent distance from camera by default * - Optional surface snapping using raycasts * - Responds to camera movement automatically * * **Common Use Cases:** * - Interactive 3D cursors or pointers * - Look-at effects combined with {@link LookAtConstraint} * - Floating UI elements that track cursor * - Interactive product showcases * - 3D header effects and hero sections * - Virtual laser pointers in XR experiences * * @example Basic cursor follow with smooth damping * ```ts * const follower = new Object3D(); * follower.position.set(0, 0, -5); // Initial position 5 units from camera * follower.addComponent(CursorFollow, { * damping: 0.2, // Smooth following with 200ms damping * keepDistance: true, // Maintain initial distance * useFullPage: true // Track cursor across entire page * }); * scene.add(follower); * ``` * * @example Surface-snapping cursor with raycast * ```ts * const cursor = new Object3D(); * cursor.addComponent(CursorFollow, { * snapToSurface: true, // Snap to surfaces in the scene * keepDistance: false, // Don't maintain distance when snapping * damping: 0.1 // Quick, responsive movement * }); * scene.add(cursor); * ``` * * @example Instant cursor following (no damping) * ```ts * gameObject.addComponent(CursorFollow, { * damping: 0, // Instant movement * useFullPage: false // Only track within canvas * }); * ``` * * @example Interactive 3D header that looks at cursor * ```ts * const character = loadModel("character.glb"); * const lookTarget = new Object3D(); * lookTarget.addComponent(CursorFollow, { damping: 0.3 }); * character.addComponent(LookAtConstraint, { target: lookTarget }); * scene.add(lookTarget, character); * ``` * * - Example: [Look At Cursor sample](https://engine.needle.tools/samples/look-at-cursor-interactive-3d-header/) - Combines CursorFollow with LookAt for an interactive 3D header * * @see {@link PointerEvents} - For more complex pointer interaction handling * @see {@link DragControls} - For dragging objects in 3D space * @see {@link OrbitControls} - For camera controls that work alongside CursorFollow * @see {@link Context.input} - The input system that provides cursor position * @see {@link Context.physics.raycastFromRay} - Used when snapToSurface is enabled * * @summary Makes objects follow the cursor/touch position in 3D space * @category Interactivity * @category Web * @group Components * @component */ export class CursorFollow extends Behaviour { // testing this for compilation static NAME = "CursorFollow"; /** * Damping factor controlling how smoothly the object follows the cursor (in seconds). * * This value determines the "lag" or smoothness of the following motion: * - `0`: Instant movement, no damping (object snaps directly to cursor position) * - `0.1-0.2`: Quick, responsive following with slight smoothing * - `0.3-0.5`: Noticeable smooth trailing effect * - `1.0+`: Slow, heavily damped movement * * The damping uses delta time, so the movement speed is framerate-independent and * provides consistent behavior across different devices. * * **Tip:** For look-at effects, values between 0.2-0.4 typically feel most natural. * For cursor indicators, 0.1 or less provides better responsiveness. * * @default 0 */ damping = 0; /** * Whether the object should track the cursor across the entire webpage or only within the canvas. * * **When `true` (default):** * - The object follows the cursor anywhere on the page, even outside the canvas bounds * - Perfect for look-at effects where you want continuous tracking * - Great for embedded 3D elements that should feel aware of the whole page * - Example: A 3D character in a hero section that watches the cursor as you scroll * * **When `false`:** * - The object only follows the cursor when it's inside the Needle Engine canvas * - Useful for contained experiences where the 3D element shouldn't react to external cursor movement * - Better for multi-canvas scenarios or when you want isolated 3D interactions * * **Note:** When enabled, the component listens to `window.pointermove` events to track the * full-page cursor position. When disabled, it uses the context's input system which is * canvas-relative. * * @see {@link Context.input.mousePositionRC} for canvas-relative cursor position * @default true */ useFullPage = true; /** * Whether to maintain the object's initial distance from the camera while following the cursor. * * **When `true` (default):** * - The object stays at a constant distance from the camera, moving in a spherical arc around it * - Creates a natural "floating at cursor position" effect * - The object's depth remains consistent as you move the cursor around * - Perfect for cursors, pointers, or look-at targets * * **When `false`:** * - The object's distance can change based on where the cursor projects in 3D space * - More useful when combined with {@link snapToSurface} to follow surface geometry * - Can create unusual depth behavior if not carefully configured * * **How it works:** * On the first update, the component measures the distance from the object to the camera. * This initial distance is then maintained throughout the object's lifetime (unless {@link updateDistance} is called). * The object moves along a ray from the camera through the cursor, staying at this fixed distance. * * @see {@link updateDistance} to manually recalculate the distance * @default true */ keepDistance = true; /** * When enabled, the object snaps to the surfaces of other objects in the scene using raycasting. * * **How it works:** * After positioning the object at the cursor location, a raycast is performed backwards toward the camera. * If the ray hits any surface, the object is moved to that hit point, effectively "snapping" to the surface. * * **Use cases:** * - 3D paint or decal placement tools * - Surface markers or waypoints * - Interactive object placement in AR/VR * - Cursor that follows terrain or mesh surfaces * * **Important notes:** * - Requires objects in the scene to have colliders for raycasting to work * - Works best with {@link keepDistance} set to `false` to allow depth changes * - Can be combined with {@link damping} for smooth surface following * - The raycast uses the physics system's raycast functionality * * **Debug mode:** * Add `?debugcursor` to your URL to visualize the raycast hits with green debug lines. * * @see {@link Context.physics.raycastFromRay} for the underlying raycast implementation * @see {@link keepDistance} should typically be false when using surface snapping * @default false */ snapToSurface = false; _distance = -1; /** * Manually recalculates the distance between the object and the camera. * * By default, the distance is calculated once when the component starts and then maintained * when {@link keepDistance} is enabled. Use this method to update the reference distance * if the camera or object has moved significantly. * * **Use cases:** * - After teleporting the camera or object * - When switching between different camera positions * - After zoom operations that change the desired following distance * - Dynamically adjusting the cursor's depth in response to user input * * @param force - If `true`, forces a recalculation even if {@link keepDistance} is enabled and distance was already set * * @example Recalculate distance after camera movement * ```ts * const cursorFollow = gameObject.getComponent(CursorFollow); * camera.position.set(0, 0, 10); // Move camera * cursorFollow?.updateDistance(true); // Update the reference distance * ``` */ updateDistance(force = false) { if (!force && (this.keepDistance && this._distance !== -1)) { return; } this._distance = this.gameObject.worldPosition.distanceTo(this.context.mainCamera.worldPosition); } /** @internal */ awake() { this._distance = -1; } /** @internal */ onEnable() { this._distance = -1; window.addEventListener('pointermove', this._onPointerMove); } /** @internal */ onDisable() { window.removeEventListener('pointermove', this._onPointerMove); } _ndc_x = 0; _ndc_y = 0; _onPointerMove = (e) => { if (!this.useFullPage) return; const x = e.clientX; const y = e.clientY; const domx = this.context.domX; const domy = this.context.domY; const domw = this.context.domWidth; const domh = this.context.domHeight; this._ndc_x = (x - domx) / domw * 2 - 1; this._ndc_y = -(y - domy) / domh * 2 + 1; }; /** @internal */ lateUpdate() { // continuously update distance in case camera or object moves this.updateDistance(); const x = this.useFullPage ? this._ndc_x : this.context.input.mousePositionRC.x; const y = this.useFullPage ? this._ndc_y : this.context.input.mousePositionRC.y; // follow cursor in screenspace but maintain initial distance from camera const camera = this.context.mainCamera; const cameraPosition = camera.worldPosition; // create ray from camera through cursor position const rayDirection = getTempVector(x, y, 1).unproject(camera); rayDirection.sub(cameraPosition).normalize(); // position object at initial distance along the ray const newPosition = getTempVector(rayDirection).multiplyScalar(this._distance).add(cameraPosition); let _position = newPosition; if (this.damping > 0) { const pos = this.gameObject.worldPosition; pos.lerp(newPosition, this.context.time.deltaTime / this.damping); this.gameObject.worldPosition = pos; _position = pos; } else { this.gameObject.worldPosition = newPosition; } if (this.snapToSurface) { ray.origin = _position; ray.direction = rayDirection.multiplyScalar(-1); const hits = this.context.physics.raycastFromRay(ray); if (hits?.length) { const hit = hits[0]; if (this.damping > 0) { this.gameObject.worldPosition = _position.lerp(hit.point, this.context.time.deltaTime / this.damping); } else { this.gameObject.worldPosition = hit.point; } if (debug) { Gizmos.DrawLine(hit.point, hit.normal.add(hit.point), 0x00FF00); } } } } } __decorate([ serializable() ], CursorFollow.prototype, "damping", void 0); __decorate([ serializable() ], CursorFollow.prototype, "useFullPage", void 0); __decorate([ serializable() ], CursorFollow.prototype, "keepDistance", void 0); __decorate([ serializable() ], CursorFollow.prototype, "snapToSurface", void 0); const ray = new Ray(); //# sourceMappingURL=CursorFollow.js.map