@ubilabs/typegpu-globe-renderer
v0.0.1
Published
A standalone **WebGPU / [TypeGPU](https://docs.swmansion.com/TypeGPU/) (v0.11)** viewer that streams **Google Photorealistic 3D Tiles** and renders them — every pixel — in its own TypeGPU pipeline. Shaders are authored in TypeScript (TGSL) and compiled to
Keywords
Readme
typegpu-globe-renderer
A standalone WebGPU / TypeGPU (v0.11) viewer that
streams Google Photorealistic 3D Tiles and renders them — every pixel — in its own
TypeGPU pipeline. Shaders are authored in TypeScript (TGSL) and compiled to WGSL by
unplugin-typegpu. No three.js, no deck.gl, no Cesium.
Live demo — the built viewer hosted on
Google Cloud Run (deployed with npm run deploy). Needs a WebGPU-capable browser (recent
Chrome / Edge on desktop).
The renderer itself is a standalone, instantiable library (src/lib/, exported as a
GlobeRenderer class + a GlobePlugin API) — index.html/src/demo/ is one reference consumer
of it, not the renderer's home. See "Using it as a library" below if you want to embed it in your
own app.
The whole point is that the rendering is ours. Supporting libraries do only the parts that aren't the point:
- loaders.gl streams the 3D-Tiles tileset and decodes each tile's glTF/Draco into plain typed arrays + textures.
- A small camera-driven viewport (
engine/selection.ts) hands loaders.gl our real perspective globe frustum + eye in ECEF so it knows which tiles to load — it never draws anything. (No deck.gl; the old mercator selection viewport was removed.) - math.gl provides the matrix / ellipsoid (ECEF) math.
On top of the photoreal city sit five GPU-driven layers:
- GPU-compute-simulated trees — each a damped spring with its own gust phase — grounded by a GPU heightmap so the trunks rest exactly on the rendered terrain.
- A day/night cycle with a real sun — two controls (a time-of-day world clock in UTC hours, and a calendar date) place the sun at a fixed direction in ECEF space. Each tile is lit per-fragment from its own geocentric normal · the sun direction, so the hemisphere facing the sun is bright and the far side dark with a soft terminator that sweeps the globe as the clock advances — fly east and you fly into night. The date sets the sun's seasonal tilt (solar declination), so the terminator runs at a slant — the North Pole in midnight sun near the June solstice, Antarctica in polar night, the slant flipping with the season — rather than pole-to-pole. The clock is corrected by the equation of time, so the sun tracks the true apparent position (accurate to ~1° against a real ephemeris), not a uniform mean sun. The sun is static (these two sliders are its only input; there is no automatic animation). Sky colour and the local scene ambient warm at golden hour and cool to night; procedural lit windows switch on, but only on the night side of the terminator. The sun also feeds a directional-light cascaded shadow map for crisp building/tree cast-shadows across the whole view (rendered from the sun's direction, the same orthographic-depth idea as the heightmap, but split into near→far frustum zones) — the scene samples it back to darken what the sun can't see.
- GPU-simulated rain — a pool of teardrop "blobs" that fall, collide against the heightmap, and splatter into a shrinking crown of smaller blobs before respawning.
- A global cloud deck — a geocentric shell raymarched in a full-screen pass: an eye ray intersects the shell (ray-sphere folded in f64 so f32 stays precise from the ground to orbit), marches a few steps through an animated 3D-noise density field keyed to the geocentric direction (so it's world-locked, like the wool look), lights it with the same ECEF sun, and composites over the scene with a per-pixel depth so buildings still occlude clouds behind them. Unlike the trees / rain / water (which live in the local ±500 m content frame), clouds are global — overhead haze at street level, cloud bands wrapping the planet zoomed out.
- A physical atmosphere — the sky, the pale horizon, the reddening sunset, the dark night side
and the blue haze over distant terrain are all the same Rayleigh + Mie + ozone single-scattering
integral (with a multiple-scattering correction), evaluated along the view ray and truncated at
whatever the ray hits. The blue is not a palette: it falls out of the physical fact that air scatters
short wavelengths ~5.7× more than long ones. The same integral, truncated at terrain instead of the
shell top, is the aerial perspective — distance haze that's guaranteed to match the sky because
it's literally the same maths. Four lookup tables keep it real-time: two static (Bruneton's
transmittance LUT, Hillaire's multiple-scattering LUT, baked once) collapse the inner sun march to a
tap; two per-frame (a full-sphere sky-view LUT for the backdrop, an aerial-perspective
froxel for near terrain) replace the dense per-pixel view-ray march with a single LUT tap — so the
cost no longer scales with how much of the screen the planet fills. On by default; the scene is
unchanged with
?noatmo.
Each layer can be toggled, and the time of day, calendar date, rain density, and cloud cover are live sliders, all from the info panel.
A second, older demo (constant-screen-size pattern fills as a transparent overlay on a 2D Google Map) lives at
2d/index.html→2d/src/main.ts, documented in2d/README.md.
Run
You need a Google Maps Platform API key with the Map Tiles API enabled (and billing on the project — this is a different API from the Maps JavaScript API the 2D demo uses).
npm install
cp .env.example .env.local # paste your key into VITE_GOOGLE_MAPS_API_KEY
npm run dev # http://localhost:PORT (needs a WebGPU browser)
npm run build # app + library + types (see "Using it as a library" below)Open the printed URL. If the key is missing or the Map Tiles API isn't enabled, the panel shows an error instead of a city.
Using it as a library
Everything under src/lib/ is a standalone, instantiable library — npm run dev's viewer
(src/demo/) is just its reference consumer, not part of the library itself. Two globes can
coexist on one page, each independently created and disposed, optionally sharing a GPUDevice.
import {GlobeRenderer, createTreesPlugin, createRainPlugin} from 'typegpu-globe-renderer';
const renderer = await GlobeRenderer.create(document.querySelector('#view'), {
apiKey: MAPS_TILES_API_KEY,
plugins: [createTreesPlugin(), createRainPlugin()],
initialCamera: {center: {lat: 53.5511, lng: 9.9937, altitude: 0}, range: 500}
});
renderer.on('camera-change', ({pose}) => console.log(pose));
await renderer.flyTo({center: {lat: 40.7484, lng: -73.9857, altitude: 0}});
renderer.dispose(); // tears down the canvas, GPU resources, and (if it created one) the device- Core (always present, not a plugin): tiles, the orbit camera +
flyTo, the physical atmosphere, cascaded sun shadows, day/night, ground collision. Options + typed events areGlobeRendererOptions/GlobeEventMap(camera-change/zoom/move/move-end/tiles/anchor-change/pick/error). - Everything else is a
GlobePlugin, registered constructor-time viaoptions.plugins: night windows, trees, rain, water (flood), clouds, and building selection all ship as built-in plugins (createWindowsPlugin,createTreesPlugin,createRainPlugin,createWaterPlugin,createCloudsPlugin,createSelectionPlugin). A plugin gets aGlobePluginContext(the core's GPU root, camera, sun/shadow handles, ground-heightmap factory, and services likereadDepth) and can hookinit/update/recordScene/recordComposite/recordShadowCasters/onResize/dispose, plus optionally contribute aTileShaderContribution— an emissive term folded directly into the composed tile fragment shader atcreatetime (the windows plugin is the worked example; seeCOMPANION.md§6 for the composition mechanism). Writing your own plugin needs no fork of this repo. <globe-renderer>custom element (typegpu-globe-renderer/element) wraps the class for non-TypeScript consumers: degree-based attributes (lat/lng/altitude/range/heading/tilt/shadows/atmosphere/time-of-day/day-of-year), apluginsproperty (constructor-time only, like the class API), and every core event re-dispatched as aglobe-<type>CustomEvent.- Package layout:
.→GlobeRenderer+ built-in plugins;./element→ the custom element (a separate entry point — importing it registerscustomElements.define, so it's opt-in). Ships as pre-TGSL-transformed ES modules (dist/lib/) +.d.ts(dist/types/) — consumers don't needunplugin-typegpu.typegpu/loaders.gl/math.gl/earcutare peer-ish runtime deps, not bundled.
Controls
| Input | Action | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | left-drag | pan — grab the surface and drag it. The same gesture is a street-level pan up close and a globe spin zoomed out, because the drag arc scales with altitude. Works in every direction, straight over the poles. | | right-drag or shift-drag | orbit — horizontal spins the heading, vertical tilts (0° straight down to 85° at the horizon) | | wheel | zoom toward the cursor — from ~30 m at street level out to ~25,000 km (the whole globe). Over empty space past the limb it just zooms toward centre. | | left-click (no drag) | select the building under the cursor — a translucent, pattern-filled prism wraps it (a random atlas pattern + tint per selection). Click the same building again, the street or empty sky (or press Esc) to clear. A click that moves is a pan, not a select. | | Cmd/Ctrl-left-click | toggle the building under the cursor in the selection set (multi-select): adds it (with its own random pattern + tint) if new, removes it if already selected. A missed Cmd/Ctrl-click keeps the existing set. |
The camera is a single geographic orbit (the <gmp-map-3d> model): it always looks at a
movable center on the surface, with range, and an orientation held as world-space vectors —
no north-relative heading and no near-vs-far regime, so it behaves the same from the street to
orbit and has no pole singularity.
The info panel
The panel in the top-left (TypeGPU · P3D) holds the live controls and a stats readout. The
controls are the easy part:
| Control | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| time of day · UTC | the sun's hour angle, 0–24 UTC hours. Sweeps the day/night terminator around the globe (the sun is otherwise static — this slider is the clock). Defaults to the real current UTC time at page load; a read-only ☀ readout shows the local solar time where you're looking (from the view longitude — astronomically exact, no timezone/DST). |
| date · season | day-of-year 1–365. Sets the sun's seasonal tilt (solar declination), so the terminator slants — midnight sun near the June solstice, etc. Defaults to today's date at load. |
The layer checkboxes below are grouped by what they actually are, architecturally: core engine
features (always present, part of GlobeRenderer itself) vs. modules (opt-in GlobePlugins —
see "Using it as a library" above; a consumer of the library could ship without any of them, or add
their own).
| Group | Control | What it does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| core | shadows | toggle the cascaded sun shadow map. |
| core | atmosphere | toggle the physical sky + aerial perspective (same effect as the ?noatmo URL param, at runtime). |
| modules | trees / rain / clouds | toggle each plugin on/off. |
| modules | rain density | drops in the pool, 0–12000. |
| modules | cloud cover | cloud amount, 0% (clear) – 100% (overcast). |
| modules | flood + flood depth | toggle a rising water plane and set its depth in metres (0–40). |
| modules | windows | toggle the procedural night-window lights. Hidden when ?chrome/?wool is active (see below) — those whole-fragment looks don't register the windows plugin at all. |
Reading the stats
The readout under the controls is a per-frame diagnostic. Most lines describe how the tile streaming and our render pipeline are coping, not the scene itself. Line by line:
| Line | Meaning |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tiles N selected · M cached | N = tiles loaders.gl picked as the right LOD for this view (what it wants drawn). M = tiles resident in our cache — decoded and uploaded to the GPU — including off-screen ones kept briefly by hysteresis and the cache budget. M ≥ N normally. |
| draws V/T (frustum) | T = drawables we have ready this frame (the selection plus the recently-selected hysteresis window). V = how many survived our own CPU frustum cull and were actually drawn. The gap is geometry kept resident but currently off-screen. |
| memory ours+content=total MB | ours = our WebGPU footprint (the vertex/index buffers + mipped textures we uploaded). content = loaders.gl's decoded glTF still resident in CPU RAM — hovers near 0 once tiles are built (we free that second copy on upload), spikes only while new tiles stream in. total = the sum, the figure to compare against the browser tab's memory. |
| eviction budget X | loaders.gl's LRU cap (maximumMemoryUsage). It no longer reflects real RAM (we already freed the decoded content), but it's what still gates eviction of our GPU copies — surfaced for tuning, not as a live usage number. |
| distance X m/km | camera range: straight-line distance from the eye to the target point on the surface it orbits (~30 m at street level out to ~25,000 km for the whole globe). |
| frame X ms · Y fps | smoothed (EMA) wall-clock time per frame, and the fps derived from it. The headline "is it smooth" number. |
| build N · X ms | tiles whose GPU geometry we built and uploaded this frame (newly streamed-in), and the CPU time that took. Non-zero only while new tiles arrive; compare against frame to tell whether a hitch is our build cost vs. loaders.gl's (untimed) parse. |
| cpu X ms encode | smoothed CPU time spent encoding the frame's command buffer (recording the render passes), separate from the build cost above. |
| gpu X ms (scene Y) | whole-frame GPU execution time, bracketed across every pass the frame submits — shadow cascades, the atmosphere sky-view/aerial LUT builds, the scene pass, the deferred atmosphere+clouds composite, the pick copy. The (scene Y) trailing figure is the scene pass alone; a big gap (frame ≫ scene) means atmosphere/shadow/composite work dominates. Read back from two timestamp queries. Shows n/a if the browser/adapter doesn't expose the timestamp-query feature, … until the first sample lands. |
How it works
The canonical world frame is Earth-Centered-Earth-Fixed (ECEF) — Earth's centre is the
origin, so no point on the surface is privileged. The camera and the tiles live here. Tiles
arrive in ECEF with million-metre coordinates; we render each one relative to its own
bounding-box centre (folding the big centre→clip translate on the CPU in f64) with a
reversed-Z float depth buffer, so the GPU only ever sees small f32 numbers and stays precise
all the way out to a globe. The ground decorations (trees / rain / heightmap / sun-shadow) instead
live in a local East-North-Up (ENU) content frame — a tangent-plane scene placed onto the globe
by one localToEcef matrix. That frame is a floating origin: it auto-hops to sit under the
camera target whenever the target drifts past a threshold (reanchorContentFrameIfDrifted), so the
decorations, sun direction and f32 precision stay locally correct anywhere on the globe, not
just near the original Hamburg anchor. The flood water is the exception — a deliberately-placed
feature, it gets its own fixed frame snapshotted where you drop it (captureWaterFrame) and holds
still as the content frame floats away.
Per frame:
- Select — a small viewport carrying the camera's real perspective frustum + eye in ECEF
(
engine/selection.ts) is handed to loaders.gl'sTileset3D.selectTiles(), whose ECEF culling + screen-space error then track the globe honestly at any pitch/altitude (LOD coarsens with altitude via the globe-blend). A render-side hysteresis keeps just-deselected tiles on screen briefly so the set shifting frame-to-frame doesn't flicker. - Build — each newly selected tile's glTF is walked into renderable primitives. Geometry
is transformed into ECEF on the CPU in f64 (
cartesianModelMatrix · node, which already bakes in the Y-up→Z-up rotation), then stored relative to the tile's bounding-box centre as small f32 vertex buffers; the centre is reapplied per draw via a matrix composed in f64 (so the large multiply never hits f32). The baseColor image becomes a GPU texture with a generated mip chain. - Heightmap — periodically the tiles are re-rendered top-down (orthographic) into an
r32floattexture whose texels hold the surface height; the topmost surface wins. Both the trees and the rain sample it (to sit on / collide with the ground). - Simulate — two compute passes step the trees (damped-spring sway) and the rain (a falling → splashing → respawn state machine, colliding against the heightmap) on the GPU.
- Shadow — buildings and trees are rendered depth-only from the sun's direction into a
cascaded
depth32floatshadow map: the view frustum is split into 3 depth zones (near→far), each fitted to its own ortho box and rendered into its own layer of atextureDepth2dArray. This covers the whole visible frustum (sharp up close, coarse into the distance) instead of a single box around the camera target. - Draw — the physical sky is painted first as a full-screen backdrop, then tiles, then
trees, water and rain, all in one render pass into an offscreen colour target sharing one
reversed-Z
depth32floatdepth buffer for correct occlusion across the huge near/far range (reversed-Z spreads float depth precision evenly, which kills the far-zoom z-fighting). The tile and tree fragment shaders sample the shadow map (a comparison sampler, PCF-filtered) to darken what the sun can't reach. - Composite — a second full-screen pass to the swapchain samples that offscreen colour + the
depth buffer and adds the aerial perspective: each geometry pixel is attenuated by, and gets
the in-scatter of, the same scattering integral truncated at its reconstructed surface distance —
read as one tap into the per-frame aerial-perspective froxel (near terrain) or, from orbit,
the full-sphere sky-view LUT — so the haze over terrain matches the sky exactly; sky pixels pass
straight through. Clouds
composite last in this pass — one full-screen triangle that raymarches the global cloud shell
and alpha-blends over the atmosphered image, writing a per-pixel
frag_depth(the cloud's near entry) so the same reversed-Z buffer (bound read-only) occludes clouds behind buildings/terrain for free.
Building selection (event-driven, on click) rides on the same machinery. Google's tiles are
opaque, unsegmented triangle soups — there's no building id to look up — so a building is derived.
On a left-click the selection plugin (plugins/selection/index.ts) copies the just-rendered
reversed-Z depth buffer back to the CPU via the core's ctx.readDepth service (WebGPU forbids
partial copies of a depth texture, so it's the whole buffer — fine for a per-click event) and
unproject the clicked texel (depth + inverse view-proj, in f64) to the ECEF point you clicked. That seeds a flood-fill over the top-down rooftop heightmap (plugins/selection/footprint.ts): the
connected blob of texels that stand above the local street level and stay height-continuous with
their neighbours is the building's footprint, contoured (marching-squares + simplify) into a
polygon. The footprint is then earcut-triangulated and extruded into a translucent prism
(plugins/selection/highlight.ts), pattern-filled with the same atlas the 2D fillstyle demo uses
(floorMod tiling, world-anchored), and drawn into the scene pass after the trees / before the
water. Like the flood, the prisms share one captured (placed) frame (captureSelectionFrame)
so they stay glued to their buildings as the content frame floats.
Multi-select (Cmd/Ctrl-click) toggles buildings in a set: each pick draws a random atlas pattern + tint (carried as per-vertex attributes, so the whole set renders in one pass without per-draw uniform churn), and re-clicking a building removes it (matched by footprint centroid + roof height, so stacked tiers stay distinct). The frame is captured once on the first pick of a set; a later pick arrives in the live decoration frame and is folded into that captured frame on the CPU in f64, so a floating-origin hop between clicks can't drift the prisms apart. A plain click replaces the set; Esc clears it.
File map
src/lib/ is the library; src/demo/ is its one reference consumer. Core engine modules
(always present, not opt-in) live in src/lib/engine/; everything opt-in is a GlobePlugin under
src/lib/plugins/.
| File | Role |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| index.html / src/demo/main.ts | canvas + info panel; pure consumer of GlobeRenderer — builds the plugin list, wires the UI to renderer/plugin methods, no rendering logic of its own |
| src/lib/globe-renderer.ts | the GlobeRenderer class: canvas + resize, the whole per-frame loop, plugin hosting, camera pose API + flyTo, typed events, the depth-pick service, getStats(), dispose() |
| src/lib/options.ts | GlobeRendererOptions/GlobeTuning (the consumer-facing performance/quality knobs) + resolveOptions |
| src/lib/events.ts | the typed GlobeEventMap + a minimal event emitter |
| src/lib/element.ts | <globe-renderer> custom element — degree-based attributes, a plugins property, globe-* CustomEvents; separate package entry (./element), not re-exported from the main barrel |
| src/lib/plugins/types.ts | the GlobePlugin/GlobePluginContext/FrameInfo/TileShaderContribution plugin API |
| src/lib/engine/gpu.ts | per-instance initGpuContext/createGpuShared (the TypeGPU root, decoration view-proj uniform, batched tileXforms storage array, depth32float format, per-tile texture bind layout) — no module-level GPU state, so two instances can coexist |
| src/lib/engine/constants.ts | engine-internal calibration constants (atmosphere physics, DSSE curve shape, probe geometry, throttle windows, …) — the consumer-facing tuning knobs live in options.ts instead |
| src/lib/engine/geomath.ts | ECEF as world frame + the local ENU content frame, ECEF↔local transforms, reversed-Z perspective / ortho projections, lng/lat helpers |
| src/lib/engine/camera.ts | single geographic-orbit camera (movable center + range + vector orientation), pole-free pan/orbit, zoom-to-cursor; adaptive reversed-Z near/far |
| src/lib/engine/selection.ts | camera-driven globe selection viewport (real ECEF frustum + eye) handed to loaders.gl for tile selection |
| src/lib/engine/tiles.ts | tileset streaming, glTF → relative-to-centre ECEF geometry + textures, tile cache, render hysteresis, single GPU residency (frees loaders.gl's decoded copy after upload), horizon LOD falloff |
| src/lib/engine/tileShaders.ts | the composed tile vertex/fragment: core lighting (photo × shadow × day/night ambient) plus every plugin's TileShaderContribution, folded together at create time with compile-time branch pruning for absent contributions |
| src/lib/engine/daynight.ts | time-of-day world clock + date → ECEF sun direction (date sets the seasonal declination; the clock is equation-of-time corrected → the apparent sun); local-frame projection drives the sky / decoration ambient + shadow sun angles |
| src/lib/engine/heightmap.ts | top-down ortho pass → ground-height texture, region factory (createHeightRegion, exposed to plugins via ctx.ground.createRegion) — the core's own decoration region grounds the trees/rain plugins; the water and windows plugins build their own |
| src/lib/engine/cameraProbe.ts | camera-following top-down ortho pass → r32float height texture, async read back to the CPU to feed the camera collision clamp (keeps the eye a min vertical clearance above the ground directly under it; zoom/pan stops short) |
| src/lib/engine/shadow.ts | sun-driven cascaded ortho depth render → depth32float textureDepth2dArray (one layer per frustum zone) + per-cascade PCF sampling; casters beyond the buildings come from every plugin's optional recordShadowCasters hook |
| src/lib/engine/atmosphere.ts | physical sky + aerial perspective: Rayleigh + Mie + ozone single scattering from one view-ray integral, Bruneton transmittance LUT + Hillaire multiple-scattering LUT (both compute-baked once), drawn as a sky backdrop (scene pass) + a deferred composite (samples offscreen colour + depth), both one tap into a per-frame full-sphere sky-view LUT + aerial-perspective froxel (no per-pixel march at any altitude) |
| src/lib/plugins/windows.ts | the night-window-lights GlobePlugin — the canonical TileShaderContribution example; owns its own wide/coarse window-gate heightmap region |
| src/lib/plugins/trees.ts | instanced tree mesh, GPU spring compute, heightmap-grounded render; owns the floating-origin grid-origin bookkeeping |
| src/lib/plugins/rain.ts | GPU drop pool, fall→splash→respawn compute, heightmap collision, teardrop + crown render |
| src/lib/plugins/water.ts | the flood plane: its own placed (captured) frame + heightmap region, alpha-blended over everything opaque |
| src/lib/plugins/clouds.ts | global geocentric cloud shell: full-screen raymarch (ray-sphere folded in f64, R-normalised), 3D density from a baked tiling noise texture, ECEF-sun single-tap self-shadow, frag_depth depth occlusion |
| src/lib/plugins/selection/footprint.ts | building selection step 1: depth-picked ECEF hit (via ctx.readDepth) → flood-fill the rooftop heightmap → marching-squares + Douglas–Peucker → a footprint polygon (CPU, off one per-click heightmap readback) |
| src/lib/plugins/selection/highlight.ts | building selection step 2: extrude each footprint into a translucent, pattern-filled prism (shared 2D atlas, world-anchored floorMod tiling, random per-selection pattern + tint) in a shared captured placed frame; multi-select set recorded into the scene pass |
| src/lib/plugins/selection/index.ts | wires footprint + highlight into one GlobePlugin: pointer listeners, the pick queue, pick event emission |
| src/demo/looks.ts | demo-only bonus content: the ?chrome/?wool whole-fragment tile-look overrides (experimental.tileFragmentOverride) and the ?fuzz shell-coat GlobePlugin — not part of the library proper |
Notable details
- Placement. Google's glTF tiles bake their ECEF placement into
tile.content.cartesianModelMatrix(including the Y-up→Z-up rotation);computedTransformis identity-translation and node matrices are zeroed. Getting this wrong is a black screen. - Vertex strides. Tile/tree positions use loose
d.disarrayOf(d.float32x3)layouts (12-byte stride). A regulard.arrayOf(d.vec3f)would pad to 16 and garble the packed data. - Relative-to-centre rendering. Absolute ECEF coordinates are ~6.4 million metres everywhere on Earth, where f32 has only ~metre granularity — enough to crack tile seams and peel tiles off the limb. So each tile's vertices are stored relative to its own centre (small, f32-exact) and the per-tile placement is folded into the clip matrix on the CPU in f64; the GPU only ever multiplies small numbers. The tile, heightmap, and sun-shadow passes all do this (and the decorations do the analogous thing relative to their ENU content-frame origin).
- World-cell tree scatter. Trees are not a stored cluster but a deterministic field generated on
the fly (
trees.ts): a grid ofTREE_CELL_SIZE-metre square cells tiles the ground, each cell holdsTREES_PER_CELLtrees whose in-cell positions are a pure hash of the cell's integer global id. Only the(2R+1)²block of cells around the camera is instanced; the vertex shader synthesises each tree from its instance index + a per-frameTreeGriduniform. Because the hash keys on the global cell id, the field is world-stable as the floating origin hops — the camera flies over it, it never jumps. The precision trick mirrors relative-to-centre rendering: the content-frame origin's position in that global grid is split into an exact integer cell id plus a small f32 remainder (main.ts treeGridOrigin*), so only small relative numbers ever reach the GPU. A naive metre accumulator would lose sub-metre precision past ~10⁴ km and jitter the scatter; the integer/fraction split stays exact anywhere on the globe. Trees pastTREE_GATE_RADIUSor standing on coarse/unrefined ground (tile geometric error >TREE_DETAIL_MAXin the heightmap's 2nd channel) collapse to a degenerate point (a hybrid distance + tile-LOD gate); the gust phase keys on the same stable cell id so sway timing doesn't reshuffle as the block re-centres. - Reversed-Z depth. A
depth32floatbuffer with the near plane at clip-z 1 and far at 0 (with agreatertest) spreads depth precision near-uniformly across the ~100 m … tens-of- thousands-of-km range instead of bunching it at the near plane — that's what removes the zoom-out z-fighting. - Globe camera. One uniform geographic-orbit model (after
<gmp-map-3d>): the camera looks at a movablecenteron the surface fromrangemetres away, so zooming never jumps off it, the same way from the street to orbit — no near-vs-far regime, no crossfade. Orientation is stored as two world-space unit vectors (eye direction + camera up), not a north-relative heading, so the camera has no pole singularity: panning rotatescenter(and carries the view) along a great circle straight over either pole, and re-levels so the horizon stays flat. Left-drag pans, right/shift-drag orbits (heading + tilt), wheel zooms toward the cursor. - Curved earth. Because the ENU transform is rigid, real curvature is preserved — zoom out and the ground genuinely bends into the globe.
- Global clouds, precise from the ground to orbit. Clouds (
clouds.ts) are a global shell atEARTH_RADIUS + CLOUD_ALTITUDE, not a local content-frame prop — so they read correctly overhead at street level and as bands on the planet zoomed out. Two precision tricks keep them sharp despite the eye sitting ~6.4 Mm from Earth's centre, each mirroring a discipline used elsewhere: (1) the ray–sphere termc = |eye|² − R²would annihilate two ~4e13 numbers in f32, so it's folded on the CPU in f64 without cancellation ((|eye|−R)(|eye|+R)) and the whole intersection runs in planet-radius-normalised units (oc ≈ 1,disc ≈ 1e-4resolves cleanly) — the same fold-the-big-part-in-f64 idea as relative-to-centre tiles; (2) the density field samples the sample's full 3D world position — but expressed asposNorm = absoluteEcef ÷ EARTH_RADIUS, which is simultaneously order-1 (so f32-exact) and world-locked, the same "scale the big number down before the GPU sees it" move. Sampling the 3D position (not just the direction) is what makes the clouds genuinely volumetric — puffs you march through with real vertical structure — rather than a painted shell that betrays itself at a glancing angle. Occlusion is free: the full-screen fragment writes its near-entry distance asfrag_depth, so the shared reversed-Zgreatertest discards cloud behind nearer geometry; the solid-Earth sphere is also intersected so the globe view never shows far-side clouds through the planet. Clouds are a local-weather effect, not a planet texture, so their opacity fades to 0 as the camera pulls back (CLOUD_FADE_START_M … CLOUD_FADE_END_M) and the pass is skipped entirely at globe scale. - Physical atmosphere, one integral for sky and haze. The sky used to be a hand-tuned blue
palette faded to space by altitude — endless per-angle/-altitude tuning. It's now physical single
scattering (
atmosphere.ts): the blue zenith, pale horizon, reddening sunset, dark night side, and the blue distance haze over terrain all emerge from three physical coefficients (Rayleigh ∝ 1/λ⁴, Mie aerosols, ozone absorption) integrated along the view ray — there is nothing to "tune to agree", because the sky and the fog are the same integral truncated at different distances. It's a deferred composite (unlike Cesium/Mapbox's forward per-layer fog): the scene renders into an offscreen colour target with the physical sky painted first as a backdrop, then a full-screen pass composites the aerial perspective over each pixel's surface — one place, guaranteed to match. Four LUTs make it real-time. Two are static (baked once): a transmittance LUT (Bruneton 2008 — the toward-sun extinction as one texture tap instead of an inner march) and a multiple-scattering LUT (Hillaire 2020 — the blue that physically reappears after 2+ bounces, which single scattering alone omits and which otherwise leaves a brown/olive horizon). Two are rebuilt per frame and turn the dense per-pixel view-ray march into a single tap: a full-sphere sky-view LUT (Hillaire 2020 — the whole backdrop, sky and bare-Earth ground, as a 2D table indexed by view direction) and an aerial-perspective froxel (a 3D camera→distance volume for near terrain). Folding the ground into the sky-view LUT (a Hillaire horizon split, lower half = bare Earth) is what removed the last per-pixel marches: previously bare-Earth and orbital-geometry pixels still marched 64 steps each, so the cost spiked in the ~500 km–few-Mm band where the Earth disc fills the screen — now every backdrop and composite pixel is one LUT tap at any altitude. Atmospheric altitude is measured against the WGS84 ellipsoid, not the mean sphere, so ground sits at altitude ≈ 0 everywhere and the low sky keeps its blue; from orbit the composite's surface endpoint blends from the froxel to the sky-view LUT's f64 analytic ray–Earth hit (the depth reconstruction quilts tile-by-tile up there). Bare Earth beyond the loaded tiles is shaded as a simple lit Lambert globe under the same haze, so un-rendered terrain reads as a lit planet through air, not opaque blue over black.?noatmodisables it;?nomsA/Bs the multiple-scattering term. The math is heavily commented inatmosphere.tsand walked through inCOMPANION.md. - Stylised shadows. Google's photoreal textures already bake in shadows from the capture time of day, so our dynamic shadow map layers on top rather than replacing them. It's kept deliberately moderate (a fully-shadowed surface still keeps ~25% brightness) so the movable sun reads as a stylised effect, not physically-correct relighting.
- One batched scene pass. All visible tiles' transforms (the clip matrix, plus the per-tile
worldLocalmatrix and geocentricup, folded in f64 on the CPU) are uploaded once per frame into thetileXformsstorage array, then the whole scene is drawn into a single render pass, one submit, sharing the colour + reversed-Z depth target: every tile first (one draw each, indexed byinstance_indexviafirstInstance), then the trees, water and rain. The colour/depth clear lives in the pass descriptor (no dummy clear primitive). The decorations' pipelines switch in after the tiles via TypeGPU's render-pass proxy — each carries its own bind groups + vertex buffers as pre-baked pipeline priors, which the proxy re-applies on every draw, so multiple pipelines share the one pass cleanly. The sun-shadow pass batches the same way for its building casters: per cascade, all light-clip matrices go intoshadowCasterXforms(laid out per cascade with aMAX_TILESstride, indexed viafirstInstance = cascade*MAX_TILES + i) and render in one pass per cascade layer, each clearing/storing its depth target once instead of load+storing between every caster. - Allocation-free per-frame uploads. The tile and shadow transform arrays are built into
reused scratch (one f64
Matrix4for the centre fold + oneFloat32Arraymatching the storage layout) and uploaded with a rawqueue.writeBuffer— no per-tileMatrix4/array/object allocation in the render loop, so no GC stutter as the camera moves. The f64 fold is unchanged; only the f32 result is uploaded, exactly as before. - Single GPU residency (RAM). Every resident tile used to be stored twice: loaders.gl keeps the
decoded glTF (CPU typed arrays +
ImageBitmaps) in its LRU cache, and we upload a second full copy into WebGPU buffers + mipped textures. Oncetiles.tshas built+uploaded a tile,releaseTileContentnulls loaders.gl'scontent.gltfand.close()s its bitmaps, so only our GPU copy stays resident — roughly halving the tab's tile RAM. We deliberately do not callunloadContent()(that marks the tile UNLOADED → loaders.gl re-fetches it from the network), and we leave loaders.gl's byte accounting untouched: that figure is its LRU eviction budget, and eviction (onTileUnload → freeTile) is the only thing that frees our copy, so zeroing it would leak. Thememstat reports our bytes + the (now near-zero) still-resident decoded glTF;evis loaders.gl's eviction budget. - Render-time frustum culling.
getDrawables()returns the selection plus the hysteresis window, so during a pan a chunk of the drawn set has slid off-screen.cullToFrustum(globe-renderer.ts) tests each tile's bounding sphere against the six ECEF view planes (the same Gribb–Hartmann planesselection.tsfeeds loaders.gl) and skips the off-screen ones — main tile pass only, since the sun-shadow (light frustum) and heightmap (top-down) passes legitimately need off-camera tiles.draws visible/totalshows the effect. It trims wasted draws, not RAM (resident ≠ drawn). - Horizon LOD falloff. A grazing street-level view's frustum reaches the ~90 km horizon and would
otherwise select the whole metro at full detail (a measured multi-GB RAM spike). loaders.gl already
ships Cesium's distance-fog screen-space-error term but leaves it dormant;
tiles.tswakes it and drives its density fromcamera.getHorizonFactor()(0 looking straight down → 1 along the horizon), so the background coarsens with distance while the foreground stays sharp — and it's an exact no-op top-down or zoomed out. Crucially it also fades out with eye altitude (getDsseHeightFalloff,DSSE_HEIGHT_FALLOFF_*inengine/constants.ts): this is Cesium's "street-view only"(1 − t)weakening, which we'd originally omitted. Without it the penalty bites the foreground once the eye is a few km up — the tiles you're centred on are themselves km away and pick up the same fog penalty as the background — so buildings went soft from ~4 km up while Google (no such term) stayed sharp. The fade is full at street/low-drone level and gone by ~4 km, restoring parity. - Day-skips the night-window shader. The procedural lit-window glow (screen-space derivatives,
hashes, smoothsteps) is gated by
nightPossible— a uniform published from the camera target's sun altitude (1 only when the view is near/past the terminator; seeNIGHT_WINDOW_GATE_ALT_DEGinengine/constants.ts). The fragment shader guards the whole computation behind it (a uniform, so control flow stays uniform and the derivatives remain valid) — whole-daytime views skip it entirely, while the per-fragment night factor still scales the glow so windows only light on the night side. - Windows are gated by building height, not per-fragment height. A subtle near-vertical
tile-seam step (a 1–2 m height mismatch between neighbouring tiles / LOD levels — invisible in
the day photo, but flagged as a "wall" by the derivative face-normal) used to light up with windows
on open terrain. Per-fragment height can't separate it from a real ground-floor window (both sit at
ground level), so the gate instead asks how tall is the whole structure here: it samples the
topmost surface at the fragment's footprint (≈ the roof — so even a low wall fragment knows the
building is tall and the facade lights floor-to-roof) and subtracts a local ground estimated
from a ring of probes around the footprint (
buildingHeightAt/surfaceHeightAtinplugins/windows.ts). A real building (8 m+,WINDOW_MIN_BUILDING_HEIGHT) passes; a 1–2 m seam reads ~1–2 m and stays dark. Because the local-ground subtraction cancels absolute elevation, it works on rolling terrain too (a single region-floor minimum did not — a seam on a rise read tall against the valley). The height data comes from a dedicated wide/coarse top-down capture (WINDOW_MAP_HALF_EXTENT±4 km into its own 2048² texture, its owncreateHeightRegioncall) — kept separate from the decoration heightmap so trees/rain keep their sharp ±500 m field, and sized to reach as far asdetailFadecan still resolve panes. Outside that ±4 km there's no height data, so windows simply stop (they've faded by then anyway). - Camera ↔ tile collision. A zoom-in (or a pan that slides the eye over a building) now stops
short of the ground/buildings instead of flying through them.
cameraProbe.tsruns a SECOND top-down ortho pass — distinct from the decoration heightmap, which is pinned to the content anchor and useless once you fly away — re-centred every frame on the eye's own ENU surface frame, into a small (256², ~1 m/texel)r32floatheight texture. WebGPU has no synchronous GPU texture read, so the one number we need — the ground height at the box centre, i.e. directly under the eye — reaches the CPU via an asynccopyTextureToBuffer+mapAsync(a ring of three staging buffers), and the probe pushes it tocamera.ts. The clamp there matches how you'd describe it: keep the eye at leastCAMERA_MIN_GEOMETRY_DISTabove whatever is directly below it. The eye's altitude and the sampled ground are both measured along the local up over the eye's surface point, so their difference is the vertical clearance. The constraintrange·cos(tilt) ≥ groundUnderEye + MIN_DISTis enforced per input, on the lever that input moves — never as a per-frame correction: zoom/pan floorrange(raise it by(MIN_DIST − clearance)/cosTiltwhen the eye is too low — dollying out alongeyeDirlifts the eye bycosTiltper metre of range), and tilt caps the pitch atacos((groundUnderEye + MIN_DIST)/range), so tilting toward flat simply STOPS at the ground. Splitting the levers is the whole point: pitch-down drops the eye toward street level (the orbit centre rides the ellipsoid), so curing a tilt by raising range would re-enable more tilt next frame → tilt and range creep out together — the "camera moves further away on tilt-down" artefact. Capping the angle stops the tilt in place instead. And because the clamp is applied only on input (not every frame), a transient coarse-tile height during streaming can no longer bumprangewhile the camera sits idle — that was the load-time distance jump. Both levers are one-sided (only raiserange/ only cap, only when too low), so far from any surface they do nothing and the camera never wobbles, and they clamp the eye's height only, never the view angle — flat or straight-down views stay reachable unless the geometry directly under the eye is genuinely withinMIN_DIST. Near-flat range raises (cosTilt → 0) are left untouched: pulling back then moves the eye sideways, not up. Earlier attempts that re-grounded the orbit pivot (bobbed the whole rig as a pan swept rooftops) or gated on the orbit target's ground (let the eye dive into a building the view ray happened to miss on a tilted look) were both dropped for exactly those reasons — sampling under the eye is what fixed them. The probe is skipped above kilometre-scale altitude (nothing to collide with) and whenever it sees no ground (ocean / off the limb), so zoomed-out globe navigation is untouched. The async readback lands ~1 frame late — the honest residual, withMIN_DISTas the margin that absorbs it.CAMERA_PROBE_*are engine-internal calibration (engine/constants.ts);cameraMinGeometryDistis consumer-facing (GlobeTuning,options.ts, default 8 m) since it's a decision about how close a caller wants the camera to get.
Known limitations
- The flood water covers a fixed ~1 km square at the spot you place it. The water is a single
horizontal quad spanning ±
HALF_EXTENT(500 m) of its own ENU frame, snapshotted where the flood was enabled (captureWaterFrame) and held fixed there while the decoration frame floats on — so a flood is local to where you dropped it: fly away and it correctly stays put, but there is no water beyond that one square and no globe-wide flood. Its base/shoreline come from the flood's own fixed-extent heightmap (the region-floor min-reduction, so within the square the surface sits at street level, not on a rooftop). Only the extent is the limitation now — placement is no longer Hamburg-only. - Dynamic shadows flicker transiently while you drag a sun slider. The shadow caster is the raw photogrammetry shell, so its silhouette is noisy at the metre scale (the "weird shapes" on facades); under a moving sun that noise strobes. The sun is now static — positioned by the time-of-day and date sliders, never auto-animated — so the shadows are stable whenever it's parked (i.e. almost always), and the strobing only shows for the moment you're actually dragging a slider. No receiver-side remedy (depth bias, normal-offset bias, wider PCF, texel-snapping) cured the moving-sun case without trading one artefact for another (shadow detachment, swimming blotches, or mush); a real fix needs cleaner caster geometry or temporal reprojection, so it's benched for now — but with no auto-animation it rarely bites.
- Cast-shadow cascades fade out past ~6 km and have a thin lit edge ring. The 3 frustum-fit
cascades (
shadow.ts) cover the whole view (sharp up close, coarse into the distance) and follow the camera anywhere on the globe via the floating origin. Honest, tunable residuals remain: (a) the far capSHADOW_MAX_DISTANCE ≈ 6 km, so a horizon look fades to unshadowed past it; (b) a thin ring at the outermost cascade's edge reads "lit" (its PCF inset has no farther cascade to fall through to); (c) zooming resizes the cascades, so the texel grid resizes transiently while the zoom is moving. Casters are drawn into all 3 cascades each frame (3× the depth fill); per-cascade caster culling is a later optimisation. This dynamic cast-shadow map is separate from the per-fragment globe day/night lighting, which already covers the whole view. - Night-window lattice can step across a tile border (accepted trade-off). The procedural lit-window grid is laid out from each tile's own centre (centre-relative coordinates, for f32 precision — see Notable details), so where two simultaneously-visible tiles meet, their grids can be out of phase and the window pattern steps across the shared edge. Only visible at night on a façade that spans a tile boundary. This is left as an honest limitation, not patched: the cheap "bake a per-tile integer cell offset" half-fix only aligns the hash phase, not the fractional grid lines, so it trades the visible step for a subtler one — a hack we decline. A clean fix needs the grid expressed in a single camera-local frame shared across tiles; the decoration content frame is explicitly placed, not camera-following (see the decorations limitation below), so it doesn't provide that shared frame. Revisit only if a genuine camera-following frame ever lands.
- The night-window grid re-orients slightly with the camera (no real surface normals). The
photogrammetry mesh carries no vertex normals, so the only way to know which way a facade faces
— to orient the window grid across it and to tell a wall from a roof — is the geometric normal
reconstructed from screen-space derivatives (
std.dpdx/dpdyof the surface position). That normal's ideal value is view-independent, but its precision collapses at grazing angles: tilt toward the horizon (or let a facade slide to the screen edge while panning) and the two derivatives go near-parallel, so the estimate turns noisy and the wall tangent wobbles — subtly re-shuffling which cells read as lit. Separately, the building-height gate can blink a whole facade on/off as its coarse gate-map re-renders while tiles stream and a near-threshold building crosses the cutoff. Both are inherent to a derivative-normal procedural effect with no real geometry to key off: a genuine fix needs per-fragment surface normals (a vertex attribute the tiles don't carry) or temporal stabilisation. The resolution-preserving gate map (its own 2048² texture rather than a wider-but-coarser one) damps the blink; the grazing-angle re-shuffle is left as an honest limitation. Same root cause as the?chromeeaster egg's faceted look — it leans on the identical derivative normal on purpose. - Selected-building footprints are derived, not real (an honest heuristic). Google's tiles
carry no per-building segmentation, so selection flood-fills the rooftop heightmap from the picked
point (
plugins/selection/footprint.ts) — which means buildings that physically touch (no street-level gap between them) merge into one blob, and bumpy photogrammetry roofs give somewhat ragged outlines (the Douglas–Peucker pass cleans the 1-texel stair-stepping but can't invent a crisp wall that isn't in the data). The same continuity gate cuts the other way on a steep/stepped structure (e.g. the Hamburg Fernsehturm): its tiers differ in height by far more thanFOOTPRINT_STEP_CONTINUITY, so the flood can't cross between them and each shelf selects as its own prism. Multi-select (Cmd/Ctrl-click) is the way to light up all the tiers at once. The thresholds (FOOTPRINT_MIN_BUILDING_HEIGHT,FOOTPRINT_STEP_CONTINUITY,FOOTPRINT_SIMPLIFY_M) are exposed as live-tunable constants inengine/constants.tsrather than hidden magic. Selection also only works within the ±500 m decoration content frame (which re-anchors under the camera, so the area you're looking at is covered) — a building near the screen edge on a grazing view can fall outside it and won't select. The depth-readback pick is async, so it lands a frame or two after the click (imperceptible). This is left as a documented limitation, not hacked around: a true footprint would need building-level segmentation the source data doesn't provide. - Tile build's residual main-thread cost is loaders.gl's parse. Our own per-tile build in
buildPrimitive(tiles.ts) is now tight: the per-vertex ECEF transform is an inlined 4×4·vec3 multiply (nomath.glVector3.transformper vertex), the f64 centre-finding scratch is a reused module-level buffer rather than a per-tile allocation, and indices are kept asUint16Arrayfor tiles whose vertex count fits in 16 bits (halving index memory, skipping the widening copy) and only widened toUint32Arraywhen needed. Decoded tiles are also not double-buffered —buildPrimitivereads loaders.gl's decoded arrays once andreleaseTileContentfrees their copy immediately (see Single GPU residency), so there's no redundant "copy" to remove. What remains on the main thread is loaders.gl's glTF post-process/parse — irreducible decode work (Draco already runs in a worker), bounded today only by request throttling (MAX_TILE_REQUESTS). Moving our own transform to a worker too would help only if that build step (not the parse) ever dominates; it doesn't today, so it's
