@supertt/three-cesium-bridge
v0.1.0
Published
Synchronize Cesium and three.js behind a single floating-origin ECEF<->local transform and one shared camera.
Maintainers
Readme
@supertt/three-cesium-bridge
Synchronize Cesium and three.js behind one floating-origin coordinate frame and one shared camera.
Cesium keeps its geospatial authority (ECEF coordinates, double precision, 3D
Tiles, terrain, imagery). three.js renders a transparent overlay that follows
Cesium's camera, so you can drop Object3Ds onto the globe at real
longitude/latitude coordinates without precision jitter.
Why not merge the two renderers?
Cesium works in Earth-centered coordinates (~6.4×10&sup6; m) and needs
double precision on the CPU plus a high/low split on the GPU to avoid ~0.5 m
float32 jitter. three.js is a local, float32 renderer. Forcing them onto one
matrix loses one of those properties. This bridge keeps both engines intact and
links them with a floating origin: a local tangent frame (X=East, Y=Up,
Z=South) that is re-centered as the camera moves.
Install
npm install three cesium @cesium/engine @supertt/three-cesium-bridgethree, cesium, and @cesium/engine are peer dependencies. This package is
ESM-only — @cesium/engine is ESM-only, and bundling it would create
duplicate Cartesian3/Quaternion instances, so a CJS build is intentionally
not provided.
Run the demo
The repo ships a working demo in example/main.ts: a glTF
house clamped onto real Cesium World Terrain at a geospatial position, a waving
custom-shader flag, and terrain-clamped markers — all glued to the globe as you
zoom and orbit.
npm install
npm run example # vite dev serverOpen the printed URL (http://localhost:5173 by default). It runs out of the
box: Cesium ships a default Ion token that serves real satellite imagery
(Cesium World Imagery) + real elevation (Cesium World Terrain) with no sign-up.
For anything long-lived, get a free token at https://ion.cesium.com and set
Cesium.Ion.defaultAccessToken = 'YOUR_TOKEN' before creating the viewer. The
scene is centered on San Francisco; change the lon/lat in
example/main.ts to drop it anywhere on Earth.
Quick start
import * as Cesium from 'cesium';
import * as THREE from 'three';
import { CesiumThreeBridge } from '@supertt/three-cesium-bridge';
const viewer = new Cesium.Viewer(container, { baseLayer: false });
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 5000),
});
const renderer = new THREE.WebGLRenderer({ alpha: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
const bridge = new CesiumThreeBridge({ viewer, renderer, scene, camera });
const box = new THREE.Mesh(
new THREE.BoxGeometry(50, 50, 50),
new THREE.MeshStandardMaterial({ color: 0xff3333 }),
);
bridge.addObject(box, Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 100));
bridge.start(); // or call bridge.render() in your own loopLoading a glTF model at a position
Cesium owns the coordinates; three.js owns the model and its shaders. Load a
model with three's GLTFLoader and clamp it onto real terrain at a
longitude/latitude with bridge.placeOnTerrain():
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const gltf = await new GLTFLoader().loadAsync('/models/house.glb');
bridge.placeOnTerrain(
gltf.scene, // three.js Object3D
Cesium.Cartographic.fromDegrees(-122.4, 37.8), // lon, lat (radians-free)
{
heightOffset: 13, // meters above the terrain surface
heading: 35, // compass heading in degrees (0° = north)
alignToTerrain: true, // lean with the slope (default)
},
);That's the whole matrix-sync story. Every frame the bridge re-projects the
model's ECEF position into the floating-origin local frame and renders it with
Cesium's camera (same position, same frustum), so the model is glued to the
globe — it moves, scales and parallaxes with the terrain as you orbit and zoom,
instead of being a screen-fixed sprite. There is nothing to update yourself;
just don't mutate gltf.scene.position (the bridge owns it).
How a "world-coordinate" model fits the terrain
A three.js model has no idea about the Earth. placeOnTerrain bridges that gap
in two steps:
- Height — it samples the terrain elevation at that longitude/latitude
(
sampleHeightMostDetailed, falling back toglobe.getHeight) and places the model's origin atheight + heightOffsetabove the ellipsoid. - Slope — with
alignToTerrain: true(default) it also samples four neighbors to estimate the terrain surface normal, then tilts the model so its up-axis follows that normal. On flat ground the model stands straight up; on a hillside it leans with the slope instead of floating or burying itself.
The height and normal re-sample automatically as finer terrain tiles load, so the model "settles" onto the surface.
Adjusting position and orientation
An anchor is a live object — you can move and rotate it after placement:
const anchor = bridge.placeOnTerrain(gltf.scene, Cesium.Cartographic.fromDegrees(lon, lat));
// Move to a new coordinate (re-samples terrain height + slope).
bridge.updateAnchorPosition(gltf.scene, Cesium.Math.toRadians(-122.42), Cesium.Math.toRadians(37.81));
// Face a new compass heading (no re-sample needed).
bridge.updateAnchorHeading(gltf.scene, 90);
// Float higher above the ground.
bridge.updateAnchorHeightOffset(gltf.scene, 20);For a fixed-height object (no terrain clamping), use addObject(object, ecef,
orientation?) with an explicit ECEF position and an optional
Cesium.Quaternion orientation.
API
CesiumThreeBridge
addObject(object, ecef, orientation?)— anchor anObject3Dat an ECEF position (Cesium.Cartesian3.fromDegrees(lon, lat, height)). Returns the placed record. Optionalorientationis aCesium.Quaternionin ECEF.placeOnTerrain(object, cartographic, options?)— clamp anObject3Dto the terrain surface.cartographicis aCesium.Cartographic(lon/lat in radians). Options:heightOffset(meters above the surface, default0),heading(compass heading in degrees, default0),alignToTerrain(tilt the up-axis to follow the terrain surface normal, defaulttrue), andorientation(raw ECEFCesium.Quaternionoverride). The object's height and slope follow the terrain as finer tiles load. Falls back toglobe.getHeightwhensampleHeightis unsupported.updateAnchorPosition(object, longitude, latitude)— move an existing terrain anchor to new coordinates (radians) and re-sample its height/slope.updateAnchorHeading(object, headingDeg)— rotate an existing terrain anchor to a new compass heading.updateAnchorHeightOffset(object, heightOffset)— change an anchor's height offset above the surface.refreshTerrainAnchors()— force an immediate re-sample of every terrain anchor.refreshOcclusion()— force an immediate re-evaluation of terrain occlusion.removeObject(object)— detach and stop tracking (works for both fixed and terrain-anchored objects).setOrigin(ecef)/rebase()— re-center the local frame (terrain anchors are re-projected to their sampled height automatically).update()— sync camera + reposition objects (no draw).render()—update()+ draw the three.js scene.start()/stop()/dispose()— manage the auto-render loop mounted on Cesium'spostRenderevent and the transparent overlay canvas.
EcefLocalFrame
toLocalPosition(ecef)/toEcefPosition(local)— position transforms.rotateToLocal(ecefDir)— direction transform (no translation).rotationToLocal(ecefQuaternion)— orientation transform.
terrainAnchorEcef(placement)
Pure helper: computes the ECEF position of a terrain anchor from its sampled height and offset. Useful for testing or computing anchor positions yourself.
Terrain geometry helpers
Pure, exported for advanced use:
geodeticUp(ecef)— the ellipsoid surface normal (unit, ECEF) at a position.axisAngleQuaternion(axis, angle)— rotation about a unit axis.quaternionBetween(from, to)— shortest-arc rotation carryingfromontoto.normalFromHeights(...)/terrainSurfaceNormal(...)— estimate the terrain surface normal from neighboring heights; these backalignToTerrain.
isRayOccludedByTerrain(camera, target, getHeight, sampleCount?)
Pure helper: line-of-sight test — returns true if terrain rises above the ray
from camera to target (both ECEF). getHeight(cartographic) returns the
terrain height (or undefined for not-yet-loaded tiles, which are skipped).
syncCamera
Copies a Cesium camera's pose + perspective frustum into a three.js camera.
Coordinate frame
| Local axis | Geospatial direction |
| ---------- | -------------------- |
| +X | East |
| +Y | Up (geodetic normal) |
| +Z | South |
Right-handed, Y-up, matching three.js conventions.
Terrain
placeOnTerrain lets you build scenes that sit on Cesium's terrain. Enable it
on the viewer as usual (viewer.scene.setTerrain(...)), then anchor three.js
objects to the surface instead of a fixed height:
const marker = new THREE.Mesh(
new THREE.CylinderGeometry(6, 6, 30, 16),
new THREE.MeshStandardMaterial({ color: 0x22cc66 }),
);
bridge.placeOnTerrain(marker, Cesium.Cartographic.fromDegrees(-122.4, 37.8005), {
heightOffset: 15, // float 15 m above the ground
heading: 0, // compass heading
alignToTerrain: true, // lean with the slope (default)
});Height anchoring is two-stage: an immediate best-effort placement from
globe.getHeight, then an accurate async refinement from
sampleHeightMostDetailed. Slope alignment (alignToTerrain) samples four
neighbors to estimate the surface normal and tilts the object to match. Anchors
re-sample automatically when Cesium finishes loading a terrain batch
(tileLoadProgressEvent reaching 0), and their sampled height is remembered
across floating-origin rebases. If sampleHeightSupported is false (e.g. certain
globe providers), it silently stays on globe.getHeight.
Depth occlusion
Enabled by default, the bridge hides any placed or terrain-anchored object that
terrain blocks from view. Each frame (throttled to intervalMs) it casts a ray
from the camera to each object's ECEF position and samples sampleCount points
along it; if globe.getHeight at any sample rises above the line of sight, the
object is hidden (object.visible = false).
const bridge = new CesiumThreeBridge({
viewer, renderer, scene, camera,
occlusion: { sampleCount: 32, intervalMs: 50 }, // tune accuracy vs. cost
});Pass occlusion: false to disable, or call isRayOccludedByTerrain yourself to
test visibility. Occlusion also accounts for the earth's curvature — an object
over the horizon is hidden. It is a per-object approximation: it occludes
against terrain but not against 3D Tiles (e.g. buildings); see Limitations.
Limitations (v1)
- Overlay compositing: three.js renders as a transparent canvas stacked on top of Cesium; there is no shared depth buffer. Per-object terrain occlusion is implemented (see "Depth occlusion"), but a three.js object is still not occluded by 3D Tiles (e.g. buildings) or correctly depth-ordered against translucent Cesium surfaces. Full depth compositing (rendering three.js through Cesium's view/projection + depth) is the planned next milestone.
- Perspective cameras only for the automatic frustum sync.
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest unit tests (math round-trip, camera sync, rebase invariance)
npm run build # tsup -> dist (esm + dts)
npm run example # vite dev server for the demoHeadless end-to-end check (optional, needs npx playwright install chromium once):
npm run build && npx vite build && node smoke.mjsPublishing
Publishing is automated: prepublishOnly runs typecheck + test + build
before the package is uploaded, so a publish can't ship broken or stale code.
npm login # one-time; authorizes this machine (and the @supertt scope)
npm publish # scoped packages default to private, but publishConfig sets --access publicThe package is published as @supertt/three-cesium-bridge under your own npm
username scope — no organization is required.
License
This package is licensed under the MIT License.
Peer dependencies (installed separately by you, not bundled into this package):
| Package | License | Notes |
| ------- | ------- | ----- |
| three | MIT | |
| cesium | Apache-2.0 | ships a default Ion access token (demo imagery/terrain) |
| @cesium/engine | Apache-2.0 | math + scene engine used for ECEF transforms |
Because three, cesium, and @cesium/engine are peerDependencies (not
bundled), installing @supertt/three-cesium-bridge does not redistribute their
code — you install those three yourself. If you then bundle them into an
application you distribute, the Apache-2.0 terms for Cesium/@cesium/engine
require you to keep their copyright notices and include a copy of the
Apache-2.0 license; three.js's MIT terms require keeping its copyright notice.
The most recent copies of each license live in the respective upstream
repositories linked above.
