Chapter 26: Geospatial Streaming
The terrain of Chapter 24 is large — kilometers across — but it is still a made-up place: noise and a heightmap image. This chapter is about rendering the real one. The geo subsystem in src/geo/ streams real-world geospatial data — photoreal city meshes, building footprints, planetary terrain, satellite imagery — and draws the actual Earth (and the Moon) at scale, anywhere on the globe, flying from orbit down to a street corner.
Doing that on a GPU is mostly a fight with two adversaries: scale and precision. The Earth is 12,742 km across; a city block is tens of meters; a building facade detail is centimeters. No single mesh, no single coordinate system, and — crucially — no single 32-bit float can span that range. The geo module is the set of techniques that make it work: a floating origin in double precision, streaming level-of-detail driven by on-screen error, and a tile cache that keeps the working set inside a memory budget.
A word on lineage. The data formats this module consumes — 3D Tiles for meshes and quantized-mesh for terrain — are open, published specifications, and the streaming approach (a tree of tiles refined by screen-space error) is the standard one across the geospatial industry, pioneered in engines like CesiumJS. Taos streams its data from Cesium ion, a hosting service that serves those open formats; an ion access token is how you authenticate. But everything in src/geo/ is written from scratch in Taos's own terms — its own f64 frame, its own tile traversal, its own cache, integrated directly into the Taos render graph. This chapter describes that code: the how and the why, not a port of anything. CesiumJS was the inspiration and the yardstick; the implementation is ours.
The module is driven by the planet_explorer, geo_minimal, geo_moon, and geo_physics samples.
26.1 The Problem of Planetary Scale#
A 32-bit float has 23 bits of mantissa: about 7 decimal significant digits. That is plenty when your coordinates are small. It falls apart at planetary scale.
Geospatial data is delivered in ECEF — Earth-Centered, Earth-Fixed — Cartesian meters with the origin at the center of the Earth. A point on the surface is therefore about 6,378,000 meters from the origin. Stored in an f32, a value near 6.4 million has its 7 significant digits land above the decimal point: the smallest representable step between two adjacent f32s up there is roughly 0.5 meters.
Half a meter of quantization on every vertex is a disaster. A building's vertices snap to a coarse lattice; worse, as the camera moves, the rounding of the view transform changes from frame to frame, so the whole world visibly shimmers and jitters. You cannot render a crisp street scene when the coordinate system can't tell two points 30 cm apart from each other.
The GPU only speaks f32. So the entire game is: never give the GPU a planet-scale coordinate. Keep the big numbers in f64 on the CPU, and only ever hand the GPU small, origin-relative values. That is the floating origin.
26.2 The Floating Origin: GeoFrame#
geo.ts holds the geospatial math, and it is f64 (Float64Array, plain JS number) from end to end. The centerpiece is GeoFrame: a local tangent frame anchored at a chosen point on the globe, in which all rendering happens.
First, the conversion between geographic coordinates (longitude, latitude, height) and ECEF, on the WGS84 ellipsoid that models the Earth's shape:
// ── from src/geo/geo.ts ──
export function geodeticToEcef(
lonRad: number, latRad: number, height: number, a = WGS84_A, b = WGS84_B,
): Vec3d {
const cosLat = Math.cos(latRad);
const sinLat = Math.sin(latRad);
const cosLon = Math.cos(lonRad);
const sinLon = Math.sin(lonRad);
const e2 = (a * a - b * b) / (a * a);
const n = a / Math.sqrt(1.0 - e2 * sinLat * sinLat); // prime vertical radius
return {
x: (n + height) * cosLat * cosLon,
y: (n + height) * cosLat * sinLon,
z: (n * (1.0 - e2) + height) * sinLat,
};
}
The a/b semi-axes default to Earth's, but are parameters — pass the Moon's radius and the same function plants lunar coordinates (§26.9). For a sphere (a == b) the eccentricity e2 is zero and it reduces to the plain spherical mapping.
Now the frame itself. At the anchor point we build an East-North-Up (ENU) basis — three orthonormal ECEF vectors pointing local east, north, and straight up — and a matrix that rotates ECEF into the engine's Y-up world (East→+X, Up→+Y, North→−Z) and translates the anchor to the world origin:
// ── from src/geo/geo.ts ──
static atLonLat(lonDeg: number, latDeg: number, height = 0): GeoFrame {
const lon = lonDeg * DEG2RAD, lat = latDeg * DEG2RAD;
const origin = geodeticToEcef(lon, lat, height);
const cosLat = Math.cos(lat), sinLat = Math.sin(lat);
const cosLon = Math.cos(lon), sinLon = Math.sin(lon);
const east: Vec3d = { x: -sinLon, y: cosLon, z: 0 };
const north: Vec3d = { x: -sinLat * cosLon, y: -sinLat * sinLon, z: cosLat };
const up: Vec3d = { x: cosLat * cosLon, y: cosLat * sinLon, z: sinLat };
// Rotation rows = [east; up; −north] → X=east, Y=up, Z=−north (right-handed).
const rot = new Float64Array([
east.x, up.x, -north.x, 0,
east.y, up.y, -north.y, 0,
east.z, up.z, -north.z, 0,
0, 0, 0, 1,
]);
const worldFromEcef = mat4dMul(rot, mat4dTranslation(-origin.x, -origin.y, -origin.z));
return new GeoFrame(origin, east, north, up, worldFromEcef);
}
The payoff: a building near the anchor, transformed by worldFromEcef, comes out at world coordinates of tens of meters, not millions — comfortably inside f32's precise range. The transform is computed in f64; only the result, already small and origin-relative, is collapsed to an f32 Mat4. The module enforces this with a single, pointed conversion function:
// ── from src/geo/geo.ts ──
/** Converts a *fully composed, origin-relative* f64 matrix to an engine Mat4.
* Only call this once the matrix's translation has been rebased near the world
* origin — collapsing a raw ECEF matrix to f32 would jitter. */
export function mat4dToEngine(m: Mat4d): Mat4 {
return new Mat4(Float32Array.from(m));
}
Every tile is delivered with its own ECEF transform; we multiply it into the f64 worldFromEcef, then call mat4dToEngine. The raw ECEF numbers never touch an f32.
26.3 Reanchoring as the Camera Roams#
A fixed anchor only solves precision near that anchor. Fly 50 km away and the camera's world coordinates are back in the hundreds of thousands — jitter returns. The fix is to move the anchor to follow the camera. Because the ENU basis is kept fixed across re-anchors (only the origin translation changes), reanchoring is a pure world-space shift — no loaded geometry needs re-baking:
// ── from src/geo/geo.ts ──
reanchor(newOriginEcef: Vec3d): void {
this.originEcef = { x: newOriginEcef.x, y: newOriginEcef.y, z: newOriginEcef.z };
const rot = new Float64Array([
this.east.x, this.up.x, -this.north.x, 0,
this.east.y, this.up.y, -this.north.y, 0,
this.east.z, this.up.z, -this.north.z, 0,
0, 0, 0, 1,
]);
this.worldFromEcef = mat4dMul(rot, mat4dTranslation(-newOriginEcef.x, -newOriginEcef.y, -newOriginEcef.z));
}
The application calls a helper from its beforeFrame — before the engine caches the camera matrices — which checks the camera's distance from the origin and, past a threshold, rebases:
// ── from src/geo/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;
}
const ecef = frame.ecefFromWorldPoint(camPos); // where is the camera, in ECEF?
frame.reanchor(ecef); // make THAT the new origin
const np = frame.worldFromEcefPoint(ecef); // its new world pos ≈ (0,0,0)
cameraGO.setPosition(np.x, np.y, np.z); // move the camera there
}
The camera ends up back near the world origin, but pointing the same way at the same scene — nothing moves on screen. The next frame, every tile's draw transform is recomputed against the new worldFromEcef, so the world shifts by exactly the same amount the camera did. (The mapping back from world to ECEF, ecefFromWorldPoint, is just the inverse of a rigid frame — origin + Rᵀ·w — and is also what the tile traversal uses to measure camera-to-tile distances in ECEF.)
Holding the ENU basis fixed is what keeps this free, but it has a limit over continent-scale roams; §26.10.1 revisits it.
26.4 Streaming Formats: 3D Tiles and Quantized Mesh#
You cannot download the planet. The data is organized as a tree of tiles, each covering a region of the world at some level of detail, and you fetch only the handful you can currently see, at only the detail you currently need.
Taos streams two open formats, each with its own traversal class:
3D Tiles (meshes — photoreal cities, OSM buildings). A tileset.json describes a tree: each node has a bounding volume, a geometric error (how many meters of detail it omits), a refinement rule, and optionally a content.uri pointing at a glTF/.b3dm payload. Tileset3D parses that tree into f64-transformed Tile nodes and, each frame, walks it deciding what to draw.
The refinement rule comes in two flavors, and the traversal honors both:
- REPLACE — children replace the parent. The parent is drawn as a coarse placeholder only until its visible children have streamed in (so there's never a hole), then it's hidden.
- ADD — children are drawn in addition to the parent (used where each level adds detail on top of the last).
// ── from src/geo/tileset.ts ──
async init(): Promise<void> {
const json = await this.asset.fetchJson(this.asset.rootUrl);
const up = (json.asset?.gltfUpAxis ?? 'Y').toUpperCase();
this.gltfUpAxis = up === 'Z' ? 'Z' : 'Y';
this.root = this.parseTile(json.root, mat4dIdentity(), this.asset.rootUrl, 'REPLACE', 0, undefined);
}
Quantized-mesh (terrain — Cesium World Terrain and compatibles). Here the quadtree is implicit: a tile at (level, x, y) has four children at (level+1, …) and you don't need a manifest to know they exist. Each tile is a compact binary blob — a 88-byte header, then zig-zag-delta-encoded u/v/height vertex streams, triangle indices, edge-vertex lists for stitching neighbors, and optional extensions (oct-encoded normals, a land/water mask). quantized_mesh.ts is a pure, device-free decoder for that layout; TerrainTileset turns the decoded u/v/height into ECEF positions using the tile's geographic rectangle.
The data comes from a data source — an object that knows a provider's root URL and how to authenticate. The common one is Cesium ion: you resolve an asset id + token into a source, then hand it to the scene.
// ── from samples/geo_minimal.ts ──
const geo = new GeoScene(engine.ctx.device, GeoFrame.atLonLat(-74.0060, 40.7128, 0));
geo.addTerrain(await resolveIonAsset(ION_ASSETS.worldTerrain, token)); // quantized-mesh
geo.add3DTiles(await resolveIonAsset(ION_ASSETS.osmBuildings, token)); // 3D Tiles
ION_ASSETS names the well-known ion ids (world terrain, OSM buildings, Google photoreal, the Moon). There are also direct-to-provider sources (resolveGoogleTiles(apiKey), resolveMapTilerTerrain(apiKey)) — all implementing the same small GeoDataSource interface (rootUrl, fetchJson, fetchArrayBuffer, attributions), so the traversals don't care where the bytes come from.
26.5 Screen-Space Error: Choosing the Level of Detail#
The heart of streaming LOD is one question asked of every tile, every frame: is this tile detailed enough for how big it currently looks on screen? The metric is screen-space error — a tile's geometric error (meters of detail it drops) projected to pixels at its current camera distance:
// ── from src/geo/traversal.ts ──
export function screenSpaceError(
geometricError: number, distance: number, screenHeight: number, fovY: number,
): number {
if (geometricError <= 0) {
return 0; // a tile with no error never needs refining
}
const d = Math.max(1, distance);
return (geometricError * screenHeight) / (d * 2 * Math.tan(fovY * 0.5));
}
The geometry is straightforward. The viewport is 2·d·tan(fovY/2) world-meters tall at distance d; a feature of size geometricError therefore occupies geometricError / (2·d·tan(fovY/2)) of the view's height, which times screenHeight is its size in pixels. The traversal refines a tile into its children while that pixel error exceeds a target, maxSSE (default 16 px). Lower targets mean sharper images and more tiles streamed; it's exposed per-tileset so a photoreal city and OSM buildings can tune independently.
Two refinements make the metric well-behaved. First, distance is measured to the tightest bound available — an oriented box where the tile provides one, not its enclosing sphere, because a sphere over-estimates size, which under-estimates distance, which inflates SSE and causes needless over-refinement. Second, a separate load priority decides which wanted tiles to fetch first when bandwidth is scarce — nearest first, but pulled forward for tiles far over the error budget:
// ── from src/geo/traversal.ts ──
export function loadPriority(distance: number, sse: number, maxSSE: number): number {
const boost = maxSSE / Math.max(sse, maxSSE); // (0, 1]
return Math.max(0, distance) * boost; // smaller = more urgent
}
Frustum culling rides alongside SSE: a tile whose bounding volume is entirely outside the view frustum is neither drawn nor refined into (with a deliberate exception for a few enormous root/continental tiles whose bounds are too loose to cull reliably — they're cheap to refine through and let their tightly-bounded descendants do the real culling).
26.6 The Tile Cache: Streaming Under a Budget#
Refinement decides what you want; the cache decides what you can afford to keep, and shoulders the asynchronous, cancellable loading. TileCache is an LRU bounded by a triangle budget and a byte budget, with a deliberately simple contract the traversal leans on every frame.
The traversal naively requests every tile it might want, each frame, passing a priority. request returns the content immediately if it's cached (marking it used this frame); otherwise it records the wish. The actual loads are started later, by a single end-of-frame dispatch:
// ── from src/geo/tile_cache.ts ──
dispatch(frame: number): void {
// Cancel in-flight loads the traversal no longer wants, freeing their slots.
for (const [key, ctrl] of this.inflightAbort) {
if (!this.requested.has(key)) {
ctrl.abort();
}
}
// Start the most urgent pending loads that fit in the remaining slots.
let slots = this.maxConcurrent - this.inflight.size;
if (slots > 0 && this.pending.size > 0) {
const queued = [...this.pending.entries()].sort((a, b) => a[1].priority - b[1].priority);
for (const [key, req] of queued) {
if (slots <= 0) { break; }
this.startLoad(key, req.frame, req.loader);
slots--;
}
}
this.pending.clear();
this.requested.clear();
this.trim(frame);
}
This "request-everything, dispatch-by-priority" split buys three things at once:
- A concurrency cap. A wide implicit quadtree (terrain) might want thousands of tiles on a single frame; firing thousands of
fetches at once gets the browser to throwERR_INSUFFICIENT_RESOURCES. Excess requests stay queued and start on later frames as the (default 16) slots free up. - Priority that tracks the camera. Because loads start after the whole frame's wishes are in, the few slots always work on the most urgent tiles (nearest to the screen center), not whatever happened to be requested first.
- Cancellation of stale loads. A tile that was in-flight but not re-requested this frame (the camera moved on) has its
fetchaborted, so a fast pan doesn't leave dozens of now-useless downloads starving the fresh ones.
Then trim enforces the budget by evicting least-recently-used content — but with a guard that is the difference between smooth and ugly streaming. Recently-used tiles (touched within a retain window, ~240 frames ≈ 4 s) are spared unless memory is far over budget:
// ── from src/geo/tile_cache.ts ──
trim(currentFrame: number): void {
if (this.underBudget()) { return; }
this.evictBefore(currentFrame - this.retainFrames); // stale tiles first
if (this.underBudget()
|| (this.totalBytes <= this.maxBytes * this.evictHard
&& this.totalTriangles <= this.maxTriangles * this.evictHard)) {
return; // tolerate soft overage up to evictHard× rather than evict the working set
}
// Far over budget — evict recent tiles too (a "hot" eviction; the kind that pops).
this.evictBefore(currentFrame, currentFrame - this.retainFrames);
}
The reasoning is subtle and worth stating. Moving the camera forward refines deeply and slams the cache with a surge of new fine tiles. If that surge evicted the fine tiles just shown, they'd immediately re-stream from the coarsest level — the dreaded "pop to lowest, then climb back up" flicker. So the cache tolerates overage (up to evictHard×, default 2) rather than evict its working set, and counts any eviction inside the retain window as a "hot eviction" so the symptom is measurable. On memory-constrained devices you pass a tighter evictHard to keep the real high-water mark near the budget.
26.7 The GeoScene Orchestrator#
GeoScene is the object an application holds. It owns the GeoFrame, the set of tilesets (terrain and 3D Tiles), their caches, and pooled draw lists. Each frame its update runs every tileset against the camera and produces the frame's draws:
// ── from src/geo/geo_scene.ts ──
update(p: GeoUpdateParams): GeoFrameResult {
this.frameId++;
// ... reset pooled lists ...
// Terrain first — its rendered tiles feed heightAt() this frame, and are exposed
// (terrainContents) for apps mirroring them elsewhere, e.g. physics colliders.
for (const e of this.terrains) {
const contents = e.tileset.update(base);
for (const c of contents) { this.emitTerrain(c, matOverride, shadows); }
}
// Then 3D Tiles, then static content (anchors).
for (const e of this.tilesets) {
const rts = e.tileset.update(/* params, with optional per-tileset world offset */);
for (const rt of rts) { this.emitContent(rt.content, rt.fade, /* … */); }
}
// ...
}
Two design choices stand out. Terrain is traversed first so that heightAt(lon, lat) reflects this frame's tiles — useful for clamping buildings to the ground, and the terrain contents are also exposed so an app can mirror them into, say, Jolt colliders (§26.9's geo_physics does exactly this).
And draws are pooled. The streamed tile set changes slowly frame to frame, so GeoScene reuses DrawItem objects from a growing pool rather than allocating fresh ones each frame, keeping the per-frame GC pressure near zero even with thousands of tiles on screen.
The floating-origin correction lives here too. Each tile's geometry was baked relative to some origin (bakeOriginEcef); as the camera reanchors, that bake origin drifts away from the live world origin. emitContent corrects for it with a uniform world translation — the "shift" — recomputed from the current frame:
// ── from src/geo/geo_scene.ts ──
const shift = this.frame.worldFromEcefPoint(c.bakeOriginEcef);
// ... applied to each drawable's model matrix ...
When the bake origin equals the live origin the shift is zero; after a reanchor it's the offset that keeps already-loaded tiles glued in place until they're re-baked.
26.8 GeoFeature: Into the Render Pipeline#
GeoScene produces draw lists; it doesn't know about render passes. GeoFeature is the RenderFeature (Chapter 3) that bridges them. Each frame it builds the camera's ECEF position and frustum, runs scene.update, and appends the streamed tile draws onto the engine's standard draw lists:
// ── from src/geo/geo_feature.ts ──
const r = scene.update({ cameraEcef, frustum, screenHeight: frame.ctx.height, fovY: this.fovY, /* … */ });
for (let i = 0; i < r.opaque.length; i++) {
frame.opaque.push(r.opaque[i]); // drawn by the stock geometry pass
}
if (shadows) {
for (let i = 0; i < r.shadowCasters.length; i++) {
frame.shadowCasters.push(/* … */); // and the stock shadow pass
}
}
Because the tiles flow through the engine's geometry, shadow, and deferred-lighting passes, streamed terrain and buildings get cascaded shadows, ambient occlusion, and image-based lighting for free, exactly like any other opaque mesh — no geo-specific lighting code. The one ordering constraint: GeoFeature appends to lists the stock features later read, so it must run before them. The application registers it with addFeatureBefore:
// ── from samples/geo_minimal.ts ──
engine.addFeatureBefore(new GeoFeature({ scene: geo, fovY: FOV_Y }), ShadowFeature.name);
engine.beforeFrame((frame) => {
controller.update(cameraGO, frame.dt);
reanchorCamera(geo.frame, cameraGO); // §26.3, before camera matrices are cached
});
Two engine-level settings make planetary rendering practical. The camera needs a huge far plane (millions of meters, to see the horizon from orbit), and to keep depth precision usable across that range the sample enables reversed-Z depth (§3.12):
// ── from samples/geo_minimal.ts ──
const engine = await Engine.create({ canvas, contextOptions: { reversedZ: true } });
cameraGO.addComponent(Camera.createPerspective(60, 1, 5_000_000, aspect)); // far = 5000 km
26.9 Beyond Earth: the Moon#
Nothing in the design is Earth-specific. The geodetic conversion takes ellipsoid semi-axes; the frame can be anchored by raw ECEF and an up vector instead of lon/lat. GeoFrame.atEcef(origin, up) builds an ENU-style basis from any surface point on any body, deriving east/north from the up vector — exactly what you need to stand on the Moon:
// ── from src/geo/geo.ts ──
static atEcef(origin: Vec3d, up: Vec3d): GeoFrame {
const u = /* normalize(up) */;
// Reference for "north": universe +Z, unless up is ~parallel to it (then +X).
const ref: Vec3d = Math.abs(u.z) > 0.99 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 0, z: 1 };
// north = normalize(ref − (ref·u)u); east = north × up (right-handed ENU)
// ...builds the same [east; up; −north] rotation as atLonLat...
}
The Moon's terrain is a 3D Tiles set (ION_ASSETS.cesiumMoon); Tileset3D takes a body offset and a custom ellipsoid (the Moon's sphere, a = b = 1,737,400 m) so its region bounding volumes resolve to lunar — not terrestrial — positions before being planted in the shared universe frame. The geo_moon sample is the result: standing on real lunar terrain, Earth hanging in the black sky.
And because terrain tiles expose their decoded geometry, geo_physics mirrors each visible terrain tile into a Jolt static mesh collider (Chapter 25) — building physics colliders on the fly from streamed terrain, so you can fire bouncing balls down a real mountainside. The two "beyond blocks" subsystems compose directly.
26.10 Roaming Farther, Streaming Smarter#
The core of the previous sections — floating origin, SSE streaming, a budgeted cache — gets you a great-looking view of one place. Four refinements push past that: roaming the whole globe without precision drift, warming tiles before you pan to them, sharing bandwidth fairly across providers, and supporting more of the tile-format zoo. They live in the same files; each is opt-in and off by default, so nothing here changes the behavior of the sections above unless you ask for it.
26.10.1 Continuous Reorientation (RTC)#
Reanchoring (§26.3) keeps the ENU basis fixed and only moves the origin, which is what makes it a free, no-rebake translation. That is exactly right for a local view, but it has a horizon: the basis was built for the anchor's latitude/longitude, and "up" on a sphere points a different direction at every point. Fly hundreds of kilometers and the fixed "up" tilts away from the true local vertical — the world subtly leans.
The fix CesiumJS uses is RTC — relative-to-center, continuously reoriented: re-derive the basis at the new location too, not just the origin. The obstacle is that geometry was baked against the old basis, so a naive reorientation would mis-orient every loaded tile. The way out is to stop treating the per-tile correction as a translation and treat it as the full rigid transform from the tile's bake frame to the current frame. GeoFrame records a snapshot of the frame each tile was baked against, and can produce that transform on demand:
// ── from src/geo/geo.ts ──
/** The rigid f64 transform mapping a point baked in `snap`'s world frame into THIS
* frame's world coordinates — worldFromEcef_current ∘ ecefFromWorld_snap. When the
* basis is unchanged it reduces EXACTLY to a translation of worldFromEcefPoint(snap
* .originEcef) — the same shift the translation path already applies. */
rigidFromSnapshot(snap: FrameSnapshot): Mat4d {
const c0 = this.worldFromEcefDir(snap.east);
const c1 = this.worldFromEcefDir(snap.up);
const c2 = this.worldFromEcefDir({ x: -snap.north.x, y: -snap.north.y, z: -snap.north.z });
const t = this.worldFromEcefPoint(snap.originEcef);
return new Float64Array([
c0.x, c0.y, c0.z, 0, c1.x, c1.y, c1.z, 0,
c2.x, c2.y, c2.z, 0, t.x, t.y, t.z, 1,
]);
}
The elegance is that this is a strict generalization of the shift in §26.7: when the basis hasn't changed (sameBasisAs(snap)), the rotation columns collapse to the identity and the matrix is precisely the old translation — proven by a unit test that bakes a point in one frame, reanchors across the planet with reorientation, applies the rigid transform, and confirms it lands exactly where the new frame independently places the same ECEF point. So reanchor(origin, /* reorient */ true) can re-derive the ENU basis at the new origin, and as long as the draw loop places baked content with rigidFromSnapshot(bakeFrame) (and rotates the camera by the same transform so the view doesn't snap), reorientation needs no re-baking. The snapshot is already stamped on every TileContent/TerrainContent; the basis core and its round-trip invariant are tested, with the draw-loop/camera wiring as the remaining browser-verified step.
26.10.2 The Preload Ring and Per-Host Fairness#
Two streaming tweaks make panning and multi-provider scenes feel smoother. Both live in tile_cache.ts and the traversals.
The preload ring warms tiles just outside the frustum so a quick pan finds them already loading instead of popping in. After the normal selection, each traversal runs a second, cheap warm pass over a frustum dilated by a margin — pushing every clip plane outward (with inward-normal planes, that is simply d += margin):
// ── from src/geo/tileset.ts ──
export function dilatePlanes(planes: Plane[], margin: number): Plane[] {
if (margin <= 0) { return planes; } // 0 = off (the default): zero overhead
return planes.map((pl) => ({ nx: pl.nx, ny: pl.ny, nz: pl.nz, d: pl.d + margin }));
}
Tiles in that ring have their content requested but never drawn, at a deprioritized urgency (PRELOAD_PENALTY + distance) large enough that a warmed tile can never sort ahead of an on-screen one in the dispatch — so the ring never steals bandwidth from what you're actually looking at. The warm pass runs after selection precisely so an in-view tile keeps its urgent priority (a tile's priority can only be lowered, never raised, within a frame). The sample scales the margin with camera altitude: a small ring near the ground, a wide one from orbit.
Per-host concurrency limits keep one slow provider from starving the others. The cache's single global cap (§26.6) is fine for one host, but a scene streaming terrain from one server and imagery from another can have the terrain host hold all 16 slots while imagery sits idle. So dispatch also caps the in-flight loads per host — derived from each cache key's URL origin — and leaves a request queued (re-tried next frame) if its host is already busy:
// ── from src/geo/tile_cache.ts ──
export function hostOf(key: string): string {
const scheme = key.indexOf('://');
if (scheme < 0) { return ''; } // synthetic keys (upsample fills) share a bucket
const start = scheme + 3;
const slash = key.indexOf('/', start);
return key.slice(start, slash < 0 ? key.length : slash);
}
The per-host cap defaults to the global cap (so a single-host dataset is never throttled); you lower it for multi-host setups.
26.10.3 Tiling Schemes: Geographic and Web Mercator#
Quantized-mesh terrain (§26.4) is an implicit quadtree, but exactly how the world tiles into that quadtree depends on the source's projection. Cesium World Terrain uses the geographic scheme (EPSG:4326): two square root tiles spanning ±180° longitude, equal lon/lat steps. Web-Mercator-tiled sources use a single square root tile, latitude clamped to the Mercator cutoff (±85.05°), with rows that are equal in projected Y but narrow toward the poles in geographic latitude.
Rather than hardcode the geographic layout, the terrain traversal reads a TilingScheme — root counts, per-level tile counts, and each tile's geographic rectangle — chosen from layer.json's projection. The Web Mercator rectangle unprojects each row's normalized Y back to latitude with the inverse Gudermannian:
// ── from src/geo/geo.ts ──
export const WEB_MERCATOR_TILING_SCHEME: TilingScheme = {
rootTilesX: 1, rootTilesY: 1,
numberOfXTilesAtLevel: (level) => 1 << level,
numberOfYTilesAtLevel: (level) => 1 << level,
tileRectangle: (x, y, level) => {
const n = 1 << level;
const d = (2 * Math.PI) / n; // step in lon AND normalized Mercator Y
const west = -Math.PI + x * d;
const yNorth = Math.PI - y * d; // y is north-down (y=0 is the top row)
return {
west, east: west + d,
north: 2 * Math.atan(Math.exp(yNorth)) - Math.PI / 2, // inverse Mercator
south: 2 * Math.atan(Math.exp(yNorth - d)) - Math.PI / 2,
};
},
};
With root count routed through the scheme, the same SSE traversal, skirts, and LOD-balancing work unchanged for either projection.
26.10.4 Implicit Tiling: Octree and External Subtrees#
3D Tiles 1.1 implicit tiling (implicit.ts) replaces an explicit child list with a quadtree (or octree) whose tiles are implied by their coordinates, with availability packed into compact .subtree files. Two extensions widen what loads: octree subdivision (the box/region bounding volume now subdivides vertically too, not just horizontally), and external availability buffers — a subtree whose availability bitstream lives in a separate file rather than the .subtree's own binary chunk is now fetched and resolved (parseSubtreeHeader + resolveSubtreeBuffers) instead of rejected. Subtree property metadata (per-feature properties via EXT_structural_metadata) is the remaining piece, deferred to share the decoder with feature picking.
26.11 Summary#
The geo module renders the real planet by fighting scale and precision with three ideas:
- A floating origin in f64. ECEF coordinates are ~6.4 million meters, where f32 quantizes to ~0.5 m and the world shimmers.
GeoFramekeeps an f64 ENU tangent frame anchored near the camera and hands the GPU only small, origin-relative coordinates —mat4dToEngineis the single, guarded f64→f32 boundary. - Reanchoring to follow the camera. With the ENU basis held fixed, moving the origin is a pure world shift;
reanchorCamerarebases past a threshold so coordinates stay f32-safe no matter how far you fly, with nothing visibly moving. - Screen-space-error streaming over a tree of tiles. Open formats — 3D Tiles (an explicit tree) and quantized-mesh (an implicit quadtree) — are refined while a tile's geometric error, projected to pixels, exceeds a target, and frustum-culled alongside.
- A budgeted, cancellable tile cache. Request-everything / dispatch-by-priority caps concurrency and keeps the loaders working on the nearest tiles; a retain window and soft overage avoid evicting the just-shown working set, which is what prevents LOD pop.
GeoSceneorchestrates,GeoFeatureintegrates. The scene runs every tileset, pools draws, and applies the origin-shift correction; the feature appends those draws to the engine's standard lists (registered before the stock passes), so streamed geometry gets shadows, AO, and IBL for free. A huge far plane plus reversed-Z make the depth range workable.- Not Earth-specific. A parameterized ellipsoid and
atEcefanchoring extend the whole stack to the Moon, and the decoded terrain feeds straight into Chapter 25's physics colliders. - Refinements for going farther (§26.10). A continuously-reoriented frame (RTC) keeps "up" true over planet-scale roams via a rigid bake-to-current transform that degenerates to today's shift; a deprioritized preload ring warms tiles just off-screen; a per-host concurrency cap shares bandwidth across providers; a
TilingSchemeabstraction adds Web Mercator terrain alongside geographic; and implicit tiling grows octree subdivision and external subtree buffers. Each is opt-in and off by default.
The data formats and the screen-space-error approach are industry standards that CesiumJS pioneered and Cesium ion serves; the implementation in src/geo/ — the f64 frame, the traversals, the cache, the render-graph integration — is Taos's own.