import { Box3Helper, Camera as Camera3, Euler, 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 { 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 { RaycastOptions } from "../engine/engine_physics.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { getBoundingBox, getTempVector, getWorldDirection, getWorldPosition, getWorldRotation, setWorldRotation } from "../engine/engine_three_utils.js"; import type { ICameraController } from "../engine/engine_types.js"; import { DeviceUtilities, getParam } from "../engine/engine_utils.js"; import { Camera } from "./Camera.js"; import { Behaviour, GameObject } from "./Component.js"; import { GroundProjectedEnv } from "./GroundProjection.js"; import { LookAtConstraint } from "./LookAtConstraint.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, } }); } } /** The OrbitControls component is used to control a camera using the [OrbitControls from three.js](https://threejs.org/docs/#examples/en/controls/OrbitControls) library. * The three OrbitControls object can be accessed via the `controls` property. * The object being controlled by the OrbitControls (usually the camera) can be accessed via the `controllerObject` property. * @category Camera Controls * @group Components */ 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 a {@link LookAtConstraint} will make the camera look at the constraint source * @default null */ @serializable(LookAtConstraint) lookAtConstraint: LookAtConstraint | null = null; /** The weight of the first lookAtConstraint source * @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; /** * @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() targetLerpDuration = 1; 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 _lookTargetLerpDuration: 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; targetElement: HTMLElement | null = null; /** @internal */ awake(): void { if (debug) console.debug("OrbitControls", this); this._didSetTarget = 0; this._startedListeningToKeyEvents = 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._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("end", 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("end", 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.fitCamera(this.context.scene.children, { 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() { const ray = new Ray(this._cameraObject?.worldPosition, this._cameraObject?.worldForward.multiplyScalar(-1)); const hits = this.context.physics.raycastFromRay(ray); 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); } } private _orbitStartAngle: number = 0; private onControlsChangeStarted = () => { if (this._controls) { this._orbitStartAngle = this._controls.getAzimuthalAngle() + this._controls.getPolarAngle(); } if (this._syncedTransform) { this._syncedTransform.requestOwnership(); } } private onControlsChangeEnded = () => { if (this._controls) { if (this.autoTarget) { const newAngle = this._controls.getAzimuthalAngle() + this._controls.getPolarAngle(); const delta = newAngle - this._orbitStartAngle; if (Math.abs(delta) < .01) { if (debug) console.debug("OrbitControls: No movement detected, updating target now"); this.updateTargetNow(); } else if (debug) console.debug("OrbitControls: Movement detected", delta); } } } private _shouldDisable: boolean = false; private afterHandleInput(evt: CustomEvent) { 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; } } } /** @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._controls.target.copy(this._lookTargetEndPosition); this._lookTargetLerpActive = false; this.dispatchEvent(new CameraTargetReachedEvent(this, "lookat")); } else { const t = Mathf.easeInOutCubic(this._lookTargetLerp01); this._controls.target.lerpVectors(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._controls) { if (this.debugLog) this._controls.domElement = this.context.renderer.domElement; 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.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.lookAtConstraint?.locked) this.setLookTargetFromConstraint(0, this.lookAtConstraint01); 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; } } /** 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._controls.target.copy(this._lookTargetEndPosition); } 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 an assigned lookAtConstraint source by index * @param index The index of the source to use * @param t The interpolation factor between the current look at target and the new target */ private setLookTargetFromConstraint(index: number = 0, t: number = 1): boolean { if (!this._controls) return false; if (this.lookAtConstraint?.enabled === false) return false; const sources = this.lookAtConstraint?.sources; if (sources && sources.length > 0) { const target = sources[index]; if (target) { target.getWorldPosition(this._lookTargetEndPosition); this.lerpLookTarget(this._lookTargetEndPosition, t); return true; } } return false; } /** @deprecated use `controls.target.lerp(position, delta)` */ public lerpTarget(position: Vector3, delta: number) { return this.lerpLookTarget(position, delta); } private lerpLookTarget(position: Vector3, delta: number) { if (!this._controls) return; if (delta >= 1) this._controls.target.copy(position); else this._controls.target.lerp(position, delta); } 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) */ fitCamera(options?: FitCameraOptions); fitCamera(objects?: Object3D | Array, options?: Omit); fitCamera(objectsOrOptions?: Object3D | Array | FitCameraOptions, options?: FitCameraOptions) { if (this.context.isInXR) { // camera fitting in XR is not supported return; } let objects: Object3D | Array | 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.children; } // 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.children; } 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 camera = this._cameraObject as PerspectiveCamera; const controls = this._controls as ThreeOrbitControls | null; if (!camera || !controls) { console.warn("No camera or controls found to fit camera to objects..."); return; } if (!options) options = {} const { immediate = false, centerCamera = "y", cameraNearFar = "auto", fitOffset = 1.1, fov = camera?.fov } = options; const size = new Vector3(); const center = new Vector3(); // TODO would be much better to calculate the bounds in camera space instead of world space - // we would get proper view-dependant fit. // Right now it's independent from where the camera is actually looking from, // and thus we're just getting some maximum that will work for sure. const box = getBoundingBox(objects, undefined, this._camera?.threeCamera?.layers); const boxCopy = box.clone(); camera.updateMatrixWorld(); camera.updateProjectionMatrix(); box.getCenter(center); const box_size = new Vector3(); box.getSize(box_size); // project this box into camera space box.applyMatrix4(camera.matrixWorldInverse); box.getSize(size); box.setFromCenterAndSize(center, size); if (Number.isNaN(size.x) || Number.isNaN(size.y) || Number.isNaN(size.z)) { console.warn("Camera fit size resultet in NaN", camera, box, [...objects]); return; } if (size.length() <= 0.0000000001) { if (debugCameraFit) console.warn("Camera fit size is zero", box, [...objects]); return; } const verticalFov = options.fov || camera.fov; const horizontalFov = 2 * Math.atan(Math.tan(verticalFov * Math.PI / 360 / 2) * camera.aspect) / Math.PI * 360; const fitHeightDistance = size.y / (2 * Math.atan(Math.PI * verticalFov / 360)); const fitWidthDistance = size.x / (2 * Math.atan(Math.PI * horizontalFov / 360)); const distance = fitOffset * Math.max(fitHeightDistance, fitWidthDistance) + size.z / 2; if (debugCameraFit) { console.log("Fit camera to objects", { fitHeightDistance, fitWidthDistance, distance, verticalFov, horizontalFov }); } this.maxZoom = distance * 10; this.minZoom = distance * 0.01; const verticalOffset = 0.05; const lookAt = center.clone(); lookAt.y -= size.y * verticalOffset; this.setLookTargetPosition(lookAt, immediate); this.setFieldOfView(options.fov, immediate); if (cameraNearFar == undefined || cameraNearFar == "auto") { // Check if the scene has a GroundProjectedEnv and include the scale to the far plane so that it doesnt cut off const groundprojection = GameObject.findObjectOfType(GroundProjectedEnv); const groundProjectionRadius = groundprojection ? groundprojection.radius : 0; const boundsMax = Math.max(box_size.x, box_size.y, box_size.z, groundProjectionRadius); // TODO: this doesnt take the Camera component nearClipPlane into account camera.near = (distance / 100); camera.far = boundsMax + distance * 10; // adjust maxZoom so that the ground projection radius is always inside if (groundprojection) { this.maxZoom = Math.max(Math.min(this.maxZoom, groundProjectionRadius * 0.5), distance); } } // ensure we're not clipping out of the current zoom level just because we're fitting const currentZoom = controls.getDistance(); if (currentZoom < this.minZoom) this.minZoom = currentZoom * 0.9; if (currentZoom > this.maxZoom) this.maxZoom = currentZoom * 1.1; camera.updateMatrixWorld(); camera.updateProjectionMatrix(); const cameraWp = getWorldPosition(camera); const direction = center.clone(); direction.sub(cameraWp); if (centerCamera === "y") direction.y = 0; direction.normalize(); direction.multiplyScalar(distance); if (centerCamera === "y") direction.y += -verticalOffset * 4 * distance; let cameraLocalPosition = center.clone().sub(direction); if (camera.parent) { cameraLocalPosition = camera.parent.worldToLocal(cameraLocalPosition); } this.setCameraTargetPosition(cameraLocalPosition, immediate); if (debugCameraFit || options.debug) { Gizmos.DrawWireBox3(box, 0xffff33, 10); Gizmos.DrawWireBox3(boxCopy, 0x00ff00, 10); if (!this._haveAttachedKeyboardEvents && debugCameraFit) { this._haveAttachedKeyboardEvents = true; document.body.addEventListener("keydown", (e) => { if (e.code === "KeyF") { // random fov for easier debugging of fov-based fitting let fov: number | undefined = undefined; if (this._cameraObject instanceof PerspectiveCamera) fov = (Math.random() * Math.random()) * 170 + 10; this.fitCamera({ objects, fitOffset, immediate: false, fov }); } if (e.code === "KeyV") { if (this._cameraObject instanceof PerspectiveCamera) this._cameraObject.fov = 60; } }); } } this.onBeforeRender(); // controls.update(); // this is not enough when calling fitCamera({immediate:true}) in an interval } private _haveAttachedKeyboardEvents: boolean = false; } /** * Options for fitting the camera to the scene. Used in {@link OrbitControls.fitCamera} */ declare type FitCameraOptions = { /** When enabled debug rendering will be shown */ debug?: boolean, /** * The objects to fit the camera to. If not provided the scene children will be used */ objects?: Object3D[] | Object3D; /** Fit offset: A factor to multiply the distance to the objects by * @default 1.1 */ fitOffset?: number, /** If true the camera will move immediately to the new position, otherwise it will lerp * @default false */ immediate?: boolean, /** If set to "y" the camera will be centered in the y axis */ centerCamera?: "none" | "y", cameraNearFar?: "keep" | "auto", fov?: number, }