From 45dfef4fd87ae500d251b793d488c22687f44c8c Mon Sep 17 00:00:00 2001 From: MuradAles Date: Wed, 26 Nov 2025 18:17:11 -0500 Subject: [PATCH 1/3] Add ThirdPersonControls camera system Implements a new third-person camera control system for three.js, following the patterns established by OrbitControls. The camera follows a target object, rotates around it using spherical coordinates, and includes collision avoidance via raycasting. Features: - Spherical coordinate-based camera positioning (theta, phi, radius) - Smooth camera movement with configurable damping - Automatic collision detection and avoidance - Target switching with optional smooth transitions - Auto-alignment behind moving targets - Configurable pivot offset for over-the-shoulder positioning - Full touch and pointer event support - Orthographic camera support Files added: - examples/jsm/controls/ThirdPersonControls.js - Main control class - examples/misc_controls_thirdperson.html - Interactive demonstration - docs/pages/ThirdPersonControls.html - Complete API documentation - examples/screenshots/misc_controls_thirdperson.jpg - Screenshot Files modified: - examples/jsm/Addons.js - Added export for ThirdPersonControls --- docs/pages/ThirdPersonControls.html | 416 ++++++ examples/jsm/Addons.js | 1 + examples/jsm/controls/ThirdPersonControls.js | 1180 +++++++++++++++++ examples/misc_controls_thirdperson.html | 1063 +++++++++++++++ .../screenshots/misc_controls_thirdperson.jpg | Bin 0 -> 48994 bytes 5 files changed, 2660 insertions(+) create mode 100644 docs/pages/ThirdPersonControls.html create mode 100644 examples/jsm/controls/ThirdPersonControls.js create mode 100644 examples/misc_controls_thirdperson.html create mode 100644 examples/screenshots/misc_controls_thirdperson.jpg diff --git a/docs/pages/ThirdPersonControls.html b/docs/pages/ThirdPersonControls.html new file mode 100644 index 00000000000000..38e401a165eb41 --- /dev/null +++ b/docs/pages/ThirdPersonControls.html @@ -0,0 +1,416 @@ + + + + + ThirdPersonControls - Three.js Docs + + + + + + +

EventDispatcherControls

+

ThirdPersonControls

+
+
+

Third-person camera controls for following and orbiting around a target object.

+

ThirdPersonControls provides smooth camera following behavior with configurable damping, +pivot-based rotation, adjustable camera offset, optional collision detection using raycasting, +and smooth target switching with interpolated camera transitions.

+
    +
  • Orbit: Left mouse / touch: one-finger move.
  • +
  • Zoom: Middle mouse, or mousewheel / touch: two-finger pinch.
  • +
+

Supports both PerspectiveCamera and OrthographicCamera.

+

Code Example

+
const controls = new ThirdPersonControls( camera, target, renderer.domElement );
+
+// Configure the controls
+controls.distance = 5;
+controls.height = 2;
+controls.enableCollision = true;
+controls.collisionObjects = [ ...sceneObjects ];
+
+// Switch targets smoothly
+controls.setTarget( newTarget, true );
+
+function animate() {
+	const delta = clock.getDelta();
+	controls.update( delta );
+	renderer.render( scene, camera );
+}
+
+
+
+

Import

+

ThirdPersonControls is an addon, and must be imported explicitly, see Installation#Addons.

+
import { ThirdPersonControls } from 'three/addons/controls/ThirdPersonControls.js';
+
+

Constructor

+

new ThirdPersonControls( object : Camera, target : Object3D, domElement : HTMLElement )

+
+
+

Constructs a new third-person controls instance.

+
+ + + + + + + + + + + + + + + +
+ object + +

The camera to be controlled (PerspectiveCamera or OrthographicCamera).

+
+ target + +

The target object to follow.

+
+ domElement + +

The HTML element used for event listeners.

+

Default is null.

+
+
+
+

Properties

+
+

.autoAlign : boolean

+
+

Whether the camera should automatically align behind the target when the target moves.

+

Default is false.

+
+
+
+

.autoAlignSpeed : number

+
+

Speed of auto-alignment when enabled.

+

Default is 0.05.

+
+
+
+

.collisionObjects : Array.<Object3D>

+
+

Array of objects to test for collision. If empty, no collision detection is performed even if enableCollision is true.

+
+
+
+

.collisionRadius : number

+
+

The radius used for collision detection. A larger value keeps the camera further from obstacles.

+

Default is 0.3.

+
+
+
+

.distance : number

+
+

The desired distance from the camera to the target pivot.

+

Default is 5.

+
+
+
+

.enableCollision : boolean

+
+

Whether collision detection is enabled. When enabled, raycasting is used to prevent the camera from clipping through geometry.

+

Default is false.

+
+
+
+

.enableRotate : boolean

+
+

Whether rotation is enabled.

+

Default is true.

+
+
+
+

.enableZoom : boolean

+
+

Whether zooming (distance adjustment) is enabled.

+

Default is true.

+
+
+
+

.height : number

+
+

The height offset of the camera pivot point relative to the target.

+

Default is 1.6.

+
+
+
+

.maxDistance : number

+
+

The maximum allowed distance from camera to target.

+

Default is 20.

+
+
+
+

.maxPolarAngle : number

+
+

The maximum polar angle (vertical rotation) in radians.

+

Default is Math.PI - 0.1.

+
+
+
+

.minDistance : number

+
+

The minimum allowed distance from camera to target.

+

Default is 1.

+
+
+
+

.minPolarAngle : number

+
+

The minimum polar angle (vertical rotation) in radians. 0 is looking straight down, Math.PI is looking straight up.

+

Default is 0.1.

+
+
+
+

.mouseButtons : Object

+
+

This object contains references to the mouse actions used by the controls.

+
controls.mouseButtons = {
+	LEFT: THREE.MOUSE.ROTATE,
+	MIDDLE: THREE.MOUSE.DOLLY,
+	RIGHT: null
+}
+
+
+
+
Overrides: Controls#mouseButtons
+
+
+
+

.pivotOffset : Vector3

+
+

The pivot offset from the target position in local space. This allows adjusting where the camera focuses on the target.

+
+
+
+

.rotationSpeed : number

+
+

The rotation speed for mouse/touch input.

+

Default is 0.005.

+
+
+
+

.smoothingFactor : number

+
+

The smoothing factor for camera movement (0 = no smoothing, 1 = instant). Lower values create smoother but slower camera movement.

+

Default is 0.1.

+
+
+
+

.target : Object3D

+
+

The target object to follow.

+
+
+
+

.targetTransitionDuration : number

+
+

Duration of target transition in seconds. Used when switching targets with smooth transition enabled via setTarget(target, true).

+

Default is 0.5.

+
+
+
+

.touches : Object

+
+

This object contains references to the touch actions used by the controls.

+
controls.touches = {
+	ONE: THREE.TOUCH.ROTATE,
+	TWO: THREE.TOUCH.DOLLY_ROTATE
+}
+
+
+
+
Overrides: Controls#touches
+
+
+
+

.zoomSpeed : number

+
+

The zoom speed for scroll/pinch input.

+

Default is 1.

+
+
+

Methods

+

.getAzimuthalAngle() : number

+
+
+

Get the current azimuthal (horizontal) angle in radians.

+
+
+
Returns: The current azimuthal angle.
+
+
+

.getDistance() : number

+
+
+

Get the current distance from camera to target pivot.

+
+
+
Returns: The current distance.
+
+
+

.getPolarAngle() : number

+
+
+

Get the current polar (vertical) angle in radians.

+
+
+
Returns: The current polar angle.
+
+
+

.getTarget() : Object3D

+
+
+

Get the current target object.

+
+
+
Returns: The current target object.
+
+
+

.isTransitioning() : boolean

+
+
+

Check if the camera is currently transitioning between targets.

+
+
+
Returns: True if transitioning.
+
+
+

.setDistance( value : number )

+
+
+

Set the camera distance from target.

+
+ + + + + + + +
+ value + +

The new distance value.

+
+
+

.setTarget( target : Object3D, smooth : boolean )

+
+
+

Set a new target object to follow with optional smooth transition.

+
+ + + + + + + + + + + +
+ target + +

The new target object.

+
+ smooth + +

Whether to smoothly transition to the new target. When true, the camera will interpolate its position and rotation over the duration specified by targetTransitionDuration.

+

Default is false.

+
+
+

.update( delta : number ) : boolean

+
+
+

Updates the controls. Should be called in the animation loop.

+
+ + + + + + + +
+ delta + +

The time delta in seconds. Used for frame-rate independent smoothing.

+
+
+
Returns: Returns true if the camera was updated.
+
+
+

Events

+

.change

+
+
+

Fires when the camera has been transformed by the controls.

+
+
Type:
+
    +
  • +Object +
  • +
+
+

.end

+
+
+

Fires when an interaction has finished.

+
+
Type:
+
    +
  • +Object +
  • +
+
+

.start

+
+
+

Fires when an interaction was initiated.

+
+
Type:
+
    +
  • +Object +
  • +
+
+

.targetchange

+
+
+

Fires when the target has been switched via setTarget().

+
+
Type:
+
    +
  • +Object +
  • +
+
+

Source

+

+ examples/jsm/controls/ThirdPersonControls.js +

+
+
+ + + + diff --git a/examples/jsm/Addons.js b/examples/jsm/Addons.js index 417e3e0cb5213b..c01eee4d2732d9 100644 --- a/examples/jsm/Addons.js +++ b/examples/jsm/Addons.js @@ -9,6 +9,7 @@ export * from './controls/FirstPersonControls.js'; export * from './controls/FlyControls.js'; export * from './controls/MapControls.js'; export * from './controls/OrbitControls.js'; +export * from './controls/ThirdPersonControls.js'; export * from './controls/PointerLockControls.js'; export * from './controls/TrackballControls.js'; export * from './controls/TransformControls.js'; diff --git a/examples/jsm/controls/ThirdPersonControls.js b/examples/jsm/controls/ThirdPersonControls.js new file mode 100644 index 00000000000000..7a80b4670d9903 --- /dev/null +++ b/examples/jsm/controls/ThirdPersonControls.js @@ -0,0 +1,1180 @@ +import { + Controls, + MOUSE, + Raycaster, + Spherical, + TOUCH, + Vector2, + Vector3, + MathUtils +} from 'three'; + +/** + * Fires when the camera has been transformed by the controls. + * + * @event ThirdPersonControls#change + * @type {Object} + */ +const _changeEvent = { type: 'change' }; + +/** + * Fires when an interaction was initiated. + * + * @event ThirdPersonControls#start + * @type {Object} + */ +const _startEvent = { type: 'start' }; + +/** + * Fires when an interaction has finished. + * + * @event ThirdPersonControls#end + * @type {Object} + */ +const _endEvent = { type: 'end' }; + +/** + * Fires when the target has been switched. + * + * @event ThirdPersonControls#targetchange + * @type {Object} + */ +const _targetChangeEvent = { type: 'targetchange' }; + +const _v = new Vector3(); +const _v2 = new Vector3(); +const _v3 = new Vector3(); +const _spherical = new Spherical(); +const _raycaster = new Raycaster(); +const _twoPI = 2 * Math.PI; +const _halfPI = Math.PI / 2; +const _EPS = 0.000001; + +const _STATE = { + NONE: - 1, + ROTATE: 0, + DOLLY: 1, + TOUCH_ROTATE: 2, + TOUCH_DOLLY: 3 +}; + +/** + * Third-person camera controls for following and orbiting around a target object. + * + * ThirdPersonControls provides smooth camera following behavior with configurable damping, + * pivot-based rotation, adjustable camera offset, and optional collision detection using raycasting. + * Supports smooth target switching with interpolated camera transitions. + * + * - Orbit: Left mouse / touch: one-finger move. + * - Zoom: Middle mouse, or mousewheel / touch: two-finger pinch. + * + * ```js + * const controls = new ThirdPersonControls( camera, target, renderer.domElement ); + * + * // Configure the controls + * controls.distance = 5; + * controls.height = 2; + * controls.enableCollision = true; + * + * // Switch targets smoothly + * controls.setTarget( newTarget, true ); + * + * function animate() { + * + * const delta = clock.getDelta(); + * controls.update( delta ); + * + * renderer.render( scene, camera ); + * + * } + * ``` + * + * @augments Controls + * @three_import import { ThirdPersonControls } from 'three/addons/controls/ThirdPersonControls.js'; + */ +class ThirdPersonControls extends Controls { + + /** + * Constructs a new third-person controls instance. + * + * @param {Camera} object - The camera to be controlled (PerspectiveCamera or OrthographicCamera). + * @param {Object3D} target - The target object to follow. + * @param {?HTMLElement} domElement - The HTML element used for event listeners. + */ + constructor( object, target, domElement = null ) { + + super( object, domElement ); + + /** + * The target object to follow. + * + * @type {Object3D} + */ + this.target = target; + + /** + * The desired distance from the camera to the target pivot. + * + * @type {number} + * @default 5 + */ + this.distance = 5; + + /** + * The height offset of the camera pivot point relative to the target. + * + * @type {number} + * @default 1.6 + */ + this.height = 1.6; + + /** + * The minimum allowed distance from camera to target. + * + * @type {number} + * @default 1 + */ + this.minDistance = 1; + + /** + * The maximum allowed distance from camera to target. + * + * @type {number} + * @default 20 + */ + this.maxDistance = 20; + + /** + * The pivot offset from the target position in local space. + * This allows adjusting where the camera focuses on the target. + * + * @type {Vector3} + */ + this.pivotOffset = new Vector3( 0, 0, 0 ); + + /** + * Whether collision detection is enabled. + * When enabled, raycasting is used to prevent the camera from clipping through geometry. + * + * @type {boolean} + * @default false + */ + this.enableCollision = false; + + /** + * The radius used for collision detection. + * A larger value keeps the camera further from obstacles. + * + * @type {number} + * @default 0.3 + */ + this.collisionRadius = 0.3; + + /** + * Array of objects to test for collision. + * If empty, no collision detection is performed even if enableCollision is true. + * + * @type {Object3D[]} + */ + this.collisionObjects = []; + + /** + * The smoothing factor for camera movement (0 = no smoothing, 1 = instant). + * Lower values create smoother but slower camera movement. + * + * @type {number} + * @default 0.1 + */ + this.smoothingFactor = 0.1; + + /** + * The rotation speed for mouse/touch input. + * + * @type {number} + * @default 0.005 + */ + this.rotationSpeed = 0.005; + + /** + * The zoom speed for scroll/pinch input. + * + * @type {number} + * @default 1 + */ + this.zoomSpeed = 1; + + /** + * The minimum polar angle (vertical rotation) in radians. + * 0 is looking straight down, Math.PI is looking straight up. + * + * @type {number} + * @default 0.1 + */ + this.minPolarAngle = 0.1; + + /** + * The maximum polar angle (vertical rotation) in radians. + * + * @type {number} + * @default Math.PI - 0.1 + */ + this.maxPolarAngle = Math.PI - 0.1; + + /** + * Whether zooming (distance adjustment) is enabled. + * + * @type {boolean} + * @default true + */ + this.enableZoom = true; + + /** + * Whether rotation is enabled. + * + * @type {boolean} + * @default true + */ + this.enableRotate = true; + + /** + * Whether the camera should automatically align behind the target + * when the target moves. + * + * @type {boolean} + * @default false + */ + this.autoAlign = false; + + /** + * Speed of auto-alignment when enabled. + * + * @type {number} + * @default 0.05 + */ + this.autoAlignSpeed = 0.05; + + /** + * Duration of target transition in seconds. + * Used when switching targets with smooth transition enabled. + * + * @type {number} + * @default 0.5 + */ + this.targetTransitionDuration = 0.5; + + /** + * This object contains references to the mouse actions used by the controls. + * + * @type {Object} + */ + this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: null }; + + /** + * This object contains references to the touch actions used by the controls. + * + * @type {Object} + */ + this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_ROTATE }; + + // Internal state + + this._state = _STATE.NONE; + + this._spherical = new Spherical(); + this._sphericalDelta = new Spherical(); + + this._targetPosition = new Vector3(); + this._pivotPosition = new Vector3(); + this._idealPosition = new Vector3(); + this._currentPosition = new Vector3(); + this._lookAtPosition = new Vector3(); + + this._lastTargetPosition = new Vector3(); + this._targetVelocity = new Vector3(); + + // Target transition state + this._isTransitioning = false; + this._transitionProgress = 0; + this._transitionStartPosition = new Vector3(); + this._transitionStartLookAt = new Vector3(); + this._previousTarget = null; + + this._scale = 1; + + this._rotateStart = new Vector2(); + this._rotateEnd = new Vector2(); + this._rotateDelta = new Vector2(); + + this._dollyStart = new Vector2(); + this._dollyEnd = new Vector2(); + this._dollyDelta = new Vector2(); + + this._pointers = []; + this._pointerPositions = {}; + + // Initialize spherical coordinates + this._spherical.radius = this.distance; + this._spherical.phi = _halfPI; // Start at horizon level + this._spherical.theta = 0; + + // Event listeners + this._onPointerDown = onPointerDown.bind( this ); + this._onPointerMove = onPointerMove.bind( this ); + this._onPointerUp = onPointerUp.bind( this ); + this._onContextMenu = onContextMenu.bind( this ); + this._onMouseWheel = onMouseWheel.bind( this ); + + this._onTouchStart = onTouchStart.bind( this ); + this._onTouchMove = onTouchMove.bind( this ); + + this._onMouseDown = onMouseDown.bind( this ); + this._onMouseMove = onMouseMove.bind( this ); + + if ( this.domElement !== null ) { + + this.connect( this.domElement ); + + } + + // Initial update + if ( this.target ) { + + this._lastTargetPosition.copy( this.target.position ); + this._currentPosition.copy( this.object.position ); + + } + + } + + connect( element ) { + + super.connect( element ); + + this.domElement.addEventListener( 'pointerdown', this._onPointerDown ); + this.domElement.addEventListener( 'pointercancel', this._onPointerUp ); + this.domElement.addEventListener( 'contextmenu', this._onContextMenu ); + this.domElement.addEventListener( 'wheel', this._onMouseWheel, { passive: false } ); + + this.domElement.style.touchAction = 'none'; + + } + + disconnect() { + + this.domElement.removeEventListener( 'pointerdown', this._onPointerDown ); + this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove ); + this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp ); + this.domElement.removeEventListener( 'pointercancel', this._onPointerUp ); + this.domElement.removeEventListener( 'wheel', this._onMouseWheel ); + this.domElement.removeEventListener( 'contextmenu', this._onContextMenu ); + + this.domElement.style.touchAction = 'auto'; + + } + + dispose() { + + this.disconnect(); + + } + + /** + * Set a new target object to follow with optional smooth transition. + * + * @param {Object3D} target - The new target object. + * @param {boolean} [smooth=false] - Whether to smoothly transition to the new target. + */ + setTarget( target, smooth = false ) { + + if ( this.target === target ) return; + + this._previousTarget = this.target; + + if ( smooth && this.target !== null && target !== null ) { + + // Start smooth transition + this._isTransitioning = true; + this._transitionProgress = 0; + this._transitionStartPosition.copy( this.object.position ); + this._transitionStartLookAt.copy( this._lookAtPosition ); + + } + + this.target = target; + + if ( target ) { + + this._lastTargetPosition.copy( target.position ); + + } + + this.dispatchEvent( _targetChangeEvent ); + + } + + /** + * Get the current target object. + * + * @return {Object3D} The current target object. + */ + getTarget() { + + return this.target; + + } + + /** + * Check if the camera is currently transitioning between targets. + * + * @return {boolean} True if transitioning. + */ + isTransitioning() { + + return this._isTransitioning; + + } + + /** + * Set the camera distance from target. + * + * @param {number} value - The new distance value. + */ + setDistance( value ) { + + this.distance = MathUtils.clamp( value, this.minDistance, this.maxDistance ); + this._spherical.radius = this.distance; + + } + + /** + * Get the current distance from camera to target pivot. + * + * @return {number} The current distance. + */ + getDistance() { + + return this._spherical.radius; + + } + + /** + * Get the current azimuthal (horizontal) angle in radians. + * + * @return {number} The current azimuthal angle. + */ + getAzimuthalAngle() { + + return this._spherical.theta; + + } + + /** + * Get the current polar (vertical) angle in radians. + * + * @return {number} The current polar angle. + */ + getPolarAngle() { + + return this._spherical.phi; + + } + + /** + * Updates the controls. Should be called in the animation loop. + * + * @param {number} [delta] - The time delta in seconds. Used for frame-rate independent smoothing. + * @return {boolean} Returns true if the camera was updated. + */ + update( delta = null ) { + + if ( this.enabled === false || this.target === null ) return false; + + // Handle target transition + if ( this._isTransitioning && delta !== null ) { + + this._transitionProgress += delta / this.targetTransitionDuration; + + if ( this._transitionProgress >= 1 ) { + + this._transitionProgress = 1; + this._isTransitioning = false; + + } + + // Use smooth easing for transition + const t = this._easeInOutCubic( this._transitionProgress ); + + // Calculate new target pivot + const newPivot = this._calculatePivotPosition( this.target ); + + // Calculate where we want the camera to end up + _v.setFromSpherical( this._spherical ); + const endPosition = _v2.copy( newPivot ).add( _v ); + + // Interpolate position + this._currentPosition.lerpVectors( this._transitionStartPosition, endPosition, t ); + + // Interpolate look-at position + this._lookAtPosition.lerpVectors( this._transitionStartLookAt, newPivot, t ); + + // Update camera + this.object.position.copy( this._currentPosition ); + this.object.lookAt( this._lookAtPosition ); + + if ( ! this._isTransitioning ) { + + // Transition complete, sync internal state + this._lastTargetPosition.copy( this.target.position ); + + } + + this.dispatchEvent( _changeEvent ); + return true; + + } + + // Apply rotation delta + this._spherical.theta += this._sphericalDelta.theta; + this._spherical.phi += this._sphericalDelta.phi; + + // Clamp polar angle + this._spherical.phi = MathUtils.clamp( + this._spherical.phi, + this.minPolarAngle, + this.maxPolarAngle + ); + + this._spherical.makeSafe(); + + // Apply zoom + this._spherical.radius = MathUtils.clamp( + this._spherical.radius * this._scale, + this.minDistance, + this.maxDistance + ); + + // Get target position + this._targetPosition.copy( this.target.position ); + + // Calculate target velocity for auto-align + if ( delta !== null && delta > 0 ) { + + this._targetVelocity.subVectors( this._targetPosition, this._lastTargetPosition ); + this._targetVelocity.divideScalar( delta ); + + } + + this._lastTargetPosition.copy( this._targetPosition ); + + // Calculate pivot position + this._pivotPosition.copy( this._calculatePivotPosition( this.target ) ); + + // Auto-align behind target when moving + if ( this.autoAlign && this._targetVelocity.lengthSq() > 0.01 ) { + + const targetAngle = Math.atan2( this._targetVelocity.x, this._targetVelocity.z ); + let angleDiff = targetAngle - this._spherical.theta; + + // Normalize angle difference + while ( angleDiff > Math.PI ) angleDiff -= _twoPI; + while ( angleDiff < - Math.PI ) angleDiff += _twoPI; + + this._spherical.theta += angleDiff * this.autoAlignSpeed; + + } + + // Calculate ideal camera position from spherical coordinates + _v.setFromSpherical( this._spherical ); + this._idealPosition.copy( this._pivotPosition ).add( _v ); + + // Collision detection + if ( this.enableCollision && this.collisionObjects.length > 0 ) { + + // Cast ray from pivot to ideal position + const direction = _v2.subVectors( this._idealPosition, this._pivotPosition ).normalize(); + const rayDistance = this._spherical.radius; + + _raycaster.set( this._pivotPosition, direction ); + _raycaster.far = rayDistance; + + const intersects = _raycaster.intersectObjects( this.collisionObjects, true ); + + if ( intersects.length > 0 ) { + + const hit = intersects[ 0 ]; + const adjustedDistance = Math.max( + hit.distance - this.collisionRadius, + this.minDistance + ); + + _v.setFromSpherical( + new Spherical( adjustedDistance, this._spherical.phi, this._spherical.theta ) + ); + this._idealPosition.copy( this._pivotPosition ).add( _v ); + + } + + } + + // Smooth camera position movement + const smoothFactor = delta !== null + ? 1 - Math.pow( 1 - this.smoothingFactor, delta * 60 ) + : this.smoothingFactor; + + this._currentPosition.lerp( this._idealPosition, smoothFactor ); + + // Update camera position + this.object.position.copy( this._currentPosition ); + + // Smooth look-at position (slight smoothing to avoid jitter) + this._lookAtPosition.lerp( this._pivotPosition, smoothFactor ); + + // Direct lookAt - clean and responsive + this.object.lookAt( this._lookAtPosition ); + + // Handle orthographic camera zoom + if ( this.object.isOrthographicCamera ) { + + // Adjust orthographic zoom based on distance + const baseSize = 10; + const zoomFactor = this._spherical.radius / this.distance; + this.object.zoom = baseSize / ( zoomFactor * this.distance ); + this.object.updateProjectionMatrix(); + + } + + // Decay deltas + this._sphericalDelta.theta *= ( 1 - this.smoothingFactor ); + this._sphericalDelta.phi *= ( 1 - this.smoothingFactor ); + this._scale = 1; + + // Check if changed + const positionChanged = this.object.position.distanceToSquared( this._currentPosition ) > _EPS; + + if ( positionChanged ) { + + this.dispatchEvent( _changeEvent ); + return true; + + } + + return false; + + } + + // + // Internals + // + + /** + * Calculate pivot position for a given target. + * @private + */ + _calculatePivotPosition( targetObj ) { + + const pivot = _v3.copy( this.pivotOffset ); + + // Transform pivot offset to world space based on target orientation + if ( targetObj.quaternion ) { + + pivot.applyQuaternion( targetObj.quaternion ); + + } + + pivot.add( targetObj.position ); + pivot.y += this.height; + + return pivot.clone(); + + } + + /** + * Ease in-out cubic function for smooth transitions. + * @private + */ + _easeInOutCubic( t ) { + + return t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow( - 2 * t + 2, 3 ) / 2; + + } + + _getZoomScale( delta ) { + + const normalizedDelta = Math.abs( delta * 0.01 ); + return Math.pow( 0.95, this.zoomSpeed * normalizedDelta ); + + } + + _handleMouseDownRotate( event ) { + + this._rotateStart.set( event.clientX, event.clientY ); + + } + + _handleMouseDownDolly( event ) { + + this._dollyStart.set( event.clientX, event.clientY ); + + } + + _handleMouseMoveRotate( event ) { + + this._rotateEnd.set( event.clientX, event.clientY ); + this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ); + + // Rotating horizontally (theta) + this._sphericalDelta.theta -= this._rotateDelta.x * this.rotationSpeed; + + // Rotating vertically (phi) + this._sphericalDelta.phi -= this._rotateDelta.y * this.rotationSpeed; + + this._rotateStart.copy( this._rotateEnd ); + + } + + _handleMouseMoveDolly( event ) { + + this._dollyEnd.set( event.clientX, event.clientY ); + this._dollyDelta.subVectors( this._dollyEnd, this._dollyStart ); + + if ( this._dollyDelta.y > 0 ) { + + this._scale /= this._getZoomScale( this._dollyDelta.y ); + + } else if ( this._dollyDelta.y < 0 ) { + + this._scale *= this._getZoomScale( this._dollyDelta.y ); + + } + + this._dollyStart.copy( this._dollyEnd ); + + } + + _handleMouseWheel( event ) { + + if ( event.deltaY < 0 ) { + + this._scale *= this._getZoomScale( event.deltaY ); + + } else if ( event.deltaY > 0 ) { + + this._scale /= this._getZoomScale( event.deltaY ); + + } + + } + + _handleTouchStartRotate( event ) { + + if ( this._pointers.length === 1 ) { + + this._rotateStart.set( event.pageX, event.pageY ); + + } else { + + const position = this._getSecondPointerPosition( event ); + const x = 0.5 * ( event.pageX + position.x ); + const y = 0.5 * ( event.pageY + position.y ); + this._rotateStart.set( x, y ); + + } + + } + + _handleTouchStartDolly( event ) { + + const position = this._getSecondPointerPosition( event ); + const dx = event.pageX - position.x; + const dy = event.pageY - position.y; + const distance = Math.sqrt( dx * dx + dy * dy ); + + this._dollyStart.set( 0, distance ); + + } + + _handleTouchMoveRotate( event ) { + + if ( this._pointers.length === 1 ) { + + this._rotateEnd.set( event.pageX, event.pageY ); + + } else { + + const position = this._getSecondPointerPosition( event ); + const x = 0.5 * ( event.pageX + position.x ); + const y = 0.5 * ( event.pageY + position.y ); + this._rotateEnd.set( x, y ); + + } + + this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ); + + this._sphericalDelta.theta -= this._rotateDelta.x * this.rotationSpeed; + this._sphericalDelta.phi -= this._rotateDelta.y * this.rotationSpeed; + + this._rotateStart.copy( this._rotateEnd ); + + } + + _handleTouchMoveDolly( event ) { + + const position = this._getSecondPointerPosition( event ); + const dx = event.pageX - position.x; + const dy = event.pageY - position.y; + const distance = Math.sqrt( dx * dx + dy * dy ); + + this._dollyEnd.set( 0, distance ); + this._dollyDelta.set( 0, Math.pow( this._dollyEnd.y / this._dollyStart.y, this.zoomSpeed ) ); + + this._scale /= this._dollyDelta.y; + this._dollyStart.copy( this._dollyEnd ); + + } + + _handleTouchMoveDollyRotate( event ) { + + if ( this.enableZoom ) this._handleTouchMoveDolly( event ); + if ( this.enableRotate ) this._handleTouchMoveRotate( event ); + + } + + // Pointer handling + + _addPointer( event ) { + + this._pointers.push( event.pointerId ); + + } + + _removePointer( event ) { + + delete this._pointerPositions[ event.pointerId ]; + + for ( let i = 0; i < this._pointers.length; i ++ ) { + + if ( this._pointers[ i ] === event.pointerId ) { + + this._pointers.splice( i, 1 ); + return; + + } + + } + + } + + _isTrackingPointer( event ) { + + for ( let i = 0; i < this._pointers.length; i ++ ) { + + if ( this._pointers[ i ] === event.pointerId ) return true; + + } + + return false; + + } + + _trackPointer( event ) { + + let position = this._pointerPositions[ event.pointerId ]; + + if ( position === undefined ) { + + position = new Vector2(); + this._pointerPositions[ event.pointerId ] = position; + + } + + position.set( event.pageX, event.pageY ); + + } + + _getSecondPointerPosition( event ) { + + const pointerId = ( event.pointerId === this._pointers[ 0 ] ) + ? this._pointers[ 1 ] + : this._pointers[ 0 ]; + + return this._pointerPositions[ pointerId ]; + + } + +} + +// Event handlers + +function onPointerDown( event ) { + + if ( this.enabled === false ) return; + + if ( this._pointers.length === 0 ) { + + this.domElement.setPointerCapture( event.pointerId ); + this.domElement.ownerDocument.addEventListener( 'pointermove', this._onPointerMove ); + this.domElement.ownerDocument.addEventListener( 'pointerup', this._onPointerUp ); + + } + + if ( this._isTrackingPointer( event ) ) return; + + this._addPointer( event ); + + if ( event.pointerType === 'touch' ) { + + this._onTouchStart( event ); + + } else { + + this._onMouseDown( event ); + + } + +} + +function onPointerMove( event ) { + + if ( this.enabled === false ) return; + + if ( event.pointerType === 'touch' ) { + + this._onTouchMove( event ); + + } else { + + this._onMouseMove( event ); + + } + +} + +function onPointerUp( event ) { + + this._removePointer( event ); + + switch ( this._pointers.length ) { + + case 0: + + this.domElement.releasePointerCapture( event.pointerId ); + this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove ); + this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp ); + + this.dispatchEvent( _endEvent ); + this._state = _STATE.NONE; + + break; + + case 1: + + const pointerId = this._pointers[ 0 ]; + const position = this._pointerPositions[ pointerId ]; + + this._onTouchStart( { pointerId: pointerId, pageX: position.x, pageY: position.y } ); + + break; + + } + +} + +function onMouseDown( event ) { + + let mouseAction; + + switch ( event.button ) { + + case 0: + mouseAction = this.mouseButtons.LEFT; + break; + + case 1: + mouseAction = this.mouseButtons.MIDDLE; + break; + + case 2: + mouseAction = this.mouseButtons.RIGHT; + break; + + default: + mouseAction = - 1; + + } + + switch ( mouseAction ) { + + case MOUSE.DOLLY: + + if ( this.enableZoom === false ) return; + + this._handleMouseDownDolly( event ); + this._state = _STATE.DOLLY; + + break; + + case MOUSE.ROTATE: + + if ( this.enableRotate === false ) return; + + this._handleMouseDownRotate( event ); + this._state = _STATE.ROTATE; + + break; + + default: + + this._state = _STATE.NONE; + + } + + if ( this._state !== _STATE.NONE ) { + + this.dispatchEvent( _startEvent ); + + } + +} + +function onMouseMove( event ) { + + switch ( this._state ) { + + case _STATE.ROTATE: + + if ( this.enableRotate === false ) return; + + this._handleMouseMoveRotate( event ); + + break; + + case _STATE.DOLLY: + + if ( this.enableZoom === false ) return; + + this._handleMouseMoveDolly( event ); + + break; + + } + +} + +function onMouseWheel( event ) { + + if ( this.enabled === false || this.enableZoom === false || this._state !== _STATE.NONE ) return; + + event.preventDefault(); + + this.dispatchEvent( _startEvent ); + + this._handleMouseWheel( event ); + + this.dispatchEvent( _endEvent ); + +} + +function onTouchStart( event ) { + + this._trackPointer( event ); + + switch ( this._pointers.length ) { + + case 1: + + switch ( this.touches.ONE ) { + + case TOUCH.ROTATE: + + if ( this.enableRotate === false ) return; + + this._handleTouchStartRotate( event ); + this._state = _STATE.TOUCH_ROTATE; + + break; + + default: + + this._state = _STATE.NONE; + + } + + break; + + case 2: + + switch ( this.touches.TWO ) { + + case TOUCH.DOLLY_ROTATE: + + if ( this.enableZoom === false && this.enableRotate === false ) return; + + this._handleTouchStartDolly( event ); + this._handleTouchStartRotate( event ); + this._state = _STATE.TOUCH_DOLLY; + + break; + + default: + + this._state = _STATE.NONE; + + } + + break; + + default: + + this._state = _STATE.NONE; + + } + + if ( this._state !== _STATE.NONE ) { + + this.dispatchEvent( _startEvent ); + + } + +} + +function onTouchMove( event ) { + + this._trackPointer( event ); + + switch ( this._state ) { + + case _STATE.TOUCH_ROTATE: + + if ( this.enableRotate === false ) return; + + this._handleTouchMoveRotate( event ); + + break; + + case _STATE.TOUCH_DOLLY: + + if ( this.enableZoom === false && this.enableRotate === false ) return; + + this._handleTouchMoveDollyRotate( event ); + + break; + + default: + + this._state = _STATE.NONE; + + } + +} + +function onContextMenu( event ) { + + if ( this.enabled === false ) return; + + event.preventDefault(); + +} + +export { ThirdPersonControls }; diff --git a/examples/misc_controls_thirdperson.html b/examples/misc_controls_thirdperson.html new file mode 100644 index 00000000000000..aa87f6e86ec94c --- /dev/null +++ b/examples/misc_controls_thirdperson.html @@ -0,0 +1,1063 @@ + + + + three.js webgl - third person controls + + + + + + + +
+ three.js - third person controls +
+ +
Switching target...
+ +
+

Controls

+
W A S D - Move character
+
Mouse Drag - Orbit camera
+
Scroll - Zoom in/out
+
Click Character - Switch target
+
1 2 3 - Select character
+
Space - Toggle collision
+
Tab - Cycle characters
+
+ +
+

Characters

+
+
+ + + + + + + diff --git a/examples/screenshots/misc_controls_thirdperson.jpg b/examples/screenshots/misc_controls_thirdperson.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9f62bff2f8fa91e64354277fd7b1608518a08980 GIT binary patch literal 48994 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�`qiTG}N$?MZ%Yi81gb=QZRh>=NbYoyYg2EB~zl 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

-9E!}-;^g2wO7?Jze= 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 Date: Wed, 26 Nov 2025 18:22:43 -0500 Subject: [PATCH 2/3] small fixes --- examples/files.json | 1 + examples/misc_controls_thirdperson.html | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/files.json b/examples/files.json index 065ccac7461dea..25304b1df21054 100644 --- a/examples/files.json +++ b/examples/files.json @@ -551,6 +551,7 @@ "misc_controls_orbit", "misc_controls_pointerlock", "misc_controls_trackball", + "misc_controls_thirdperson", "misc_controls_transform", "misc_exporter_draco", "misc_exporter_gltf", diff --git a/examples/misc_controls_thirdperson.html b/examples/misc_controls_thirdperson.html index aa87f6e86ec94c..006065c4582bea 100644 --- a/examples/misc_controls_thirdperson.html +++ b/examples/misc_controls_thirdperson.html @@ -178,8 +178,7 @@

Characters

left: false, right: false }; - - const characterVelocity = new THREE.Vector3(); + const characterDirection = new THREE.Vector3(); const moveSpeed = 5; From ceab8d1a1f0e7dbe8c23f032a5076f70bdca8b9b Mon Sep 17 00:00:00 2001 From: mrdoob Date: Thu, 27 Nov 2025 12:48:18 +0900 Subject: [PATCH 3/3] Delete docs/pages/ThirdPersonControls.html --- docs/pages/ThirdPersonControls.html | 416 ---------------------------- 1 file changed, 416 deletions(-) delete mode 100644 docs/pages/ThirdPersonControls.html diff --git a/docs/pages/ThirdPersonControls.html b/docs/pages/ThirdPersonControls.html deleted file mode 100644 index 38e401a165eb41..00000000000000 --- a/docs/pages/ThirdPersonControls.html +++ /dev/null @@ -1,416 +0,0 @@ - - - - - ThirdPersonControls - Three.js Docs - - - - - - -

EventDispatcherControls

-

ThirdPersonControls

-
-
-

Third-person camera controls for following and orbiting around a target object.

-

ThirdPersonControls provides smooth camera following behavior with configurable damping, -pivot-based rotation, adjustable camera offset, optional collision detection using raycasting, -and smooth target switching with interpolated camera transitions.

-
    -
  • Orbit: Left mouse / touch: one-finger move.
  • -
  • Zoom: Middle mouse, or mousewheel / touch: two-finger pinch.
  • -
-

Supports both PerspectiveCamera and OrthographicCamera.

-

Code Example

-
const controls = new ThirdPersonControls( camera, target, renderer.domElement );
-
-// Configure the controls
-controls.distance = 5;
-controls.height = 2;
-controls.enableCollision = true;
-controls.collisionObjects = [ ...sceneObjects ];
-
-// Switch targets smoothly
-controls.setTarget( newTarget, true );
-
-function animate() {
-	const delta = clock.getDelta();
-	controls.update( delta );
-	renderer.render( scene, camera );
-}
-
-
-
-

Import

-

ThirdPersonControls is an addon, and must be imported explicitly, see Installation#Addons.

-
import { ThirdPersonControls } from 'three/addons/controls/ThirdPersonControls.js';
-
-

Constructor

-

new ThirdPersonControls( object : Camera, target : Object3D, domElement : HTMLElement )

-
-
-

Constructs a new third-person controls instance.

-
- - - - - - - - - - - - - - - -
- object - -

The camera to be controlled (PerspectiveCamera or OrthographicCamera).

-
- target - -

The target object to follow.

-
- domElement - -

The HTML element used for event listeners.

-

Default is null.

-
-
-
-

Properties

-
-

.autoAlign : boolean

-
-

Whether the camera should automatically align behind the target when the target moves.

-

Default is false.

-
-
-
-

.autoAlignSpeed : number

-
-

Speed of auto-alignment when enabled.

-

Default is 0.05.

-
-
-
-

.collisionObjects : Array.<Object3D>

-
-

Array of objects to test for collision. If empty, no collision detection is performed even if enableCollision is true.

-
-
-
-

.collisionRadius : number

-
-

The radius used for collision detection. A larger value keeps the camera further from obstacles.

-

Default is 0.3.

-
-
-
-

.distance : number

-
-

The desired distance from the camera to the target pivot.

-

Default is 5.

-
-
-
-

.enableCollision : boolean

-
-

Whether collision detection is enabled. When enabled, raycasting is used to prevent the camera from clipping through geometry.

-

Default is false.

-
-
-
-

.enableRotate : boolean

-
-

Whether rotation is enabled.

-

Default is true.

-
-
-
-

.enableZoom : boolean

-
-

Whether zooming (distance adjustment) is enabled.

-

Default is true.

-
-
-
-

.height : number

-
-

The height offset of the camera pivot point relative to the target.

-

Default is 1.6.

-
-
-
-

.maxDistance : number

-
-

The maximum allowed distance from camera to target.

-

Default is 20.

-
-
-
-

.maxPolarAngle : number

-
-

The maximum polar angle (vertical rotation) in radians.

-

Default is Math.PI - 0.1.

-
-
-
-

.minDistance : number

-
-

The minimum allowed distance from camera to target.

-

Default is 1.

-
-
-
-

.minPolarAngle : number

-
-

The minimum polar angle (vertical rotation) in radians. 0 is looking straight down, Math.PI is looking straight up.

-

Default is 0.1.

-
-
-
-

.mouseButtons : Object

-
-

This object contains references to the mouse actions used by the controls.

-
controls.mouseButtons = {
-	LEFT: THREE.MOUSE.ROTATE,
-	MIDDLE: THREE.MOUSE.DOLLY,
-	RIGHT: null
-}
-
-
-
-
Overrides: Controls#mouseButtons
-
-
-
-

.pivotOffset : Vector3

-
-

The pivot offset from the target position in local space. This allows adjusting where the camera focuses on the target.

-
-
-
-

.rotationSpeed : number

-
-

The rotation speed for mouse/touch input.

-

Default is 0.005.

-
-
-
-

.smoothingFactor : number

-
-

The smoothing factor for camera movement (0 = no smoothing, 1 = instant). Lower values create smoother but slower camera movement.

-

Default is 0.1.

-
-
-
-

.target : Object3D

-
-

The target object to follow.

-
-
-
-

.targetTransitionDuration : number

-
-

Duration of target transition in seconds. Used when switching targets with smooth transition enabled via setTarget(target, true).

-

Default is 0.5.

-
-
-
-

.touches : Object

-
-

This object contains references to the touch actions used by the controls.

-
controls.touches = {
-	ONE: THREE.TOUCH.ROTATE,
-	TWO: THREE.TOUCH.DOLLY_ROTATE
-}
-
-
-
-
Overrides: Controls#touches
-
-
-
-

.zoomSpeed : number

-
-

The zoom speed for scroll/pinch input.

-

Default is 1.

-
-
-

Methods

-

.getAzimuthalAngle() : number

-
-
-

Get the current azimuthal (horizontal) angle in radians.

-
-
-
Returns: The current azimuthal angle.
-
-
-

.getDistance() : number

-
-
-

Get the current distance from camera to target pivot.

-
-
-
Returns: The current distance.
-
-
-

.getPolarAngle() : number

-
-
-

Get the current polar (vertical) angle in radians.

-
-
-
Returns: The current polar angle.
-
-
-

.getTarget() : Object3D

-
-
-

Get the current target object.

-
-
-
Returns: The current target object.
-
-
-

.isTransitioning() : boolean

-
-
-

Check if the camera is currently transitioning between targets.

-
-
-
Returns: True if transitioning.
-
-
-

.setDistance( value : number )

-
-
-

Set the camera distance from target.

-
- - - - - - - -
- value - -

The new distance value.

-
-
-

.setTarget( target : Object3D, smooth : boolean )

-
-
-

Set a new target object to follow with optional smooth transition.

-
- - - - - - - - - - - -
- target - -

The new target object.

-
- smooth - -

Whether to smoothly transition to the new target. When true, the camera will interpolate its position and rotation over the duration specified by targetTransitionDuration.

-

Default is false.

-
-
-

.update( delta : number ) : boolean

-
-
-

Updates the controls. Should be called in the animation loop.

-
- - - - - - - -
- delta - -

The time delta in seconds. Used for frame-rate independent smoothing.

-
-
-
Returns: Returns true if the camera was updated.
-
-
-

Events

-

.change

-
-
-

Fires when the camera has been transformed by the controls.

-
-
Type:
-
    -
  • -Object -
  • -
-
-

.end

-
-
-

Fires when an interaction has finished.

-
-
Type:
-
    -
  • -Object -
  • -
-
-

.start

-
-
-

Fires when an interaction was initiated.

-
-
Type:
-
    -
  • -Object -
  • -
-
-

.targetchange

-
-
-

Fires when the target has been switched via setTarget().

-
-
Type:
-
    -
  • -Object -
  • -
-
-

Source

-

- examples/jsm/controls/ThirdPersonControls.js -

-
-
- - - -