Taos Engine ▦ Taos: API Documentation

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 GeoFrame does 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 in loadAnchor for 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:

  1. Call it every frame, from beforeFrame.
  2. Call it before the engine caches camera matrices (i.e. before scene.update runs — beforeFrame is the right hook).

The fly camera here is a CameraController component, so the engine moves the camera during scene.updateafter 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#