UNPKG

3d-tiles-renderer

Version:

https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification

1,888 lines (1,313 loc) 46.7 kB
/** @import { Object3D, Camera } from 'three' */ import { Matrix4, Quaternion, Vector2, Vector3, Raycaster, Plane, EventDispatcher, MathUtils, Ray, } from 'three'; import { PivotPointMesh } from './PivotPointMesh.js'; import { PointerTracker } from './PointerTracker.js'; import { adjustedPointerToCoords, makeRotateAroundPoint, setRaycasterFromCamera } from './utils.js'; export const NONE = 0; export const DRAG = 1; export const ROTATE = 2; export const ZOOM = 3; export const WAITING = 4; export const FREE_ROTATE = 5; const DRAG_PLANE_THRESHOLD = 0.05; const DRAG_UP_THRESHOLD = 0.025; const _rotMatrix = /* @__PURE__ */ new Matrix4(); const _invMatrix = /* @__PURE__ */ new Matrix4(); const _delta = /* @__PURE__ */ new Vector3(); const _vec = /* @__PURE__ */ new Vector3(); const _pos = /* @__PURE__ */ new Vector3(); const _center = /* @__PURE__ */ new Vector3(); const _forward = /* @__PURE__ */ new Vector3(); const _right = /* @__PURE__ */ new Vector3(); const _targetRight = /* @__PURE__ */ new Vector3(); const _rotationAxis = /* @__PURE__ */ new Vector3(); const _quaternion = /* @__PURE__ */ new Quaternion(); const _plane = /* @__PURE__ */ new Plane(); const _localUp = /* @__PURE__ */ new Vector3(); const _mouseBefore = /* @__PURE__ */ new Vector3(); const _mouseAfter = /* @__PURE__ */ new Vector3(); const _identityQuat = /* @__PURE__ */ new Quaternion(); const _ray = /* @__PURE__ */ new Ray(); const _flightDir = /* @__PURE__ */ new Vector3(); const _zoomPointPointer = /* @__PURE__ */ new Vector2(); const _pointer = /* @__PURE__ */ new Vector2(); const _prevPointer = /* @__PURE__ */ new Vector2(); const _deltaPointer = /* @__PURE__ */ new Vector2(); const _centerPoint = /* @__PURE__ */ new Vector2(); const _startCenterPoint = /* @__PURE__ */ new Vector2(); const _changeEvent = { type: 'change' }; const _startEvent = { type: 'start' }; const _endEvent = { type: 'end' }; /** * Camera controls for exploring a 3D environment. Supports drag-to-pan, scroll-to-zoom, * right-click-to-rotate, and optional damping/inertia. Works with any Three.js scene. * @extends EventDispatcher * @param {Object3D} [scene=null] - The scene to raycast against for surface interaction. * @param {Camera} [camera=null] - The camera to control. * @param {HTMLElement} [domElement=null] - The DOM element to attach pointer events to. */ export class EnvironmentControls extends EventDispatcher { /** * Whether the controls are active. When set to false, all input is ignored * and inertia is cleared. * @type {boolean} * @default true */ get enabled() { return this._enabled; } set enabled( v ) { if ( v !== this.enabled ) { this._enabled = v; this.resetState(); this.pointerTracker.reset(); if ( ! this.enabled ) { this.dragInertia.set( 0, 0, 0 ); this.rotationInertia.set( 0, 0 ); } } } constructor( scene = null, camera = null, domElement = null ) { super(); this.isEnvironmentControls = true; this.domElement = null; this.camera = null; this.scene = null; this.tilesRenderer = null; // settings this._enabled = true; /** * Minimum camera distance above the surface in world units. Prevents clipping into terrain. * @type {number} * @default 5 */ this.cameraRadius = 5; /** * Rotation sensitivity multiplier. * @type {number} * @default 1 */ this.rotationSpeed = 1; /** * Minimum camera angle above the horizon in radians. * @type {number} * @default 0 */ this.minAltitude = 0; /** * Maximum camera angle above the horizon in radians. * @type {number} * @default 0.45 * Math.PI */ this.maxAltitude = 0.45 * Math.PI; /** * Minimum zoom distance in world units. * @type {number} * @default 10 */ this.minDistance = 10; /** * Maximum zoom distance in world units. * @type {number} * @default Infinity */ this.maxDistance = Infinity; /** * Minimum orthographic zoom level. * @type {number} * @default 0 */ this.minZoom = 0; /** * Maximum orthographic zoom level. * @type {number} * @default Infinity */ this.maxZoom = Infinity; /** * Zoom sensitivity multiplier. * @type {number} * @default 1 */ this.zoomSpeed = 1; /** * When true, the camera height is automatically adjusted to avoid clipping into the terrain. * @type {boolean} * @default true */ this.adjustHeight = true; /** * When true, camera movements decelerate gradually after input ends. * @type {boolean} * @default false */ this.enableDamping = false; /** * Rate of inertia decay per frame when damping is enabled. Lower values produce longer coasting. * @type {number} * @default 0.15 */ this.dampingFactor = 0.15; /** * Fallback plane used for drag/zoom when no scene geometry is hit. * @type {Plane} * @default new Plane( UP, 0 ) */ this.fallbackPlane = new Plane( new Vector3( 0, 1, 0 ), 0 ); /** * When true, the fallback plane is used when raycasting misses scene geometry. * @type {boolean} * @default true */ this.useFallbackPlane = true; /** * When true, enables keyboard flight: W/A/S/D and arrow keys move forward/back/strafe, Q/E move * up/down, and Shift multiplies speed by `flightSpeedMultiplier`. Right-click or Shift+left-click * enters free-look mode, rotating the camera in place without requiring a surface hit. Only * supported for perspective cameras. * @type {boolean} * @default false */ this.enableFlight = false; /** * Base camera speed in world units per second during keyboard flight. * @type {number} * @default 10 */ this.flightSpeed = 10; /** * Speed multiplier applied when the fast key is held during flight. * @type {number} * @default 4 */ this.flightSpeedMultiplier = 4; // settings for GlobeControls this.scaleZoomOrientationAtEdges = false; this.autoAdjustCameraRotation = true; // internal state this.state = NONE; this.pointerTracker = new PointerTracker(); this.needsUpdate = false; this.actionHeightOffset = 0; this.pivotPoint = new Vector3(); // used for zoom this.zoomDirectionSet = false; this.zoomPointSet = false; this.zoomDirection = new Vector3(); this.zoomPoint = new Vector3(); this.zoomDelta = 0; // fields used for inertia this.rotationInertiaPivot = new Vector3(); this.rotationInertia = new Vector2(); this.dragInertia = new Vector3(); this.inertiaTargetDistance = Infinity; // track the distance from the camera that we want to use to calculate the inertia end threshold this.inertiaStableFrames = 0; // the number of frames that the camera has not moved while the user is interacting // circular pivot mesh this.pivotMesh = new PivotPointMesh(); this.pivotMesh.raycast = () => {}; this.pivotMesh.scale.setScalar( 0.25 ); // raycaster this.raycaster = new Raycaster(); this.raycaster.firstHitOnly = true; this.up = new Vector3( 0, 1, 0 ); this._lastTime = performance.now(); this._keysDown = new Set(); this._detachCallback = null; this._upInitialized = false; this._lastUsedState = NONE; this._zoomPointWasSet = false; // always update the zoom target point in case the tiles are changing this._tilesOnChangeCallback = () => this.zoomPointSet = false; // init if ( domElement ) this.attach( domElement ); if ( camera ) this.setCamera( camera ); if ( scene ) this.setScene( scene ); } _getDeltaTime() { // custom delta time function that increments the last time used since "Clock" has // been removed from three.js. const curr = performance.now(); const delta = curr - this._lastTime; this._lastTime = curr; return delta * 1e-3; } /** * Sets the scene to raycast against for surface-based interaction. * @param {Object3D} scene */ setScene( scene ) { this.scene = scene; } /** * Sets the camera to control. * @param {Camera} camera */ setCamera( camera ) { this.camera = camera; this._upInitialized = false; this.zoomDirectionSet = false; this.zoomPointSet = false; this.needsUpdate = true; this.raycaster.camera = camera; this.resetState(); } /** * Attaches the controls to a DOM element, registering all pointer and keyboard event listeners. * @param {HTMLElement} domElement */ attach( domElement ) { if ( this.domElement ) { throw new Error( 'EnvironmentControls: Controls already attached to element' ); } // set the touch action to none so the browser does not // drag the page to refresh or scroll this.domElement = domElement; this.pointerTracker.domElement = domElement; domElement.style.touchAction = 'none'; // Ensure the element can receive keyboard focus. If no tabindex attribute is // present, set it to -1 so the element is programmatically focusable without // being inserted into the tab order. if ( ! domElement.hasAttribute( 'tabindex' ) ) { domElement.tabIndex = - 1; } const contextMenuCallback = e => { // exit early if the controls are disabled if ( ! this.enabled ) { return; } e.preventDefault(); }; const pointerdownCallback = e => { const { camera, raycaster, domElement, up, pivotMesh, pointerTracker, scene, pivotPoint, enabled, enableFlight, _keysDown, } = this; // exit early if the controls are disabled if ( ! this.enabled ) { return; } e.preventDefault(); domElement.focus(); // init the pointer pointerTracker.addPointer( e ); this.needsUpdate = true; // handle cases where we need to capture the pointer or // reset state when we have too many pointers if ( pointerTracker.isPointerTouch() ) { pivotMesh.visible = false; if ( pointerTracker.getPointerCount() === 0 ) { // TODO: is it correct to only capture a single pointer? Or do we need to do it for // every unique pointer id? domElement.setPointerCapture( e.pointerId ); } else if ( pointerTracker.getPointerCount() > 2 ) { this.resetState(); return; } } // the "pointer" for zooming and rotating should be based on the center point pointerTracker.getCenterPoint( _pointer ); adjustedPointerToCoords( _pointer, domElement, _pointer ); setRaycasterFromCamera( raycaster, _pointer, camera ); // prevent the drag distance from getting too severe by limiting the drag point // to a reasonable angle and reasonable distance with the drag plane const dot = Math.abs( raycaster.ray.direction.dot( up ) ); if ( dot < DRAG_PLANE_THRESHOLD || dot < DRAG_UP_THRESHOLD ) { return; } // free-look around the camera origin when flight is active with any flight key held, or shift/right-click const anyFlightKey = _keysDown.has( 'w' ) || _keysDown.has( 's' ) || _keysDown.has( 'a' ) || _keysDown.has( 'd' ) || _keysDown.has( 'q' ) || _keysDown.has( 'e' ) || _keysDown.has( 'arrowup' ) || _keysDown.has( 'arrowdown' ) || _keysDown.has( 'arrowleft' ) || _keysDown.has( 'arrowright' ) || _keysDown.has( 'shift' ); if ( enableFlight && anyFlightKey && ! pointerTracker.isPointerTouch() && ( pointerTracker.isRightClicked() || pointerTracker.isLeftClicked() ) ) { pivotPoint.copy( camera.position ); this.setState( FREE_ROTATE ); return; } // find the hit point const hit = this._raycast( raycaster ); if ( hit ) { // if two fingers, right click, or shift click are being used then we trigger // a rotation action to begin if ( pointerTracker.getPointerCount() === 2 || pointerTracker.isRightClicked() || pointerTracker.isLeftClicked() && e.shiftKey ) { pivotPoint.copy( hit.point ); pivotMesh.position.copy( hit.point ); pivotMesh.visible = pointerTracker.isPointerTouch() ? false : enabled; pivotMesh.updateMatrixWorld(); scene.add( pivotMesh ); this.setState( pointerTracker.isPointerTouch() ? WAITING : ROTATE ); } else if ( pointerTracker.isLeftClicked() ) { pivotPoint.copy( hit.point ); pivotMesh.position.copy( hit.point ); pivotMesh.updateMatrixWorld(); scene.add( pivotMesh ); // if the clicked point is coming from below the plane then don't perform the drag this.setState( DRAG ); } } }; let _pointerMoveQueued = false; const pointermoveCallback = e => { // exit early if the controls are disabled const { pointerTracker } = this; if ( ! this.enabled ) { return; } e.preventDefault(); const { pivotMesh, enabled, } = this; // whenever the pointer moves we need to re-derive the zoom direction and point this.zoomDirectionSet = false; this.zoomPointSet = false; if ( this.state !== NONE ) { this.needsUpdate = true; } pointerTracker.setHoverEvent( e ); if ( ! pointerTracker.updatePointer( e ) ) { return; } if ( pointerTracker.isPointerTouch() && pointerTracker.getPointerCount() === 2 ) { // We queue this event to ensure that all pointers have been updated if ( ! _pointerMoveQueued ) { _pointerMoveQueued = true; queueMicrotask( () => { _pointerMoveQueued = false; // adjust the pointer position to be the center point pointerTracker.getCenterPoint( _centerPoint ); // detect zoom transition const startDist = pointerTracker.getStartTouchPointerDistance(); const pointerDist = pointerTracker.getTouchPointerDistance(); const separateDelta = pointerDist - startDist; if ( this.state === NONE || this.state === WAITING ) { // check which direction was moved in first - if the pointers are pinching then // it's a zoom. But if they move in parallel it's a rotation pointerTracker.getCenterPoint( _centerPoint ); pointerTracker.getStartCenterPoint( _startCenterPoint ); // adjust the drag requirement by the dpr const dragThreshold = 2.0 * window.devicePixelRatio; const parallelDelta = _centerPoint.distanceTo( _startCenterPoint ); if ( Math.abs( separateDelta ) > dragThreshold || parallelDelta > dragThreshold ) { if ( Math.abs( separateDelta ) > parallelDelta ) { this.setState( ZOOM ); this.zoomDirectionSet = false; } else { this.setState( ROTATE ); } } } if ( this.state === ZOOM ) { const previousDist = pointerTracker.getPreviousTouchPointerDistance(); this.zoomDelta += pointerDist - previousDist; pivotMesh.visible = false; } else if ( this.state === ROTATE ) { pivotMesh.visible = enabled; } } ); } } // TODO: we have the potential to fire change multiple times per frame - should we debounce? this.dispatchEvent( _changeEvent ); }; const pointerupCallback = e => { // exit early if the controls are disabled const { pointerTracker } = this; if ( ! this.enabled || pointerTracker.getPointerCount() === 0 ) { return; } pointerTracker.deletePointer( e ); if ( pointerTracker.getPointerType() === 'touch' && pointerTracker.getPointerCount() === 0 ) { domElement.releasePointerCapture( e.pointerId ); } this.resetState(); this.needsUpdate = true; }; const wheelCallback = e => { // exit early if the controls are disabled if ( ! this.enabled ) { return; } e.preventDefault(); const { pointerTracker } = this; pointerTracker.setHoverEvent( e ); pointerTracker.updatePointer( e ); // TODO: do we need events here? this.dispatchEvent( _startEvent ); let delta; switch ( e.deltaMode ) { case 2: // Pages delta = e.deltaY * 800; break; case 1: // Lines delta = e.deltaY * 40; break; case 0: // Pixels delta = e.deltaY; break; } // use LOG to scale the scroll delta and hopefully normalize them across platforms const deltaSign = Math.sign( delta ); const normalizedDelta = Math.abs( delta ); this.zoomDelta -= 0.25 * deltaSign * normalizedDelta; this.needsUpdate = true; this._lastUsedState = ZOOM; this.dispatchEvent( _endEvent ); }; const pointerleaveCallback = e => { // exit early if the controls are disabled if ( ! this.enabled ) { return; } this.resetState(); }; domElement.addEventListener( 'contextmenu', contextMenuCallback ); domElement.addEventListener( 'pointerdown', pointerdownCallback ); domElement.addEventListener( 'wheel', wheelCallback, { passive: false } ); // Register movement events on the root element so dragging does not break when dragging over other elements. // Use "getRootNode" to enable offscreenCanvas usage. // "pointerleave" event fires when leaving the window. const document = domElement.getRootNode(); document.addEventListener( 'pointermove', pointermoveCallback ); document.addEventListener( 'pointerup', pointerupCallback ); document.addEventListener( 'pointerleave', pointerleaveCallback ); const keydownCallback = e => { const { _keysDown, state } = this; _keysDown.add( e.key.toLowerCase() ); // reset any activities if a key is pressed unless FREE_ROTATE is being used const anyFlightKey = _keysDown.has( 'w' ) || _keysDown.has( 's' ) || _keysDown.has( 'a' ) || _keysDown.has( 'd' ) || _keysDown.has( 'q' ) || _keysDown.has( 'e' ) || _keysDown.has( 'arrowup' ) || _keysDown.has( 'arrowdown' ) || _keysDown.has( 'arrowleft' ) || _keysDown.has( 'arrowright' ); if ( anyFlightKey && state !== FREE_ROTATE ) { this.resetState(); } }; const keyupCallback = e => { this._keysDown.delete( e.key.toLowerCase() ); }; const blurCallback = () => { this._keysDown.clear(); }; domElement.addEventListener( 'keydown', keydownCallback ); window.addEventListener( 'keyup', keyupCallback ); window.addEventListener( 'blur', blurCallback ); this._detachCallback = () => { domElement.removeEventListener( 'contextmenu', contextMenuCallback ); domElement.removeEventListener( 'pointerdown', pointerdownCallback ); domElement.removeEventListener( 'wheel', wheelCallback ); document.removeEventListener( 'pointermove', pointermoveCallback ); document.removeEventListener( 'pointerup', pointerupCallback ); document.removeEventListener( 'pointerleave', pointerleaveCallback ); domElement.removeEventListener( 'keydown', keydownCallback ); window.removeEventListener( 'keyup', keyupCallback ); window.removeEventListener( 'blur', blurCallback ); }; } /** * Detaches the controls from the DOM element, removing all event listeners. */ detach() { this.domElement = null; if ( this._detachCallback ) { this._detachCallback(); this._detachCallback = null; this.pointerTracker.reset(); } } /** * Returns the local up direction at a world-space point. Override to provide terrain-aware * up vectors (e.g. ellipsoid normals). Default returns the controls' `up` vector. * @param {Vector3} point - World-space point to query. * @param {Vector3} target - Target vector to write the result into. */ getUpDirection( point, target ) { target.copy( this.up ); } /** * Returns the local up direction at the camera's current position. * @param {Vector3} target - Target vector to write the result into. */ getCameraUpDirection( target ) { this.getUpDirection( this.camera.position, target ); } /** * Returns the current drag or rotation pivot point in world space. * @param {Vector3} target - Target vector to write the result into. * @returns {Vector3|null} The target vector, or null if no pivot is active. */ getPivotPoint( target ) { let result = null; // get the last interacted point as the focus if ( this._lastUsedState === ZOOM ) { if ( this._zoomPointWasSet ) { result = target.copy( this.zoomPoint ); } } else if ( this._lastUsedState === ROTATE || this._lastUsedState === DRAG ) { result = target.copy( this.pivotPoint ); } // If the last used point is outside the camera view then skip it const { camera, raycaster } = this; if ( result !== null ) { _vec.copy( result ).project( camera ); if ( _vec.x < - 1 || _vec.x > 1 || _vec.y < - 1 || _vec.y > 1 ) { result = null; } } // default to the raycast hit if we have not result or the hit is closer to the camera // set a ray in the local ellipsoid frame setRaycasterFromCamera( raycaster, { x: 0, y: 0 }, camera ); const hit = this._raycast( raycaster ); if ( hit ) { if ( result === null || hit.distance < result.distanceTo( raycaster.ray.origin ) ) { result = target.copy( hit.point ); } } return result; } /** * Clears the current interaction state, cancelling any active drag, rotate, or zoom. */ resetState() { if ( this.state !== NONE ) { this.dispatchEvent( _endEvent ); } this.state = NONE; this.pivotMesh.removeFromParent(); this.pivotMesh.visible = this.enabled; this.actionHeightOffset = 0; this.pointerTracker.reset(); } /** * Sets the current control state (e.g. `NONE`, `DRAG`, `ROTATE`, `ZOOM`). * @param {number} [state] - One of the exported state constants. Defaults to current state. * @param {boolean} [fireEvent=true] - Whether to dispatch `'start'` and `'end'` events. */ setState( state = this.state, fireEvent = true ) { if ( this.state === state ) { return; } if ( this.state === NONE && fireEvent ) { this.dispatchEvent( _startEvent ); } this.pivotMesh.visible = this.enabled; this.dragInertia.set( 0, 0, 0 ); this.rotationInertia.set( 0, 0 ); this.inertiaStableFrames = 0; this.state = state; if ( state !== NONE && state !== WAITING ) { this._lastUsedState = state; } } /** * Applies pending input and inertia to the camera. Must be called each frame. * @param {number} [deltaTime] - Time in seconds since the last frame. Defaults to the clock delta, capped at 64ms. */ update( deltaTime = Math.min( this._getDeltaTime(), 64 / 1000 ) ) { if ( ! this.enabled || ! this.camera || deltaTime === 0 ) { return; } const { camera, cameraRadius, pivotPoint, up, state, adjustHeight, autoAdjustCameraRotation, } = this; camera.updateMatrixWorld(); // set the "up" vector immediately so it's available in the following functions this.getCameraUpDirection( _localUp ); if ( ! this._upInitialized ) { this._upInitialized = true; this.up.copy( _localUp ); } // we need to update the zoom point whenever we update in case the scene is animating or changing this.zoomPointSet = false; // update the actions const inertiaNeedsUpdate = this._inertiaNeedsUpdate(); const adjustCameraRotation = this.needsUpdate || inertiaNeedsUpdate; if ( this.needsUpdate || inertiaNeedsUpdate ) { const zoomDelta = this.zoomDelta; this._updateZoom(); this._updatePosition( deltaTime ); this._updateRotation( deltaTime ); if ( state === DRAG || state === ROTATE || state === FREE_ROTATE ) { _forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); this.inertiaTargetDistance = _vec.copy( pivotPoint ).sub( camera.position ).dot( _forward ); } else if ( state === NONE ) { this._updateInertia( deltaTime ); } if ( state !== NONE || zoomDelta !== 0 || inertiaNeedsUpdate ) { this.dispatchEvent( _changeEvent ); } this.needsUpdate = false; } const didFly = this._updateFlight( deltaTime ); if ( didFly ) { this.dragInertia.set( 0, 0, 0 ); this.rotationInertia.set( 0, 0, 0 ); this.dispatchEvent( _changeEvent ); } // update the up direction based on where the camera moved to // if using an orthographic camera then rotate around drag pivot // reuse the "hit" information since it can be slow to perform multiple hits const hit = camera.isOrthographicCamera ? null : ( adjustHeight && ! didFly && this._getPointBelowCamera() ) || null; this.getCameraUpDirection( _localUp ); this._setFrame( _localUp ); // when dragging the camera and drag point may be moved // to accommodate terrain so we try to move it back down // to the original point. if ( ( this.state === DRAG || this.state === ROTATE || this.state === FREE_ROTATE ) && this.actionHeightOffset !== 0 ) { const { actionHeightOffset } = this; camera.position.addScaledVector( up, - actionHeightOffset ); pivotPoint.addScaledVector( up, - actionHeightOffset ); // adjust the height if ( hit ) { hit.distance -= actionHeightOffset; } } this.actionHeightOffset = 0; if ( hit ) { const dist = hit.distance; if ( dist < cameraRadius ) { const delta = cameraRadius - dist; camera.position.addScaledVector( up, delta ); pivotPoint.addScaledVector( up, delta ); this.actionHeightOffset = delta; } } this.pointerTracker.updateFrame(); if ( ( adjustCameraRotation && autoAdjustCameraRotation ) || didFly ) { this.getCameraUpDirection( _localUp ); this._alignCameraUp( _localUp, 1 ); this.getCameraUpDirection( _localUp ); this._clampRotation( _localUp ); } } /** * Adjusts the camera to satisfy altitude and distance constraints. Called automatically by `update`. * Override in subclasses to add custom camera adjustment behaviour (e.g. near/far plane updates). * @param {Camera} camera */ adjustCamera( camera ) { const { adjustHeight, cameraRadius } = this; if ( camera.isPerspectiveCamera ) { // adjust the camera height this.getUpDirection( camera.position, _localUp ); const hit = adjustHeight && this._getPointBelowCamera( camera.position, _localUp ) || null; if ( hit ) { const dist = hit.distance; if ( dist < cameraRadius ) { camera.position.addScaledVector( _localUp, cameraRadius - dist ); } } } } /** * Disposes of event listeners and internal resources. Calls `detach` if currently attached. */ dispose() { this.detach(); } // private _updateInertia( deltaTime ) { // update the damping of momentum variables const { rotationInertia, pivotPoint, dragInertia, enableDamping, dampingFactor, camera, cameraRadius, minDistance, inertiaTargetDistance, } = this; if ( ! this.enableDamping || this.inertiaStableFrames > 1 ) { dragInertia.set( 0, 0, 0 ); rotationInertia.set( 0, 0, 0 ); return; } // Based on Freya Holmer's frame-rate independent lerp function const factor = Math.pow( 2, - deltaTime / dampingFactor ); const stableDistance = Math.max( camera.near, cameraRadius, minDistance, inertiaTargetDistance ); const resolution = 2 * 1e3; const pixelWidth = 2 / resolution; const pixelThreshold = 0.25 * pixelWidth; // scale the residual rotation motion if ( rotationInertia.lengthSq() > 0 ) { // calculate two screen points at 1 pixel apart in our notional resolution so we can stop when the delta is ~ 1 pixel // projected into world space setRaycasterFromCamera( _ray, _vec.set( 0, 0, - 1 ), camera ); _ray.applyMatrix4( camera.matrixWorldInverse ); _ray.direction.normalize(); _ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _vec ); _vec.applyMatrix4( camera.matrixWorld ); setRaycasterFromCamera( _ray, _delta.set( pixelThreshold, pixelThreshold, - 1 ), camera ); _ray.applyMatrix4( camera.matrixWorldInverse ); _ray.direction.normalize(); _ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _delta ); _delta.applyMatrix4( camera.matrixWorld ); // get implied angle _vec.sub( pivotPoint ).normalize(); _delta.sub( pivotPoint ).normalize(); // calculate the rotation threshold const threshold = _vec.angleTo( _delta ) / deltaTime; rotationInertia.multiplyScalar( factor ); if ( rotationInertia.lengthSq() < threshold ** 2 || ! enableDamping ) { rotationInertia.set( 0, 0 ); } } // scale the residual translation motion if ( dragInertia.lengthSq() > 0 ) { // calculate two screen points at 1 pixel apart in our notional resolution so we can stop when the delta is ~ 1 pixel // projected into world space setRaycasterFromCamera( _ray, _vec.set( 0, 0, - 1 ), camera ); _ray.applyMatrix4( camera.matrixWorldInverse ); _ray.direction.normalize(); _ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _vec ); _vec.applyMatrix4( camera.matrixWorld ); setRaycasterFromCamera( _ray, _delta.set( pixelThreshold, pixelThreshold, - 1 ), camera ); _ray.applyMatrix4( camera.matrixWorldInverse ); _ray.direction.normalize(); _ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _delta ); _delta.applyMatrix4( camera.matrixWorld ); // calculate movement threshold const threshold = _vec.distanceTo( _delta ) / deltaTime; dragInertia.multiplyScalar( factor ); if ( dragInertia.lengthSq() < threshold ** 2 || ! enableDamping ) { dragInertia.set( 0, 0, 0 ); } } // apply the inertia changes if ( rotationInertia.lengthSq() > 0 ) { this._applyRotation( rotationInertia.x * deltaTime, rotationInertia.y * deltaTime, pivotPoint ); } if ( dragInertia.lengthSq() > 0 ) { camera.position.addScaledVector( dragInertia, deltaTime ); camera.updateMatrixWorld(); } } _inertiaNeedsUpdate() { const { rotationInertia, dragInertia } = this; return rotationInertia.lengthSq() !== 0 || dragInertia.lengthSq() !== 0; } _getFlightSpeedScale() { return 1; } _updateFlight( deltaTime ) { const { camera, enableFlight, flightSpeed, flightSpeedMultiplier, _keysDown, } = this; if ( ! enableFlight || camera.isOrthographicCamera ) { return false; } // get key state const forward = _keysDown.has( 'w' ) || _keysDown.has( 'arrowup' ); const back = _keysDown.has( 's' ) || _keysDown.has( 'arrowdown' ); const left = _keysDown.has( 'a' ) || _keysDown.has( 'arrowleft' ); const right = _keysDown.has( 'd' ) || _keysDown.has( 'arrowright' ); const up = _keysDown.has( 'q' ); const down = _keysDown.has( 'e' ); // calculate speed const mult = _keysDown.has( 'shift' ) ? flightSpeedMultiplier : 1; const speed = mult * flightSpeed * this._getFlightSpeedScale() * deltaTime; // calculate direction _flightDir.set( ( right ? 1 : 0 ) - ( left ? 1 : 0 ), ( up ? 1 : 0 ) - ( down ? 1 : 0 ), ( back ? 1 : 0 ) - ( forward ? 1 : 0 ), ); // early out if there's no flight direction if ( _flightDir.lengthSq() === 0 ) { return false; } // fly relative to the camera direction _flightDir .normalize() .transformDirection( camera.matrixWorld ); camera.position.addScaledVector( _flightDir, speed ); camera.updateMatrixWorld(); return true; } _updateZoom() { const { zoomPoint, zoomDirection, camera, minDistance, maxDistance, pointerTracker, domElement, minZoom, maxZoom, zoomSpeed, state, } = this; let scale = this.zoomDelta; this.zoomDelta = 0; // get the latest hover / touch point if ( ! pointerTracker.getLatestPoint( _pointer ) || ( scale === 0 && state !== ZOOM ) ) { return; } // reset momentum this.rotationInertia.set( 0, 0 ); this.dragInertia.set( 0, 0, 0 ); if ( camera.isOrthographicCamera ) { // update the zoom direction this._updateZoomDirection(); // zoom straight into the globe if we haven't hit anything const zoomIntoPoint = this.zoomPointSet || this._updateZoomPoint(); // get the mouse position before zoom _mouseBefore.unproject( camera ); // zoom the camera const normalizedDelta = Math.pow( 0.95, Math.abs( scale * 0.05 ) ); let scaleFactor = scale > 0 ? 1 / Math.abs( normalizedDelta ) : normalizedDelta; scaleFactor *= zoomSpeed; if ( scaleFactor > 1 ) { if ( maxZoom < camera.zoom * scaleFactor ) { scaleFactor = 1; } } else { if ( minZoom > camera.zoom * scaleFactor ) { scaleFactor = 1; } } camera.zoom *= scaleFactor; camera.updateProjectionMatrix(); // adjust the surface point to be in the same position if the globe is hovered over if ( zoomIntoPoint ) { // get the mouse position after zoom adjustedPointerToCoords( _pointer, domElement, _mouseAfter ); _mouseAfter.unproject( camera ); // shift the camera on the near plane so the mouse is in the same spot camera.position.sub( _mouseAfter ).add( _mouseBefore ); camera.updateMatrixWorld(); } } else { // initialize the zoom direction this._updateZoomDirection(); // track the zoom direction we're going to use const finalZoomDirection = _vec.copy( zoomDirection ); if ( this.zoomPointSet || this._updateZoomPoint() ) { const dist = zoomPoint.distanceTo( camera.position ); // scale the distance based on how far there is to move if ( scale < 0 ) { const remainingDistance = Math.min( 0, dist - maxDistance ); scale = scale * dist * zoomSpeed * 0.0025; scale = Math.max( scale, remainingDistance ); } else { const remainingDistance = Math.max( 0, dist - minDistance ); scale = scale * Math.max( dist - minDistance, 0 ) * zoomSpeed * 0.0025; scale = Math.min( scale, remainingDistance ); } camera.position.addScaledVector( zoomDirection, scale ); camera.updateMatrixWorld(); } else { // if we're zooming into nothing then use the distance from the ground to scale movement const hit = this._getPointBelowCamera(); if ( hit ) { const dist = hit.distance; finalZoomDirection.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); camera.position.addScaledVector( finalZoomDirection, scale * dist * 0.01 ); camera.updateMatrixWorld(); } else { camera.position.addScaledVector( zoomDirection, scale ); camera.updateMatrixWorld(); } } } } _updateZoomDirection() { if ( this.zoomDirectionSet ) { return; } const { domElement, raycaster, camera, zoomDirection, pointerTracker } = this; pointerTracker.getLatestPoint( _pointer ); adjustedPointerToCoords( _pointer, domElement, _mouseBefore ); setRaycasterFromCamera( raycaster, _mouseBefore, camera ); zoomDirection.copy( raycaster.ray.direction ).normalize(); this.zoomDirectionSet = true; } // update the point being zoomed in to based on the zoom direction _updateZoomPoint() { const { camera, zoomDirectionSet, zoomDirection, raycaster, zoomPoint, pointerTracker, domElement, } = this; this._zoomPointWasSet = false; if ( ! zoomDirectionSet ) { return false; } // If using an orthographic camera we have to account for the mouse position when picking the point if ( camera.isOrthographicCamera && pointerTracker.getLatestPoint( _zoomPointPointer ) ) { adjustedPointerToCoords( _zoomPointPointer, domElement, _zoomPointPointer ); setRaycasterFromCamera( raycaster, _zoomPointPointer, camera ); } else { raycaster.ray.origin.copy( camera.position ); raycaster.ray.direction.copy( zoomDirection ); raycaster.near = 0; raycaster.far = Infinity; } // get the hit point const hit = this._raycast( raycaster ); if ( hit ) { zoomPoint.copy( hit.point ); this.zoomPointSet = true; this._zoomPointWasSet = true; return true; } return false; } // returns the point below the camera _getPointBelowCamera( point = this.camera.position, up = this.up ) { const { raycaster } = this; raycaster.ray.direction.copy( up ).multiplyScalar( - 1 ); raycaster.ray.origin.copy( point ).addScaledVector( up, 1e5 ); raycaster.near = 0; raycaster.far = Infinity; const hit = this._raycast( raycaster ); if ( hit ) { hit.distance -= 1e5; } return hit; } // update the drag action _updatePosition( deltaTime ) { const { raycaster, camera, pivotPoint, up, pointerTracker, domElement, state, dragInertia, } = this; if ( state === DRAG ) { // get the pointer and plane pointerTracker.getCenterPoint( _pointer ); adjustedPointerToCoords( _pointer, domElement, _pointer ); _plane.setFromNormalAndCoplanarPoint( up, pivotPoint ); setRaycasterFromCamera( raycaster, _pointer, camera ); // prevent the drag distance from getting too severe by limiting the drag point // to a reasonable angle with the drag plane if ( Math.abs( raycaster.ray.direction.dot( up ) ) < DRAG_PLANE_THRESHOLD ) { // rotate the pointer direction down to the correct angle for horizontal dragging const angle = Math.acos( DRAG_PLANE_THRESHOLD ); _rotationAxis .crossVectors( raycaster.ray.direction, up ) .normalize(); raycaster.ray.direction .copy( up ) .applyAxisAngle( _rotationAxis, angle ) .multiplyScalar( - 1 ); } // TODO: dragging causes the camera to rise because we're getting "pushed" up by lower resolution tiles and // don't lower back down. We should maintain a target height above tiles where possible // prevent the drag from inverting // if we drag to a point that's near the edge of the earth then we want to prevent it // from wrapping around and causing unexpected rotations this.getUpDirection( pivotPoint, _localUp ); if ( Math.abs( raycaster.ray.direction.dot( _localUp ) ) < DRAG_UP_THRESHOLD ) { const angle = Math.acos( DRAG_UP_THRESHOLD ); _rotationAxis .crossVectors( raycaster.ray.direction, _localUp ) .normalize(); raycaster.ray.direction .copy( _localUp ) .applyAxisAngle( _rotationAxis, angle ) .multiplyScalar( - 1 ); } // find the point on the plane that we should drag to if ( raycaster.ray.intersectPlane( _plane, _vec ) ) { _delta.subVectors( pivotPoint, _vec ); camera.position.add( _delta ); camera.updateMatrixWorld(); // update the drag inertia _delta.multiplyScalar( 1 / deltaTime ); if ( pointerTracker.getMoveDistance() / deltaTime < 2 * window.devicePixelRatio ) { this.inertiaStableFrames ++; } else { dragInertia.copy( _delta ); this.inertiaStableFrames = 0; } } } } _updateRotation( deltaTime ) { const { pivotPoint, pointerTracker, domElement, state, rotationInertia, } = this; if ( state === ROTATE || state === FREE_ROTATE ) { // keep the pivot glued to the camera for first-person look-around if ( state === FREE_ROTATE ) { pivotPoint.copy( this.camera.position ); } // get the rotation motion and divide out the container height to normalize for element size pointerTracker.getCenterPoint( _pointer ); pointerTracker.getPreviousCenterPoint( _prevPointer ); _deltaPointer.subVectors( _pointer, _prevPointer ).multiplyScalar( 2 * Math.PI / domElement.clientHeight ); this._applyRotation( _deltaPointer.x, _deltaPointer.y, pivotPoint ); // update rotation inertia _deltaPointer.multiplyScalar( 1 / deltaTime ); if ( pointerTracker.getMoveDistance() / deltaTime < 2 * window.devicePixelRatio ) { this.inertiaStableFrames ++; } else { rotationInertia.copy( _deltaPointer ); this.inertiaStableFrames = 0; } } } _applyRotation( x, y, pivotPoint ) { if ( x === 0 && y === 0 ) { return; } const { camera, minAltitude, maxAltitude, rotationSpeed, } = this; const azimuth = - x * rotationSpeed; let altitude = y * rotationSpeed; // calculate current angles and clamp _forward.set( 0, 0, 1 ).transformDirection( camera.matrixWorld ); _right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld ); this.getUpDirection( pivotPoint, _localUp ); // get the signed angle relative to the top down view let angle; if ( _localUp.dot( _forward ) > 1 - 1e-10 ) { angle = 0; } else { _vec.crossVectors( _localUp, _forward ).normalize(); const sign = Math.sign( _vec.dot( _right ) ); angle = sign * _localUp.angleTo( _forward ); } // clamp the rotation to be within the provided limits // clamp to 0 here, as well, so we don't "pop" to the the value range if ( altitude > 0 ) { altitude = Math.min( angle - minAltitude, altitude ); altitude = Math.max( 0, altitude ); } else { altitude = Math.max( angle - maxAltitude, altitude ); altitude = Math.min( 0, altitude ); } // rotate around the up axis _quaternion.setFromAxisAngle( _localUp, azimuth ); makeRotateAroundPoint( pivotPoint, _quaternion, _rotMatrix ); camera.matrixWorld.premultiply( _rotMatrix ); // get a rotation axis for altitude and rotate _right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld ); _quaternion.setFromAxisAngle( _right, - altitude ); makeRotateAroundPoint( pivotPoint, _quaternion, _rotMatrix ); camera.matrixWorld.premultiply( _rotMatrix ); // update the transform members camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec ); } // sets the "up" axis for the current surface of the tileset _setFrame( newUp ) { const { up, camera, zoomPoint, zoomDirectionSet, zoomPointSet, scaleZoomOrientationAtEdges, } = this; // If we're zooming then reorient around the zoom point if ( zoomDirectionSet && ( zoomPointSet || this._updateZoomPoint() ) ) { // get the amount needed to rotate _quaternion.setFromUnitVectors( up, newUp ); if ( scaleZoomOrientationAtEdges ) { this.getUpDirection( zoomPoint, _vec ); let amt = Math.max( _vec.dot( up ) - 0.6, 0 ) / 0.4; amt = MathUtils.mapLinear( amt, 0, 0.5, 0, 1 ); amt = Math.min( amt, 1 ); // scale the value if we're using an orthographic camera so // GlobeControls works correctly if ( camera.isOrthographicCamera ) { amt *= 0.1; } _quaternion.slerp( _identityQuat, 1.0 - amt ); } // rotates the camera position around the point being zoomed in to makeRotateAroundPoint( zoomPoint, _quaternion, _rotMatrix ); camera.updateMatrixWorld(); camera.matrixWorld.premultiply( _rotMatrix ); camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec ); // recompute the zoom direction after updating rotation to align with frame this.zoomDirectionSet = false; this._updateZoomDirection(); } up.copy( newUp ); camera.updateMatrixWorld(); } _raycast( raycaster ) { const { scene, useFallbackPlane, fallbackPlane } = this; const result = raycaster.intersectObject( scene )[ 0 ] || null; if ( result ) { return result; } else if ( useFallbackPlane ) { // if we don't hit any geometry then try to intersect the fallback // plane so the camera can still be manipulated const plane = fallbackPlane; if ( raycaster.ray.intersectPlane( plane, _vec ) ) { const planeHit = { point: _vec.clone(), distance: raycaster.ray.origin.distanceTo( _vec ), }; return planeHit; } } return null; } // tilt the camera to align with the provided "up" value _alignCameraUp( up, alpha = 1 ) { const { camera, state, pivotPoint, zoomPoint, zoomPointSet } = this; // get the transform vectors camera.updateMatrixWorld(); _forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); _right.set( - 1, 0, 0 ).transformDirection( camera.matrixWorld ); // compute an alpha based on the camera direction so we don't try to update the up direction // when the camera is facing that way. let multiplier = MathUtils.mapLinear( 1 - Math.abs( _forward.dot( up ) ), 0, 0.2, 0, 1 ); multiplier = MathUtils.clamp( multiplier, 0, 1 ); alpha *= multiplier; // calculate the target direction for the right-facing vector _targetRight.crossVectors( up, _forward ); _targetRight.lerp( _right, 1 - alpha ).normalize(); // adjust the camera transformation _quaternion.setFromUnitVectors( _right, _targetRight ); camera.quaternion.premultiply( _quaternion ); // calculate the active point let fixedPoint = null; if ( state === DRAG || state === ROTATE || state === FREE_ROTATE ) { fixedPoint = _pos.copy( pivotPoint ); } else if ( zoomPointSet ) { fixedPoint = _pos.copy( zoomPoint ); } // shift the camera in an effort to keep the fixed point in the same spot if ( fixedPoint ) { _invMatrix.copy( camera.matrixWorld ).invert(); _vec.copy( fixedPoint ).applyMatrix4( _invMatrix ); camera.updateMatrixWorld(); _vec.applyMatrix4( camera.matrixWorld ); _center.subVectors( fixedPoint, _vec ); camera.position.add( _center ); } camera.updateMatrixWorld(); } // clamp rotation to the given "up" vector _clampRotation( up ) { const { camera, minAltitude, maxAltitude, state, pivotPoint, zoomPoint, zoomPointSet } = this; camera.updateMatrixWorld(); // calculate current angles and clamp _forward.set( 0, 0, 1 ).transformDirection( camera.matrixWorld ); _right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld ); // get the signed angle relative to the top down view let angle; if ( up.dot( _forward ) > 1 - 1e-10 ) { angle = 0; } else { _vec.crossVectors( up, _forward ); const sign = Math.sign( _vec.dot( _right ) ); angle = sign * up.angleTo( _forward ); } // find the angle to target let targetAngle; if ( angle > maxAltitude ) { targetAngle = maxAltitude; } else if ( angle < minAltitude ) { targetAngle = minAltitude; } else { return; } // construct a rotation basis _forward.copy( up ); _quaternion.setFromAxisAngle( _right, targetAngle ); _forward.applyQuaternion( _quaternion ).normalize(); _vec.crossVectors( _forward, _right ).normalize(); _rotMatrix.makeBasis( _right, _vec, _forward ); camera.quaternion.setFromRotationMatrix( _rotMatrix ); // calculate the active point let fixedPoint = null; if ( state === DRAG || state === ROTATE || state === FREE_ROTATE ) { fixedPoint = _pos.copy( pivotPoint ); } else if ( zoomPointSet ) { fixedPoint = _pos.copy( zoomPoint ); } // shift the camera in an effort to keep the fixed point in the same spot if ( fixedPoint ) { _invMatrix.copy( camera.matrixWorld ).invert(); _vec.copy( fixedPoint ).applyMatrix4( _invMatrix ); camera.updateMatrixWorld(); _vec.applyMatrix4( camera.matrixWorld ); _center.subVectors( fixedPoint, _vec ); camera.position.add( _center ); } camera.updateMatrixWorld(); } }