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.

1,134 lines (1,016 loc) • 48.1 kB
import { Camera as Camera3, Object3D, PerspectiveCamera, Ray, Vector2, Vector3, Vector3Like } from "three"; import { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { isDevEnvironment } from "../engine/debug/index.js"; import { fitCamera, FitCameraOptions } from "../engine/engine_camera.fit.js"; import { setCameraController } from "../engine/engine_camera.js"; import { Gizmos } from "../engine/engine_gizmos.js"; import { InputEventQueue, NEPointerEvent } from "../engine/engine_input.js"; import { Mathf } from "../engine/engine_math.js"; import { IRaycastOptions, RaycastOptions } from "../engine/engine_physics.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { getTempVector, getWorldPosition } from "../engine/engine_three_utils.js"; import type { ICameraController } from "../engine/engine_types.js"; import { DeviceUtilities, getParam } from "../engine/engine_utils.js"; import { NeedleEngineWebComponent } from "../engine/webcomponents/needle-engine.js"; import { Camera } from "./Camera.js"; import { Behaviour, GameObject } from "./Component.js"; import { SyncedTransform } from "./SyncedTransform.js"; import { type AfterHandleInputEvent, EventSystem, EventSystemEvents } from "./ui/EventSystem.js"; import { tryGetUIComponent } from "./ui/Utils.js"; const debug = getParam("debugorbit"); const freeCam = getParam("freecam"); const debugCameraFit = getParam("debugcamerafit"); const smoothcam = getParam("smoothcam"); const disabledKeys = { LEFT: "", UP: "", RIGHT: "", BOTTOM: "" }; let defaultKeys: any = undefined; export enum OrbitControlsEventsType { /** Invoked with a CameraTargetReachedEvent */ CameraTargetReached = "target-reached", } export class CameraTargetReachedEvent extends CustomEvent<{ controls: OrbitControls, type: "camera" | "lookat" }> { constructor(ctrls: OrbitControls, type: "camera" | "lookat") { super(OrbitControlsEventsType.CameraTargetReached, { detail: { controls: ctrls, type: type, } }); } } declare module 'three/examples/jsm/controls/OrbitControls.js' { export interface OrbitControls { _sphericalDelta: import("three").Spherical, _rotateLeft: (angleInRadians: number) => void; _rotateUp: (angleInRadians: number) => void; _pan: (dx: number, dy: number) => void; _dollyIn: (dollyScale: number) => void; _dollyOut: (dollyScale: number) => void; } export interface OrbitControlsEventMap { endMovement: Event; } } /** * [OrbitControls](https://engine.needle.tools/docs/api/OrbitControls) provides interactive camera control using three.js OrbitControls. * Users can rotate, pan, and zoom the camera to explore 3D scenes. * * **Features:** * - Rotation around a target point (orbit) * - Panning to move the view * - Zooming via scroll or pinch * - Auto-rotation for showcases * - Configurable angle and distance limits * - Smooth damping for natural feel * * ![](https://cloud.needle.tools/-/media/ylC34hrC3srwyzGNhFRbEQ.gif) * * **Access underlying controls:** * - `controls` - The three.js OrbitControls instance * - `controllerObject` - The object being controlled (usually the camera) * * **Debug options:** * - `?debugorbit` - Log orbit control events * - `?freecam` - Enable unrestricted camera movement * * @example Basic setup * ```ts * const orbitControls = camera.getComponent(OrbitControls); * orbitControls.autoRotate = true; * orbitControls.autoRotateSpeed = 2; * ``` * * @example Set look-at target * ```ts * orbitControls.setLookTargetPosition(new Vector3(0, 1, 0), true); * // Or move both camera and target * orbitControls.setCameraTargetPosition(new Vector3(5, 2, 5), new Vector3(0, 0, 0)); * ``` * * @summary Camera controller using three.js OrbitControls * @category Camera and Controls * @group Components * @see {@link SmoothFollow} for smooth camera following * @see {@link Camera} for camera configuration * @link https://threejs.org/docs/#examples/en/controls/OrbitControls * @link https://engine.needle.tools/samples/panorama-controls alternative controls in samples */ export class OrbitControls extends Behaviour implements ICameraController { /** * @inheritdoc */ get isCameraController(): boolean { return true; } /** The underlying three.js OrbitControls. * See {@link https://threejs.org/docs/#examples/en/controls/OrbitControls} * @returns {@type ThreeOrbitControls | null} */ public get controls() { return this._controls; } /** The object being controlled by the OrbitControls (usually the camera) * See {@link https://threejs.org/docs/#examples/en/controls/OrbitControls.object} * @returns {@type Object3D | null} */ public get controllerObject(): Object3D | null { return this._cameraObject; } /** Register callback when user starts interacting with the orbit controls */ public onStartInteraction(callback: Function) { this.controls?.addEventListener("start", callback as any); } /** When enabled OrbitControls will automatically raycast find a look at target in start * @default true */ @serializable() autoTarget: boolean = true; /** When enabled the scene will be automatically fitted into the camera view in onEnable * @default false */ @serializable() autoFit: boolean = false; /** When enabled the camera can be rotated * @default true */ @serializable() enableRotate: boolean = true; /** When enabled the camera will rotate automatically * @default false */ @serializable() autoRotate: boolean = false; /** The speed at which the camera will rotate automatically. Will only be used when `autoRotate` is enabled * @default 1.0 */ @serializable() autoRotateSpeed: number = 1.0; /** The minimum azimuth angle in radians */ @serializable() minAzimuthAngle: number = Infinity; /** The maximum azimuth angle in radians */ @serializable() maxAzimuthAngle: number = Infinity; /** The minimum polar angle in radians * @default 0 */ @serializable() minPolarAngle: number = 0; /** The maximum polar angle in radians * @default Math.PI */ @serializable() maxPolarAngle: number = Math.PI; /** When enabled the camera can be moved using keyboard keys. The keys are defined in the `controls.keys` property * @default false */ @serializable() enableKeys: boolean = false; /** When enabled the camera movement will be damped * @default true */ @serializable() enableDamping: boolean = true; /** The damping factor for the camera movement. For more information see the [three.js documentation](https://threejs.org/docs/#examples/en/controls/OrbitControls.dampingFactor) * @default 0.1 */ @serializable() dampingFactor: number = 0.1; /** When enabled the camera can be zoomed * @default true */ @serializable() enableZoom: boolean = true; /** The minimum zoom level * @default 0 */ @serializable() minZoom: number = 0; /** The maximum zoom level * @default Infinity */ @serializable() maxZoom: number = Infinity; /** * Sets the zoom speed of the OrbitControls * @default 1 */ @serializable() zoomSpeed: number = 1; /** * Set to true to enable zooming to the cursor position. * @default false */ zoomToCursor: boolean = false; /** When enabled the camera can be panned * @default true */ @serializable() enablePan: boolean = true; /** Assigning an Object3D will make the camera look at this target's position. * The camera will orbit around this target. * @default null */ @serializable(Object3D) lookAtTarget: Object3D | null = null; /** When enabled the camera will continuously follow the lookAtTarget's position every frame. * When disabled the target is only used for the initial look direction. * @default true */ @serializable() lockLookAtTarget: boolean = true; /** The weight for the lookAtTarget interpolation * @default 1 */ @serializable() lookAtConstraint01: number = 1; /** If true user input interrupts the camera from animating to a target * @default true */ @serializable() allowInterrupt: boolean = true; /** If true the camera will focus on the target when the middle mouse button is clicked */ @serializable() middleClickToFocus: boolean = true; /** If true the camera will focus on the target when the left mouse button is double clicked * @default true */ @serializable() doubleClickToFocus: boolean = true; /** * When enabled the camera will fit the scene to the camera view when the background is clicked the specified number of times within a short time * @default 2 */ @serializable() clickBackgroundToFitScene: number = 2; /** * This is the DOM element that the OrbitControls will listen to for input events. By default this is the renderer's canvas element. * Set this to a different element to make the OrbitControls listen to that element instead. */ get targetElement(): HTMLElement | null { return this._controls?.domElement ?? this._targetElement; } set targetElement(value: HTMLElement | null) { this._targetElement = value; if (this._controls && this._controls.domElement !== value) { this._controls.disconnect(); this._controls.domElement = value; this._controls.connect(); } } private _targetElement: HTMLElement | null = null; /** * @internal If true debug information will be logged to the console * @default false */ debugLog: boolean = false; /** * @deprecated use `targetLerpDuration` instead * ~~The speed at which the camera target and the camera will be lerping to their destinations (if set via script or user input)~~ * */ get targetLerpSpeed() { return 5 } set targetLerpSpeed(v) { this.targetLerpDuration = 1 / v; } /** The duration in seconds it takes for the camera look ad and position lerp to reach their destination (when set via `setCameraTargetPosition` and `setLookTargetPosition`) * @default 1 */ @serializable() get targetLerpDuration() { return this._lookTargetLerpDuration; } set targetLerpDuration(v) { this._lookTargetLerpDuration = v; } private _lookTargetLerpDuration: number = 1; @serializable(Object3D) targetBounds: Object3D | null = null; /** * Rotate the camera left (or right) by the specified angle in radians. * For positive angles the camera will rotate to the left, for negative angles it will rotate to the right. * Tip: Use Mathf to convert between degrees and radians. * @param angleInRadians The angle in radians to rotate the camera left * @example * ```typescript * // Rotate the camera left by 0.1 radians * orbitControls.rotateLeft(0.1); * ``` */ rotateLeft(angleInRadians: number) { this._controls?._rotateLeft(angleInRadians); } /** * Rotate the camera up (or down) by the specified angle in radians. * For positive angles the camera will rotate up, for negative angles it will rotate down. * Tip: Use Mathf to convert between degrees and radians. * @param angleInRadians The angle in radians to rotate the camera up * @example * ```typescript * // Rotate the camera up by 0.1 radians * orbitControls.rotateUp(0.1); * ``` */ rotateUp(angleInRadians: number) { this._controls?._rotateUp(angleInRadians); } /** * Pan the camera by the specified amount in the x and y direction in pixels. * @param dx The amount to pan the camera in the x direction in pixels. * @param dy The amount to pan the camera in the y direction in pixels. */ pan(dx: number, dy: number) { this._controls?._pan(dx, dy); } /** * Zoom the camera in or out by the specified scale factor. The factor is applied to the current zoom radius / distance. * If the scale is greater than 0 then the camera will zoom in, if it is less than 0 then the camera will zoom out. * @param scale The scale factor to zoom the camera in or out. Expected range is between -1 and 1, where 0 means no zoom. * @example * ```typescript * // Zoom in by 0.1 * orbitControls.zoomIn(0.1); * // Zoom out by 0.1 * orbitControls.zoomIn(-0.1); * ``` */ zoomIn(scale: number) { if (scale > 0) { this._controls?._dollyIn(1 - scale); } else if (scale < 0) { this._controls?._dollyOut(1 + scale); } } private _controls: ThreeOrbitControls | null = null; private _cameraObject: Object3D | null = null; private _lookTargetLerpActive: boolean = false; private _lookTargetStartPosition: Vector3 = new Vector3(); private _lookTargetEndPosition: Vector3 = new Vector3(); private _lookTargetLerp01: number = 0; private _cameraLerpActive: boolean = false; private _cameraStartPosition: Vector3 = new Vector3(); private _cameraEndPosition: Vector3 = new Vector3(); private _cameraLerp01: number = 0; private _cameraLerpDuration: number = 0; private _fovLerpActive: boolean = false; private _fovLerpStartValue: number = 0; private _fovLerpEndValue: number = 0; private _fovLerp01: number = 0; private _fovLerpDuration: number = 0; private _inputs: number = 0; private _enableTime: number = 0; // use to disable double click when double clicking on UI private _startedListeningToKeyEvents: boolean = false; private _eventSystem?: EventSystem; private _afterHandleInputFn?: any; private _camera: Camera | null = null; private _syncedTransform?: SyncedTransform; private _didSetTarget = 0; private _didApplyLookAtTarget = false; /** @internal */ awake(): void { if (debug) console.debug("OrbitControls", this); this._didSetTarget = 0; this._didApplyLookAtTarget = false; this._startedListeningToKeyEvents = false; if ((this.context.domElement as NeedleEngineWebComponent).cameraControls === false) { this.enabled = false; } } /** @internal */ start() { this._eventSystem = EventSystem.get(this.context) ?? undefined; if (this._eventSystem) { this._afterHandleInputFn = this.afterHandleInput.bind(this); this._eventSystem.addEventListener(EventSystemEvents.AfterHandleInput, this._afterHandleInputFn!); } } /** @internal */ onDestroy() { this._controls?.dispose(); this._eventSystem?.removeEventListener(EventSystemEvents.AfterHandleInput, this._afterHandleInputFn!); } /** @internal */ onEnable() { this._didSetTarget = 0; this._didApplyLookAtTarget = false; this._enableTime = this.context.time.time; const cameraComponent = GameObject.getComponent(this.gameObject, Camera); this._camera = cameraComponent; let cam = cameraComponent?.threeCamera; if (!cam && this.gameObject instanceof PerspectiveCamera) { cam = this.gameObject; } if (cam) setCameraController(cam, this, true); if (!this._controls && cam instanceof Object3D) { this._cameraObject = cam; // Using the parent if possible to make it possible to disable input on the canvas // for having HTML content behind it and still receive input const element = this.targetElement ?? this.context.renderer.domElement; // HACK: workaround for three orbit controls forcing an update when being created.... const mat = cam?.quaternion.clone(); this._controls = new ThreeOrbitControls(cam!, element); cam?.quaternion.copy(mat!) if (defaultKeys === undefined) defaultKeys = { ...this._controls.keys }; // set controls look point in front of the current camera by default // it may be overriden by the autoTarget feature // but if we don't do this and autoTarget is OFF then the camera will turn to look at 0 0 0 of the scene const worldPosition = getWorldPosition(cam); const forward = this.gameObject.worldForward; const dist = 2.5; const lookAt = worldPosition.clone().sub(forward.multiplyScalar(dist)); this._controls.target.copy(lookAt); } if (this._controls) { if (freeCam) { this.enablePan = true; this.enableZoom = true; this.middleClickToFocus = true; if (DeviceUtilities.isMobileDevice()) this.doubleClickToFocus = true; } this._controls.addEventListener("start", this.onControlsChangeStarted); this._controls.addEventListener("endMovement", this.onControlsChangeEnded); if (!this._startedListeningToKeyEvents && this.enableKeys) { this._startedListeningToKeyEvents = true; this._controls.listenToKeyEvents(this.context.domElement); } else { try { this._controls.stopListenToKeyEvents(); } catch { /** this fails if we never listened to key events... */ } } } this._syncedTransform = GameObject.getComponent(this.gameObject, SyncedTransform) ?? undefined; this.context.pre_render_callbacks.push(this.__onPreRender); this._activePointerEvents = []; this.context.input.addEventListener("pointerdown", this._onPointerDown, { queue: InputEventQueue.Early }); this.context.input.addEventListener("pointerdown", this._onPointerDownLate, { queue: InputEventQueue.Late }); this.context.input.addEventListener("pointerup", this._onPointerUp, { queue: InputEventQueue.Early }); this.context.input.addEventListener("pointerup", this._onPointerUpLate, { queue: InputEventQueue.Late }); } /** @internal */ onDisable() { if (this._camera?.threeCamera) { setCameraController(this._camera.threeCamera, this, false); } if (this._controls) { this._controls.enabled = false; this._controls.autoRotate = false; this._controls.removeEventListener("start", this.onControlsChangeStarted); this._controls.removeEventListener("endMovement", this.onControlsChangeEnded); try { this._controls.stopListenToKeyEvents(); } catch { /** this fails if we never listened to key events... */ } this._startedListeningToKeyEvents = false; } this._activePointerEvents.length = 0; this.context.input.removeEventListener("pointerdown", this._onPointerDown); this.context.input.removeEventListener("pointerdown", this._onPointerDownLate); this.context.input.removeEventListener("pointerup", this._onPointerUp); this.context.input.removeEventListener("pointerup", this._onPointerUpLate); } private _activePointerEvents!: NEPointerEvent[]; private _lastTimeClickOnBackground: number = -1; private _clickOnBackgroundCount: number = 0; private _onPointerDown = (_evt: NEPointerEvent) => { this._activePointerEvents.push(_evt); } private _onPointerDownLate = (evt: NEPointerEvent) => { if (evt.used && this._controls) { // Disabling orbit controls here because otherwise we get a slight movement when e.g. using DragControls this._controls.enabled = false; } } private _onPointerUp = (evt: NEPointerEvent) => { // make sure we cleanup the active pointer events for (let i = this._activePointerEvents.length - 1; i >= 0; i--) { const registered = this._activePointerEvents[i]; if (registered.pointerId === evt.pointerId && registered.button === evt.button) { this._activePointerEvents.splice(i, 1); break; } } if (this.clickBackgroundToFitScene > 0 && evt.isClick && evt.button === 0) { // it's possible that we didnt raycast in this frame if (!evt.hasRay) { evt.intersections.push(...this.context.physics.raycast()); } if (evt.intersections.length <= 0) { const dt = this.context.time.time - this._lastTimeClickOnBackground; this._lastTimeClickOnBackground = this.context.time.time; if (this.clickBackgroundToFitScene <= 1 || dt < this.clickBackgroundToFitScene * .15) { this._clickOnBackgroundCount += 1; if (this._clickOnBackgroundCount >= this.clickBackgroundToFitScene - 1) { this.autoRotate = false; this.fitCamera({ objects: this.context.scene, immediate: false, }); } } else { this._clickOnBackgroundCount = 0; } } if (debug) console.log(this.clickBackgroundToFitScene, evt.intersections.length, this._clickOnBackgroundCount) } }; private _onPointerUpLate = (evt: NEPointerEvent) => { if (this.doubleClickToFocus && evt.isDoubleClick && !evt.used) { this.setTargetFromRaycast(); } // Automatically update the camera focus // else if (!evt.used && this.autoTarget) { // this.updateTargetNow(); // } }; private updateTargetNow(options?: IRaycastOptions) { if(debug) console.warn("OrbitControls: updateTargetNow is using raycasting to update the target immediately. This can be expensive and should be used with caution.", options); const ray = new Ray(this._cameraObject?.worldPosition, this._cameraObject?.worldForward.multiplyScalar(-1)); const hits = this.context.physics.raycastFromRay(ray, options); const hit = hits.length > 0 ? hits[0] : undefined; if (hit && hit.distance > this.minZoom && hit.distance < this.maxZoom) { if (debug) Gizmos.DrawWireSphere(hit.point, 0.1, 0xff0000, 2); this._controls?.target.copy(hits[0].point); } else { if (debug) console.log("OrbitControls: No hit found when updating target", { hits: [...hits] }); } } private _orbitStartAngle: number = 0; private _zoomStartDistance: number = 0; private onControlsChangeStarted = () => { if (debug) console.debug("OrbitControls: Change started"); if (this._controls) { this._orbitStartAngle = this._controls.getAzimuthalAngle() + this._controls.getPolarAngle(); this._zoomStartDistance = this._controls.getDistance(); } if (this._syncedTransform) { this._syncedTransform.requestOwnership(); } } private onControlsChangeEnded = () => { if (debug) console.debug("OrbitControls: Change ended", { autoTarget: this.autoTarget }); if (this._controls) { if (this.autoTarget) { const newAngle = this._controls.getAzimuthalAngle() + this._controls.getPolarAngle(); const deltaAngle = newAngle - this._orbitStartAngle; // TODO: "just zoom" probably shouldnt update the target either, unless zoomToCursor is enabled if (Math.abs(deltaAngle) < .01) { if (debug) console.debug("OrbitControls: Update target", { deltaAngle }); this.updateTargetNow({ allowSlowRaycastFallback: false }); } else if (debug) console.debug("OrbitControls: No target update", { deltaAngle }); } } } private _shouldDisable: boolean = false; private afterHandleInput(evt: CustomEvent<AfterHandleInputEvent>) { if (evt.detail.args.pointerId === 0) { if (evt.detail.args.isDown) { if (this._controls && this._eventSystem) { this._shouldDisable = this._eventSystem.hasActiveUI; } } else if (!evt.detail.args.isPressed || evt.detail.args.isUp) { this._shouldDisable = false; } } } onPausedChanged(isPaused: boolean): void { if (!this._controls) return; if (isPaused) this._controls.enabled = false; } /** @internal */ onBeforeRender() { if (!this._controls) return; if (this._cameraObject !== this.context.mainCamera) { this._controls.enabled = false; return; } this._controls.enabled = true; if (this.context.input.getPointerDown(1) || this.context.input.getPointerDown(2) || this.context.input.mouseWheelChanged || (this.context.input.getPointerPressed(0) && this.context.input.getPointerPositionDelta(0)?.length() || 0 > .1)) { this._inputs += 1; } if (this._inputs > 0 && this.allowInterrupt) { // if a user has disabled rotation but enabled auto rotate we don't want to change it when we receive input if (this.enableRotate) { this.autoRotate = false; } this._cameraLerpActive = false; this._lookTargetLerpActive = false; } this._inputs = 0; if (this.autoTarget) { // we want to wait one frame so all matrixWorlds are updated // otherwise raycasting will not work correctly if (this._didSetTarget++ === 0) { const camGo = GameObject.getComponent(this.gameObject, Camera); if (camGo && !this.setLookTargetFromConstraint()) { if (this.debugLog) console.log("NO TARGET"); const worldPosition = getWorldPosition(camGo.threeCamera); // Handle case where the camera is in 0 0 0 of the scene // if the look at target is set to the camera position we can't move at all anymore const distanceToCenter = Math.max(.01, worldPosition.length()); const forward = new Vector3(0, 0, -distanceToCenter).applyMatrix4(camGo.threeCamera.matrixWorld); if (debug) Gizmos.DrawLine(worldPosition, forward, 0x5555ff, 10) this.setLookTargetPosition(forward, true); } if (!this.setLookTargetFromConstraint()) { const opts = new RaycastOptions(); // center of the screen: opts.screenPoint = new Vector2(0, 0); opts.lineThreshold = 0.1; const hits = this.context.physics.raycast(opts); if (hits.length > 0) { this.setLookTargetPosition(hits[0].point, true); } if (debugCameraFit) console.log("OrbitControls hits", ...hits); } } } const focusAtPointer = (this.middleClickToFocus && this.context.input.getPointerClicked(1)); if (focusAtPointer) { this.setTargetFromRaycast(); } if (this._lookTargetLerpActive || this._cameraLerpActive || this._fovLerpActive) { // lerp the camera if (this._cameraLerpActive && this._cameraObject) { this._cameraLerp01 += this.context.time.deltaTime / this._cameraLerpDuration; if (this._cameraLerp01 >= 1) { this._cameraObject.position.copy(this._cameraEndPosition); this._cameraLerpActive = false; this.dispatchEvent(new CameraTargetReachedEvent(this, "camera")); } else { const t = Mathf.easeInOutCubic(this._cameraLerp01); this._cameraObject.position.lerpVectors(this._cameraStartPosition, this._cameraEndPosition, t); } } // lerp the look target if (this._lookTargetLerpActive) { this._lookTargetLerp01 += this.context.time.deltaTime / this._lookTargetLerpDuration; if (this._lookTargetLerp01 >= 1) { this.lerpLookTarget(this._lookTargetEndPosition, this._lookTargetEndPosition, 1); this._lookTargetLerpActive = false; this.dispatchEvent(new CameraTargetReachedEvent(this, "lookat")); } else { const t = Mathf.easeInOutCubic(this._lookTargetLerp01); this.lerpLookTarget(this._lookTargetStartPosition, this._lookTargetEndPosition, t); } } // lerp the fov if (this._fovLerpActive && this._cameraObject) { const cam = this._cameraObject as PerspectiveCamera; this._fovLerp01 += this.context.time.deltaTime / this._fovLerpDuration; if (this._fovLerp01 >= 1) { cam.fov = this._fovLerpEndValue; this._fovLerpActive = false; } else { const t = Mathf.easeInOutCubic(this._fovLerp01); cam.fov = Mathf.lerp(this._fovLerpStartValue, this._fovLerpEndValue, t); } cam.updateProjectionMatrix(); } } if (this.targetBounds) { // #region target bounds const targetVector = this._controls.target; const boundsCenter = this.targetBounds.worldPosition; const boundsHalfSize = getTempVector(this.targetBounds.worldScale).multiplyScalar(0.5); const min = getTempVector(boundsCenter).sub(boundsHalfSize); const max = getTempVector(boundsCenter).add(boundsHalfSize); const newTarget = getTempVector(this._controls.target).clamp(min, max); const duration = .1; if (duration <= 0) targetVector.copy(newTarget); else targetVector.lerp(newTarget, this.context.time.deltaTime / duration); if (this._lookTargetLerpActive) { if (duration <= 0) this._lookTargetEndPosition.copy(newTarget); else this._lookTargetEndPosition.lerp(newTarget, this.context.time.deltaTime / (duration * 5)); } if (debug) { Gizmos.DrawWireBox(boundsCenter, boundsHalfSize.multiplyScalar(2), 0xffaa00); } } if (this._controls) { if (this.debugLog) this._controls.domElement = this.context.renderer.domElement; const viewZoomFactor = 1 / (this.context.focusRectSettings?.zoom || 1); this._controls.enabled = !this._shouldDisable && this._camera === this.context.mainCameraComponent && !this.context.isInXR && !this._activePointerEvents.some(e => e.used); this._controls.keys = this.enableKeys ? defaultKeys : disabledKeys; this._controls.autoRotate = this.autoRotate; this._controls.autoRotateSpeed = this.autoRotateSpeed; this._controls.enableZoom = this.enableZoom; this._controls.zoomSpeed = this.zoomSpeed; this._controls.zoomToCursor = this.zoomToCursor; this._controls.enableDamping = this.enableDamping; this._controls.dampingFactor = this.dampingFactor; this._controls.enablePan = this.enablePan; this._controls.panSpeed = viewZoomFactor; this._controls.enableRotate = this.enableRotate; this._controls.minAzimuthAngle = this.minAzimuthAngle; this._controls.maxAzimuthAngle = this.maxAzimuthAngle; this._controls.minPolarAngle = this.minPolarAngle; this._controls.maxPolarAngle = this.maxPolarAngle; // set the min/max zoom if it's not a free cam if (!freeCam) { if (this._camera?.threeCamera?.type === "PerspectiveCamera") { this._controls.minDistance = this.minZoom; this._controls.maxDistance = this.maxZoom; this._controls.minZoom = 0; this._controls.maxZoom = Infinity; } else { this._controls.minDistance = 0; this._controls.maxDistance = Infinity; this._controls.minZoom = this.minZoom; this._controls.maxZoom = this.maxZoom; } } if (typeof smoothcam === "number" || smoothcam === true) { this._controls.enableDamping = true; const factor = typeof smoothcam === "number" ? smoothcam : .99; this._controls.dampingFactor = Math.max(.001, 1 - Math.min(1, factor)); } if (!this.allowInterrupt) { if (this._lookTargetLerpActive) { this._controls.enablePan = false; } if (this._cameraLerpActive) { this._controls.enableRotate = false; this._controls.autoRotate = false; } if (this._lookTargetLerpActive || this._cameraLerpActive) { this._controls.enableZoom = false; } } // this._controls.zoomToCursor = this.zoomToCursor; if (!this.context.isInXR) { if (!freeCam && this.lookAtTarget && !this._lookTargetLerpActive) { if (this.lockLookAtTarget) { this.setLookTargetFromConstraint(this.lookAtConstraint01); } else if (!this._didApplyLookAtTarget) { this._didApplyLookAtTarget = true; this.setLookTargetFromConstraint(1); } } this._controls.update(this.context.time.deltaTime); if (debug) { Gizmos.DrawWireSphere(this._controls.target, 0.1, 0x00ff00); } } } } private __onPreRender = () => { // We call this only once when the camera becomes active and use the engine pre_render_callbacks because they are run // after all scripts have been executed const index = this.context.pre_render_callbacks.indexOf(this.__onPreRender); if (index >= 0) { this.context.pre_render_callbacks.splice(index, 1); } if (this.autoFit) { // we don't want to autofit again if the component is disabled and re-enabled this.autoFit = false; this.fitCamera({ centerCamera: "y", immediate: true, objects: this.scene.children, }) } } /** * Sets camera target position and look direction using a raycast in forward direction of the object. * * @param source The object to raycast from. If a camera is passed in the camera position will be used as the source. * @param immediateOrDuration If true the camera target will move immediately to the new position, otherwise it will lerp. If a number is passed in it will be used as the duration of the lerp. * * This is useful for example if you want to align your camera with an object in your scene (or another camera). Simply pass in this other camera object * @returns true if the target was set successfully */ public setCameraAndLookTarget(source: Object3D | Camera, immediateOrDuration: number | boolean = false): boolean { if (!source) { if (isDevEnvironment() || debug) console.warn("[OrbitControls] setCameraAndLookTarget target is null"); return false; } if (!(source instanceof Object3D) && !(source instanceof Camera)) { if (isDevEnvironment() || debug) console.warn("[OrbitControls] setCameraAndLookTarget target is not an Object3D or Camera"); return false; } if (source instanceof Camera) { source = source.gameObject; } const worldPosition = source.worldPosition; const forward = source.worldForward; // The camera render direction is -Z. When a camera is passed in then we'll take the view direction OR the object Z forward direction. if (source instanceof Camera3) { if (debug) console.debug("[OrbitControls] setCameraAndLookTarget flip forward direction for camera"); forward.multiplyScalar(-1); } const ray = new Ray(worldPosition, forward); if (debug) Gizmos.DrawRay(ray.origin, ray.direction, 0xff0000, 10); if (!this.setTargetFromRaycast(ray, immediateOrDuration)) { this.setLookTargetPosition(ray.at(2, getTempVector()), immediateOrDuration); } this.setCameraTargetPosition(worldPosition, immediateOrDuration); return true; } /** Moves the camera to position smoothly. * @param position The position in local space of the controllerObject to move the camera to. If null the camera will stop lerping to the target. * @param immediateOrDuration If true the camera will move immediately to the new position, otherwise it will lerp. If a number is passed in it will be used as the duration of the lerp. */ public setCameraTargetPosition(position?: Object3D | Vector3Like | null, immediateOrDuration: boolean | number = false) { if (!position) return; if (position instanceof Object3D) { position = getWorldPosition(position) as Vector3; } if (!this._cameraEndPosition) this._cameraEndPosition = new Vector3(); this._cameraEndPosition.copy(position); if (immediateOrDuration === true) { this._cameraLerpActive = false; if (this._cameraObject) { this._cameraObject.position.copy(this._cameraEndPosition); } } else if (this._cameraObject) { this._cameraLerpActive = true; this._cameraLerp01 = 0; this._cameraStartPosition.copy(this._cameraObject?.position); if (typeof immediateOrDuration === "number") { this._cameraLerpDuration = immediateOrDuration; } else this._cameraLerpDuration = this.targetLerpDuration; } } // public setCameraTargetRotation(rotation: Vector3 | Euler | Quaternion, immediateOrDuration: boolean | number = false): void { // if (!this._cameraObject) return; // if (typeof immediateOrDuration === "boolean") immediateOrDuration = immediateOrDuration ? 0 : this.targetLerpDuration; // const ray = new Ray(this._cameraObject.worldPosition, getTempVector(0, 0, 1)); // // if the camera is in the middle of lerping we use the end position for the raycast // if (immediateOrDuration > 0 && this._cameraEndPosition && this._cameraLerpActive) { // ray.origin = getTempVector(this._cameraEndPosition) // } // if (rotation instanceof Vector3) { // rotation = new Euler().setFromVector3(rotation); // } // if (rotation instanceof Euler) { // rotation = new Quaternion().setFromEuler(rotation); // } // ray.direction.applyQuaternion(rotation); // ray.direction.multiplyScalar(-1); // const hits = this.context.physics.raycastFromRay(ray); // if (hits.length > 0) { // this.setCameraTargetPosition(hits[0].point, immediateOrDuration); // } // else { // this.setLookTargetPosition(ray.at(2, getTempVector())); // } // } /** True while the camera position is being lerped */ get cameraLerpActive() { return this._cameraLerpActive; } /** Call to stop camera position lerping */ public stopCameraLerp() { this._cameraLerpActive = false; } public setFieldOfView(fov: number | undefined, immediateOrDuration: boolean | number = false) { if (!this._controls) return; if (typeof fov !== "number") return; const cam = this._camera?.threeCamera as PerspectiveCamera; if (!cam) return; if (immediateOrDuration === true) { cam.fov = fov; } else { this._fovLerpActive = true; this._fovLerp01 = 0; this._fovLerpStartValue = cam.fov; this._fovLerpEndValue = fov; if (typeof immediateOrDuration === "number") { this._fovLerpDuration = immediateOrDuration; } else this._fovLerpDuration = this.targetLerpDuration; } // if (this.context.mainCameraComponent) this.context.mainCameraComponent.fieldOfView = fov; } /** Moves the camera look-at target to a position smoothly. * @param position The position in world space to move the camera target to. If null the camera will stop lerping to the target. * @param immediateOrDuration If true the camera target will move immediately to the new position, otherwise it will lerp. If a number is passed in it will be used as the duration of the lerp. */ public setLookTargetPosition(position: Object3D | Vector3Like | null = null, immediateOrDuration: boolean | number = false) { if (!this._controls) return; if (!position) return if (position instanceof Object3D) { position = getWorldPosition(position) as Vector3; } this._lookTargetEndPosition.copy(position); // if a user calls setLookTargetPosition we don't want to perform autoTarget in onBeforeRender (and override whatever the user set here) this._didSetTarget++; if (debug) { console.warn("OrbitControls: setLookTargetPosition", position, immediateOrDuration); Gizmos.DrawWireSphere(this._lookTargetEndPosition, .2, 0xff0000, 2); } if (immediateOrDuration === true) { this.lerpLookTarget(this._lookTargetEndPosition, this._lookTargetEndPosition, 1); } else { this._lookTargetLerpActive = true; this._lookTargetLerp01 = 0; this._lookTargetStartPosition.copy(this._controls.target); if (typeof immediateOrDuration === "number") { this._lookTargetLerpDuration = immediateOrDuration; } else this._lookTargetLerpDuration = this.targetLerpDuration; } } /** True while the camera look target is being lerped */ get lookTargetLerpActive() { return this._lookTargetLerpActive; } /** Call to stop camera look target lerping */ public stopLookTargetLerp() { this._lookTargetLerpActive = false; } /** Sets the look at target from the assigned lookAtTarget Object3D * @param t The interpolation factor between the current look at target and the new target */ private setLookTargetFromConstraint(t: number = 1): boolean { if (!this._controls) return false; if (!this.lookAtTarget) return false; this.lookAtTarget.getWorldPosition(this._lookTargetEndPosition); this.lerpLookTarget(this._controls.target, this._lookTargetEndPosition, t); return true; } private lerpLookTarget(start: Vector3, position: Vector3, t: number) { if (!this._controls) return; if (t >= 1) this._controls.target.copy(position); else this._controls.target.lerpVectors(start, position, t); if (this.lookAtTarget && this.lockLookAtTarget) this.lookAtTarget.worldPosition = this._controls.target; } private setTargetFromRaycast(ray?: Ray, immediateOrDuration: number | boolean = false): boolean { if (!this.controls) return false; const rc = ray ? this.context.physics.raycastFromRay(ray) : this.context.physics.raycast(); for (const hit of rc) { if (hit.distance > 0 && GameObject.isActiveInHierarchy(hit.object)) { const uiComponent = tryGetUIComponent(hit.object); if (uiComponent) { const canvas = uiComponent.canvas; if (canvas?.screenspace) { break; } } this.setLookTargetPosition(hit.point, immediateOrDuration); return true; } } return false; } // Adapted from https://discourse.threejs.org/t/camera-zoom-to-fit-object/936/24 // Slower but better implementation that takes bones and exact vertex positions into account: https://github.com/google/model-viewer/blob/04e900c5027de8c5306fe1fe9627707f42811b05/packages/model-viewer/src/three-components/ModelScene.ts#L321 /** * Fits the camera to show the objects provided (defaults to the scene if no objects are passed in) * @param options The options for fitting the camera. Use to provide objects to fit to, fit direction and size and other settings. */ fitCamera(options?: OrbitFitCameraOptions); /** @deprecated Use fitCamera(options) */ fitCamera(objects?: Object3D | Array<Object3D>, options?: Omit<OrbitFitCameraOptions, "objects">); fitCamera(objectsOrOptions?: Object3D | Array<Object3D> | OrbitFitCameraOptions, options?: OrbitFitCameraOptions): void { let objects: Object3D | Array<Object3D> | undefined = undefined; // If the user passed in an array as first argument if (Array.isArray(objectsOrOptions)) { objects = objectsOrOptions; } // If the user passed in an object as first argument else if (objectsOrOptions && "type" in objectsOrOptions) { objects = objectsOrOptions; } // If the user passed in an object as first argument and options as second argument else if (objectsOrOptions && typeof objectsOrOptions === "object") { if (!(objectsOrOptions instanceof Object3D) && !Array.isArray(objectsOrOptions)) { options = objectsOrOptions; objects = options.objects; } } // Ensure objects are setup correctly if (objects && !Array.isArray(objects)) { objects = [objects]; } if (!Array.isArray(objects) || objects && objects.length <= 0) { objects = this.context.scene.children; } // Make sure there's anything to fit to if (!Array.isArray(objects) || objects.length <= 0) { console.warn("No objects to fit camera to..."); return; } const res = fitCamera({ objects: [...objects], ...options, autoApply: false, context: this.context, camera: this._cameraObject as Camera3, currentZoom: this._controls?.getDistance() || undefined, minZoom: this.minZoom, maxZoom: this.maxZoom, }); if (!res) return; this.setLookTargetPosition(res.lookAt, options?.immediate || false); this.setCameraTargetPosition(res.position, options?.immediate || false); this.setFieldOfView(options?.fov, options?.immediate || false); this.onBeforeRender(); } private _haveAttachedKeyboardEvents: boolean = false; } type OrbitFitCameraOptions = FitCameraOptions & { immediate?: boolean, }