CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-camera-controls

A camera control for three.js, similar to THREE.OrbitControls yet supports smooth transitions and more features

Pending

Quality

Pending

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Eval results
Files

event-system.mddocs/

Event System

Comprehensive event handling for camera interactions, transitions, and state changes with the built-in EventDispatcher system.

Capabilities

EventDispatcher Base Class

CameraControls extends EventDispatcher, providing event management functionality.

/**
 * Event listener function type
 */
type Listener = (event?: DispatcherEvent) => void;

/**
 * Base event interface
 */
interface DispatcherEvent {
  type: string;
  [key: string]: any;
}

/**
 * Add event listener for specific event type
 * @param type - Event name to listen for
 * @param listener - Function to call when event fires
 */
addEventListener(type: string, listener: Listener): void;

/**
 * Check if specific listener exists for event type
 * @param type - Event name to check
 * @param listener - Listener function to check for
 * @returns True if listener exists
 */
hasEventListener(type: string, listener: Listener): boolean;

/**
 * Remove specific event listener
 * @param type - Event name
 * @param listener - Listener function to remove
 */
removeEventListener(type: string, listener: Listener): void;

/**
 * Remove all listeners for event type (or all events if no type specified)
 * @param type - Optional event name (removes all if omitted)
 */
removeAllEventListeners(type?: string): void;

/**
 * Fire an event to all registered listeners
 * @param event - Event object to dispatch
 */
dispatchEvent(event: DispatcherEvent): void;

Camera Controls Events

CameraControls fires specific events during camera operations and user interactions.

interface CameraControlsEventMap {
  update: { type: 'update' };
  wake: { type: 'wake' };
  rest: { type: 'rest' };
  sleep: { type: 'sleep' };
  transitionstart: { type: 'transitionstart' };
  controlstart: { type: 'controlstart' };
  control: { type: 'control' };
  controlend: { type: 'controlend' };
}

Event Descriptions:

  • update: Fired every frame when camera position/orientation changes
  • wake: Fired when controls become active (first user interaction)
  • rest: Fired when camera stops moving and reaches rest state
  • sleep: Fired when controls become inactive (no recent interaction)
  • transitionstart: Fired when programmatic transition begins
  • controlstart: Fired when user interaction starts (mouse down, touch start)
  • control: Fired during active user interaction (mouse move, touch move)
  • controlend: Fired when user interaction ends (mouse up, touch end)

Basic Event Handling

// Listen for camera updates
cameraControls.addEventListener('update', () => {
  console.log('Camera updated - position changed');
  renderer.render(scene, camera);
});

// Listen for user interactions
cameraControls.addEventListener('controlstart', () => {
  console.log('User started interacting with camera');
});

cameraControls.addEventListener('controlend', () => {
  console.log('User stopped interacting with camera');
});

// Listen for camera rest state
cameraControls.addEventListener('rest', () => {
  console.log('Camera has stopped moving');
});

Advanced Event Usage

Performance Optimization with Update Events:

let renderRequested = false;

cameraControls.addEventListener('update', () => {
  if (!renderRequested) {
    renderRequested = true;
    requestAnimationFrame(() => {
      renderer.render(scene, camera);
      renderRequested = false;
    });
  }
});

// Alternative: Only render on camera changes
function animate() {
  const delta = clock.getDelta();
  const hasControlsUpdated = cameraControls.update(delta);
  
  if (hasControlsUpdated) {
    renderer.render(scene, camera);
  }
  
  requestAnimationFrame(animate);
}

UI State Management:

// Show/hide UI elements based on camera state
const loadingIndicator = document.getElementById('loading');
const cameraInfo = document.getElementById('camera-info');

cameraControls.addEventListener('transitionstart', () => {
  loadingIndicator.style.display = 'block';
});

cameraControls.addEventListener('rest', () => {
  loadingIndicator.style.display = 'none';
});

cameraControls.addEventListener('controlstart', () => {
  cameraInfo.style.opacity = '0.5'; // Fade info during interaction
});

cameraControls.addEventListener('controlend', () => {
  cameraInfo.style.opacity = '1.0'; // Restore info after interaction
});

Sleep/Wake Cycle Management:

// Manage power consumption and updates
cameraControls.addEventListener('sleep', () => {
  console.log('Camera controls sleeping - reducing update frequency');
  // Reduce animation loop frequency or pause updates
});

cameraControls.addEventListener('wake', () => {
  console.log('Camera controls waking - resuming full updates');
  // Resume full update frequency
});

Interaction Tracking:

let interactionCount = 0;
let isInteracting = false;

cameraControls.addEventListener('controlstart', () => {
  isInteracting = true;
  interactionCount++;
  console.log(`User interaction #${interactionCount} started`);
});

cameraControls.addEventListener('control', () => {
  if (isInteracting) {
    // Update interaction UI, show coordinates, etc.
    updateCameraDebugInfo();
  }
});

cameraControls.addEventListener('controlend', () => {
  isInteracting = false;
  console.log('User interaction ended');
});

function updateCameraDebugInfo() {
  const position = new THREE.Vector3();
  const target = new THREE.Vector3();
  
  cameraControls.getPosition(position);
  cameraControls.getTarget(target);
  
  document.getElementById('debug-position').textContent = 
    `Position: ${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`;
  document.getElementById('debug-target').textContent = 
    `Target: ${target.x.toFixed(2)}, ${target.y.toFixed(2)}, ${target.z.toFixed(2)}`;
}

Event-Driven Animation

Coordinated Animations:

// Chain animations using events
async function performCameraTour() {
  const positions = [
    { x: 10, y: 5, z: 10 },
    { x: -10, y: 8, z: 5 },
    { x: 0, y: 15, z: 0 },
    { x: 5, y: 2, z: -10 }
  ];
  
  for (const pos of positions) {
    // Start movement
    const movePromise = cameraControls.moveTo(pos.x, pos.y, pos.z, true);
    
    // Wait for transition to start
    await new Promise(resolve => {
      cameraControls.addEventListener('transitionstart', resolve, { once: true });
    });
    
    console.log(`Moving to position: ${pos.x}, ${pos.y}, ${pos.z}`);
    
    // Wait for movement to complete
    await movePromise;
    
    // Wait for camera to rest
    await new Promise(resolve => {
      cameraControls.addEventListener('rest', resolve, { once: true });
    });
    
    console.log('Position reached, pausing...');
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
}

Event-Based State Machine:

enum CameraState {
  IDLE = 'idle',
  USER_CONTROLLING = 'user_controlling',
  TRANSITIONING = 'transitioning',
  SLEEPING = 'sleeping'
}

class CameraStateMachine {
  private currentState = CameraState.IDLE;
  
  constructor(controls: CameraControls) {
    controls.addEventListener('controlstart', () => this.setState(CameraState.USER_CONTROLLING));
    controls.addEventListener('controlend', () => this.setState(CameraState.IDLE));
    controls.addEventListener('transitionstart', () => this.setState(CameraState.TRANSITIONING));
    controls.addEventListener('rest', () => this.setState(CameraState.IDLE));
    controls.addEventListener('sleep', () => this.setState(CameraState.SLEEPING));
    controls.addEventListener('wake', () => this.setState(CameraState.IDLE));
  }
  
  private setState(newState: CameraState) {
    const oldState = this.currentState;
    this.currentState = newState;
    
    console.log(`Camera state: ${oldState} -> ${newState}`);
    
    // Trigger state-specific behaviors
    this.onStateChange(oldState, newState);
  }
  
  private onStateChange(from: CameraState, to: CameraState) {
    switch (to) {
      case CameraState.USER_CONTROLLING:
        document.body.classList.add('camera-interacting');
        break;
      case CameraState.TRANSITIONING:
        document.body.classList.add('camera-transitioning');
        break;
      case CameraState.IDLE:
        document.body.classList.remove('camera-interacting', 'camera-transitioning');
        break;
      case CameraState.SLEEPING:
        document.body.classList.add('camera-sleeping');
        break;
    }
  }
  
  getCurrentState(): CameraState {
    return this.currentState;
  }
}

const stateMachine = new CameraStateMachine(cameraControls);

Custom Event Handling

Creating Custom Events:

// Extend controls with custom events
class ExtendedCameraControls extends CameraControls {
  
  private targetObject: THREE.Object3D | null = null;
  
  focusOnObject(object: THREE.Object3D) {
    this.targetObject = object;
    
    // Fire custom event
    this.dispatchEvent({ 
      type: 'focus-start', 
      target: object 
    });
    
    // Perform focus operation
    this.fitToBox(object, true).then(() => {
      this.dispatchEvent({ 
        type: 'focus-complete', 
        target: object 
      });
    });
  }
}

// Use custom events
const extendedControls = new ExtendedCameraControls(camera, renderer.domElement);

extendedControls.addEventListener('focus-start', (event) => {
  console.log('Starting focus on object:', event.target);
});

extendedControls.addEventListener('focus-complete', (event) => {
  console.log('Focus complete on object:', event.target);
});

Event Cleanup

Proper Event Listener Management:

class CameraControlsManager {
  private controls: CameraControls;
  private listeners: Map<string, Listener[]> = new Map();
  
  constructor(camera: THREE.Camera, domElement: HTMLElement) {
    this.controls = new CameraControls(camera, domElement);
  }
  
  addEventListener(type: string, listener: Listener) {
    this.controls.addEventListener(type, listener);
    
    // Track listeners for cleanup
    if (!this.listeners.has(type)) {
      this.listeners.set(type, []);
    }
    this.listeners.get(type)!.push(listener);
  }
  
  dispose() {
    // Remove all tracked listeners
    for (const [type, listeners] of this.listeners) {
      for (const listener of listeners) {
        this.controls.removeEventListener(type, listener);
      }
    }
    this.listeners.clear();
    
    // Dispose controls
    this.controls.dispose();
  }
}

// Usage
const controlsManager = new CameraControlsManager(camera, renderer.domElement);

controlsManager.addEventListener('rest', () => {
  console.log('Camera at rest');
});

// Clean up when done
controlsManager.dispose();

Install with Tessl CLI

npx tessl i tessl/npm-camera-controls

docs

boundaries-constraints.md

camera-fitting.md

camera-movement.md

core-controls.md

event-system.md

index.md

input-configuration.md

state-management.md

tile.json