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.js) 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. |
decoder:* / encoder:* | GeoPBF format conversion (dropped files, exports). |
A region pack can add roles (Japan adds its statistics worker) through a build-time slot, 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 render worker 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) |
| a previous WebGPU start failed in this tab | WebGL2 |
| 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 run on both backends. Transition anti-aliasing: 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.
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.
?ell=1); 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][/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: 1ยฐ cells at three resolutions, 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.
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.
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' 3DBAG) | 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 or ?g= links |
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.