A map engine that draws everything on a sphere, in the browser, with no map server of its own
@ortho-earth/core · @ortho-earth/globe · WebGPU with a WebGL2 fallback · true-scale terrain · worker pipeline
ortho-map, the first engine (v1: d3 orthographic projection, one worker per layer, WebGL2 only).
v1 was retired in September 2026. Its ideas โ orthographic globe, OffscreenCanvas, GeoPBF/AltPBF/Gint โ carried over; the implementation did not.
The engine is split so that the lower a part sits, the harder it is: guarded by test gates, type definitions and versions. Apps on top are shells and may change freely. New work starts by naming the layer it belongs to.
createGlobe() โ boot, the world band, region-free gadgets, the SDK types, the gates
The rule that holds it together: core and globe never name a country.
A machine gate (verify:regionless) counts region words in globe's code and fails if the count grows.
The direction of imports is one-way โ japan โ globe โ core โ ephem and japan โ jp โ core; globe and jp never import each other.
They meet only through the declaration that the app passes in.
The main thread holds the camera, input and DOM. Everything that decodes, builds or draws runs in workers.
All workers start from one entry file (worker.ts) and are told their role by the Worker's name โ
so the bundler builds one worker graph, and shared code (the GeoPBF core, loaders.gl, the shaders) exists once, not once per worker.
draw {cam}| Role | What it does |
|---|---|
render | Owns the canvas. Draws globe, terrain, basemap scene, rasters, gint layers, labels and overlays in one frame with one camera. |
ortho:tile / ortho:scene | Tile fetch and build / scene merge (the basemap pipeline). |
mesh / meshdecoder | Streams large 3D meshes (buildings) โ decode, dedup, ground, LOD, persist to OPFS/IndexedDB. |
model | glTF/GLB, extrusions, 3D Tiles and I3S content; also sun-shadow and viewshed analysis. |
gintbake | Bakes gint layers (LOD ranks, edges) off the render thread. |
ortho:height | Decodes elevation tiles. |
rastertiles / imagequad | Serves raster tiles from local GeoPackage/MBTiles, or from an image pinned by four corners. |
columnar | Reads and packs column-chunk layers (GeoParquet, chunked GeoPBF) for map.addColumnar. |
vtdraw / vtextrude | MapLibre fill / line / circle / symbol layers and fill-extrusions on vector sources. |
iiif / copc | Georeferenced IIIF map images baked into tiles / COPC point clouds read by range. |
decoder:* / encoder:* / geopbf:* | GeoPBF format conversion (dropped files, exports). |
A region pack starts its own workers from its own entry (Japan's statistics worker, estat-worker.ts in @ortho-earth/jp), so the host never knows about them.
Cross-origin isolation is not required: with it, GeoPBF buffers travel to workers without copying (SharedArrayBuffer);
without it, they are copied once. Every feature works either way, and a gate (verify:nocoi) checks that.
The core has two renderers with the same face โ set, draw, dispose โ one on WebGPU, one on WebGL2.
The boot picks one at start:
| Condition | Backend |
|---|---|
?gl2=1 | WebGL2 (manual escape hatch) |
no navigator.gpu | WebGL2 directly |
| Android | WebGL2 (silent black-screen faults on some mobile GPU drivers) |
Safari / WebKit, on a page that declares glOverlay: true | WebGL2 (a WebGL2 overlay over WebGPU is not composited there) |
| a previous WebGPU start failed in this tab | WebGL2 (once: the next boot only; twice: for the rest of the tab session; ?gpu=1 retries) |
| otherwise | WebGPU |
WebGPU has three safety nets: an init failure falls back to WebGL2 inside the worker; a silent failure (initialised, but nothing reaches the screen) is caught by reading back the first frame; and if no frame arrives in 20 seconds the page restarts on WebGL2.
Two quality levers keep frames smooth. Transition anti-aliasing (WebGPU; WebGL2 fixes its anti-aliasing when the context is created): while the camera moves, frames are drawn at 1ร MSAA; once it has been still for about half a second, one 4ร frame is drawn. Dynamic resolution: when frames get slow, the canvas resolution steps down, and line widths stay constant on screen (both backends).
Text on the GPU (since 2.1): place names, road names, icons and road shields are baked into an SDF glyph atlas
and drawn by WebGPU in the same frame as the map. Seen from straight above, text is drawn at its style layer's position, so a style that
puts lines above its labels stacks as it does in MapLibre. On WebGL2 the engine falls back to Canvas2D text by itself; ?text=2d chooses it on WebGPU too.
The camera is a small plain object โ center, zoom, pitch, bearing โ and the projection is one 4ร4 matrix.
Orthographic and perspective are one continuum: orthographic is perspective with the eye at infinite distance, so the projection is a slider, not a wall.
ellipsoid option or ?ell=1 / ?ell=0. Measurements (distance, area) always use the ellipsoid.flyTo, easeTo, fitBounds) are plans evaluated in time; scene playback uses a virtual clock with a 35 ms step cap, so recordings on slow machines stay in sync.
The view lives in the URL โ #zoom/lat/lon[/45t][/-30r][/15roll][/l=โฆ][/c=theme][/t=UTC][/s=speed] โ so the address bar is always a link to what you see.
Elevation is drawn without exaggeration. The heights come from AltPBF: tiles of three spans (1ยฐ, 10ยฐ and 90ยฐ), decoded in a worker and packed into two elevation atlases โ a near atlas of fine cells around the camera, and a far atlas of coarse cells for the horizon. The shader reads one continuous height field from both.
raster-dem source (Terrarium, Mapbox, GSI encodings) can replace the built-in heights: map.setTerrain(โฆ).Below about z8, the globe draws the world on its own โ Natural Earth 10 m countries, lakes, rivers and maritime boundaries, and a hypsometric tint computed every frame from global elevation and a climate field, so no raster tiles are downloaded. That is honestly as far as world data can carry.
Above that, a region takes over from the zoom its declaration names (Japan: z6.5, with the GSI vector basemap).
Vector tiles go through the tile and scene workers; in tilted 3D views, a zoom-out keeps the finer tiles on screen while they still cover the ground, instead of swapping them for coarser ones.
Tile formats are plugins on one registry: MVT is built in, and MLT (MapLibre Tile) comes from @ortho-earth/tile-formats,
enabled at build time by aliasing #tile-formats to @ortho-earth/core/tileformats-mlt, which registers it โ the decoder loads on the first MLT tile, so a map that
never meets one pays nothing. A vector source says "encoding": "mlt" as in MapLibre; a PMTiles archive is recognised by its header.
Rasters โ XYZ, WMS, WMTS, PMTiles, local GeoPackage/MBTiles, or one image by four corners โ are first composited into a ground atlas,
and the terrain shader samples that atlas per pixel. That is why a raster follows the slope of a mountain instead of lying flat across it.
External MapLibre style.json files are converted into the same pipeline (map.setStyle).
Tiles are for drawing; Gint is for knowing. A Gint layer holds the whole geometry of a GeoPBF file on the GPU, so every feature can be picked, highlighted and joined to a table.
Gint's strengths cost a full pass before the first frame: shared arcs, ranks, degree anchors, a bake. For files you only want to look at,
map.addColumnar(src) takes the other road (#90):
GeoParquet row groups, or a GeoPBF cut into spatial chunks, are packed in a worker straight into GPU arrays (unit-sphere xyz relative to a chunk origin,
earcut triangles, 2โ3 grid-snapped LODs) and drawn by a same-frame overlay. Attributes stay columns; paint and filter (the Gint vocabulary) are evaluated
in the worker into the same fid table Gint uses, so recolouring never rebuilds geometry. A dropped .parquet always takes this road;
a .geopbf or .fgb does above columnarBytes (4 MB) โ unless the reader's first chunk looks like a choropleth with long shared arcs
(meta.share: admin1, administrative maps), which Gint draws better and keeps. The layer drapes on the terrain per vertex and is hidden by the scene depth;
shared borders are drawn twice, and editing hands the data back to Gint.
One mesh core streams every kind of 3D data. It does not know which country the buildings belong to โ the region only declares where the data is.
| Source | How it enters |
|---|---|
| City buildings (Japan's PLATEAU, the Netherlands' 3D Basisvoorziening (PDOK)) | per-district loads declared by the region pack |
| Any 3D Tiles tileset | map.add3DTiles(url) โ screen-space-error traversal, b3dm / i3dm / pnts / cmpt / glb |
| I3S scene layers | map.addI3S(url) โ decoded with loaders.gl |
| glTF / GLB models | dropped files, ?g= links or map.model(src, { at, heading, scale, slot }) โ several side by side (one per slot); the handle moves, turns, scales and lifts the model afterwards (setLngLat / setHeading / setScale / setAltitude, no re-bake: the batches get a rigid transform, grounding follows the new spot) and gizmo(true) shows drag handles for placing by hand โ a dropped model shows them at once |
The path is the same for all: decode (Draco included) โ Earth-centred coordinates โ the ortho sphere โ duplicate faces removed โ grounded on the terrain โ LOD โ relative-to-origin vertices โ a coverage mask that hides the basemap's own flat building footprints underneath. Decoded districts are kept in OPFS and IndexedDB in the form the GPU eats, so a second visit goes straight from disk to the GPU.
Below about z5 the globe shows the sky: real stars, planets and the Moon, constellations, the ecliptic and the celestial equator, and the night side. Zooming out further leads into the solar system at real scale.
All of it follows one clock (map.clock, from @ortho-earth/ephem โ the same clock the Solar System app uses).
It runs in real time by default, and can be paused, fast-forwarded, run backwards or set to any moment between 1800 and 2049.
The Sun's sub-solar point and sidereal time come from the same formulas in both apps, so the night side, the stars and the satellites always agree.
The clock is shared with the render worker as one anchor โ {sim, wall, rate} โ sent only when its state changes.
import { createGlobe } from "@ortho-earth/globe";
const map = await createGlobe({ target: "#map", view: "#3/20/140" });
map.flyTo({ center: [139.767, 35.681], zoom: 14, pitch: 60 });
| Area | Examples |
|---|---|
| Camera | jumpTo ยท easeTo ยท flyTo ยท fitBounds ยท cameraForBounds ยท setMaxBounds |
| Style and layers | setStyle ยท addSource / addLayer ยท setPaintProperty ยท setFilter ยท queryRenderedFeatures |
| 3D and terrain | add3DTiles ยท addI3S ยท setTerrain ยท extrusions ยท models |
| Analysis | sunShadow ยท viewshed ยท lineOfSight ยท profiles ยท measurement |
| Time | map.clock ยท map.on("time") |
| Extending | map.overlay(module) โ your own canvas drawn in the render worker in the same frame, with the same camera and api.time ยท addProtocol ยท transformRequest ยท Marker / Popup |
The shape of the API follows MapLibre where it can, so existing styles and habits carry over.
Everything above is covered by gates that boot pages through the published package itself โ what is tested is what you install.
Since 2.0 the libraries are written in strict TypeScript and ship their type definitions (compiled .js + .d.ts on npm); the 1.x compatibility layers are gone โ see MIGRATION.md.