OrbitControls + Custom Zoom Based on Surface Distance
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.
Drag — Rotate the globe
Scroll wheel — Zoom (30m to Earth radius x5)
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.
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.
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.
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 panningThere are three key points.
controls.update() every frame.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.
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:
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:
In scene coordinates, the Y-axis corresponds to the polar direction (ECEF Z), so dir.y goes into the b term in the formula.
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)
);
}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.
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.
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 coefficientThe process consists of four steps.
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.
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.
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
// 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);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.
// Linearly proportional to actual surface distance
const surfaceDistance =
Math.max(0, smoothed - globeRadius);
controls.rotateSpeed =
0.25 * (surfaceDistance / globeRadius);| Surface distance | Ratio | rotateSpeed |
|---|---|---|
| 30m (closest) | 0.0000047 | 0.0000012 |
| 6,378km (one radius) | 1.0 | 0.25 |
| 31,890km (radius x5) | 5.0 | 1.25 |
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.
return () => {
cancelAnimationFrame(animationId);
canvas.removeEventListener(
'wheel', handleWheel
);
controls.dispose();
geometry.dispose();
material.dispose();
renderer.dispose();
};In this chapter, we implemented camera controls suited for a globe UI.
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.