1 — Hello, Geo
▶ Live demo: run this tutorial in your browser — built from src/01_hello_geo.ts in the standalone project.
The geo equivalent of Hello, Cube: a complete, runnable app that streams OpenStreetMap buildings on AWS Terrarium elevation at New York and lets you fly around — using only keyless data sources, so there's no Cesium ion token to set up. Distilled from samples/geo_osm_buildings.ts — the clearest end-to-end keyless reference in the repo.
By the end you'll have a globe streaming real photorealistic-scale data through the same deferred pipeline you used for the cube. The hard parts — f64 floating-origin precision, screen-space-error LOD, frustum culling, tile caching/eviction, attribution — are handled by three types:
GeoFrame— a floating origin. It anchors an f64 point on the globe and expresses everything relative to it, so near geometry stays in f32-safe world coordinates. (Tutorial 2 goes deep on this.)GeoScene— the orchestrator. You add tilesets to it; each frame it traverses them against the camera, streams tiles within a memory budget, and produces pooled draw lists.GeoFeature— aRenderFeaturethat drives the scene each frame and appends streamed tile draws toframe.opaque/frame.shadowCasters, so the engine's stock geometry and shadow passes render them. You rarely construct it directly —GeoScene.attachregisters it (and the floating-origin reanchor) for you.
Frame anchors the world → scene streams tiles → feature feeds them to the renderer.
Prerequisites#
- You've read Hello, Cube — same project
scaffold (
package.json,tsconfig.json,vite.config.ts,index.html). - No API key. Both datasets we stream are keyless and free: AWS Terrarium elevation (Mapzen/AWS open elevation tiles) and OpenFreeMap vector-tile buildings (OSM-derived). Some other sources do need credentials — Cesium ion wants a Bearer token, Google and MapTiler want a key — but this tutorial avoids all of that. (Tutorial 10's Moon is the one place we still reach for ion.)
The project layout and the four config files are identical to Hello, Cube — only
src/main.ts changes. If you're starting fresh, copy that project first.
Want to run it now? This exact app is build-verified at
standalone/geo_tutorials/(entrysrc/01_hello_geo.ts).cd standalone/geo_tutorials && npm install && npm run dev, then open01_hello_geo.html. Each tutorial there is self-contained — the engine/camera boilerplate is inlined per file, exactly as the walkthrough below spells it out.
src/main.ts#
import { Vec3 } from 'taos/math/index.js';
import {
Engine, GameObject, Camera, DirectionalLight, deferredPreset,
} from 'taos/engine/index.js';
import { CameraController } from 'taos/engine/camera_controller.js';
import { GeoFrame, GeoScene } from 'taos/geo/index.js';
import { AWS_TERRARIUM, ESRI_WORLD_IMAGERY, OPENFREEMAP_VECTOR } from 'taos/geo/index.js';
async function main(): Promise<void> {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
// Reversed-Z gives the depth precision a planet-scale far plane needs.
const engine = await Engine.create({
canvas,
contextOptions: { enableErrorHandling: true, reversedZ: true },
});
// The deferred renderer preset.
deferredPreset({
sky: { kind: 'color', color: [0.52, 0.72, 0.92, 1] },
})(engine);
// A sun for the deferred lighting.
const sunGO = new GameObject({ name: 'Sun' });
sunGO.addComponent(new DirectionalLight(
new Vec3(-0.5, -1, -0.35).normalize(), new Vec3(1, 0.97, 0.9), 3));
engine.scene.add(sunGO);
// Camera ~2 km from the New York origin, tilted down, with a fly controller.
// Note the millions-of-meters far plane (paired with reversed-Z above).
const cameraGO = new GameObject({ name: 'Camera' });
cameraGO.setPosition(0, 400, 2000);
cameraGO.addComponent(Camera.createPerspective(
60, 1, 5_000_000, engine.ctx.width / engine.ctx.height));
engine.scene.add(cameraGO);
// CameraController is a Component: added to the camera, the engine updates it
// every frame and auto-attaches its mouse/keyboard listeners to the canvas.
const controller = CameraController.create({
yaw: 0, pitch: -0.15, speed: 250, sensitivity: 0.002,
});
cameraGO.addComponent(controller);
// The geo scene: floating origin at New York + two keyless streamed sources.
const geo = new GeoScene(engine.ctx.device, GeoFrame.atLonLat(-74.0060, 40.7128, 0));
// AWS Terrarium elevation, draped with Esri imagery. Terrain first, so the
// buildings clamp to its height as they stream in.
geo.addRasterDemTerrain(AWS_TERRARIUM, { imagery: ESRI_WORLD_IMAGERY });
// OpenFreeMap vector-tile buildings: OSM footprints extruded by height. Decoding
// happens off the main thread automatically.
geo.addVectorTiles(OPENFREEMAP_VECTOR);
// One call wires the scene into the pipeline: it registers a GeoFeature (just before
// the stock Shadow + Geometry features, which read frame.opaque / frame.shadowCasters)
// and installs the floating-origin auto-reanchor for the camera. Sections 4 and 5
// below unpack both halves.
geo.attach(engine, { cameraGO });
engine.run();
}
main().catch((e: unknown) => {
const msg = e instanceof Error ? (e.stack ?? e.message) : String(e);
document.body.innerHTML = `<pre style="color:#f88;padding:1rem;font-family:ui-monospace,monospace">${msg}</pre>`;
console.error(e);
});
No token to paste — just:
npm install
npm run dev
Drag to look, WASD to fly. Tiles stream in over a few seconds and sharpen as
you approach — that's screen-space-error LOD pulling finer tiles.
What's new compared to Hello, Cube#
Five things, and only five:
1. Reversed-Z and a giant far plane#
contextOptions: { enableErrorHandling: true, reversedZ: true }
// ...
Camera.createPerspective(60, 1, 5_000_000, aspect)
A planet is ~12,700 km across. A normal depth buffer can't resolve a 1 m building against a 5,000 km far plane — you'd get catastrophic z-fighting. Reversed-Z redistributes depth precision so the near field stays crisp at any far plane. Always pair these two for geo.
2. A GeoFrame as the floating origin#
GeoFrame.atLonLat(-74.0060, 40.7128, 0) // lon, lat, height(m)
This anchors the world at New York. Engine world-space (0,0,0) is that
lat/lon; everything streams in relative to it. Tutorial 2 explains why this
matters and what reanchorCamera is doing.
3. A GeoScene holding tilesets#
geo.addRasterDemTerrain(AWS_TERRARIUM, { imagery: ESRI_WORLD_IMAGERY });
geo.addVectorTiles(OPENFREEMAP_VECTOR);
addRasterDemTerrain streams a raster height-map (DEM) and turns it into terrain
mesh on the GPU; addVectorTiles streams OSM vector tiles and extrudes the
building layer into 3D. Both return immediately with { tileset, ready } — the
ready promise resolves when the root tile loads, but it's safe to render
before then (the tileset no-ops until it's ready). Tutorial 3 stacks more layers.
The keyed alternatives. If you do have a Cesium ion token, the higher-fidelity swap is
geo.addTerrain(await resolveIonAsset(ION_ASSETS.worldTerrain, token))(quantized-mesh world terrain) andgeo.add3DTiles(await resolveIonAsset( ION_ASSETS.osmBuildings, token), { useWorker: () => true })(the Cesium OSM Buildings 3D Tileset). SameGeoSceneAPI — just a different data source.
4. Wiring it in with geo.attach#
geo.attach(engine, { cameraGO });
attach bundles the two things every geo app does. First, it registers a GeoFeature
— the RenderFeature that drives the scene each frame — before the stock shadow and
geometry features. That ordering is the single easiest thing to get wrong: GeoFeature
appends streamed draws to the frame's draw lists, and those features consume them,
so geo must run before them or your tiles render under your scene or not at all.
Second, given cameraGO, it installs the floating-origin reanchor (next section).
The screen-space-error LOD needs the camera's vertical FOV; GeoFeature reads it live
from the camera each frame, so there's nothing to pass or keep in sync — change the
camera's FOV and the LOD follows. (Pass an explicit fovY only to override it, e.g. when
you also supply a cullProjection built with a different FOV.)
Prefer to wire it by hand?
attachis exactly this — reach for it when you need to order other per-frame work around the reanchor (as tutorials 4, 6 and 8 do):engine.addFeatureBefore(new GeoFeature({ scene: geo }), ShadowFeature.name); engine.beforeFrame(() => reanchorCamera(geo.frame, cameraGO));
5. The floating-origin reanchor#
Passing cameraGO to attach installs this in beforeFrame:
engine.beforeFrame(() => {
reanchorCamera(geo.frame, cameraGO);
});
reanchorCamera rebases the floating origin onto the camera once it drifts past
~10 km, shifting the camera so nothing moves on screen. It runs in beforeFrame,
before the engine caches camera matrices. Skip it and far-from-origin coordinates
jitter. Tutorial 2 is all about this. (Omit cameraGO — as tutorial 9 does — to pin the
origin instead, e.g. to keep baked physics colliders valid.)
Performance#
If the framerate is poor while flying, in priority order:
- Raise
maxSSEto pull fewer/coarser tiles:const { tileset } = geo.addVectorTiles(...); tileset.maxSSE = 24;(default 16; higher = faster, blurrier). The same knob exists on the terrain tileset. - Decode tiles off the main thread. The vector-tile builder already decodes in
a worker. For ion 3D Tiles (
add3DTiles) pass{ useWorker: () => true }— without it every streamed glTF/Draco tile is decoded inside the frame loop, hitching the picture the whole time tiles stream over a city. - Build for production.
npm run devserves the engine unbundled;npm run build && npm run previewis markedly smoother. - Trim the preset.
deferredPresetturns on PCSS shadows, SSAO, and TAA by default. Droppingshadow: falseorao: falsebuys frames on weaker GPUs.
Common mistakes#
- Tiles invisible / under the ground.
GeoFeatureended up after the shadow/geometry features.geo.attachorders it before them for you; if you wire it by hand, useaddFeatureBefore(..., ShadowFeature.name). - Z-fighting / shimmering surfaces. You forgot
reversedZ: trueor used a small far plane. - Wrong LOD (too blurry or stuttering). Usually a
cullProjectionwhose FOV disagrees with the camera, or an explicitfovYoverride that's stale. With neither set,GeoFeaturereads the camera's live FOV, so this doesn't happen. 401/403in the console. Only relevant for keyed sources — a bad ion token or Google/MapTiler key. The keyless AWS/OpenFreeMap sources here don't authenticate, so this won't happen with the tutorial as written.- Nothing loads but no errors. Streaming is async and budget-bound;
await readyonly means the root loaded. Give it a few frames.
Next#
- Tutorial 2 — The Floating Origin: what
GeoFrameandreanchorCameraactually do, and how to convert between lat/lon and world space. - The full reference for everything above is the Geo module guide.