Taos Engine · Geo Tutorials

Geo Tutorials · Step 12

Performance, Quality & Debugging

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.

TypeScript
const { tileset } = geo.add3DTiles(asset);
tileset.maxSSE = 16;   // default. Lower = sharper + more tiles + slower. Higher = blurrier + faster.

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:

TypeScript
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 crisp

SSE tracks the camera's FOV automatically. The metric is computed from the camera's vertical FOV, which GeoFeature reads live each frame — so zooming or otherwise changing the FOV reprioritizes LOD correctly, with nothing to keep in sync. You can pass an explicit fovY to geo.attach / GeoFeature to override it (e.g. alongside a custom cullProjection built 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:

TypeScript
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:

TypeScript
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:

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:

TypeScript
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:

TypeScript
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:

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:

TypeScript
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:

TypeScript
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:

  1. Raise maxSSE (24–32) — biggest, fastest win.
  2. useWorker: () => true on every add3DTiles — kills the streaming hitch.
  3. Lower maxRenderDistance in dense cities.
  4. Drop shadow/ao from the preset on weak GPUs.
  5. Lower budgetBytes / maxConcurrent only if memory-bound or bandwidth-bound.
  6. 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:

TypeScript
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 ratio

The 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:

TypeScript
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:

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).