Skip to content

Globe projection, exact map camera, underground camera and volumetric clouds story - #17

Merged
zamderax merged 7 commits into
mainfrom
claude/globe-camera-volumetric-clouds-70c55f
Sep 28, 2026
Merged

zamderax merged 7 commits into
mainfrom
claude/globe-camera-volumetric-clouds-70c55f

Conversation

@zamderax

@zamderax zamderax commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Globe projection support, an exact map camera, an opt-in underground camera for MapLibre, and a volumetric clouds story.

Library

Globe projection (MapLibre >= 5, Mapbox >= 3). In globe mode, custom layers get a different matrix than in Mercator. MapLibre hands over a unit-sphere matrix. Mapbox hands over a Mercator matrix plus a globe-to-Mercator matrix. The library multiplied either one by a Mercator model matrix, so on a MapLibre globe every object ended up small and clustered in the wrong place. src/core/projection.ts now builds the projection-view-model matrix for both projections. While the globe fades into Mercator (MapLibre: zoom 11–12, Mapbox: zoom 5–6), it blends the two matrices the same way the tiles do. <Canvas>, <Coordinates> and raycasting all go through it. In overlay mode, a depth-only globe occluder hides objects behind the planet; in-layer mode already gets that from MapLibre's depth buffer.

Exact camera. syncCamera now reads the camera straight from the map matrix:

  • camera.position is the real eye. Before, it was a point about 2× the near distance in front of it.
  • The orientation handles off-centre (padded) projections.
  • The projection matrix is a canonical perspective, so three.js' isPerspectiveMatrix checks work.
  • fov, aspect, near and far match the map. They used to be 75° and near: 0.

Anything that reads the camera now gets correct values: lighting, drei's Billboard/Html/calculateScaleFactor, postprocessing that linearises depth.

Fixes

  • Pick rays: they started at the centre of the near plane instead of at the pointer, so they missed the eye (9 m off with a 20 m near plane in the test). They also used offsetX, which is relative to whatever element was hit, e.g. a marker, popup or <Html> inside the map container.
  • In-layer pixel ratio: in-layer rendering now uses the map canvas' real pixel ratio. MapLibre caps its canvas (maxCanvasSize, 4096px by default) on large retina screens, where devicePixelRatio is wrong.
  • Mercator conversions: coordsToVector3 uses the closed-form Mercator northing, and vector3ToCoords is now its exact inverse. At London's latitude it used to drift about 10 m at 10 km and about 1 km at 100 km. averageMercatorScale stays exported but is exact and no longer grows a cache.
  • Small fixes: CanvasOverlay display name (Mapbox), ESLint root: true, and the two existing lint errors.

UndergroundCamera / allowUndergroundCamera (MapLibre only). MapLibre 5 accepts maxPitch up to 180°, but a private step (_elevateCameraIfInsideTerrain) pushes the camera back above ground on every camera change. This lifts it while mounted, with an optional minAltitude floor.

  • Restoring leaves anyone else's override alone.
  • On MapLibre versions without the method it warns and does nothing.
  • A canary test fails if MapLibre renames it.

Mapbox can't do this: it throws for pitch > 85° and clamps the camera above terrain.

Stories

  • Globe: pillars on six cities next to the map's own DOM markers, fly-to buttons, globe/Mercator switch.
  • Underground: illustrative tube tunnels under Bank junction with moving trains, a station shaft and a translucent street level. Buttons dive into the tunnels or look up at the map from below; a HUD shows the camera altitude.
  • Volumetric Clouds: ray-marched cloud layers with flight-sim style controls:
    • Up to 3 layers (low, mid, high), each with type (cumulus / stratocumulus / stratus), base, thickness, coverage, density, cell size, wispiness and wind.
    • Weather presets, sun elevation and azimuth.
    • Shadows cast on the map, with an on/off toggle and darkness.
    • Quality settings.
    • Rendering details:
      • noise is generated on the GPU (tileable Perlin–Worley)
      • lighting uses self-shadowing, multiple-scattering octaves, a dual-lobe phase function and the shade of higher layers
      • the march is jittered, accumulated over frames and reprojected with per-pixel cloud depth
    • Works in overlay and in-layer mode. About 120 fps on a 2560×1600 buffer at half cloud resolution (M-series Mac).

Testing

  • Unit tests: 80 passing (53 before). New tests cover the render-argument parsing for MapLibre 4/5 and Mapbox, the globe model matrices against MapLibre's getMatrixForModel and Mapbox's globe matrix, the transition blend, camera decomposition (off-centre, scaled, below ground, orthographic), pick rays, the Mercator round trip against <Canvas> space, and the underground helper.
  • tsc -p tsconfig.app.json, npm run build, npm run verify:pack (all 6 entry points) and a production storybook build pass. Lint has no errors, and the only new warning source was fixed.
  • Checked in the browser against MapLibre 5.9, overlay and in-layer, on the new stories and the existing sunlight, compass, postprocessing (N8AO), multi-coordinates and raycasting stories.

Notes for review

  • Mapbox wasn't checked visually. The token hard-coded in the stories returns 401. The Mapbox globe path is covered by unit tests against Mapbox's globe matrix code. VITE_MAPBOX_TOKEN needs a working token to try it. GitHub push protection also flags that token literal, so this PR drops it from story-map-storybook.tsx; Storybook's config still injects the same default.
  • Visible change: anything sized with drei's calculateScaleFactor (e.g. ScreenSizer in the sunlight story) now gets the size it asked for. Before, it was about 2.3× larger because of the default 75° fov.
  • Release: this PR includes the 1.1.0 version bump and CHANGELOG entry (changeset version), plus the README feature list and npm keywords. Merging it makes the Release workflow publish @wendylabsinc/react-three-map@1.1.0 to npm and create the GitHub release.

Custom layers get a unit-sphere matrix from MapLibre >= 5 and a Mercator
matrix plus a globe-to-Mercator matrix from Mapbox >= 3 when rendering a
globe, so 3D objects used to end up misplaced on it. Build the
projection-view-model matrix for each projection and blend them like the
tiles do while the globe fades into Mercator.

Derive the camera from that matrix exactly: the real eye position,
orientation and a canonical perspective projection with matching fov,
aspect, near and far, instead of a point in front of the eye and three.js
defaults. Hide what is behind the planet in overlay mode, where the map
depth buffer isn't available.
- Pointer rays started from the centre of the near plane instead of the
  pointer, missing the eye; and offsetX was relative to whatever element
  was hit, like a marker or a popup inside the map container.
- Render in-layer at the map canvas' actual pixel ratio, which differs
  from devicePixelRatio when the map caps its canvas size.
- coordsToVector3 uses the closed-form Mercator northing, and
  vector3ToCoords is now its exact inverse (it drifted ~10 m at 10 km).
MapLibre >= 5 accepts a maxPitch of up to 180 degrees but pushes the
camera back above the ground on every camera update. Lift that constraint
while mounted, with an optional floor, and show it in an Underground
story that flies through tube tunnels under London.
Ray marched cloud layers carved from GPU-generated Perlin-Worley noise,
lit with self shadowing, multiple scattering octaves and the shade of the
layers above, and accumulated over frames. Each layer is tuned like a
flight simulator weather panel, and the clouds cast shadows on the map.
Mark the ESLint config as root so it doesn't merge with configs further
up the tree, and fix the two lint errors.
@zamderax
zamderax merged commit aff381e into main Sep 28, 2026
1 check passed
@zamderax
zamderax deleted the claude/globe-camera-volumetric-clouds-70c55f branch September 28, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant