From 2167c747b49f2f515447bc48fe693621c2d8853f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 09:29:46 +0900 Subject: [PATCH 1/6] EnvironmentControls: Add flight support --- example/three/index.js | 55 ++-- .../three/src/controls/FlyOrbitControls.js | 279 ------------------ .../controls/EnvironmentControls.d.ts | 5 + .../renderer/controls/EnvironmentControls.js | 116 +++++++- 4 files changed, 145 insertions(+), 310 deletions(-) delete mode 100644 example/three/src/controls/FlyOrbitControls.js diff --git a/example/three/index.js b/example/three/index.js index 55f6f6cb8..c61d7deff 100644 --- a/example/three/index.js +++ b/example/three/index.js @@ -1,5 +1,6 @@ import { TilesRenderer, + EnvironmentControls, } from '3d-tiles-renderer'; import { DebugTilesPlugin, @@ -24,7 +25,6 @@ import { OrthographicCamera, Sphere, } from 'three'; -import { FlyOrbitControls } from './src/controls/FlyOrbitControls.js'; import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; @@ -144,6 +144,7 @@ function init() { camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 1, 10000 ); camera.position.set( 400, 400, 400 ); + camera.lookAt( 0, 0, 0 ); cameraHelper = new CameraHelper( camera ); scene.add( cameraHelper ); @@ -168,10 +169,12 @@ function init() { secondRenderer.domElement.style.outline = '#0f1416 solid 2px'; secondRenderer.domElement.tabIndex = 1; - secondControls = new FlyOrbitControls( secondCamera, secondRenderer.domElement ); - secondControls.screenSpacePanning = false; + secondControls = new EnvironmentControls( null, secondCamera, secondRenderer.domElement ); + secondControls.enableFlight = true; + secondControls.flightSpeed = 200; secondControls.minDistance = 1; secondControls.maxDistance = 5000; + secondControls.useFallbackPlane = false; secondCameraHelper = new CameraHelper( secondCamera ); scene.add( secondCameraHelper ); @@ -192,16 +195,20 @@ function init() { thirdPersonRenderer.domElement.style.bottom = '5px'; thirdPersonRenderer.domElement.tabIndex = 1; - thirdPersonControls = new FlyOrbitControls( thirdPersonCamera, thirdPersonRenderer.domElement ); - thirdPersonControls.screenSpacePanning = false; + thirdPersonControls = new EnvironmentControls( null, thirdPersonCamera, thirdPersonRenderer.domElement ); + thirdPersonControls.enableFlight = true; + thirdPersonControls.flightSpeed = 200; thirdPersonControls.minDistance = 1; thirdPersonControls.maxDistance = 5000; + thirdPersonControls.useFallbackPlane = false; // controls - controls = new FlyOrbitControls( camera, renderer.domElement ); - controls.screenSpacePanning = false; + controls = new EnvironmentControls( null, camera, renderer.domElement ); + controls.enableFlight = true; + controls.flightSpeed = 200; controls.minDistance = 1; controls.maxDistance = 5000; + controls.useFallbackPlane = false; // lights const dirLight = new DirectionalLight( 0xffffff, 4 ); @@ -217,6 +224,10 @@ function init() { offsetParent = new Group(); scene.add( offsetParent ); + controls.setScene( offsetParent ); + secondControls.setScene( offsetParent ); + thirdPersonControls.setScene( offsetParent ); + geospatialRotationParent = new Group(); offsetParent.add( geospatialRotationParent ); @@ -405,28 +416,12 @@ function onPointerUp( e ) { if ( results.length ) { const object = results[ 0 ].object; - const info = tiles.getPluginByName( 'DEBUG_TILES_PLUGIN' ).getTileInformationFromActiveObject( object ); + const tile = tiles.getPluginByName( 'DEBUG_TILES_PLUGIN' ).getTileFromObject3D( object ); let str = ''; - for ( const key in info ) { - - let val = info[ key ]; - if ( typeof val === 'number' ) { - - val = Math.floor( val * 1e5 ) / 1e5; - - } - - let name = key; - while ( name.length < 20 ) { - - name += ' '; - - } - - str += `${ name } : ${ val }\n`; - - } + str += `geometricError : ${ tile.geometricError.toFixed( 3 ) }\n`; + str += `error : ${ tile.traversal.error.toFixed( 3 ) }\n`; + str += `refine : ${ tile.refine }\n`; console.log( str ); @@ -439,7 +434,7 @@ function updateOrthoCamera() { orthoCamera.position.copy( camera.position ); orthoCamera.rotation.copy( camera.rotation ); - const scale = camera.position.distanceTo( controls.target ) / 2.0; + const scale = camera.position.distanceTo( controls.pivotPoint ) / 2.0; let aspect = window.innerWidth / window.innerHeight; if ( params.showSecondView ) { @@ -590,6 +585,10 @@ function animate() { window.tiles = tiles; if ( params.enableUpdate ) { + controls.update(); + secondControls.update(); + thirdPersonControls.update(); + secondCamera.updateMatrixWorld(); camera.updateMatrixWorld(); orthoCamera.updateMatrixWorld(); diff --git a/example/three/src/controls/FlyOrbitControls.js b/example/three/src/controls/FlyOrbitControls.js deleted file mode 100644 index ccd844c88..000000000 --- a/example/three/src/controls/FlyOrbitControls.js +++ /dev/null @@ -1,279 +0,0 @@ -import { Clock, Vector3, Vector4 } from 'three'; -import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; - -const changeEvent = { type: 'fly-change' }; -const startEvent = { type: 'fly-start' }; -const endEvent = { type: 'fly-end' }; -const tempVector = new Vector4( 0, 0, 0, 0 ); -export class FlyOrbitControls extends OrbitControls { - - constructor( camera, domElement ) { - - // Disable use of shift key so we can use it for acceleration - const disableShiftKeyCallback = e => { - - if ( this.enabled ) { - - Object.defineProperty( e, 'shiftKey', { get() { - - return false; - - } } ); - - } - - }; - - domElement.addEventListener( 'pointerdown', disableShiftKeyCallback ); - - super( camera, domElement ); - - this.enableKeys = false; - this.enableFlight = true; - this.baseSpeed = 1; - this.fastSpeed = 4; - this.forwardKey = 'w'; - this.backKey = 's'; - this.leftKey = 'a'; - this.rightKey = 'd'; - this.upKey = 'q'; - this.downKey = 'e'; - this.fastKey = 'shift'; - - let fastHeld = false; - let forwardHeld = false; - let backHeld = false; - let leftHeld = false; - let rightHeld = false; - let upHeld = false; - let downHeld = false; - - let originalDistance = 0; - let originalMinDistance = 0; - let originalMaxDistance = 0; - let rafHandle = - 1; - const originalTarget = new Vector3(); - const clock = new Clock(); - - const endFlight = () => { - - if ( rafHandle !== - 1 ) { - - // cancel the animation playing - cancelAnimationFrame( rafHandle ); - rafHandle = - 1; - - // store the original distances for the controls - this.minDistance = originalMinDistance; - this.maxDistance = originalMaxDistance; - - const targetDistance = Math.min( originalDistance, camera.position.distanceTo( originalTarget ) ); - tempVector - .set( 0, 0, - 1, 0 ) - .applyMatrix4( camera.matrixWorld ); - this - .target - .copy( camera.position ) - .addScaledVector( tempVector, targetDistance ); - - this.dispatchEvent( endEvent ); - - } - - }; - - const updateFlight = () => { - - if ( ! this.enabled || ! this.enableFlight ) { - - return; - - } - - rafHandle = requestAnimationFrame( updateFlight ); - - // get the direction - tempVector.set( 0, 0, 0, 0 ); - if ( forwardHeld ) tempVector.z -= 1; - if ( backHeld ) tempVector.z += 1; - if ( leftHeld ) tempVector.x -= 1; - if ( rightHeld ) tempVector.x += 1; - if ( upHeld ) tempVector.y += 1; - if ( downHeld ) tempVector.y -= 1; - tempVector.applyMatrix4( camera.matrixWorld ); - - // apply the movement - const delta = 60 * clock.getDelta(); - const speed = fastHeld ? this.fastSpeed : this.baseSpeed; - camera - .position - .addScaledVector( tempVector, speed * delta ); - this - .target - .addScaledVector( tempVector, speed * delta ); - - this.dispatchEvent( changeEvent ); - - }; - - const keyDownCallback = e => { - - const key = e.key.toLowerCase(); - - if ( rafHandle === - 1 ) { - - originalMaxDistance = this.maxDistance; - originalMinDistance = this.minDistance; - originalDistance = camera.position.distanceTo( this.target ); - originalTarget.copy( this.target ); - - } - - switch ( key ) { - - case this.forwardKey: - forwardHeld = true; - break; - case this.backKey: - backHeld = true; - break; - case this.leftKey: - leftHeld = true; - break; - case this.rightKey: - rightHeld = true; - break; - case this.upKey: - upHeld = true; - break; - case this.downKey: - downHeld = true; - break; - case this.fastKey: - fastHeld = true; - break; - - } - - switch ( key ) { - - case this.fastKey: - case this.forwardKey: - case this.backKey: - case this.leftKey: - case this.rightKey: - case this.upKey: - case this.downKey: - e.stopPropagation(); - e.preventDefault(); - - } - - if ( forwardHeld || backHeld || leftHeld || rightHeld || upHeld || downHeld || fastHeld ) { - - this.minDistance = 0.01; - this.maxDistance = 0.01; - - // Move the orbit target out to just in front of the camera - tempVector - .set( 0, 0, - 1, 0 ) - .applyMatrix4( camera.matrixWorld ); - this - .target - .copy( camera.position ) - .addScaledVector( tempVector, 0.01 ); - - if ( rafHandle === - 1 ) { - - // start the flight and reset the clock - this.dispatchEvent( startEvent ); - clock.getDelta(); - updateFlight(); - - } - - } - - }; - - const keyUpCallback = e => { - - const key = e.key.toLowerCase(); - - switch ( key ) { - - case this.fastKey: - case this.forwardKey: - case this.backKey: - case this.leftKey: - case this.rightKey: - case this.upKey: - case this.downKey: - e.stopPropagation(); - e.preventDefault(); - - } - - switch ( key ) { - - case this.forwardKey: - forwardHeld = false; - break; - case this.backKey: - backHeld = false; - break; - case this.leftKey: - leftHeld = false; - break; - case this.rightKey: - rightHeld = false; - break; - case this.upKey: - upHeld = false; - break; - case this.downKey: - downHeld = false; - break; - case this.fastKey: - fastHeld = false; - break; - - } - - if ( ! ( forwardHeld || backHeld || leftHeld || rightHeld || upHeld || downHeld || fastHeld ) ) { - - endFlight(); - - } - - }; - - const blurCallback = () => { - - endFlight(); - - }; - - this.blurCallback = blurCallback; - this.keyDownCallback = keyDownCallback; - this.keyUpCallback = keyUpCallback; - this.disableShiftKeyCallback = disableShiftKeyCallback; - - this.domElement.addEventListener( 'blur', blurCallback ); - this.domElement.addEventListener( 'keydown', keyDownCallback ); - this.domElement.addEventListener( 'keyup', keyUpCallback ); - - } - - dispose() { - - super.dispose(); - - this.domElement.removeEventListener( 'blur', this.blurCallback ); - this.domElement.removeEventListener( 'keydown', this.keyDownCallback ); - this.domElement.removeEventListener( 'keyup', this.keyUpCallback ); - this.domElement.removeEventListener( 'pointerdown', this.disableShiftKeyCallback ); - - } - -} diff --git a/src/three/renderer/controls/EnvironmentControls.d.ts b/src/three/renderer/controls/EnvironmentControls.d.ts index a7155f823..491a9aa3e 100644 --- a/src/three/renderer/controls/EnvironmentControls.d.ts +++ b/src/three/renderer/controls/EnvironmentControls.d.ts @@ -35,6 +35,11 @@ export class EnvironmentControls extends EventDispatcher { + + this._keysDown.add( e.key.toLowerCase() ); + + }; + + const keyupCallback = e => { + + this._keysDown.delete( e.key.toLowerCase() ); + + }; + + const blurCallback = () => { + + this._keysDown.clear(); + + }; + + window.addEventListener( 'keydown', keydownCallback ); + window.addEventListener( 'keyup', keyupCallback ); + window.addEventListener( 'blur', blurCallback ); + this._detachCallback = () => { domElement.removeEventListener( 'contextmenu', contextMenuCallback ); @@ -631,6 +675,10 @@ export class EnvironmentControls extends EventDispatcher { document.removeEventListener( 'pointerup', pointerupCallback ); document.removeEventListener( 'pointerleave', pointerleaveCallback ); + window.removeEventListener( 'keydown', keydownCallback ); + window.removeEventListener( 'keyup', keyupCallback ); + window.removeEventListener( 'blur', blurCallback ); + }; } @@ -850,10 +898,19 @@ export class EnvironmentControls extends EventDispatcher { } + 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 && this._getPointBelowCamera() || null; + const hit = camera.isOrthographicCamera ? null : ( adjustHeight && ! didFly && this._getPointBelowCamera() ) || null; this.getCameraUpDirection( _localUp ); this._setFrame( _localUp ); @@ -893,7 +950,7 @@ export class EnvironmentControls extends EventDispatcher { this.pointerTracker.updateFrame(); - if ( adjustCameraRotation && autoAdjustCameraRotation ) { + if ( ( adjustCameraRotation && autoAdjustCameraRotation ) || didFly ) { this.getCameraUpDirection( _localUp ); this._alignCameraUp( _localUp, 1 ); @@ -901,7 +958,6 @@ export class EnvironmentControls extends EventDispatcher { this.getCameraUpDirection( _localUp ); this._clampRotation( _localUp ); - } } @@ -1057,6 +1113,60 @@ export class EnvironmentControls extends EventDispatcher { } + _updateFlight( deltaTime ) { + + const { + camera, + enableFlight, + flightSpeed, + flightSpeedMultiplier, + _keysDown, + } = this; + + if ( ! enableFlight ) { + + 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 * 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 { From 4c685d70aad201a57dce64fb259fb948c4608d46 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 09:53:44 +0900 Subject: [PATCH 2/6] Add free rotation --- .../renderer/controls/EnvironmentControls.js | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/three/renderer/controls/EnvironmentControls.js b/src/three/renderer/controls/EnvironmentControls.js index 69ab76d9e..e1551f0a9 100644 --- a/src/three/renderer/controls/EnvironmentControls.js +++ b/src/three/renderer/controls/EnvironmentControls.js @@ -19,6 +19,7 @@ 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; @@ -410,6 +411,21 @@ export class EnvironmentControls extends EventDispatcher { } + // flight mode with shift held: free-look around the camera origin, skip raycasting + if ( + this.enableFlight && + ! pointerTracker.isPointerTouch() && ( + pointerTracker.isRightClicked() || + pointerTracker.isLeftClicked() && e.shiftKey + ) + ) { + + pivotPoint.copy( camera.position ); + this.setState( FREE_ROTATE ); + return; + + } + // find the hit point const hit = this._raycast( raycaster ); if ( hit ) { @@ -877,7 +893,7 @@ export class EnvironmentControls extends EventDispatcher { this._updatePosition( deltaTime ); this._updateRotation( deltaTime ); - if ( state === DRAG || state === ROTATE ) { + 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 ); @@ -917,7 +933,7 @@ export class EnvironmentControls extends EventDispatcher { // 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.actionHeightOffset !== 0 ) { + if ( ( this.state === DRAG || this.state === ROTATE || this.state === FREE_ROTATE ) && this.actionHeightOffset !== 0 ) { const { actionHeightOffset } = this; camera.position.addScaledVector( up, - actionHeightOffset ); @@ -1483,7 +1499,14 @@ export class EnvironmentControls extends EventDispatcher { rotationInertia, } = this; - if ( state === ROTATE ) { + 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 ); @@ -1689,7 +1712,7 @@ export class EnvironmentControls extends EventDispatcher { // calculate the active point let fixedPoint = null; - if ( state === DRAG || state === ROTATE ) { + if ( state === DRAG || state === ROTATE || state === FREE_ROTATE ) { fixedPoint = _pos.copy( pivotPoint ); @@ -1770,7 +1793,7 @@ export class EnvironmentControls extends EventDispatcher { // calculate the active point let fixedPoint = null; - if ( state === DRAG || state === ROTATE ) { + if ( state === DRAG || state === ROTATE || state === FREE_ROTATE ) { fixedPoint = _pos.copy( pivotPoint ); From 7be9f2e8343ea805775befd3fd0f096aec6c9100 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 10:46:01 +0900 Subject: [PATCH 3/6] Updates --- example/three/googleMapsExample.js | 3 + src/three/renderer/API.md | 30 ++++++++ .../renderer/controls/EnvironmentControls.js | 70 +++++++++++++++---- src/three/renderer/controls/GlobeControls.js | 61 +++++++++++++++- 4 files changed, 149 insertions(+), 15 deletions(-) diff --git a/example/three/googleMapsExample.js b/example/three/googleMapsExample.js index 533a52248..dfea2d00a 100644 --- a/example/three/googleMapsExample.js +++ b/example/three/googleMapsExample.js @@ -135,6 +135,9 @@ function init() { // controls controls = new GlobeControls( scene, transition.camera, renderer.domElement, null ); controls.enableDamping = true; + controls.enableFlight = true; + controls.flightSpeed = 0.5; + controls.maxAltitude = Math.PI / 2; // initialize tiles reinstantiateTiles(); diff --git a/src/three/renderer/API.md b/src/three/renderer/API.md index 17074c301..8045c311d 100644 --- a/src/three/renderer/API.md +++ b/src/three/renderer/API.md @@ -600,6 +600,36 @@ useFallbackPlane: boolean When true, the fallback plane is used when raycasting misses scene geometry. Default is true. +### .enableFlight + +```js +enableFlight: boolean +``` + +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. Default is false. + + +### .flightSpeed + +```js +flightSpeed: number +``` + +Base camera speed in world units per second during keyboard flight. Default is 10. + + +### .flightSpeedMultiplier + +```js +flightSpeedMultiplier: number +``` + +Speed multiplier applied when the fast key is held during flight. Default is 4. + + ### .constructor ```js diff --git a/src/three/renderer/controls/EnvironmentControls.js b/src/three/renderer/controls/EnvironmentControls.js index e1551f0a9..c04f0f6d3 100644 --- a/src/three/renderer/controls/EnvironmentControls.js +++ b/src/three/renderer/controls/EnvironmentControls.js @@ -192,13 +192,12 @@ export class EnvironmentControls extends EventDispatcher { */ this.useFallbackPlane = true; - // settings for GlobeControls - this.scaleZoomOrientationAtEdges = false; - this.autoAdjustCameraRotation = true; - // flight /** - * When true, WASD/QE keys move the camera freely through space. Default is false. + * 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. Default is false. * @type {boolean} */ this.enableFlight = false; @@ -215,7 +214,10 @@ export class EnvironmentControls extends EventDispatcher { */ this.flightSpeedMultiplier = 4; - this._keysDown = new Set(); + + // settings for GlobeControls + this.scaleZoomOrientationAtEdges = false; + this.autoAdjustCameraRotation = true; // internal state this.state = NONE; @@ -252,6 +254,8 @@ export class EnvironmentControls extends EventDispatcher { this.up = new Vector3( 0, 1, 0 ); this._lastTime = performance.now(); + this._keysDown = new Set(); + this._detachCallback = null; this._upInitialized = false; this._lastUsedState = NONE; @@ -370,6 +374,8 @@ export class EnvironmentControls extends EventDispatcher { scene, pivotPoint, enabled, + enableFlight, + _keysDown, } = this; // init the pointer @@ -411,12 +417,25 @@ export class EnvironmentControls extends EventDispatcher { } - // flight mode with shift held: free-look around the camera origin, skip raycasting + // 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 ( - this.enableFlight && + enableFlight && anyFlightKey && ! pointerTracker.isPointerTouch() && ( pointerTracker.isRightClicked() || - pointerTracker.isLeftClicked() && e.shiftKey + pointerTracker.isLeftClicked() ) ) { @@ -661,7 +680,28 @@ export class EnvironmentControls extends EventDispatcher { const keydownCallback = e => { - this._keysDown.add( e.key.toLowerCase() ); + const { _keysDown, state } = this; + + _keysDown.add( e.key.toLowerCase() ); + + 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(); + + } + }; @@ -1129,6 +1169,12 @@ export class EnvironmentControls extends EventDispatcher { } + _getFlightSpeedScale() { + + return 1; + + } + _updateFlight( deltaTime ) { const { @@ -1139,7 +1185,7 @@ export class EnvironmentControls extends EventDispatcher { _keysDown, } = this; - if ( ! enableFlight ) { + if ( ! enableFlight || camera.isOrthographicCamera ) { return false; @@ -1155,7 +1201,7 @@ export class EnvironmentControls extends EventDispatcher { // calculate speed const mult = _keysDown.has( 'shift' ) ? flightSpeedMultiplier : 1; - const speed = mult * flightSpeed * deltaTime; + const speed = mult * flightSpeed * this._getFlightSpeedScale() * deltaTime; // calculate direction _flightDir.set( diff --git a/src/three/renderer/controls/GlobeControls.js b/src/three/renderer/controls/GlobeControls.js index 48810a2dd..99a562463 100644 --- a/src/three/renderer/controls/GlobeControls.js +++ b/src/three/renderer/controls/GlobeControls.js @@ -8,7 +8,7 @@ import { Ray, Group, } from 'three'; -import { DRAG, ZOOM, EnvironmentControls, NONE } from './EnvironmentControls.js'; +import { DRAG, ZOOM, FREE_ROTATE, EnvironmentControls, NONE } from './EnvironmentControls.js'; import { makeRotateAroundPoint, adjustedPointerToCoords, setRaycasterFromCamera } from './utils.js'; import { Ellipsoid } from '../math/Ellipsoid.js'; import { WGS84_ELLIPSOID } from '../math/GeoConstants.js'; @@ -279,7 +279,7 @@ export class GlobeControls extends EnvironmentControls { this.adjustCamera( camera ); // align the camera up vector if the camera as updated - if ( adjustCameraRotation && this._isNearControls() ) { + if ( adjustCameraRotation && ( this._isNearControls() || this.state === FREE_ROTATE ) ) { this.getCameraUpDirection( _globalUp ); this._alignCameraUp( _globalUp, 1 ); @@ -459,6 +459,54 @@ export class GlobeControls extends EnvironmentControls { } + _getFlightSpeedScale() { + + // Scale speed proportionally to altitude so movement feels consistent at any distance. + // The 1000 m floor prevents movement becoming imperceptibly slow near the surface. + const altitude = this.getDistanceToCenter() - this._getMaxWorldRadius(); + return 2 * Math.max( altitude, 1000 ); + + } + + _updateFlight( deltaTime ) { + + const { camera } = this; + + const didFly = super._updateFlight( deltaTime ); + if ( didFly ) { + + // prevent flying past the point where the globe would be too small, just like mouse zoom. + const maxDistance = this._getMaxPerspectiveDistance(); + const distToCenter = this.getDistanceToCenter(); + if ( distToCenter > maxDistance ) { + + this.getVectorToCenter( _vec ).normalize(); + camera.position.addScaledVector( _vec, distToCenter - maxDistance ); + camera.updateMatrixWorld(); + + } + + // Outside the near-controls zone (high altitude / space view), gently nudge the + // camera to keep the globe centered and the horizon level — matching the behavior + // of scroll-zoom at the same distance. Alpha scales from 0 at the transition + // threshold to full strength at maxDistance. + if ( ! this._isNearControls() ) { + + const distanceAlpha = MathUtils.clamp( + MathUtils.mapLinear( this.getDistanceToCenter(), this._getPerspectiveTransitionDistance(), maxDistance, 0, 1 ), + 0, 1, + ); + this._tiltTowardsCenter( 0.02 * distanceAlpha ); + this._alignCameraUpToNorth( 0.01 * distanceAlpha ); + + } + + } + + return didFly; + + } + _updatePosition( deltaTime ) { if ( this.state === DRAG ) { @@ -540,6 +588,14 @@ export class GlobeControls extends EnvironmentControls { // disable rotation once we're outside the control transition _updateRotation( ...args ) { + // FREE_ROTATE is always allowed regardless of globe proximity + if ( this.state === FREE_ROTATE ) { + + super._updateRotation( ...args ); + return; + + } + if ( this._rotationMode === 1 || this._isNearControls() ) { this._rotationMode = 1; @@ -552,7 +608,6 @@ export class GlobeControls extends EnvironmentControls { } - } _updateZoom() { From bf36de521daab812cd4e7e82198ae4be8264f8a4 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 11:38:57 +0900 Subject: [PATCH 4/6] fix focus --- .../renderer/controls/EnvironmentControls.js | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/three/renderer/controls/EnvironmentControls.js b/src/three/renderer/controls/EnvironmentControls.js index c04f0f6d3..4f829dc80 100644 --- a/src/three/renderer/controls/EnvironmentControls.js +++ b/src/three/renderer/controls/EnvironmentControls.js @@ -340,6 +340,15 @@ export class EnvironmentControls extends EventDispatcher { 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 @@ -355,15 +364,6 @@ export class EnvironmentControls extends EventDispatcher { const pointerdownCallback = e => { - // exit early if the controls are disabled - if ( ! this.enabled ) { - - return; - - } - - e.preventDefault(); - const { camera, raycaster, @@ -378,6 +378,17 @@ export class EnvironmentControls extends EventDispatcher { _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; @@ -717,7 +728,7 @@ export class EnvironmentControls extends EventDispatcher { }; - window.addEventListener( 'keydown', keydownCallback ); + domElement.addEventListener( 'keydown', keydownCallback ); window.addEventListener( 'keyup', keyupCallback ); window.addEventListener( 'blur', blurCallback ); @@ -731,7 +742,7 @@ export class EnvironmentControls extends EventDispatcher { document.removeEventListener( 'pointerup', pointerupCallback ); document.removeEventListener( 'pointerleave', pointerleaveCallback ); - window.removeEventListener( 'keydown', keydownCallback ); + domElement.removeEventListener( 'keydown', keydownCallback ); window.removeEventListener( 'keyup', keyupCallback ); window.removeEventListener( 'blur', blurCallback ); From 558f48b4756da689244f000a47083e58acad0e0b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 11:44:59 +0900 Subject: [PATCH 5/6] Remove lines --- src/three/renderer/controls/EnvironmentControls.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/three/renderer/controls/EnvironmentControls.js b/src/three/renderer/controls/EnvironmentControls.js index 4f829dc80..01c42e5fe 100644 --- a/src/three/renderer/controls/EnvironmentControls.js +++ b/src/three/renderer/controls/EnvironmentControls.js @@ -192,7 +192,6 @@ export class EnvironmentControls extends EventDispatcher { */ this.useFallbackPlane = true; - // flight /** * 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 @@ -214,7 +213,6 @@ export class EnvironmentControls extends EventDispatcher { */ this.flightSpeedMultiplier = 4; - // settings for GlobeControls this.scaleZoomOrientationAtEdges = false; this.autoAdjustCameraRotation = true; From 9adc50025e2adca45bcdd2bf7c771e3bb60b5f70 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 11:46:23 +0900 Subject: [PATCH 6/6] Stylistic --- src/three/renderer/controls/EnvironmentControls.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/three/renderer/controls/EnvironmentControls.js b/src/three/renderer/controls/EnvironmentControls.js index 01c42e5fe..38d2f5953 100644 --- a/src/three/renderer/controls/EnvironmentControls.js +++ b/src/three/renderer/controls/EnvironmentControls.js @@ -376,7 +376,6 @@ export class EnvironmentControls extends EventDispatcher { _keysDown, } = this; - // exit early if the controls are disabled if ( ! this.enabled ) { @@ -693,6 +692,7 @@ export class EnvironmentControls extends EventDispatcher { _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' ) || @@ -711,7 +711,6 @@ export class EnvironmentControls extends EventDispatcher { } - }; const keyupCallback = e => {