Chapter 2: Camera Controls

OrbitControls + Custom Zoom Based on Surface Distance

Introduction

In web maps, drag and zoom are provided out of the box by mapping libraries, but in 3D, customization suited for a globe UI is required. The built-in zoom of OrbitControls simply increases or decreases camera distance, resulting in vastly different user experiences between a space-level view and a near-surface view.

What corresponds to zoom level switching in web maps is implemented as "camera-to-ellipsoid surface distance control" in 3D space. In this chapter, you will experience how exponential zoom based on surface distance provides a consistent user experience at any altitude.

What You Will Learn in This Chapter

  • Drag rotation with OrbitControls — an interaction like spinning a globe by hand
  • Disabling built-in zoom and implementing custom surface-distance-based zoom — recreating a web map zoom level experience in 3D
  • Directional radius calculation of the WGS84 ellipsoid — GIS-aware handling of the oblate ellipsoid
  • Smooth zoom interpolation (exponential smoothing) — natural camera animation via exponential decay
  • Dynamic rotation speed adjustment based on surface distance — fine control near the surface, broad control from space

Controls

Drag — Rotate the globe

Scroll wheel — Zoom (30m to Earth radius x5)

ThreeRenderer Options Design

Before diving into camera control implementation, let's review the constructor arguments of ThreeRenderer. In the latest implementation, rather than simply receiving a canvas, it accepts options through a ThreeRendererOptions interface.

ThreeRendererOptions
export interface ThreeRendererOptions {
  canvas: HTMLCanvasElement;
  initialPosition?: {
    lat: number;
    lon: number;
    altitude?: number;
  };
}

The initialPosition option allows specifying the camera's initial position by latitude, longitude, and altitude. When omitted, the default is Tokyo (lat=35.68, lon=139.75). There are two design reasons for using an object-style constructor argument.

  • Clarity of named parameters: It becomes clear at the call site what is being passed
  • Extensibility: New properties can be added in the future without modifying existing code

Basic OrbitControls Setup

Three.js's OrbitControls is a helper for controlling the camera with mouse or touch input. Its basic behavior is to orbit the camera around the origin, but the default zoom and pan are not suited for a globe UI, so we disable them.

OrbitControls
const controls = new OrbitControls(
  camera,
  renderer.domElement
);
controls.enableDamping = true;    // Smooth rotation with inertia
controls.enableZoom = false;      // Disable built-in zoom
controls.enablePan = false;       // Disable panning

There are three key points.

  • enableZoom = false: Disables the built-in zoom of OrbitControls and replaces it with custom zoom. The built-in zoom moves linearly based on the distance between the camera and the origin, making the distance from the Earth's surface unintuitive.
  • enablePan = false: Panning (translation) is not meaningful in a globe UI. Rotation serves as a substitute.
  • enableDamping = true: Enables inertia (damping), creating a natural motion that gradually decelerates after dragging. However, when using damping, you must call controls.update() every frame.

Why Custom Zoom Is Needed

The built-in zoom of OrbitControls has two problems in Earth-scale scenes.

Problem 1: Constant-speed zoom

The built-in zoom moves at a constant ratio relative to camera distance. While comfortable in outer space, near the surface it is too fast and the camera penetrates through the ground.

Problem 2: The sphere is not considered

The built-in zoom only looks at the distance from the origin. However, since the Earth is an ellipsoid, the distance to the "surface" differs between the polar and equatorial directions.

For example, if the Earth's radius is approximately 6,378 km, when the camera distance is 12,756 km (= radius x2) versus 6,378.167 km (= radius + 30m), the surface distances are 6,378 km and 30 m respectively, but the camera distance only changes by about 50%. Meanwhile, the surface distance changes by more than 200,000 times. A default linear zoom cannot absorb this difference.

Ellipsoid Directional Radius Calculation

To implement custom zoom, we first need to determine the distance to the ellipsoid surface in the camera's direction. The Earth is not a perfect sphere but an oblate ellipsoid that bulges at the equator. In the WGS84 definition, the equatorial radius (a) is 6,378,137 m and the polar radius (b) is 6,356,752 m, a difference of about 21 km (0.3%).

The WGS84 ellipsoid is described by the following equation:

x²/a² + y²/a² + z²/b² = 1

The radius r of the ellipsoid in the direction of a direction vector d = (dx, dy, dz) is derived from the condition that r * d lies on the ellipsoid:

r = 1 / sqrt( (dx² + dz²)/a² + dy²/b² )

In scene coordinates, the Y-axis corresponds to the polar direction (ECEF Z), so dir.y goes into the b term in the formula.

ellipsoidRadiusInDirection
function ellipsoidRadiusInDirection(
  dir: THREE.Vector3
): number {
  const a = WGS84.a;
  const b = WGS84.b;
  return 1 / Math.sqrt(
    (dir.x * dir.x + dir.z * dir.z)
      / (a * a)
    + (dir.y * dir.y)
      / (b * b)
  );
}

Derivation of the Formula

Here is a supplementary explanation of how the above formula is derived. The point that reaches the ellipsoid surface in the direction of the direction vector d is the point r*d = (r*dx, r*dy, r*dz) at distance r from the origin. The condition for this point to lie on the ellipsoid is obtained by substituting into the ellipsoid equation:

(r*dx)²/a² + (r*dz)²/a² + (r*dy)²/b² = 1

r² * ( (dx²+dz²)/a² + dy²/b² ) = 1

r = 1 / sqrt( (dx²+dz²)/a² + dy²/b² )

In the scene coordinate system (see Chapter 3), the Y-axis corresponds to the polar direction (ECEF Z-axis), so the equatorial plane components are dx² + dz² (scene X and scene Z), which go into the semi-major axis a term. Meanwhile, dy (scene Y = polar direction) goes into the semi-minor axis b term.

Wheel Event Handling

In surface-distance-based exponential zoom, each wheel operation changes the surface distance by a constant ratio (ZOOM_FACTOR=0.25, i.e., 25%). Whether from space or near the surface, the same input produces the same "magnification feel" of zoom.

Custom zoom constants
const MIN_SURFACE_DISTANCE = 30;       // 30m (closest approach distance)
const MAX_SURFACE_DISTANCE = WGS84.a * 5; // Maximum distance
const ZOOM_FACTOR = 0.25;              // Zoom sensitivity
const ZOOM_SMOOTH = 0.15;              // Interpolation coefficient

The process consists of four steps.

  • Normalize scroll amount: Normalize deltaY to a range of 0 to 2, suppressing extreme scrolling
  • Calculate exponential factor: (1 + 0.25)^intensity computes a zoom multiplier proportional to the scroll amount
  • Determine direction: Scroll down (deltaY > 0) zooms out, scroll up zooms in
  • Update surface distance: Multiply the current surface distance by the factor and clamp to the range of 30m to maximum
Wheel handler
const handleWheel = (e: WheelEvent) => {
  e.preventDefault();
  const intensity =
    Math.min(Math.abs(e.deltaY), 200) / 100;
  const factor =
    Math.pow(1 + ZOOM_FACTOR, intensity);
  const direction =
    e.deltaY > 0 ? factor : 1 / factor;
  targetSurfaceDistance = Math.max(
    MIN_SURFACE_DISTANCE,
    Math.min(
      MAX_SURFACE_DISTANCE,
      targetSurfaceDistance * direction
    )
  );
};
canvas.addEventListener(
  'wheel', handleWheel,
  { passive: false }
);

The key point is that zoom is performed by "multiplication". When the surface distance is large the movement is large, and when it is small the movement is small, providing a consistent user experience whether from space or near the surface.

Per-Frame Zoom Interpolation

While targetSurfaceDistance changes instantly from wheel input, the camera moves 15% toward the target each frame (ZOOM_SMOOTH=0.15), resulting in smooth animation that avoids sudden jumps.

smoothed = current + (target - current) x 0.15

This is a simplified form of linear interpolation (lerp). Each frame, the camera moves by 15% of the difference between the current and target values. The larger the difference, the faster the movement; the smaller the difference, the slower the approach — producing exponential decay.

Distance

 |  \

 |   \

 |    \________

 |

 +------------------ Frame

  Approaches quickly, converges slowly near target

Zoom interpolation (per frame)
// Dynamically calculate ellipsoid radius in camera direction
const dir = camera.position.clone().normalize();
const globeRadius =
  ellipsoidRadiusInDirection(dir);

// Zoom: interpolate toward ellipsoid radius + target surface distance
const targetDist =
  globeRadius + targetSurfaceDistance;
const currentDist = camera.position.length();
const smoothed = currentDist
  + (targetDist - currentDist) * ZOOM_SMOOTH;
camera.position.setLength(smoothed);

Dynamic Rotation Speed Adjustment

Near the surface, a small drag should move the map significantly, while from space, a large drag should rotate the entire globe — this is the natural behavior. By making the rotation speed proportional to the surface distance, we maintain the sensation of "the ground moves as much as you drag" regardless of zoom level.

rotateSpeed
// Linearly proportional to actual surface distance
const surfaceDistance =
  Math.max(0, smoothed - globeRadius);
controls.rotateSpeed =
  0.25 * (surfaceDistance / globeRadius);
Surface distanceRatiorotateSpeed
30m (closest)0.00000470.0000012
6,378km (one radius)1.00.25
31,890km (radius x5)5.01.25

Resource Cleanup

Event listeners for camera controls must be reliably removed during disposal. Since Three.js's OrbitControls registers event listeners internally, forgetting to call controls.dispose() will cause memory leaks.

dispose
return () => {
  cancelAnimationFrame(animationId);
  canvas.removeEventListener(
    'wheel', handleWheel
  );
  controls.dispose();
  geometry.dispose();
  material.dispose();
  renderer.dispose();
};

Summary

In this chapter, we implemented camera controls suited for a globe UI.

  • OrbitControls: Only rotation enabled; built-in zoom and pan disabled
  • Ellipsoid radius calculation: Computes the WGS84 ellipsoid surface position for each camera direction
  • Custom zoom: Modifies surface distance by multiplication, achieving an exponential user experience
  • Interpolation: Smooth animation moving 15% toward the target each frame
  • Rotation speed adjustment: Proportional to surface distance, providing a zoom-level-independent user experience

In the next chapter, we will implement the coordinate transformation pipeline for WGS84, ECEF, and ENU coordinate systems from scratch.

Column: Why disable OrbitControls' built-in zoom?

The built-in zoom of OrbitControls moves camera.position linearly toward the origin. Since this does not account for the ellipsoid, the "apparent surface distance" differs between the equatorial and polar directions. Our custom zoom recalculates the ellipsoid radius in the camera's direction every frame, always using the correct "distance from the surface" as the reference.