Skip to main content

Overview

Camera synchronization is one of the most complex parts of Threebox. It ensures that Three.js objects appear correctly positioned and scaled as the Mapbox camera moves, rotates, and zooms. The CameraSync class handles all camera transformations between Mapbox GL JS and Three.js coordinate systems.

The CameraSync Class

Initialization

From source (CameraSync.js:9-40):
function CameraSync(map, camera, world) {
  this.map = map;
  this.camera = camera;
  this.active = true;
  
  // We control the camera completely
  this.camera.matrixAutoUpdate = false;
  
  // Position the world group
  this.world = world || new THREE.Group();
  this.world.position.x = this.world.position.y = WORLD_SIZE / 2;
  this.world.matrixAutoUpdate = false;
  
  // Camera state
  this.state = {
    translateCenter: new THREE.Matrix4()
      .makeTranslation(WORLD_SIZE / 2, -WORLD_SIZE / 2, 0),
    worldSizeRatio: TILE_SIZE / WORLD_SIZE,
    worldSize: TILE_SIZE * this.map.transform.scale
  };
  
  // Listen for map events
  this.map
    .on('move', () => { this.updateCamera(); })
    .on('resize', () => { this.setupCamera(); });
  
  this.setupCamera();
}

Key Properties

camera.matrixAutoUpdate
boolean
default:"false"
Disabled because Threebox manually controls the camera matrix
world.matrixAutoUpdate
boolean
default:"false"
Disabled because the world matrix is manually calculated each frame

Camera Setup

The setupCamera method calculates camera parameters from the Mapbox transform:
setupCamera: function () {
  const t = this.map.transform;
  this.camera.aspect = t.width / t.height;
  this.halfFov = t._fov / 2;
  this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height;
  const maxPitch = t._maxPitch * Math.PI / 180;
  this.acuteAngle = Math.PI / 2 - maxPitch;
  this.updateCamera();
}

Camera Distance Calculation

The camera distance from the map center is calculated to match Mapbox’s perspective:
this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height;
This ensures the Three.js camera has the same field of view as Mapbox.

Camera Update

The updateCamera method is called on every map move event. It synchronizes:
  1. Projection matrix (perspective or orthographic)
  2. Camera world matrix (position and rotation)
  3. World group matrix (scaling and translation)

Projection Matrix

Perspective Camera

From source (CameraSync.js:111):
this.camera.projectionMatrix = utils.makePerspectiveMatrix(
  t._fov, 
  w / h, 
  nearZ, 
  farZ
);
The makePerspectiveMatrix function (utils.js:17-32):
makePerspectiveMatrix: function (fovy, aspect, near, far) {
  var out = new THREE.Matrix4();
  var f = 1.0 / Math.tan(fovy / 2),
      nf = 1 / (near - far);
  
  var newMatrix = [
    f / aspect, 0, 0, 0,
    0, f, 0, 0,
    0, 0, (far + near) * nf, -1,
    0, 0, (2 * far * near) * nf, 0
  ]
  
  out.elements = newMatrix
  return out;
}

Orthographic Camera

From source (CameraSync.js:109):
this.camera.projectionMatrix = utils.makeOrthographicMatrix(
  w / -2, w / 2, 
  h / 2, h / -2, 
  nearZ, farZ
);

Near and Far Plane Calculation

The near and far clipping planes are calculated based on pitch and field of view:
const nz = (t.height / 50); // Min near z
const nearZ = Math.max(nz * pitchAngle, nz);

const topHalfSurfaceDistance = 
  Math.sin(this.halfFov) * this.cameraToCenterDistance / 
  Math.sin(Math.PI - groundAngle - this.halfFov);

const furthestDistance = 
  pitchAngle * topHalfSurfaceDistance + this.cameraToCenterDistance;

const farZ = furthestDistance * 1.01;
This ensures that objects are clipped appropriately based on camera angle.

Mapbox v2.0+ Terrain Support

For Mapbox GL JS v2.0+ with terrain:
if (this.map.tb.mapboxVersion >= 2.0) {
  const pixelsPerMeter = 
    this.mercatorZfromAltitude(1, t.center.lat) * worldSize;
  
  const fovAboveCenter = 
    t._fov * (0.5 + t.centerOffset.y / t.height);
  
  // Adjust for minimum elevation
  const minElevationInPixels = 
    t.elevation ? t.elevation.getMinElevationBelowMSL() * pixelsPerMeter : 0;
  
  const cameraToSeaLevelDistance = 
    ((t._camera.position[2] * worldSize) - minElevationInPixels) / 
    Math.cos(t._pitch);
  
  const topHalfSurfaceDistance = 
    Math.sin(fovAboveCenter) * cameraToSeaLevelDistance / 
    Math.sin(utils.clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01));
  
  furthestDistance = 
    pitchAngle * topHalfSurfaceDistance + cameraToSeaLevelDistance;
  
  const horizonDistance = cameraToSeaLevelDistance * (1 / t._horizonShift);
  farZ = Math.min(furthestDistance * 1.01, horizonDistance);
}

Camera World Matrix

The camera’s position and rotation are set via its world matrix:
let cameraWorldMatrix = this.calcCameraMatrix(t._pitch, t.angle);

// Adjust for terrain height
if (t.elevation) 
  cameraWorldMatrix.elements[14] = t._camera.position[2] * worldSize;

this.camera.matrixWorld.copy(cameraWorldMatrix);

calcCameraMatrix

From source (CameraSync.js:170-180):
calcCameraMatrix(pitch, angle, trz) {
  const t = this.map.transform;
  const _pitch = (pitch === undefined) ? t._pitch : pitch;
  const _angle = (angle === undefined) ? t.angle : angle;
  const _trz = (trz === undefined) ? this.cameraTranslateZ : trz;
  
  return new THREE.Matrix4()
    .premultiply(_trz)
    .premultiply(new THREE.Matrix4().makeRotationX(_pitch))
    .premultiply(new THREE.Matrix4().makeRotationZ(_angle));
}
This applies transformations in order:
  1. Translate camera back from center
  2. Rotate around X-axis (pitch)
  3. Rotate around Z-axis (bearing)

World Matrix

The world group matrix handles:
  • Scaling objects based on zoom level
  • Translating the world based on map center
  • Rotating to match coordinate systems
From source (CameraSync.js:124-141):
let zoomPow = t.scale * this.state.worldSizeRatio;

let scale = new THREE.Matrix4();
let translateMap = new THREE.Matrix4();
let rotateMap = new THREE.Matrix4();

scale.makeScale(zoomPow, zoomPow, zoomPow);

let x = t.x || t.point.x;
let y = t.y || t.point.y;
translateMap.makeTranslation(-x, y, 0);
rotateMap.makeRotationZ(Math.PI);

this.world.matrix = new THREE.Matrix4()
  .premultiply(rotateMap)
  .premultiply(this.state.translateCenter)
  .premultiply(scale)
  .premultiply(translateMap)

Transformation Order

  1. Translate by map position (-x, y, 0)
  2. Scale by zoom level
  3. Translate to center
  4. Rotate 180° to flip Y-axis
This ensures Three.js objects align with Mapbox’s coordinate system.

Coordinate System Alignment

Mapbox uses a different coordinate system than Three.js:
AxisMapboxThree.js
XEast →East →
YSouth ↓North ↑
ZUp ↑Up ↑
The 180° Z-rotation flips the Y-axis to match Mapbox’s south-down orientation.

Zoom Scaling

Objects scale with zoom level:
let zoomPow = t.scale * this.state.worldSizeRatio;
scale.makeScale(zoomPow, zoomPow, zoomPow);
Where:
  • t.scale = Math.pow(2, zoom)
  • worldSizeRatio = TILE_SIZE / WORLD_SIZE = 512 / 536870912
This ensures objects maintain consistent size relative to map features.

Fixed Zoom Objects

Some objects can maintain fixed size regardless of zoom:
// From Threebox.js:865-866
setObjectsScale: function () {
  this.world.children
    .filter(o => (o.fixedZoom != null))
    .forEach((o) => { o.setObjectScale(this.map.transform.scale); });
}
Called on zoom events to rescale fixedZoom objects.

Camera Synchronization Event

After each update, a CameraSynced event is fired:
this.map.fire('CameraSynced', { 
  detail: { 
    nearZ: nearZ, 
    farZ: farZ, 
    pitch: t._pitch, 
    angle: t.angle, 
    furthestDistance: furthestDistance, 
    cameraToCenterDistance: this.cameraToCenterDistance, 
    t: this.map.transform, 
    tbProjMatrix: this.camera.projectionMatrix.elements, 
    tbWorldMatrix: this.world.matrix.elements
  } 
});
You can listen to this event:
map.on('CameraSynced', function(e) {
  console.log('Near:', e.detail.nearZ, 'Far:', e.detail.farZ);
  console.log('Pitch:', e.detail.pitch, 'Bearing:', e.detail.angle);
});

Raycasting

For mouse interactions, the camera’s projection matrix is used for raycasting:
// From Threebox.js:790-804
queryRenderedFeatures: function (point) {
  let mouse = new THREE.Vector2();
  
  // Scale to normalized device coordinates (-1 to +1)
  mouse.x = (point.x / this.map.transform.width) * 2 - 1;
  mouse.y = 1 - (point.y / this.map.transform.height) * 2;
  
  this.raycaster.setFromCamera(mouse, this.camera);
  
  // Calculate intersecting objects
  let intersects = this.raycaster.intersectObjects(this.world.children, true);
  
  return intersects
}

Camera Type Switching

You can switch between perspective and orthographic cameras:
// Switch to orthographic
tb.orthographic = true;

// Switch to perspective
tb.orthographic = false;

// Change FOV (perspective only)
tb.fov = 35;
From source (Threebox.js:599-620):
set orthographic(value) {
  const h = this.map.getCanvas().clientHeight;
  const w = this.map.getCanvas().clientWidth;
  if (value) {
    this.map.transform.fov = 0;
    this.camera = new THREE.OrthographicCamera(
      w / -2, w / 2, h / 2, h / -2, 0.1, 1e21
    );
  } else {
    this.map.transform.fov = this.fov;
    this.camera = new THREE.PerspectiveCamera(
      this.map.transform.fov, w / h, 0.1, 1e21
    );
  }
  this.camera.layers.enable(0);
  this.camera.layers.enable(1);
  this.cameraSync = new CameraSync(this.map, this.camera, this.world);
  this.map.repaint = true;
  this.options.orthographic = value;
}

Performance Optimization

Camera updates are optimized:
  1. Matrix auto-update disabled: Manual control prevents redundant calculations
  2. Event-driven updates: Only updates on move and resize events
  3. Shared context: Uses Mapbox’s WebGL context

Common Issues

Objects appear in wrong position
  • Ensure coordinates are in [lng, lat] format, not [lat, lng]
  • Check that units option is set correctly (‘meters’ or ‘scene’)
  • Verify camera synchronization is running (check CameraSynced event)
Objects disappear at certain angles
  • Near/far plane clipping issue
  • Increase far plane or adjust object positions
  • Check pitch angle is within valid range
Raycasting not working
  • Ensure camera.aspect is updated on resize
  • Check that camera.matrixWorld is updated
  • Verify raycaster is using correct camera instance

Next Steps

Coordinate Systems

Learn about coordinate conversions between lnglat, meters, and units

Threebox Instance

Back to Threebox instance documentation

Build docs developers (and LLMs) love