2 — The Floating Origin
▶ Live demo: run this tutorial in your browser — built from src/02_floating_origin.ts.
Origin shifting is the foundation everything else in the geo module sits on. This tutorial explains why you can't just put a city at its ECEF coordinates, what
GeoFramedoes about it, and how to move between lat/lon, ECEF, and engine world space. Source: src/geo/geo.ts and src/geo/geo_feature.ts.
The problem: f32 can't hold the planet#
Geospatial data lives in ECEF (Earth-Centered, Earth-Fixed) — a Cartesian frame whose origin is the center of the Earth. A point on the surface is ~6.38 million meters from that origin. A 32-bit float has ~7 significant decimal digits, so at 6,378,137 m the smallest representable step is roughly 0.5 m. Your GPU does everything in f32. Render a building at its raw ECEF position and its vertices snap to a half-meter grid — it visibly jitters and swims as the camera moves.
You can't fix this by being careful. The numbers are simply too big for f32.
The fix: anchor a local origin, keep f64 on the CPU#
A GeoFrame holds an f64 origin somewhere near what you're looking at, plus
the local East-North-Up (ENU) basis at that point. All the planet-scale math
happens in f64 on the CPU; what reaches the GPU is each object's position
relative to the origin — small numbers, f32-safe.
import { GeoFrame } from 'taos/geo/index.js';
const frame = GeoFrame.atLonLat(-74.0060, 40.7128, 0); // lon, lat, height(m)
After this, engine world-space (0, 0, 0) is that lat/lon, +Y is up,
+X is roughly east, -Z roughly north. A building 500 m east of the anchor
sits at world (500, 0, 0) — a number f32 holds exactly.
You can also anchor anywhere in ECEF directly (used for non-Earth bodies — see tutorial 10):
GeoFrame.atEcef(originEcef, up); // both Vec3d (f64)
Converting between coordinate systems#
GeoFrame is your bridge. The point/direction transforms:
frame.worldFromEcefPoint(p); // ECEF → engine world position
frame.worldFromEcefDir(d); // ECEF → world direction (rotation only)
frame.ecefFromWorldPoint(w); // world → ECEF
frame.ecefFromWorldDir(w); // world → ECEF direction
And the geodesy helpers (free functions, not methods) convert lat/lon ↔ ECEF:
import { lonLatToEcef, geodeticToEcef, ecefToGeodetic, DEG2RAD, RAD2DEG } from 'taos/geo/index.js';
const ecef = lonLatToEcef(-74.0060, 40.7128, 100); // degrees, degrees, height(m)
const world = frame.worldFromEcefPoint(ecef); // where to put a GameObject
// And back — e.g. "what lat/lon did the user click?"
const g = ecefToGeodetic(frame.ecefFromWorldPoint(world));
console.log(g.lonRad * RAD2DEG, g.latRad * RAD2DEG, g.height);
geodeticToEcef(lonRad, latRad, height, a?, b?) is the radians form and takes
optional ellipsoid radii (default WGS84); lonLatToEcef is the degrees
convenience wrapper. The WGS84 constants WGS84_A / WGS84_B are exported too.
Place a GameObject at a lat/lon with exactly this chain:
lonLatToEcef → frame.worldFromEcefPoint → go.setPosition. (Tutorial 4 wraps this inloadAnchorfor glTF models.)
Reanchoring: keeping the origin near the camera#
One anchor is fine until you fly away from it. Cross ~10 km from the origin and
world coordinates grow large enough that f32 precision degrades again. The fix is
to move the origin to follow the camera. GeoScene.autoOriginShift installs
exactly that:
geo.autoOriginShift(engine, cameraGO, /* reanchorDist = */ 10_000);
That's a one-liner over the real work — registering a per-frame check in
beforeFrame that calls reanchorCamera:
import { reanchorCamera } from 'taos/geo/index.js';
engine.beforeFrame(() => {
reanchorCamera(geo.frame, cameraGO, /* reanchorDist = */ 10_000);
});
(geo.attach(engine, { cameraGO }) from tutorial 1 calls autoOriginShift
for you. Wire reanchorCamera directly only when you need to order other per-frame
work around the reanchor — as tutorials 4, 6 and 8 do.)
reanchorCamera's whole job, from geo_feature.ts:
export function reanchorCamera(frame: GeoFrame, cameraGO: GameObject, reanchorDist = 10_000): void {
const w = cameraGO.localToWorld();
const camPos = { x: w.data[12], y: w.data[13], z: w.data[14] };
if (Math.hypot(camPos.x, camPos.y, camPos.z) <= reanchorDist) {
return; // still close — do nothing
}
const ecef = frame.ecefFromWorldPoint(camPos);
frame.reanchor(ecef); // move the f64 origin under the camera
const np = frame.worldFromEcefPoint(ecef);
cameraGO.position.set(np.x, np.y, np.z); // shift camera so nothing moves on screen
}
When the camera passes reanchorDist meters from the origin it rebases the frame
onto the camera's current position and shifts the camera to compensate — so the
view is pixel-identical, but world coordinates are small again. The streamed
tiles re-resolve against the new origin automatically.
Two rules:
- Call it every frame, from
beforeFrame. - Call it before the engine caches camera matrices (i.e. before
scene.updateruns —beforeFrameis the right hook).
The fly camera here is a CameraController component, so the engine moves the
camera during scene.update — after this beforeFrame. That means reanchor
sees the previous frame's camera position, which is harmless: at a 10 km
threshold the few meters a camera travels in one frame are well within the slack,
and the reanchor is pixel-identical regardless. If you instead drive the camera
manually from beforeFrame, move it before calling reanchorCamera.
Lower reanchorDist reanchors more often (tighter precision, more frequent
origin shifts); the 10 km default is a good balance for ground-level flying.
Lower-level frame moves#
You rarely need these, but they exist:
frame.reanchor(newOriginEcef); // move origin, keep the tangent basis fixed
frame.reanchor(newOriginEcef, true); // also re-orient the ENU basis (used by RTC)
frame.rebase(lonDeg, latDeg, height); // teleport far away + re-orient (e.g. jump cities)
Use rebase when you teleport the camera to a completely different part of the
globe — it re-derives the ENU basis so "up" is correct at the new location, which
a plain reanchor (which keeps the old basis) wouldn't.
Geodesy reference#
The conversions above cover everything most apps need. The rest of geo.ts is a small geodesy toolkit you'll occasionally reach for — collected here so you don't have to go hunting.
f64 matrices (Mat4d)#
The engine's Mat4 is f32 — fine for world space, but it loses precision for the
planet-scale (ECEF) transforms tile placement does. The geo module carries a
parallel f64 4×4 type, Mat4d, with its own arithmetic, then converts to the
engine Mat4 only at the very end (after the numbers are small):
import {
mat4dIdentity, mat4dMul, mat4dTransformPoint,
mat4dTranslation, mat4dFromArray, mat4dToEngine,
} from 'taos/geo/index.js';
const m = mat4dMul(frame.transform().worldFromEcef, tileEcefMatrix); // compose in f64
const engineMatrix = mat4dToEngine(m); // → Mat4 for a GameObject / draw, once it's local
This is exactly how tile and Gaussian-splat placement is done internally (and in
samples/geo_splat.ts): keep every matrix multiply
in Mat4d while the values are huge, call mat4dToEngine last.
Tiling schemes#
Imagery and terrain providers tile the globe under one of two schemes; the geo module exposes both plus a helper that picks one from a projection string:
import {
GEOGRAPHIC_TILING_SCHEME, // EPSG:4326 — lon/lat grid (2 tiles wide at z0)
WEB_MERCATOR_TILING_SCHEME, // EPSG:3857 — the slippy-map default (1 tile at z0)
tilingSchemeForProjection, // 'EPSG:3857' | 'EPSG:4326' | undefined → a scheme
geographicTileRectangle, // (x, y, level) → the lon/lat Rectangle of a tile
} from 'taos/geo/index.js';
You need these only when wiring a new imagery/terrain source by hand;
the built-in sources (tutorial 11) already declare theirs.
WEB_MERCATOR_MAX_LATITUDE is the ±85.05° clamp Mercator can represent.
Distances and horizon#
import { distance } from 'taos/geo/index.js';
const meters = distance(ecefA, ecefB); // straight-line ECEF distance between two points
distance is the chord (straight-line) length, not the great-circle arc — fine
for proximity checks and LOD math, but for "how far to walk" along the surface
you'd integrate over the ellipsoid yourself.
Horizon culling — hiding tiles that sit behind the curve of the globe — is handled
internally by EllipsoidalOccluder (occlusion.ts,
a port of CesiumJS's), which the terrain tileset uses automatically. You won't
call it directly, but it's why looking toward the horizon from altitude doesn't
draw tiles on the back of the Earth.
Next#
- Tutorial 3 — Tileset & Imagery Layers: now that the world is anchored, stack multiple datasets into it.