3d-tiles-renderer
Version:
https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification
1,888 lines (1,313 loc) • 46.7 kB
JavaScript
/** @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();
}
}