npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.html2d/src/main.ts, documented in 2d/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 are GlobeRendererOptions/GlobeEventMap (camera-change/zoom/move/move-end/tiles/ anchor-change/pick/error).
  • Everything else is a GlobePlugin, registered constructor-time via options.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 a GlobePluginContext (the core's GPU root, camera, sun/shadow handles, ground-heightmap factory, and services like readDepth) and can hook init/update/recordScene/recordComposite/recordShadowCasters/onResize/ dispose, plus optionally contribute a TileShaderContribution — an emissive term folded directly into the composed tile fragment shader at create time (the windows plugin is the worked example; see COMPANION.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), a plugins property (constructor-time only, like the class API), and every core event re-dispatched as a globe-<type> CustomEvent.
  • Package layout: .GlobeRenderer + built-in plugins; ./element → the custom element (a separate entry point — importing it registers customElements.define, so it's opt-in). Ships as pre-TGSL-transformed ES modules (dist/lib/) + .d.ts (dist/types/) — consumers don't need unplugin-typegpu. typegpu/loaders.gl/math.gl/earcut are 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:

  1. Select — a small viewport carrying the camera's real perspective frustum + eye in ECEF (engine/selection.ts) is handed to loaders.gl's Tileset3D.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.
  2. 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.
  3. Heightmap — periodically the tiles are re-rendered top-down (orthographic) into an r32float texture 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).
  4. 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.
  5. Shadow — buildings and trees are rendered depth-only from the sun's direction into a cascaded depth32float shadow 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 a textureDepth2dArray. This covers the whole visible frustum (sharp up close, coarse into the distance) instead of a single box around the camera target.
  6. 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 depth32float depth 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.
  7. 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); computedTransform is 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 regular d.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 of TREE_CELL_SIZE-metre square cells tiles the ground, each cell holds TREES_PER_CELL trees 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-frame TreeGrid uniform. 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 past TREE_GATE_RADIUS or standing on coarse/unrefined ground (tile geometric error > TREE_DETAIL_MAX in 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 depth32float buffer with the near plane at clip-z 1 and far at 0 (with a greater test) 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 movable center on the surface from range metres 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 rotates center (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 at EARTH_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 term c = |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-4 resolves 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 as posNorm = 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 as frag_depth, so the shared reversed-Z greater test 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. ?noatmo disables it; ?noms A/Bs the multiple-scattering term. The math is heavily commented in atmosphere.ts and walked through in COMPANION.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 worldLocal matrix and geocentric up, folded in f64 on the CPU) are uploaded once per frame into the tileXforms storage 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 by instance_index via firstInstance), 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 into shadowCasterXforms (laid out per cascade with a MAX_TILES stride, indexed via firstInstance = 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 Matrix4 for the centre fold + one Float32Array matching the storage layout) and uploaded with a raw queue.writeBuffer — no per-tile Matrix4/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. Once tiles.ts has built+uploaded a tile, releaseTileContent nulls loaders.gl's content.gltf and .close()s its bitmaps, so only our GPU copy stays resident — roughly halving the tab's tile RAM. We deliberately do not call unloadContent() (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. The mem stat reports our bytes + the (now near-zero) still-resident decoded glTF; ev is 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 planes selection.ts feeds 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/total shows 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.ts wakes it and drives its density from camera.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_* in engine/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; see NIGHT_WINDOW_GATE_ALT_DEG in engine/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/surfaceHeightAt in plugins/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 own createHeightRegion call) — kept separate from the decoration heightmap so trees/rain keep their sharp ±500 m field, and sized to reach as far as detailFade can 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.ts runs 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) r32float height 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 async copyTextureToBuffer + mapAsync (a ring of three staging buffers), and the probe pushes it to camera.ts. The clamp there matches how you'd describe it: keep the eye at least CAMERA_MIN_GEOMETRY_DIST above 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 constraint range·cos(tilt) ≥ groundUnderEye + MIN_DIST is enforced per input, on the lever that input moves — never as a per-frame correction: zoom/pan floor range (raise it by (MIN_DIST − clearance)/cosTilt when the eye is too low — dollying out along eyeDir lifts the eye by cosTilt per metre of range), and tilt caps the pitch at acos((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 bump range while the camera sits idle — that was the load-time distance jump. Both levers are one-sided (only raise range / 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 within MIN_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, with MIN_DIST as the margin that absorbs it. CAMERA_PROBE_* are engine-internal calibration (engine/constants.ts); cameraMinGeometryDist is 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 cap SHADOW_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/dpdy of 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 ?chrome easter 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 than FOOTPRINT_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 in engine/constants.ts rather 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 (no math.gl Vector3.transform per vertex), the f64 centre-finding scratch is a reused module-level buffer rather than a per-tile allocation, and indices are kept as Uint16Array for tiles whose vertex count fits in 16 bits (halving index memory, skipping the widening copy) and only widened to Uint32Array when needed. Decoded tiles are also not double-buffered — buildPrimitive reads loaders.gl's decoded arrays once and releaseTileContent frees 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