diff --git a/example/googleMapsExample.js b/example/googleMapsExample.js index f8a3c7c7b..5d7408847 100644 --- a/example/googleMapsExample.js +++ b/example/googleMapsExample.js @@ -1,39 +1,31 @@ -import { GeoUtils, WGS84_ELLIPSOID, WGS84_RADIUS, DebugGoogleTilesRenderer as GoogleTilesRenderer } from '../src/index.js'; +import { GeoUtils, WGS84_ELLIPSOID, DebugGoogleTilesRenderer as GoogleTilesRenderer } from '../src/index.js'; import { Scene, WebGLRenderer, PerspectiveCamera, - Raycaster, - Box3, MathUtils, } from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; -import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'; +import { estimateBytesUsed } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import Stats from 'three/examples/jsm/libs/stats.module.js'; - -import { MapControls } from './src/lib/MapControls.js'; +import { GlobeControls } from './src/controls/GlobeControls.js'; let camera, controls, scene, renderer, tiles; let statsContainer, stats; -const raycaster = new Raycaster(); -raycaster.firstHitOnly = true; - const apiKey = localStorage.getItem( 'googleApiKey' ) ?? 'put-your-api-key-here'; const params = { - 'enableUpdate': true, - 'enableCacheDisplay': false, - 'enableRendererStats': false, - 'apiKey': apiKey, + enableCacheDisplay: false, + enableRendererStats: false, + apiKey: apiKey, - 'displayBoxBounds': false, - 'displaySphereBounds': false, - 'displayRegionBounds': false, 'reload': reinstantiateTiles, + displayBoxBounds: false, + displayRegionBounds: false, }; @@ -42,8 +34,6 @@ animate(); function reinstantiateTiles() { - localStorage.setItem( 'googleApiKey', params.apiKey ); - if ( tiles ) { scene.remove( tiles.group ); @@ -52,6 +42,8 @@ function reinstantiateTiles() { } + localStorage.setItem( 'googleApiKey', params.apiKey ); + tiles = new GoogleTilesRenderer( params.apiKey ); tiles.group.rotation.x = - Math.PI / 2; @@ -69,6 +61,8 @@ function reinstantiateTiles() { tiles.setResolutionFromRenderer( camera, renderer ); tiles.setCamera( camera ); + controls.setScene( tiles.group ); + } function init() { @@ -82,14 +76,11 @@ function init() { document.body.appendChild( renderer.domElement ); camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 1, 160000000 ); - camera.position.set( 7326000, 10279000, - 823000 ); + camera.position.set( 4800000, 2570000, 14720000 ); + camera.lookAt( 0, 0, 0 ); // controls - controls = new MapControls( camera, renderer.domElement ); - controls.minDistance = 1; - controls.maxDistance = Infinity; - controls.minPolarAngle = Math.PI / 4; - controls.target.set( 0, 0, 1 ); + controls = new GlobeControls( scene, camera, renderer.domElement ); reinstantiateTiles(); @@ -103,30 +94,14 @@ function init() { const mapsOptions = gui.addFolder( 'Google Tiles' ); mapsOptions.add( params, 'apiKey' ); mapsOptions.add( params, 'reload' ); - mapsOptions.open(); const debug = gui.addFolder( 'Debug Options' ); debug.add( params, 'displayBoxBounds' ); - debug.add( params, 'displaySphereBounds' ); debug.add( params, 'displayRegionBounds' ); const exampleOptions = gui.addFolder( 'Example Options' ); - exampleOptions.add( params, 'enableUpdate' ).onChange( v => { - - tiles.parseQueue.autoUpdate = v; - tiles.downloadQueue.autoUpdate = v; - - if ( v ) { - - tiles.parseQueue.scheduleJobRun(); - tiles.downloadQueue.scheduleJobRun(); - - } - - } ); exampleOptions.add( params, 'enableCacheDisplay' ); exampleOptions.add( params, 'enableRendererStats' ); - gui.open(); statsContainer = document.createElement( 'div' ); document.getElementById( 'info' ).appendChild( statsContainer ); @@ -152,39 +127,6 @@ function onWindowResize() { } -function updateControls() { - - const raycaster = new Raycaster(); - raycaster.ray.origin.copy( controls.target ).normalize().multiplyScalar( WGS84_RADIUS * 1.5 ); - raycaster.ray.direction.copy( raycaster.ray.origin ).normalize().multiplyScalar( - 1 ); - raycaster.firstHitOnly = true; - - const hit = raycaster.intersectObject( scene, true )[ 0 ]; - if ( hit ) { - - controls.target.copy( hit.point ); - - } else { - - controls.target.normalize().multiplyScalar( WGS84_RADIUS ); - - } - controls.panPlane.copy( controls.target ).normalize(); - - const dist = camera.position.length(); - camera.position.copy( controls.target ).normalize().multiplyScalar( dist ); - camera.lookAt( controls.target ); - controls.update(); - - const box = new Box3(); - tiles.getBounds( box ); - - camera.far = dist; - camera.near = Math.max( 1, dist - Math.max( ...box.min, ...box.max ) ); - camera.updateProjectionMatrix(); - -} - function updateHash() { if ( ! tiles ) { @@ -195,7 +137,7 @@ function updateHash() { const res = {}; const mat = tiles.group.matrixWorld.clone().invert(); - const vec = controls.target.clone().applyMatrix4( mat ); + const vec = camera.position.clone().applyMatrix4( mat ); WGS84_ELLIPSOID.getPositionToCartographic( vec, res ); res.lat *= MathUtils.RAD2DEG; @@ -215,11 +157,11 @@ function initFromHash() { } const [ lat, lon ] = tokens; - WGS84_ELLIPSOID.getCartographicToPosition( lat * MathUtils.DEG2RAD, lon * MathUtils.DEG2RAD, 0, controls.target ); + WGS84_ELLIPSOID.getCartographicToPosition( lat * MathUtils.DEG2RAD, lon * MathUtils.DEG2RAD, 0, camera.position ); tiles.group.updateMatrixWorld(); - controls.target.applyMatrix4( tiles.group.matrixWorld ); - updateControls(); + camera.position.applyMatrix4( tiles.group.matrixWorld ).multiplyScalar( 2 ); + camera.lookAt( 0, 0, 0 ); } @@ -229,36 +171,35 @@ function animate() { if ( ! tiles ) return; - updateControls(); + controls.update(); // update options tiles.setResolutionFromRenderer( camera, renderer ); tiles.setCamera( camera ); + tiles.displayBoxBounds = params.displayBoxBounds; + tiles.displayRegionBounds = params.displayRegionBounds; // update tiles - if ( params.enableUpdate ) { + camera.updateMatrixWorld(); + tiles.update(); - camera.updateMatrixWorld(); - tiles.update(); - - } - - render(); + renderer.render( scene, camera ); stats.update(); -} + updateHtml(); -function render() { +} - // render primary view - renderer.render( scene, camera ); +function updateHtml() { // render html text updates const cacheFullness = tiles.lruCache.itemList.length / tiles.lruCache.maxSize; - let str = `Downloading: ${ tiles.stats.downloading } Parsing: ${ tiles.stats.parsing } Visible: ${ tiles.visibleTiles.size }`; + let str = ''; if ( params.enableCacheDisplay ) { + str += `Downloading: ${ tiles.stats.downloading } Parsing: ${ tiles.stats.parsing } Visible: ${ tiles.visibleTiles.size }
`; + const geomSet = new Set(); tiles.traverse( tile => { @@ -282,10 +223,10 @@ function render() { let count = 0; geomSet.forEach( g => { - count += BufferGeometryUtils.estimateBytesUsed( g ); + count += estimateBytesUsed( g ); } ); - str += `
Cache: ${ ( 100 * cacheFullness ).toFixed( 2 ) }% ~${ ( count / 1000 / 1000 ).toFixed( 2 ) }mb`; + str += `Cache: ${ ( 100 * cacheFullness ).toFixed( 2 ) }% ~${ ( count / 1000 / 1000 ).toFixed( 2 ) }mb
`; } @@ -293,7 +234,7 @@ function render() { const memory = renderer.info.memory; const programCount = renderer.info.programs.length; - str += `
Geometries: ${ memory.geometries } Textures: ${ memory.textures } Programs: ${ programCount }`; + str += `Geometries: ${ memory.geometries } Textures: ${ memory.textures } Programs: ${ programCount }`; } @@ -303,15 +244,11 @@ function render() { } - if ( tiles ) { - - const mat = tiles.group.matrixWorld.clone().invert(); - const vec = camera.position.clone().applyMatrix4( mat ); - - const res = {}; - WGS84_ELLIPSOID.getPositionToCartographic( vec, res ); - document.getElementById( 'credits' ).innerText = GeoUtils.toLatLonString( res.lat, res.lon ) + '\n' + tiles.getCreditsString(); + const mat = tiles.group.matrixWorld.clone().invert(); + const vec = camera.position.clone().applyMatrix4( mat ); - } + const res = {}; + WGS84_ELLIPSOID.getPositionToCartographic( vec, res ); + document.getElementById( 'credits' ).innerText = GeoUtils.toLatLonString( res.lat, res.lon ) + '\n' + tiles.getCreditsString(); } diff --git a/example/src/FadeTilesRenderer.js b/example/src/FadeTilesRenderer.js index 0cd8ace9f..0ba4961d4 100644 --- a/example/src/FadeTilesRenderer.js +++ b/example/src/FadeTilesRenderer.js @@ -30,7 +30,7 @@ function onTileVisibilityChange( scene, tile, visible ) { } - if ( ! isRootTile || ! this.fadeRootTiles || this.initialLayerRendered ) { + if ( ! isRootTile || this.fadeRootTiles || this.initialLayerRendered ) { this._fadeManager.fadeIn( scene ); diff --git a/example/src/controls/GlobeControls.js b/example/src/controls/GlobeControls.js new file mode 100644 index 000000000..5fb2bd4d9 --- /dev/null +++ b/example/src/controls/GlobeControls.js @@ -0,0 +1,286 @@ +import { + Matrix4, + Quaternion, + Vector2, + Vector3, + MathUtils, +} from 'three'; +import { TileControls, NONE } from './TileControls.js'; +import { makeRotateAroundPoint } from './utils.js'; +import { WGS84_ELLIPSOID } from '../../../src/index.js'; + +const _invMatrix = new Matrix4(); +const _rotMatrix = new Matrix4(); +const _vec = new Vector3(); +const _center = new Vector3(); +const _up = new Vector3(); +const _forward = new Vector3(); +const _right = new Vector3(); +const _targetRight = new Vector3(); +const _globalUp = new Vector3(); +const _quaternion = new Quaternion(); + +const _pointer = new Vector2(); +const _prevPointer = new Vector2(); +const _deltaPointer = new Vector2(); + +const MAX_GLOBE_DISTANCE = 2 * 1e7; +const GLOBE_TRANSITION_THRESHOLD = 0.75 * 1e7; +export class GlobeControls extends TileControls { + + constructor( ...args ) { + + // store which mode the drag stats are in + super( ...args ); + this.ellipsoid = WGS84_ELLIPSOID; + this._dragMode = 0; + this._rotationMode = 0; + + } + + // get the vector to the center of the provided globe + getVectorToCenter( target ) { + + const { scene, camera } = this; + return target + .setFromMatrixPosition( scene.matrixWorld ) + .sub( camera.position ); + + } + + // get the distance to the center of the globe + getDistanceToCenter() { + + return this + .getVectorToCenter( _vec ) + .length(); + + } + + getUpDirection( point, target ) { + + // get the "up" direction based on the wgs84 ellipsoid + const { scene, ellipsoid } = this; + const invMatrix = _invMatrix.copy( scene.matrixWorld ).invert(); + const pos = _vec.copy( point ).applyMatrix4( invMatrix ); + + ellipsoid.getPositionToNormal( pos, target ); + target.transformDirection( scene.matrixWorld ); + + } + + update() { + + super.update(); + + const { + camera, + scene, + pivotMesh, + } = this; + + // clamp the camera distance + let distanceToCenter = this.getDistanceToCenter(); + if ( distanceToCenter > MAX_GLOBE_DISTANCE ) { + + _vec.setFromMatrixPosition( scene.matrixWorld ).sub( camera.position ).normalize().multiplyScalar( - 1 ); + camera.position.setFromMatrixPosition( scene.matrixWorld ).addScaledVector( _vec, MAX_GLOBE_DISTANCE ); + camera.updateMatrixWorld(); + + distanceToCenter = MAX_GLOBE_DISTANCE; + + } + + // if we're outside the transition threshold then we toggle some reorientation behavior + // when adjusting the up frame while moving hte camera + if ( distanceToCenter > GLOBE_TRANSITION_THRESHOLD ) { + + if ( this.state !== NONE && this._dragMode !== 1 && this._rotationMode !== 1 ) { + + pivotMesh.visible = false; + + } + this.reorientOnDrag = false; + this.reorientOnZoom = true; + + } else { + + this.reorientOnDrag = true; + this.reorientOnZoom = false; + + } + + // update the projection matrix + const largestDistance = Math.max( ...WGS84_ELLIPSOID.radius ); + camera.near = Math.max( 1, distanceToCenter - largestDistance * 1.25 ); + camera.far = distanceToCenter + largestDistance + 0.1; + camera.updateProjectionMatrix(); + + } + + // resets the "stuck" drag modes + resetState() { + + super.resetState(); + this._dragMode = 0; + this._rotationMode = 0; + + } + + // animate the frame to align to an up direction + setFrame( ...args ) { + + super.setFrame( ...args ); + + if ( this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD ) { + + this._alignCameraUp( this.up ); + + } + + } + + _updatePosition( ...args ) { + + if ( this._dragMode === 1 || this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD ) { + + this._dragMode = 1; + + super._updatePosition( ...args ); + + } else { + + this._dragMode = - 1; + + const { + pointerTracker, + rotationSpeed, + camera, + pivotMesh, + dragPoint, + } = this; + + // get the delta movement with magic numbers scaled by the distance to the + // grabbed point so it feels okay + // TODO: it would be better to properly calculate angle based on drag distance + pointerTracker.getCenterPoint( _pointer ); + pointerTracker.getPreviousCenterPoint( _prevPointer ); + _deltaPointer + .subVectors( _pointer, _prevPointer ) + .multiplyScalar( camera.position.distanceTo( dragPoint ) * 1e-11 * 5 ); + + const azimuth = - _deltaPointer.x * rotationSpeed; + const altitude = - _deltaPointer.y * rotationSpeed; + + _center.setFromMatrixPosition( this.scene.matrixWorld ); + _right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld ); + _up.set( 0, 1, 0 ).transformDirection( camera.matrixWorld ); + + // apply the altitude and azimuth adjustment + _quaternion.setFromAxisAngle( _right, altitude ); + camera.quaternion.premultiply( _quaternion ); + makeRotateAroundPoint( _center, _quaternion, _rotMatrix ); + camera.matrixWorld.premultiply( _rotMatrix ); + + _quaternion.setFromAxisAngle( _up, azimuth ); + camera.quaternion.premultiply( _quaternion ); + makeRotateAroundPoint( _center, _quaternion, _rotMatrix ); + camera.matrixWorld.premultiply( _rotMatrix ); + + camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec ); + + pivotMesh.visible = false; + + } + + } + + // disable rotation once we're outside the control transition + _updateRotation( ...args ) { + + if ( this._rotationMode === 1 || this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD ) { + + this._rotationMode = 1; + super._updateRotation( ...args ); + + } else { + + this.pivotMesh.visible = false; + this._rotationMode = - 1; + + } + + } + + _updateZoom( delta ) { + + if ( this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD || delta > 0 ) { + + super._updateZoom( delta ); + + } else { + + // orient the camera to focus on the earth during the zoom + const alpha = MathUtils.mapLinear( this.getDistanceToCenter(), GLOBE_TRANSITION_THRESHOLD, MAX_GLOBE_DISTANCE, 0, 1 ); + this._tiltTowardsCenter( MathUtils.lerp( 1, 0.8, alpha ) ); + this._alignCameraUpToNorth( MathUtils.lerp( 1, 0.9, alpha ) ); + + // zoom out directly from the globe center + this.getVectorToCenter( _vec ); + this.camera.position.addScaledVector( _vec, delta * 0.0025 ); + this.camera.updateMatrixWorld(); + + } + + } + + // tilt the camera to align with north + _alignCameraUpToNorth( alpha ) { + + const { scene } = this; + _globalUp.set( 0, 0, 1 ).transformDirection( scene.matrixWorld ); + this._alignCameraUp( _globalUp, alpha ); + + } + + // tilt the camera to align with the provided "up" value + _alignCameraUp( up, alpha = null ) { + + const { camera } = this; + _forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); + _right.set( - 1, 0, 0 ).transformDirection( camera.matrixWorld ); + _targetRight.crossVectors( up, _forward ); + + if ( alpha === null ) { + + alpha = Math.abs( _forward.dot( up ) ); + + } + + _targetRight.lerp( _right, alpha ).normalize(); + + _quaternion.setFromUnitVectors( _right, _targetRight ); + camera.quaternion.premultiply( _quaternion ); + camera.updateMatrixWorld(); + + } + + // tilt the camera to look at the center of the globe + _tiltTowardsCenter( alpha ) { + + const { + camera, + scene, + } = this; + + _forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ).normalize(); + _vec.setFromMatrixPosition( scene.matrixWorld ).sub( camera.position ).normalize(); + _vec.lerp( _forward, alpha ).normalize(); + + _quaternion.setFromUnitVectors( _forward, _vec ); + camera.quaternion.premultiply( _quaternion ); + camera.updateMatrixWorld(); + + } + +} diff --git a/example/src/controls/PivotPointMesh.js b/example/src/controls/PivotPointMesh.js new file mode 100644 index 000000000..3b6531479 --- /dev/null +++ b/example/src/controls/PivotPointMesh.js @@ -0,0 +1,106 @@ +import { Mesh, PlaneGeometry, ShaderMaterial, Vector2 } from 'three'; +export class PivotPointMesh extends Mesh { + + constructor() { + + super( new PlaneGeometry( 0, 0 ), new PivotMaterial() ); + this.renderOrder = Infinity; + + } + + onBeforeRender( renderer ) { + + const uniforms = this.material.uniforms; + renderer.getSize( uniforms.resolution.value ); + uniforms.pixelRatio.value = renderer.getPixelRatio(); + + } + + updateMatrixWorld() { + + this.matrixWorld.makeTranslation( this.position ); + + } + + dispose() { + + this.geometry.dispose(); + this.material.dispose(); + + } + +} + +class PivotMaterial extends ShaderMaterial { + + constructor() { + + super( { + + depthWrite: false, + depthTest: false, + transparent: true, + + uniforms: { + + resolution: { value: new Vector2() }, + pixelRatio: { value: 1 }, + size: { value: 7.5 }, + thickness: { value: 1 }, + opacity: { value: 1 }, + + }, + + vertexShader: /* glsl */` + + uniform float pixelRatio; + uniform float size; + uniform float thickness; + uniform vec2 resolution; + varying vec2 vUv; + + void main() { + + vUv = uv; + + float aspect = resolution.x / resolution.y; + vec2 offset = uv * 2.0 - vec2( 1.0 ); + offset.y *= aspect; + + vec4 screenPoint = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + screenPoint.xy += offset * pixelRatio * ( size + thickness ) * screenPoint.w / resolution.x; + + gl_Position = screenPoint; + + } + `, + + fragmentShader: /* glsl */` + + uniform float size; + uniform float thickness; + uniform float opacity; + + varying vec2 vUv; + void main() { + + float ht = 0.5 * thickness; + float planeDim = size + thickness; + float offset = ( planeDim - ht - 2.0 ) / planeDim; + float texelThickness = ht / planeDim; + + vec2 vec = vUv * 2.0 - vec2( 1.0 ); + float dist = abs( length( vec ) - offset ); + float fw = fwidth( dist ); + float a = smoothstep( texelThickness - fw, texelThickness + fw, dist ); + + gl_FragColor = vec4( 1, 1, 1, opacity * ( 1.0 - a ) ); + + } + `, + + } ); + + } + +} diff --git a/example/src/controls/PointerTracker.js b/example/src/controls/PointerTracker.js new file mode 100644 index 000000000..2dcaf53a2 --- /dev/null +++ b/example/src/controls/PointerTracker.js @@ -0,0 +1,188 @@ +import { Vector2 } from 'three'; + +export class PointerTracker { + + constructor() { + + this.buttons = 0; + this.pointerType = null; + this.pointerOrder = []; + this.previousPositions = {}; + this.pointerPositions = {}; + this.startPositions = {}; + this.hoverPosition = new Vector2(); + this.hoverSet = false; + + } + + setHoverEvent( e ) { + + if ( e.pointerType === 'mouse' ) { + + this.hoverPosition.set( e.clientX, e.clientY ); + this.hoverSet = true; + + } + + } + + getLatestPoint( target ) { + + if ( this.pointerType !== null ) { + + this.getCenterPoint( target ); + return target; + + } else if ( this.hoverSet ) { + + target.copy( this.hoverPosition ); + return target; + + } else { + + return null; + + } + + } + + addPointer( e ) { + + const id = e.pointerId; + const position = new Vector2( e.clientX, e.clientY ); + this.pointerOrder.push( id ); + this.pointerPositions[ id ] = position; + this.previousPositions[ id ] = position.clone(); + this.startPositions[ id ] = position.clone(); + + if ( this.getPointerCount() === 1 ) { + + this.pointerType = e.pointerType; + this.buttons = e.buttons; + + } + + } + + updatePointer( e ) { + + const id = e.pointerId; + if ( ! ( id in this.pointerPositions ) ) { + + return false; + + } + + const position = this.pointerPositions[ id ]; + this.previousPositions[ id ].copy( position ); + this.pointerPositions[ id ].set( e.clientX, e.clientY ); + return true; + + } + + deletePointer( e ) { + + const id = e.pointerId; + const pointerOrder = this.pointerOrder; + pointerOrder.splice( pointerOrder.indexOf( id ), 1 ); + delete this.pointerPositions[ id ]; + delete this.previousPositions[ id ]; + delete this.startPositions[ id ]; + + if ( this.getPointerCount.length === 0 ) { + + this.buttons = 0; + this.pointerType = null; + + } + + } + + getPointerCount() { + + return this.pointerOrder.length; + + } + + getCenterPoint( target, pointerPositions = this.pointerPositions ) { + + const pointerOrder = this.pointerOrder; + if ( this.getPointerCount() === 1 || this.getPointerType() === 'mouse' ) { + + const id = pointerOrder[ 0 ]; + target.copy( pointerPositions[ id ] ); + return target; + + } else if ( this.getPointerCount() === 2 ) { + + const id0 = this.pointerOrder[ 0 ]; + const id1 = this.pointerOrder[ 1 ]; + + const p0 = pointerPositions[ id0 ]; + const p1 = pointerPositions[ id1 ]; + + target.addVectors( p0, p1 ).multiplyScalar( 0.5 ); + return target; + + } + + return null; + + } + + getPreviousCenterPoint( target ) { + + return this.getCenterPoint( target, this.previousPositions ); + + } + + getPointerDistance( pointerPositions = this.pointerPositions ) { + + if ( this.getPointerCount() <= 1 || this.getPointerType() === 'mouse' ) { + + return 0; + + } + + const { pointerOrder } = this; + const id0 = pointerOrder[ 0 ]; + const id1 = pointerOrder[ 1 ]; + + const p0 = pointerPositions[ id0 ]; + const p1 = pointerPositions[ id1 ]; + + return p0.distanceTo( p1 ); + + } + + getPreviousPointerDistance() { + + return this.getPointerDistance( this.previousPositions ); + + } + + getPointerType() { + + return this.pointerType; + + } + + getPointerButtons() { + + return this.buttons; + + } + + isLeftClicked() { + + return Boolean( this.buttons & 1 ); + + } + + isRightClicked() { + + return Boolean( this.buttons & 2 ); + + } + +} diff --git a/example/src/controls/TileControls.js b/example/src/controls/TileControls.js new file mode 100644 index 000000000..23128ae25 --- /dev/null +++ b/example/src/controls/TileControls.js @@ -0,0 +1,793 @@ +import { + Matrix4, + Quaternion, + Vector2, + Vector3, + Raycaster, + Plane, +} from 'three'; +import { PivotPointMesh } from './PivotPointMesh.js'; +import { PointerTracker } from './PointerTracker.js'; +import { mouseToCoords, makeRotateAroundPoint } from './utils.js'; + +export const NONE = 0; +export const DRAG = 1; +export const ROTATE = 2; +export const ZOOM = 3; + +const DRAG_PLANE_THRESHOLD = 0.05; +const DRAG_UP_THRESHOLD = 0.025; + +const _rotMatrix = new Matrix4(); +const _delta = new Vector3(); +const _vec = new Vector3(); +const _forward = new Vector3(); +const _rotationAxis = new Vector3(); +const _quaternion = new Quaternion(); +const _plane = new Plane(); +const _localUp = new Vector3(); + +const _pointer = new Vector2(); +const _prevPointer = new Vector2(); +const _deltaPointer = new Vector2(); +const _centerPoint = new Vector2(); +const _originalCenterPoint = new Vector2(); + +export class TileControls { + + constructor( scene, camera, domElement ) { + + this.domElement = null; + this.camera = null; + this.scene = null; + + // settings + this.state = NONE; + this.cameraRadius = 5; + this.rotationSpeed = 5; + this.minAltitude = 0; + this.maxAltitude = 0.45 * Math.PI; + this.minDistance = 10; + this.maxDistance = Infinity; + this.reorientOnDrag = true; + this.reorientOnZoom = false; + + // internal state + this.pointerTracker = new PointerTracker(); + + this.dragPointSet = false; + this.dragPoint = new Vector3(); + this.startDragPoint = new Vector3(); + + this.rotationPointSet = false; + this.rotationPoint = new Vector3(); + + this.zoomDirectionSet = false; + this.zoomPointSet = false; + this.zoomDirection = new Vector3(); + this.zoomPoint = new Vector3(); + + this.pivotMesh = new PivotPointMesh(); + this.pivotMesh.raycast = () => {}; + this.pivotMesh.scale.setScalar( 0.25 ); + + this.raycaster = new Raycaster(); + this.raycaster.firstHitOnly = true; + + this.up = new Vector3( 0, 1, 0 ); + + this._detachCallback = null; + this._upInitialized = false; + + // init + this.attach( domElement ); + this.setCamera( camera ); + this.setScene( scene ); + + } + + setScene( scene ) { + + this.scene = scene; + + } + + setCamera( camera ) { + + this.camera = camera; + + } + + attach( domElement ) { + + if ( this.domElement ) { + + throw new Error( 'GlobeControls: 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; + domElement.style.touchAction = 'none'; + + let pinchAction = NONE; + let shiftClicked = false; + + const contextMenuCallback = e => { + + e.preventDefault(); + + }; + + const keydownCallback = e => { + + if ( e.key === 'Shift' ) { + + shiftClicked = true; + + } + + }; + + const keyupCallback = e => { + + if ( e.key === 'Shift' ) { + + shiftClicked = false; + + } + + }; + + const pointerdownCallback = e => { + + const { + camera, + raycaster, + domElement, + scene, + up, + pivotMesh, + pointerTracker, + } = this; + + // init the pointer + pointerTracker.addPointer( e ); + + // handle cases where we need to capture the pointer or + // reset state when we have too many pointers + if ( pointerTracker.getPointerType() === 'touch' ) { + + pivotMesh.visible = false; + + if ( pointerTracker.getPointerCount() === 0 ) { + + domElement.setPointerCapture( e.pointerId ); + + } else if ( pointerTracker.getPointerCount() > 2 ) { + + this.resetState(); + pinchAction = NONE; + return; + + } + + } + + // the "pointer" for zooming and rotating should be based on the center point + pointerTracker.getCenterPoint( _pointer ); + mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer ); + + // find the hit point + raycaster.setFromCamera( _pointer, camera ); + const hit = raycaster.intersectObject( scene )[ 0 ] || null; + 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() && shiftClicked + ) { + + this.state = ROTATE; + this.rotationPoint.copy( hit.point ); + this.rotationPointSet = true; + + this.pivotMesh.position.copy( hit.point ); + this.pivotMesh.updateMatrixWorld(); + this.scene.add( this.pivotMesh ); + + } else if ( pointerTracker.isLeftClicked() ) { + + // if the clicked point is coming from below the plane then don't perform the drag + if ( raycaster.ray.direction.dot( up ) < 0 ) { + + this.state = DRAG; + this.dragPoint.copy( hit.point ); + this.startDragPoint.copy( hit.point ); + this.dragPointSet = true; + + this.pivotMesh.position.copy( hit.point ); + this.pivotMesh.updateMatrixWorld(); + this.scene.add( this.pivotMesh ); + + } + + } + + } + + }; + + let _pointerMoveQueued = false; + const pointermoveCallback = e => { + + // whenever the pointer moves we need to re-derive the zoom direction and point + this.zoomDirectionSet = false; + this.zoomPointSet = false; + + const { pointerTracker } = this; + pointerTracker.setHoverEvent( e ); + if ( ! pointerTracker.updatePointer( e ) ) { + + return; + + } + + if ( pointerTracker.getPointerType() === 'touch' ) { + + if ( pointerTracker.getPointerCount() === 1 ) { + + if ( this.state === DRAG ) { + + this._updatePosition(); + + } + + } else if ( 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 previousDist = pointerTracker.getPreviousPointerDistance(); + const pointerDist = pointerTracker.getPointerDistance(); + const separateDelta = pointerDist - previousDist; + if ( pinchAction === NONE ) { + + // 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.getPreviousCenterPoint( _originalCenterPoint ); + + const parallelDelta = _centerPoint.distanceTo( _originalCenterPoint ); + if ( Math.abs( separateDelta ) > 0 || parallelDelta > 0 ) { + + if ( Math.abs( separateDelta ) > parallelDelta ) { + + this.resetState(); + pinchAction = ZOOM; + this.zoomDirectionSet = false; + + } else { + + pinchAction = ROTATE; + + } + + } + + } + + if ( pinchAction === ZOOM ) { + + this._updateZoom( separateDelta ); + + } else if ( pinchAction === ROTATE ) { + + this._updateRotation(); + this.pivotMesh.visible = true; + + } + + } ); + + } + + } + + } else if ( pointerTracker.getPointerType() === 'mouse' ) { + + if ( this.state === DRAG ) { + + this._updatePosition(); + + } else if ( this.state === ROTATE ) { + + this._updateRotation(); + + } + + } + + }; + + const pointerupCallback = e => { + + const { pointerTracker } = this; + + pointerTracker.deletePointer( e ); + pinchAction = NONE; + + if ( + pointerTracker.getPointerType() === 'touch' && + pointerTracker.getPointerCount() === 0 + ) { + + domElement.releasePointerCapture( e.pointerId ); + + } + + this.resetState(); + + }; + + const wheelCallback = e => { + + this._updateZoom( - e.deltaY ); + + }; + + const pointerenterCallback = e => { + + const { pointerTracker } = this; + + shiftClicked = false; + + if ( e.buttons !== pointerTracker.getPointerButtons() ) { + + pointerTracker.deletePointer( e ); + this.resetState(); + + } + + }; + + domElement.addEventListener( 'contextmenu', contextMenuCallback ); + domElement.addEventListener( 'keydown', keydownCallback ); + domElement.addEventListener( 'keyup', keyupCallback ); + domElement.addEventListener( 'pointerdown', pointerdownCallback ); + domElement.addEventListener( 'pointermove', pointermoveCallback ); + domElement.addEventListener( 'pointerup', pointerupCallback ); + domElement.addEventListener( 'wheel', wheelCallback ); + domElement.addEventListener( 'pointerenter', pointerenterCallback ); + + this._detachCallback = () => { + + domElement.removeEventListener( 'contextmenu', contextMenuCallback ); + domElement.removeEventListener( 'keydown', keydownCallback ); + domElement.removeEventListener( 'keyup', keyupCallback ); + domElement.removeEventListener( 'pointerdown', pointerdownCallback ); + domElement.removeEventListener( 'pointermove', pointermoveCallback ); + domElement.removeEventListener( 'pointerup', pointerupCallback ); + domElement.removeEventListener( 'wheel', wheelCallback ); + domElement.removeEventListener( 'pointerenter', pointerenterCallback ); + + }; + + } + + getUpDirection( point, target ) { + + target.copy( this.up ); + + } + + detach() { + + if ( this._detachCallback ) { + + this._detachCallback(); + this._detachCallback = null; + this.pointerTracker = new PointerTracker(); + + } + + } + + resetState() { + + this.state = NONE; + this.dragPointSet = false; + this.rotationPointSet = false; + this.scene.remove( this.pivotMesh ); + this.pivotMesh.visible = true; + + } + + update() { + + const { + raycaster, + camera, + cameraRadius, + dragPoint, + startDragPoint, + up, + } = this; + + // reuse the "hit" information since it can be slow to perform multiple hits + const hit = this._getPointBelowCamera(); + if ( this.getUpDirection ) { + + this.getUpDirection( camera.position, _localUp ); + if ( ! this._upInitialized ) { + + this._upInitialized = true; + this.up.copy( _localUp ); + + } else { + + this._setFrame( _localUp, hit && hit.point || null ); + + } + + } + + // 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 ) { + + _delta.subVectors( startDragPoint, dragPoint ); + camera.position.add( _delta ); + dragPoint.copy( startDragPoint ); + + } + + // cast down from the camera + if ( hit ) { + + const dist = hit.distance; + if ( dist < cameraRadius ) { + + const delta = cameraRadius - dist; + camera.position.copy( hit.point ).addScaledVector( raycaster.ray.direction, - cameraRadius ); + dragPoint.addScaledVector( up, delta ); + + } + + } + + } + + dispose() { + + this.detach(); + + } + + // private + _updateZoom( scale ) { + + const { + zoomPoint, + zoomDirection, + camera, + minDistance, + maxDistance, + raycaster, + pointerTracker, + domElement, + } = this; + + // get the latest hover / touch point + if ( ! pointerTracker.getLatestPoint( _pointer ) ) { + + return; + + } + + // initialize the zoom direction + mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer ); + raycaster.setFromCamera( _pointer, camera ); + zoomDirection.copy( raycaster.ray.direction ).normalize(); + this.zoomDirectionSet = true; + + // track the zoom direction we're going to use + const finalZoomDirection = _vec.copy( zoomDirection ); + + // always update the zoom target point in case the tiles are changing + let dist = Infinity; + if ( this._updateZoomPoint() ) { + + 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 - 0 ) * 0.01; + scale = Math.max( scale, remainingDistance ); + + } else { + + const remainingDistance = Math.max( 0, dist - minDistance ); + scale = scale * ( dist - minDistance ) * 0.01; + 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 ) { + + dist = hit.distance; + finalZoomDirection.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); + camera.position.addScaledVector( finalZoomDirection, scale * dist * 0.01 ); + camera.updateMatrixWorld(); + + } + + } + + } + + // update the point being zoomed in to based on the zoom direction + _updateZoomPoint() { + + const { + camera, + zoomDirectionSet, + zoomDirection, + raycaster, + scene, + zoomPoint, + } = this; + + if ( ! zoomDirectionSet ) { + + return false; + + } + + raycaster.ray.origin.copy( camera.position ); + raycaster.ray.direction.copy( zoomDirection ); + + const hit = raycaster.intersectObject( scene )[ 0 ] || null; + if ( hit ) { + + zoomPoint.copy( hit.point ); + this.zoomPointSet = true; + return true; + + } + + return false; + + } + + // returns the point below the camera + _getPointBelowCamera() { + + const { camera, raycaster, scene, up } = this; + raycaster.ray.direction.copy( up ).multiplyScalar( - 1 ); + raycaster.ray.origin.copy( camera.position ).addScaledVector( up, 1e5 ); + + const hit = raycaster.intersectObject( scene )[ 0 ] || null; + if ( hit ) { + + hit.distance -= 1e5; + + } + + return hit; + + } + + // update the drag action + _updatePosition() { + + const { + raycaster, + camera, + dragPoint, + up, + pointerTracker, + domElement, + } = this; + + // get the pointer and plane + pointerTracker.getCenterPoint( _pointer ); + mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer ); + + _plane.setFromNormalAndCoplanarPoint( up, dragPoint ); + raycaster.setFromCamera( _pointer, camera ); + + // prevent the drag distance from getting too severe by limiting the drag point + // to a reasonable angle with the drag plane + if ( - 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 ( this.getUpDirection ) { + + // 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( dragPoint, _localUp ); + if ( - 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( dragPoint, _vec ); + this.camera.position.add( _delta ); + this.camera.updateMatrixWorld(); + + } + + } + + _updateRotation() { + + const { + camera, + rotationPoint, + minAltitude, + maxAltitude, + up, + domElement, + pointerTracker, + rotationSpeed, + } = this; + + // get the rotation motion + pointerTracker.getCenterPoint( _pointer ); + mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer ); + + pointerTracker.getPreviousCenterPoint( _prevPointer ); + mouseToCoords( _prevPointer.x, _prevPointer.y, domElement, _prevPointer ); + + _deltaPointer.subVectors( _pointer, _prevPointer ); + + const azimuth = - _deltaPointer.x * rotationSpeed; + let altitude = - _deltaPointer.y * rotationSpeed; + + // calculate current angles and clamp + _forward + .set( 0, 0, - 1 ) + .transformDirection( camera.matrixWorld ) + .multiplyScalar( - 1 ); + + if ( this.getUpDirection ) { + + this.getUpDirection( rotationPoint, _localUp ); + + } else { + + _localUp.copy( up ); + + } + + // 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 + const angle = up.angleTo( _forward ); + if ( altitude > 0 ) { + + altitude = Math.min( angle - minAltitude - 1e-2, 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( rotationPoint, _quaternion, _rotMatrix ); + camera.matrixWorld.premultiply( _rotMatrix ); + + // get a rotation axis for altitude and rotate + _rotationAxis.set( - 1, 0, 0 ).transformDirection( camera.matrixWorld ); + + _quaternion.setFromAxisAngle( _rotationAxis, altitude ); + makeRotateAroundPoint( rotationPoint, _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 tile set + _setFrame( newUp, pivot ) { + + const { up, camera, state, zoomPoint, zoomDirection } = this; + camera.updateMatrixWorld(); + + // get the amount needed to rotate + _quaternion.setFromUnitVectors( up, newUp ); + + if ( this.zoomDirectionSet && ( this.zoomPointSet || this._updateZoomPoint() ) ) { + + if ( this.reorientOnZoom ) { + + // rotates the camera position around the point being zoomed in to + makeRotateAroundPoint( zoomPoint, _quaternion, _rotMatrix ); + camera.matrixWorld.premultiply( _rotMatrix ); + camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec ); + + zoomDirection.subVectors( zoomPoint, camera.position ).normalize(); + + } + + } else if ( state === NONE || state === DRAG && this.reorientOnDrag ) { + + // NOTE: We used to derive the pivot point here by getting the point below the camera + // but decided to pass it in via "update" to avoid multiple ray casts + + if ( pivot ) { + + // perform a simple realignment by rotating the camera and adjusting the height + const dist = pivot.distanceTo( camera.position ); + camera.position.copy( pivot ).addScaledVector( newUp, dist ); + camera.quaternion.premultiply( _quaternion ); + camera.updateMatrixWorld(); + + } + + } + + up.copy( newUp ); + + } + +} diff --git a/example/src/controls/utils.js b/example/src/controls/utils.js new file mode 100644 index 000000000..9fb6e0255 --- /dev/null +++ b/example/src/controls/utils.js @@ -0,0 +1,26 @@ +import { Matrix4 } from 'three'; + +const _matrix = new Matrix4(); + +// helper function for constructing a matrix for rotating around a point +export function makeRotateAroundPoint( point, quat, target ) { + + target.makeTranslation( - point.x, - point.y, - point.z ); + + _matrix.makeRotationFromQuaternion( quat ); + target.premultiply( _matrix ); + + _matrix.makeTranslation( point.x, point.y, point.z ); + target.premultiply( _matrix ); + + return target; + +} + +// get the three.js pointer coords from an event +export function mouseToCoords( clientX, clientY, element, target ) { + + target.x = ( ( clientX - element.offsetLeft ) / element.clientWidth ) * 2 - 1; + target.y = - ( ( clientY - element.offsetTop ) / element.clientHeight ) * 2 + 1; + +} diff --git a/src/three/renderers/GoogleTilesRenderer.js b/src/three/renderers/GoogleTilesRenderer.js index 50a538404..e9efa45b0 100644 --- a/src/three/renderers/GoogleTilesRenderer.js +++ b/src/three/renderers/GoogleTilesRenderer.js @@ -27,7 +27,7 @@ const GoogleTilesRendererMixin = base => class extends base { this.downloadQueue.maxJobs = 30; this.lruCache.minSize = 3000; this.lruCache.maxSize = 5000; - this.errorTarget = 20; + this.errorTarget = 40; this.onLoadTileSet = tileset => {