Streaming a planet is a constant trade between detail and framerate. This reference tutorial collects every knob the geo module exposes — screen-space error, memory budgets, render distance, preloading, worker decode — and says which way each one pushes that trade, then covers the tools for seeing what your globe is doing: the wireframe overlay, the data-source/cache log, per-tileset LOD stats, and the attribution you're required to display. Sources: src/geo/tileset.ts, src/geo/geo_scene.ts, src/geo/geo_feature.ts, src/geo/wireframe_feature.ts, src/geo/data_source.ts.
Like tutorial 11, this is a reference — reach for it when
the framerate is poor or the picture is blurry, not as a step in the path. The
single most important knob is maxSSE; the rest are situational.
The one knob that matters: maxSSE#
Every tileset selects detail by screen-space error (SSE): the on-screen pixel
size of a tile's geometric error. maxSSE is the target — the tileset refines
(pulls finer tiles) until each visible tile's error drops under it. It's
CesiumJS's maximumScreenSpaceError.
const { tileset } = geo.add3DTiles(asset);
tileset.maxSSE = 16; // default. Lower = sharper + more tiles + slower. Higher = blurrier + faster.- Lower (e.g.
8) → sharper, far more tiles streamed and drawn, heavier on bandwidth, memory, and the GPU. - Higher (e.g.
24–32) → coarser, fewer tiles, much faster. The first thing to raise on a weak GPU or a slow connection.
maxSSE lives on every tileset type — Tileset3D (3D Tiles), TerrainTileset
(quantized-mesh), RasterDemTileset (AWS/MapTiler raster terrain), and the vector
tileset — and is per-tileset, so you can keep photoreal city tiles sharp
(maxSSE = 8) while letting distant terrain go coarse (maxSSE = 24)
independently:
const terrain = geo.addRasterDemTerrain(AWS_TERRARIUM, { imagery: ESRI_WORLD_IMAGERY }).tileset;
const buildings = geo.add3DTiles(ionBuildings).tileset;
terrain.maxSSE = 24; // coarse ground is fine
buildings.maxSSE = 10; // keep the city crispSSE tracks the camera's FOV automatically. The metric is computed from the camera's vertical FOV, which
GeoFeaturereads live each frame — so zooming or otherwise changing the FOV reprioritizes LOD correctly, with nothing to keep in sync. You can pass an explicitfovYtogeo.attach/GeoFeatureto override it (e.g. alongside a customcullProjectionbuilt with a different FOV) — but then a stale override is the one way to make every tileset pick the wrong LOD, too blurry or too heavy across the board.
Render distance: maxRenderDistance#
A hard far-distance cutoff (world meters). Content tiles whose nearest bound is
farther than this are neither drawn nor refined — so the sprawl of tiny buildings
out to the horizon costs nothing. Defaults to 5 km; set Infinity to draw all
the way to the horizon (expensive), or lower it to claw back frames in a dense
city:
buildings.maxRenderDistance = 3000; // stop drawing buildings past 3 km
buildings.maxRenderDistance = Infinity; // draw to the horizon (heavy)The root and large container tiles are exempt (they must be traversed to reach near descendants), and terrain — a separate tileset — is never distance-culled this way.
Memory & concurrency budgets (GeoSceneOptions)#
These are set once on the GeoScene and shared by every tileset's cache. The
defaults suit a desktop GPU; trim them for constrained devices:
const geo = new GeoScene(device, frame, {
budgetBytes: 512 * 1024 * 1024, // GPU byte budget per tileset cache. Default 512 MiB.
budgetTris: 30_000_000, // triangle budget per cache. Default 30 M.
maxConcurrent: 16, // max in-flight tile loads per cache. Default 16.
retainFrames: 240, // eviction grace window in frames (~4 s @ 60 fps). Default 240.
evictHard: 2, // soft-overage multiplier before recently-used tiles evict. Default 2.
maxPerHost: 16, // cap loads to any single host so one slow provider can't starve others.
});How they trade:
budgetBytes/budgetTris— the cache eviction ceiling. Larger = fewer re-streams when you fly back somewhere (smoother), at the cost of GPU memory. Lower these first on a memory-limited device. You can change them live (e.g. from a quality slider) withgeo.setCacheBudget(bytes, tris).maxConcurrent— more parallel loads fill detail faster but contend for bandwidth and main-thread decode time. Lower it if streaming hitches the frame.maxPerHost— when you mix sources (tutorial 11), cap per host so one slow provider can't hog all the load slots and starve the rest.retainFrames/evictHard— how long just-off-screen tiles linger before eviction. Higher = less popping when you pan back, more memory held.
Preloading off-screen tiles: preloadMargin#
By default a tileset only loads what's in the frustum, so a quick pan pops tiles
in. Set preloadMargin (world meters) to warm selection leaves just outside the
frustum at a deprioritized urgency — they're already loading when they scroll on
screen. 0 (the default) is off, with zero overhead:
engine.beforeFrame(() => {
// Scale the ring with altitude — more lookahead when you're high and moving fast.
const altitude = /* camera height above ground */;
buildings.preloadMargin = altitude * 0.5;
});It's a per-frame value (set it in beforeFrame) because a good margin scales with
camera altitude and speed. Trades a little extra streaming for smoother panning.
Decode off the main thread: useWorker#
The single biggest hitch fix for 3D Tiles. Without it, every streamed glTF / Draco tile is decoded inside the frame loop — the picture stutters the whole time tiles stream over a city. Turn it on per tileset:
geo.add3DTiles(asset, { useWorker: () => true });Vector tiles (addVectorTiles) already decode in a worker automatically; this
flag is for add3DTiles. Always pass it for ion / Google photoreal tilesets.
Trim the render pipeline#
The geo cost isn't only streaming — the deferred pipeline draws it. From tutorial 1:
deferredPresetturns on PCSS shadows, SSAO, and TAA by default. On a weak GPU,shadow: falseorao: falsebuys real frames.- Reversed-Z + a giant far plane aren't optional for geo —
contextOptions: { reversedZ: true }plus a millions-of-meters far plane is what keeps a 1 m building from z-fighting against a 5,000 km horizon. Not a perf knob, but the fix for "shimmering surfaces." - Build for production.
npm run devserves the engine unbundled;npm run build && npm run previewis markedly smoother — measure there, not in dev.
Gaussian-splat budgets#
Streaming splat tilesets (tutorial 11) have their own SSE plus a fixed GPU slot budget — splats live in one merged buffer, so you cap slots, not bytes:
new GaussianSplatTileset(pass, root, {
maxScreenSpaceError: 16, // same lower=sharper/heavier trade as maxSSE
maxSlots: 64, // fixed GPU buffer slots; the LOD budget ceiling
slotSize: /* splats per slot */,
});Diagnosing LOD pops#
If detail visibly "pops" (a region drops to a coarser ancestor then climbs back), turn on per-tileset diagnostics to count regressions — cheap, but off by default:
buildings.diagnostics = true;
// after geo.update(): buildings.stats.lodRegressions, .maxRegressionLevels, …A high lodRegressions usually means the budget is too tight (tiles evicted then
re-needed) — raise budgetBytes/retainFrames, or back off maxSSE.
A tuning recipe#
When the framerate is poor, in priority order:
- Raise
maxSSE(24–32) — biggest, fastest win. useWorker: () => trueon everyadd3DTiles— kills the streaming hitch.- Lower
maxRenderDistancein dense cities. - Drop
shadow/aofrom the preset on weak GPUs. - Lower
budgetBytes/maxConcurrentonly if memory-bound or bandwidth-bound. - Build for production and measure there.
When it's blurry instead: lower maxSSE, raise the memory budgets so finer tiles
survive eviction, and — only if you set one — check that an explicit fovY override
isn't disagreeing with the camera (by default it tracks the camera, so this is rarely it).
When the picture is wrong rather than slow — missing tiles, the wrong LOD, a suspicious download storm, a blank globe — these tools show what's actually happening. None is on by default; each is a few lines to wire up.
What's streaming: the data-source log#
Every tile fetch flows through a shared DataSourceLog — which source served it,
how many bytes, and whether it came from the network or the persistent cache. It's
disabled by default (tracking has a small cost); turn it on, then either read
the counters or drop in the ready-made floating panel:
import { dataSourceLog, makeDataSourceLogView } from 'taos/geo/index.js';
dataSourceLog.enabled = true; // start recording (off by default)
const logView = makeDataSourceLogView(); // self-installing DOM panel, hidden until shown
logView.setVisible(true); // 🌐 net vs 💾 cache, per source, with a running hit ratioThe panel filters by source / URL substring and by network-vs-cache, and shows a
live cache-hit percentage and byte totals. Without the panel you can still read
dataSourceLog.cacheHits, .cacheableTotal, .bytesDownloaded, .bytesFromCache
directly. This is the first place to look for a 401/403 (a bad ion/Google/
MapTiler key — see tutorial 11) or a runaway re-stream (a
budget set too tight, evicting tiles you immediately need again).
The cache itself needs a secure context (https or localhost). If the panel says
cache UNAVAILABLE, that's why — you'll re-download every tile each run.
What's drawing: the wireframe overlay#
GeoWireframeFeature re-draws the streamed tiles' triangle edges over the lit
scene, depth-tested against the GBuffer so only visible surfaces show. It's the
"is this tile actually here, and how is it triangulated?" view:
import { GeoWireframeFeature } from 'taos/geo/index.js';
const wire = new GeoWireframeFeature({
drawItems: () => geoResult.opaque, // the GeoScene's opaque draw list for this frame
color: [0, 1, 0.6], // linear HDR, tonemapped with the scene
});
wire.enabled = false; // toggle on demand (e.g. a debug key)
engine.addFeature(wire);It pulls geometry straight from each mesh's vertex/index buffers in the vertex
shader, which needs them storage-readable — the geo tile/terrain/anchor loaders
already set storageBuffers: true, so streamed content "just works." Pair it with
the flat-color Tile LOD view (color by tree depth) to see which LOD is
selected where.
Why the LOD pops: per-tileset stats#
Covered under diagnosing LOD pops above — tileset.diagnostics = true then read tileset.stats (visited / rendered / culled,
lodRegressions, maxRegressionLevels). A blank globe with visited > 0 but
rendered == 0 usually means everything's being culled — a far plane too small, the
camera under the terrain, or (if set) an fovY override fighting the camera.
Attribution: not optional#
Every source carries attribution you're contractually required to display (Cesium / Google / OSM ToS). It comes from three places, and you must show whichever apply:
- Tilesets / terrain: the static
attributionson the resolved source (tutorial 11), e.g.ionAsset.attributions. - Photoreal 3D Tiles (Google): the visible-tile copyrights change as you fly —
read
tileset.visibleCopyrightseach frame (it's!==-stable until the on-screen providers actually change, so you can guard the DOM write). - Imagery drape: the active provider's credit, surfaced on the terrain layer.
GeoScene's per-frame result also aggregates the deduped copyrights of
everything currently on screen — the simplest single source for an attribution bar.
You've finished the reference tutorials#
That's the full geo series — the linear path (1–10) plus the two references: data sources and tuning (this page).
- Geo module guide — the complete API reference.
- samples/planet_explorer.ts — wires most of these knobs into live UI controls (a pixel-error slider, underlay toggles, dataset switcher).