diff --git a/dev-docs/coordinate-systems.md b/dev-docs/coordinate-systems.md index 9bccad8a..9c7095b9 100644 --- a/dev-docs/coordinate-systems.md +++ b/dev-docs/coordinate-systems.md @@ -44,8 +44,9 @@ controls how deck.gl interprets the position attribute on a layer. | `identity` (non-geospatial default) | pixel-space coords | `[0, 0, 0]` | For non-geospatial views; no Mercator math. | We use `coordinateSystem: 'cartesian'` for non-globe raster tiles and -set up a `modelMatrix` to convert EPSG:3857 meters → common space. We -do not currently support globe mode. +set up a `modelMatrix` to convert EPSG:3857 meters → common space. Globe +mode renders in `coordinateSystem: 'lnglat'` instead — see +[Globe view (prototype)](#globe-view-prototype) below. ## Common space and `WEB_MERCATOR_TO_WORLD_SCALE` @@ -336,8 +337,90 @@ dozens of tiles in flight — microseconds total per frame. Fragment work dominates by orders of magnitude. fp64 vertex math is essentially free for us. +## Globe view (prototype) + +Globe mode is supported as a prototype. It is reached via deck.gl's +`_GlobeView`, or — what the `cog-globe` example uses — MapLibre's +`projection: 'globe'` with deck.gl interleaved; both hand layers a +`_GlobeViewport`. We detect it with `viewport.resolution !== undefined` (and +`viewport instanceof _GlobeViewport` in the tile traversal). Full design and the +deferred work are in +[`dev-docs/specs/2026-05-21-globe-view-design.md`](specs/2026-05-21-globe-view-design.md). + +### Rendering: lnglat directly, no common-space mapping + +On a globe we render in `coordinateSystem: 'lnglat'` with mesh vertices in WGS84 +(via the tileset's `projectTo4326`). deck.gl's globe projection maps each +lng/lat vertex onto the sphere, so there is no projection distortion and no need +for the cartesian common-space `modelMatrix` mapping (that exists only as a +high-zoom *precision* workaround for mercator; globe is used at low/mid zoom). +The `MeshTextureLayer` vertex shader is collapsed to a single direct-projection +path so this works for both cartesian and lnglat — see the shader header note +and the `composeModelMatrix` discussion above. + +### Tile selection: two coordinate spaces + +`RasterTileNode` builds the per-tile bounding volume from reference points +(`REF_POINTS_11`) reprojected to WGS84, then keeps two things: + +- **Frustum-culling OBB** — each lng/lat point is projected onto the globe + sphere via `viewport.projectPosition`, giving an oriented bounding box in + *globe common space*. +- **`commonSpaceBounds`** — the same lng/lat points are converted with + `lngLatToWorld` to a *Web-Mercator-world* AABB. This stays comparable to the + dataset `bounds` pre-filter (also mercator-world) **and** is what the LOD reads + the latitude from (below). + +Globe and mercator volumes live in different common spaces, so the +`BoundingVolumeCache` (keyed only by `z/x/y`) is discarded and rebuilt when the +viewport's projection mode flips (in `RasterTileset2D.getTileIndices`). + +### LOD gotcha: read latitude from the mercator-world bounds, not the OBB + +Screen resolution is estimated as `getMetersPerPixel(lat, zoom) = C·cos(lat) / +2^(zoom+8)`. The `lat` must come from `commonSpaceBounds` (mercator world), **not** +the 3D OBB center: on a globe the OBB center is in globe common space (its `y` is +far outside the `[0, 512]` mercator world range), so `worldToLngLat` returns +~−89° near the Mercator singularity. `cos(−89°)` makes meters-per-pixel 10–270× +too small, `devicePixelsPerSourcePixel` is then always ≫ 1, and the traversal +recurses to the *finest* level everywhere — catastrophic when zoomed out. + +**Caveat (approximation):** using the tile's own center latitude in the mercator +`metersPerPixel` formula is exact at the view center but ignores globe *limb +foreshortening*, so tiles near the globe's edge fetch slightly more detail than +strictly needed (over-fetch, never under). A camera-aware screen-space-error +metric would be more accurate; deferred. + +### Z-fighting with the basemap: depthCompare + cull, not polygon offset + +In interleaved mode the raster mesh is coplanar with MapLibre's globe basemap +sphere and z-fights in the shared depth buffer. A depth bias / polygon offset +does **not** help with maplibre's globe depth encoding. The fix +(`MeshTextureLayer` `parameters`, set by `RasterLayer` only for globe): + +- `depthCompare: 'always'` — skip the depth comparison so the raster never + z-fights the basemap. +- `cullMode: 'back'` — without depth occlusion the far hemisphere would bleed + through, so back-face culling hides it. MapLibre globe uses a flipped + handedness; `'back'` is correct for *our* grid winding (`'front'` culls the + near, visible side). See + [visgl/deck.gl#9592](https://github.com/visgl/deck.gl/issues/9592). This is + tied to the maplibre-interleaved setup; a standalone `_GlobeView` may need the + opposite cull mode. + +### Anti-faceting mesh scaffold (throwaway) + +`RasterReprojector` subdivides on *reprojection* error (pixel-space), which is +~0 for an EPSG:4326 source → 2 triangles → flat chords that facet the sphere at +low zoom. As a stopgap, globe mode builds a uniform `N×N` grid per tile +([`globe-grid-mesh.ts`](../packages/deck.gl-raster/src/globe-grid-mesh.ts)) +instead of the adaptive mesh. The real fix — a curvature-aware reprojection +error metric — is deferred (see the spec). + ## See also +- [`dev-docs/specs/2026-05-21-globe-view-design.md`](specs/2026-05-21-globe-view-design.md) + — globe-view prototype design + deferred work - [`dev-docs/specs/2026-05-19-high-zoom-precision-design.md`](specs/2026-05-19-high-zoom-precision-design.md) — the high-zoom jitter fix using fp64 mesh attributes - [`dev-docs/texture-alignment.md`](texture-alignment.md) — diff --git a/dev-docs/specs/2026-05-21-globe-view-design.md b/dev-docs/specs/2026-05-21-globe-view-design.md new file mode 100644 index 00000000..c3267c55 --- /dev/null +++ b/dev-docs/specs/2026-05-21-globe-view-design.md @@ -0,0 +1,223 @@ +# Globe view: a rendering prototype + +- **Date:** 2026-05-21 +- **Issues:** none yet +- **Status:** Proposed +- **Related:** [`dev-docs/coordinate-systems.md`](../coordinate-systems.md) — projection/precision background + +## Context + +We want to render raster tiles on a 3D globe (deck.gl `GlobeView` / +MapLibre `projection="globe"`) instead of only the flat Web Mercator map. + +Exploration found the globe path is **~70% scaffolded but does not work**: + +- The render path already branches on globe + ([`raster-tile-layer.ts` `isGlobe`](../../packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts)), + setting `coordinateSystem: "lnglat"` and reprojecting tile vertices to + WGS84 via `descriptor.projectTo4326`. It has never been exercised. +- Tile selection **crashes**: `computeBoundingVolume` does + `assert(false, "TODO: implement getBoundingVolume in Globe view")` + ([`raster-tile-traversal.ts`](../../packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts)) + whenever a globe `project` function is supplied — i.e. immediately, in any + `GlobeView`. +- **No globe example exists.** Every example overlays deck.gl on MapLibre via + `MapboxOverlay` ([`deckgl-overlay.tsx`](../../examples/_shared/components/deckgl-overlay.tsx)), + riding MapLibre's mercator camera. + +deck.gl 9.3 [fully supports MapLibre's globe projection](https://deck.gl/docs/api-reference/mapbox/overview) +and keeps a `MapView` **or `GlobeView`** in sync with the map. So rendering +deck.gl layers **interleaved** over a `` should hand +our layers a `_GlobeViewport` (with `resolution` set) — exactly what both the +`isGlobe` render branch and the traversal's globe detector key off — and the +basemap comes for free from MapLibre. + +The intended outcome of *this* spec: a working globe prototype that exercises +the real code paths end-to-end, plus a clear, sequenced path to production. + +## Scope + +**In scope (this spec):** + +1. Implement the globe tile-selection bounding volume (clear the `assert`). +2. Make the lng/lat render path correct (a small, documented shader change). +3. A `cog-globe` example (and near-free `zarr-globe` sibling) over MapLibre + globe. +4. A clearly-throwaway anti-faceting scaffold so the prototype is legible. + +**Deliberately out of scope (own follow-up design):** + +- **Spherical reprojection correctness.** The reprojector + ([`packages/raster-reproject`](../../packages/raster-reproject/src/delatin.ts)) + was designed for **linear output spaces**; reprojecting onto a sphere is + nonlinear, and its pixel-space error metric is blind to faceting (see + "Faceting" below). Choosing the right error metric / mesh strategy for a + sphere deserves a dedicated brainstorm + spec. We render first so that + design can be validated against a live globe. +- **Cutline on globe** — disabled on globe for now + ([`cutline-bbox.ts`](../../packages/deck.gl-raster/src/gpu-modules/cutline-bbox.ts) + notes the limitation). Rather than build a `CutlineBboxGlobe` variant, we + expect to switch cutline handling to deck.gl's `ClipExtension` + ([#561](https://github.com/developmentseed/deck.gl-raster/issues/561)), which + would likely make a globe-specific cutline module unnecessary. +- Sphere-normal re-orientation / lighting — rasters are unlit, so this is moot. +- Fast bounding-volume paths for 3857 / UTM sources (the "Future Case 2/3" + TODOs in `computeBoundingVolume`). + +## Design + +### 1. Globe tile-selection bounding volume (the hard blocker) + +Replace the `assert(false)` globe branch in +[`RasterTileNode.computeBoundingVolume`](../../packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts) +with an implementation that mirrors the existing generic case +(`_getGenericBoundingVolume`), swapping the "reproject → EPSG:3857 → rescale to +common space" step for "reproject → WGS84 → project to the globe sphere": + +- Sample the tile's reference points (reuse `REF_POINTS_9` and the existing + reference-point sampling) in the source CRS. +- Reproject them to **WGS84 lng/lat** via `descriptor.projectTo4326` (instead + of `projectTo3857`). +- Map each `[lng, lat, z]` through the supplied `project` function (= + `viewport.projectPosition`, already threaded in for globe at + [`raster-tile-traversal.ts` ~L740](../../packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts)) + to obtain 3D positions in deck.gl's globe common space. +- Build the volume with `makeOrientedBoundingBoxFromPoints(...)` — already used + by the generic path. An **oriented** box (not axis-aligned) is required + because tiles project to non-axis-aligned volumes on the sphere. +- Define `commonSpaceBounds` for globe from the projected 3D points (it is a + coarse pre-filter; document the chosen semantics). + +This follows upstream deck.gl's globe tile-volume convention (the code already +notes "Only define `project` function for Globe viewports, same as upstream"). + +**Cache fix.** [`BoundingVolumeCache`](../../packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts) +is keyed by `z/x/y` only and explicitly assumes non-globe traversal. A globe +volume lives in a different common space than its mercator counterpart, so the +cache must not collide them: add a projection-mode discriminator to the key (or +invalidate the cache when the projection mode changes). + +### 2. Render path: lng/lat-direct + a documented shader unification + +Rendering on the globe should use lng/lat directly (`coordinateSystem: +"lnglat"`, mesh vertices = lng/lat from `projectTo4326`). deck.gl's globe +projection maps lng/lat → sphere exactly, so there is **no projection +distortion** and **no need** for the manual common-space mapping the mercator +path uses (that mapping exists only as a high-zoom *precision* workaround). + +The wrinkle: `MeshTextureLayer` extends `SimpleMeshLayer`, whose vertex shader +picks a branch via `shouldComposeModelMatrix(viewport, coordinateSystem)` — +`true` for `cartesian`, **`false` for `lnglat`**. The `false` branch +([`mesh-layer-vertex.glsl.ts`](../../packages/deck.gl-raster/src/mesh-layer/mesh-layer-vertex.glsl.ts)) +assumes the mesh is a small **meters-scale** model offset from an anchor and +runs `project_size(pos)` — which, applied to lng/lat **degrees**, is garbage +and would not land on the sphere. + +`MeshTextureLayer` always draws exactly **one** non-instanced, identity-transform +mesh at the origin ([`mesh-layer.ts`](../../packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts)), +so the instanced / model-orient branches never apply. **Unify the vertex +shader to a single, documented direct-projection path:** + +```glsl +gl_Position = project_position_to_clipspace(pos, positions64Low, vec3(0.0), position_commonspace); +``` + +This is exactly what the cartesian path already collapses to today (anchor = +`[0,0,0]`), and it makes the lnglat (globe) path correct identically. It also +keeps the per-vertex `positions64Low` in play, so the lng/lat path retains +**full fp64 precision** (sub-cm) — no precision penalty for rendering lng/lat +directly. Document the change in the shader header (extending the existing +upstream-override note) and in `dev-docs/coordinate-systems.md`. + +### 3. Faceting: a throwaway scaffold (not the real fix) + +A raster tile covers a lng/lat patch that is curved on the sphere; we draw flat +triangles whose faces (chords) sag below the true surface between vertices. At +low zoom a tile spans many degrees, so a coarse mesh visibly facets — the globe +looks like a cut gem. + +The current Delatin error metric cannot fix this. It measures *tangential* +reprojection error in pixel space (inverse-reproject the interpolated/chord +point and compare pixels). Faceting is a *radial* deviation, and the chord +point projects radially to the **same lng/lat** as the true point — so the +metric sees ~zero error. For a 4326 source the reprojection is the identity, so +Delatin emits the minimal 2 triangles → maximal faceting. + +Fixing this properly is the deferred reprojection design. For the prototype, +add a **clearly-marked, temporary** anti-faceting scaffold so culling, tile +loading, seams, and precision are all evaluable on a smooth-enough globe: + +- In globe mode only, build a **uniform grid mesh** per tile (e.g. an `N×N` + grid in pixel/UV space, mapped to lng/lat via the existing `forwardTransform` + + `forwardReproject`), bypassing Delatin's adaptive refinement. This keeps + the reprojector **untouched** (we are deferring reprojector changes) and is + trivially removable once the sphere-aware reprojection lands. +- Mark it unmistakably as throwaway in code comments, pointing to the future + reprojection spec. + +### 4. Examples: `cog-globe` (+ `zarr-globe`) + +New `examples/cog-globe/`, modeled on `cog-basic` / `land-cover`: + +- `` (react-map-gl / MapLibre) supplying the globe + basemap and controls. +- `DeckGlOverlay` with `interleaved` (required for globe alignment). +- A `COGLayer` pointed at a **global EPSG:4326 COG** (see open question on URL). + +`examples/zarr-globe/` is a near-free sibling reusing the global ECMWF Zarr +data and `ZarrLayer` from `dynamical-zarr-ecmwf`. The core work (bounding +volume + shader fix + scaffold) is shared; each example is a thin wrapper. + +## Files to touch + +- `packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts` — + implement the globe `computeBoundingVolume` case. +- `packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts` — + projection-aware cache key. +- `packages/deck.gl-raster/src/mesh-layer/mesh-layer-vertex.glsl.ts` (and + `mesh-layer.ts` if needed) — unify to the direct-projection path + docs. +- `packages/deck.gl-raster/src/raster-layer.ts` — globe-mode uniform-grid mesh + scaffold (gated on `isGlobe`). +- `examples/cog-globe/` (new), `examples/zarr-globe/` (new). +- `dev-docs/coordinate-systems.md` — document the globe path and shader change. + +## Verification + +1. `pnpm build` the affected packages. +2. `pnpm --filter cog-globe dev` (and `zarr-globe`) and load in a browser. +3. Confirm: + - No `assert` throw — globe tile selection runs. + - The mosaic drapes onto the globe and is geographically aligned with the + MapLibre basemap (interleaved). + - Panning / zooming culls correctly (tiles load and unload; off-screen and + back-of-globe tiles are not drawn). + - No tile-boundary seams. + - No jitter at the zooms globe view is actually used (fp64 path intact). + - Faceting is acceptable with the scaffold enabled. +4. Tests (vitest, matching existing patterns): globe `computeBoundingVolume` + produces a sane oriented box for a known tile; globe-mode tile selection + returns expected indices for a known viewport. + +## Open questions / risks + +- **Primary risk:** does MapLibre `projection="globe"` + `MapboxOverlay` + (`interleaved`) actually hand our layers a `_GlobeViewport` with `resolution` + set under deck.gl 9.3? The whole design assumes yes; confirm empirically + early (it gates everything). +- **Global 4326 COG URL** — needs a concrete, public, whole-globe EPSG:4326 + COG. Candidates: EOx Sentinel-2 cloudless, Natural Earth, Blue Marble; or host + one in the project's `ds-deck.gl-raster-public` S3 bucket. To confirm during + implementation. +- `commonSpaceBounds` semantics for the globe case (coarse pre-filter only). +- Exact scope of the `BoundingVolumeCache` key change. + +## Sequenced path to production + +1. **This spec** — rendering prototype (above). +2. **Spherical reprojection correctness** (own brainstorm + spec): the right + error metric / mesh strategy for a sphere; removes the scaffold. The + reprojector's linear-output-space assumption is revisited here. +3. Re-enable cutline on globe — likely by switching to deck.gl's + `ClipExtension` ([#561](https://github.com/developmentseed/deck.gl-raster/issues/561)) + rather than building a `CutlineBboxGlobe` module. +4. Tests for globe selection + a globe render check in CI. diff --git a/examples/cog-globe/README.md b/examples/cog-globe/README.md new file mode 100644 index 00000000..dd9af7d8 --- /dev/null +++ b/examples/cog-globe/README.md @@ -0,0 +1,11 @@ +# COGLayer Globe Example + +Renders a Cloud-Optimized GeoTIFF on a 3D globe using MapLibre's +`projection="globe"` with deck.gl interleaved rendering. + +```bash +pnpm install +pnpm build +cd examples/cog-globe +pnpm dev +``` diff --git a/examples/cog-globe/index.html b/examples/cog-globe/index.html new file mode 100644 index 00000000..f707d548 --- /dev/null +++ b/examples/cog-globe/index.html @@ -0,0 +1,22 @@ + + + + + + COGLayer Globe Example + + + +
+ + + diff --git a/examples/cog-globe/package.json b/examples/cog-globe/package.json new file mode 100644 index 00000000..24b1145e --- /dev/null +++ b/examples/cog-globe/package.json @@ -0,0 +1,35 @@ +{ + "name": "deck.gl-cog-globe-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "publish": "pnpm build && gh-pages -d dist -b gh-pages -e examples/cog-globe" + }, + "dependencies": { + "@chakra-ui/react": "^3.34.0", + "@deck.gl/core": "^9.3.2", + "@deck.gl/geo-layers": "^9.3.2", + "@deck.gl/layers": "^9.3.2", + "@deck.gl/mapbox": "^9.3.2", + "@deck.gl/mesh-layers": "^9.3.2", + "@developmentseed/deck.gl-geotiff": "workspace:^", + "@emotion/react": "^11.14.0", + "@luma.gl/core": "^9.3.2", + "deck.gl-raster-examples-shared": "workspace:*", + "maplibre-gl": "^5.24.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-map-gl": "^8.1.1" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "gh-pages": "^6.3.0", + "vite": "^8.0.0" + } +} diff --git a/examples/cog-globe/src/App.tsx b/examples/cog-globe/src/App.tsx new file mode 100644 index 00000000..e62fcfa4 --- /dev/null +++ b/examples/cog-globe/src/App.tsx @@ -0,0 +1,172 @@ +import { NativeSelect, Text } from "@chakra-ui/react"; +import { COGLayer } from "@developmentseed/deck.gl-geotiff"; +import type { DebugState } from "deck.gl-raster-examples-shared"; +import { + ControlPanel, + DebugControls, + DeckGlOverlay, + ExternalLink, + Field, +} from "deck.gl-raster-examples-shared"; +import "maplibre-gl/dist/maplibre-gl.css"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import { Map as MaplibreMap } from "react-map-gl/maplibre"; + +const COG_OPTIONS: { title: string; url: string; attribution?: ReactNode }[] = [ + { + title: "EOxCloudless 2020 RGB", + url: "https://s2downloads.eox.at/demo/EOxCloudless/2020/rgb_corrected_geodetic/3/0/0.tif", + attribution: ( + <> + + EOxCloudless - https://cloudless.eox.at + + {" (Contains modified Copernicus Sentinel data 2020)"} + + ), + }, + { + title: "Sentinel-2 True Color Image (New York, 2026)", + url: "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif", + }, + { + title: "New Zealand 2024-2025 10m RGB", + url: "https://nz-imagery.s3-ap-southeast-2.amazonaws.com/new-zealand/new-zealand_2024-2025_10m/rgb/2193/CC11.tiff", + }, + { + title: "NAIP Aerial (New York, 2022)", + url: "https://ds-wheels.s3.us-east-1.amazonaws.com/m_4007307_sw_18_060_20220803.tif", + }, + { + title: "NLCD Land Cover 2023", + url: "https://ds-wheels.s3.us-east-1.amazonaws.com/Annual_NLCD_LndCov_2023_CU_C1V0.tif", + }, + { + title: "Swisstopo National Map 1:1 million", + url: "https://data.geo.admin.ch/ch.swisstopo.pixelkarte-farbe-pk1000.noscale/swiss-map-raster1000_1000/swiss-map-raster1000_1000_krel_50_2056.tif", + }, + { + title: "Anderson Co. Ortho Pan 2ft (2000)", + url: "https://data.source.coop/giswqs/tn-imagery/imagery/AndersonCo_OrthoPan_2ft_2000.tif", + }, + { + title: "Umbra Port of Rotterdam (rotated COG)", + url: "http://umbra-open-data-catalog.s3.amazonaws.com/sar-data/tasks/Port%20of%20Rotterdam%2C%20Netherlands/00864c2c-0b0f-49ef-b283-997735b27878/2025-07-29-11-17-12_UMBRA-08/2025-07-29-11-17-12_UMBRA-08_GEC.tif", + attribution: ( + <> + Umbra Synthetic Aperture Radar (SAR) Open Data accessed from{" "} + + https://registry.opendata.aws/umbra-open-data + + . + + ), + }, + { + title: "USGS Topographic Map (Kanab Point, AZ, 1962, 1:62,500)", + url: "https://prd-tnm.s3.amazonaws.com/StagedProducts/Maps/HistoricalTopo/GeoTIFF/AZ/AZ_Kanab%20Point_314712_1962_62500_geo.tif", + attribution: ( + <> + + USGS Historical Topographic Map program + + . + + ), + }, +]; + +export default function App() { + const mapRef = useRef(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [debugState, setDebugState] = useState({ + debug: false, + debugOpacity: 0.25, + }); + + const selected = COG_OPTIONS[selectedIndex]; + + const cogLayer = new COGLayer({ + id: "cog-layer", + geotiff: selected.url, + debug: debugState.debug, + debugOpacity: debugState.debugOpacity, + onGeoTIFFLoad: (tiff, options) => { + (window as unknown as { tiff: unknown }).tiff = tiff; + const { west, south, east, north } = options.geographicBounds; + mapRef.current?.fitBounds( + [ + [west, south], + [east, north], + ], + { padding: 40, duration: 1000 }, + ); + }, + // On a globe the raster mesh is coplanar with MapLibre's basemap sphere and + // they share the interleaved depth buffer, which z-fights. A depth bias does + // not help with maplibre's globe depth encoding; instead skip the depth + // comparison and occlude the far hemisphere with back-face culling. The cull + // mode depends on the compositing setup — `back` for this MapLibre + // interleaved globe (a standalone deck.gl _GlobeView may need `front`), + // which is why the app sets it, not the library. See visgl/deck.gl#9592. + parameters: { depthCompare: "always", cullMode: "back" }, + // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps + // doesn't know about it. + beforeId: "boundary_country_outline", + }); + + return ( +
+ + + + + + + Renders{" "} + + Cloud-Optimized GeoTIFFs + {" "} + on a 3D globe (MapLibre globe projection, deck.gl interleaved). + + + + setSelectedIndex(Number(e.target.value))} + > + {COG_OPTIONS.map((opt, i) => ( + + ))} + + + + + {selected.attribution ? ( + + {selected.attribution} + + ) : null} + + +
+ ); +} diff --git a/examples/cog-globe/src/main.tsx b/examples/cog-globe/src/main.tsx new file mode 100644 index 00000000..171b36eb --- /dev/null +++ b/examples/cog-globe/src/main.tsx @@ -0,0 +1,12 @@ +import { ExampleProvider } from "deck.gl-raster-examples-shared"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.js"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/examples/cog-globe/src/vite-env.d.ts b/examples/cog-globe/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/cog-globe/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cog-globe/tsconfig.json b/examples/cog-globe/tsconfig.json new file mode 100644 index 00000000..4bd6962d --- /dev/null +++ b/examples/cog-globe/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src"] +} diff --git a/examples/cog-globe/vite.config.ts b/examples/cog-globe/vite.config.ts new file mode 100644 index 00000000..a06360b3 --- /dev/null +++ b/examples/cog-globe/vite.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + base: "/deck.gl-raster/examples/cog-globe/", + worker: { format: "es" }, + server: { + port: 3000, + }, +}); diff --git a/packages/deck.gl-raster/src/globe-grid-mesh.ts b/packages/deck.gl-raster/src/globe-grid-mesh.ts new file mode 100644 index 00000000..760a9a2b --- /dev/null +++ b/packages/deck.gl-raster/src/globe-grid-mesh.ts @@ -0,0 +1,84 @@ +import type { ReprojectionFns } from "@developmentseed/raster-reproject"; +import { splitFloat64Array } from "./fp64.js"; + +/** + * Default per-tile grid resolution for the globe scaffold. An `n × n` grid of + * cells (so `(n+1)²` vertices). 32 keeps low-zoom tiles smooth on the sphere + * while staying cheap (≈1089 verts / 2048 triangles per tile). + */ +export const GLOBE_GRID_SIZE = 32; + +/** + * THROWAWAY globe scaffold. Builds a uniform `gridSize × gridSize` triangle + * grid over a tile in UV space and reprojects each vertex through the same + * `forwardTransform` → `forwardReproject` chain {@link RasterReprojector} uses, + * producing output positions in the layer's output CRS (lng/lat in globe mode). + * + * Why this exists: the adaptive Delatin mesh subdivides on *reprojection* + * error, which is ~0 for an EPSG:4326 source, so it emits 2 triangles that + * chord straight through the sphere and visibly facet at low zoom. A uniform + * grid is a stopgap so the prototype is legible. It is NOT the real fix — + * remove it once sphere-aware reprojection lands. See + * `dev-docs/specs/2026-05-21-globe-view-design.md`. + * + * `width`/`height` match {@link RasterReprojector}'s convention (pass the + * image dimensions + 1); pixel coordinates span `[0, width-1] × [0, height-1]`. + */ +export function buildUniformGridMesh( + reprojectionFns: ReprojectionFns, + width: number, + height: number, + gridSize: number = GLOBE_GRID_SIZE, +): { + indices: Uint32Array; + positions64High: Float32Array; + positions64Low: Float32Array; + texCoords: Float32Array; +} { + const { forwardTransform, forwardReproject } = reprojectionFns; + const cols = gridSize; + const rows = gridSize; + const numVerts = (cols + 1) * (rows + 1); + + const positions = new Float64Array(numVerts * 3); + const texCoords = new Float32Array(numVerts * 2); + + let vi = 0; + for (let r = 0; r <= rows; r++) { + for (let c = 0; c <= cols; c++) { + const u = c / cols; + const v = r / rows; + const pixelX = u * (width - 1); + const pixelY = v * (height - 1); + const [inputX, inputY] = forwardTransform(pixelX, pixelY); + const [outX, outY] = forwardReproject(inputX, inputY); + positions[vi * 3] = outX; + positions[vi * 3 + 1] = outY; + positions[vi * 3 + 2] = 0; + texCoords[vi * 2] = u; + texCoords[vi * 2 + 1] = v; + vi++; + } + } + + const [positions64Low, positions64High] = splitFloat64Array(positions); + + const indices = new Uint32Array(cols * rows * 6); + let ii = 0; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const i0 = r * (cols + 1) + c; + const i1 = i0 + 1; + const i2 = i0 + (cols + 1); + const i3 = i2 + 1; + indices[ii++] = i0; + indices[ii++] = i2; + indices[ii++] = i1; + indices[ii++] = i1; + indices[ii++] = i2; + indices[ii++] = i3; + } + } + + return { indices, positions64High, positions64Low, texCoords }; +} diff --git a/packages/deck.gl-raster/src/mesh-layer/mesh-layer-vertex.glsl.ts b/packages/deck.gl-raster/src/mesh-layer/mesh-layer-vertex.glsl.ts index 92ae3761..e84daca9 100644 --- a/packages/deck.gl-raster/src/mesh-layer/mesh-layer-vertex.glsl.ts +++ b/packages/deck.gl-raster/src/mesh-layer/mesh-layer-vertex.glsl.ts @@ -7,11 +7,20 @@ // 1. Adds `in vec3 positions64Low;` — per-vertex low part of the // fp64-split mesh position. Supplied by MeshTextureLayer via // attributeManager.add (non-instanced). -// 2. In the composeModelMatrix branch, passes -// `positions64Low + instancePositions64Low` (instead of just -// `instancePositions64Low`) to project_position_to_clipspace, so the -// shader's existing fp64 path recovers the mesh-vertex precision lost -// by the float32 attribute pipeline. +// 2. Passes `positions64Low + instancePositions64Low` to +// project_position_to_clipspace, so the shader's fp64 path recovers the +// mesh-vertex precision lost by the float32 attribute pipeline. +// 3. Collapses upstream's `composeModelMatrix` branch to a single +// direct-projection path. MeshTextureLayer always draws ONE +// non-instanced, identity-transform mesh anchored at the origin +// (instancePositions = [0,0,0], identity instanceModelMatrix, sizeScale = +// 1), so the instanced / meters-offset (upstream's `else`) branch never +// applied. Projecting `pos` directly is correct for BOTH cartesian +// (common-space mesh, Web Mercator) and lnglat (degrees, GlobeView): +// project_position_to_clipspace handles each coordinate system. This is +// what makes GlobeView render correctly — upstream's `else` branch ran +// project_size(pos) on lng/lat degrees, which is meaningless. See +// dev-docs/specs/2026-05-21-globe-view-design.md. // // The fp64 correction is only valid when the per-instance transforms are // identity. MeshTextureLayer enforces that by fixing those props and omitting @@ -58,25 +67,20 @@ void main(void) { mat3 instanceModelMatrix = mat3(instanceModelMatrixCol0, instanceModelMatrixCol1, instanceModelMatrixCol2); vec3 pos = (instanceModelMatrix * positions) * simpleMesh.sizeScale + instanceTranslation; - if (simpleMesh.composeModelMatrix) { - DECKGL_FILTER_SIZE(pos, geometry); - // using instancePositions as world coordinates - // when using globe mode, this branch does not re-orient the model to align with the surface of the earth - // call project_normal before setting position to avoid rotation - normals_commonspace = project_normal(instanceModelMatrix * normals); - geometry.worldPosition += pos; + DECKGL_FILTER_SIZE(pos, geometry); + // Call project_normal before project_position so the normal isn't affected by + // a position offset (unused for unlit raster, kept for parity with upstream). + normals_commonspace = project_normal(instanceModelMatrix * normals); + geometry.worldPosition += pos; - // NOTE: this is the one line that changed to support fp64 emulation - gl_Position = project_position_to_clipspace(pos + instancePositions, positions64Low + instancePositions64Low, vec3(0.0), position_commonspace); - geometry.position = position_commonspace; - } - else { - pos = project_size(pos); - DECKGL_FILTER_SIZE(pos, geometry); - gl_Position = project_position_to_clipspace(instancePositions, instancePositions64Low, pos, position_commonspace); - geometry.position = position_commonspace; - normals_commonspace = project_normal(instanceModelMatrix * normals); - } + // No composeModelMatrix branch: that flag only matters when placing an + // instanced model offset from an anchor. MeshTextureLayer always draws one + // mesh at instancePositions = [0,0,0] with identity transforms, so we project + // the mesh vertex directly (with its fp64 low part). This is correct for both + // cartesian (common-space, Web Mercator) and lnglat (degrees, GlobeView) — + // project_position_to_clipspace handles each coordinate system. + gl_Position = project_position_to_clipspace(pos + instancePositions, positions64Low + instancePositions64Low, vec3(0.0), position_commonspace); + geometry.position = position_commonspace; geometry.normal = normals_commonspace; DECKGL_FILTER_GL_POSITION(gl_Position, geometry); diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts index 3080e0e3..448814ed 100644 --- a/packages/deck.gl-raster/src/raster-layer.ts +++ b/packages/deck.gl-raster/src/raster-layer.ts @@ -10,6 +10,7 @@ import { PolygonLayer } from "@deck.gl/layers"; import type { ReprojectionFns } from "@developmentseed/raster-reproject"; import { RasterReprojector } from "@developmentseed/raster-reproject"; import { splitFloat64Array } from "./fp64.js"; +import { buildUniformGridMesh } from "./globe-grid-mesh.js"; import type { RasterModule } from "./gpu-modules/types.js"; import { MeshTextureLayer } from "./mesh-layer/mesh-layer.js"; @@ -201,6 +202,31 @@ export class RasterLayer extends CompositeLayer { maxError = DEFAULT_MAX_ERROR, } = this.props; + // TEMPORARY GLOBE VIEW HACK: + // + // GlobeView (lnglat) uses viewport.resolution, the same detection as + // RasterTileLayer. THROWAWAY: globe renders a uniform grid instead of the + // adaptive mesh, because Delatin's reprojection-error metric is blind to + // sphere curvature and facets at low zoom. See globe-grid-mesh.ts and + // dev-docs/specs/2026-05-21-globe-view-design.md. + const isGlobe = this.context?.viewport?.resolution !== undefined; + if (isGlobe) { + const { indices, positions64High, positions64Low, texCoords } = + buildUniformGridMesh(reprojectionFns, width + 1, height + 1); + this.setState({ + reprojector: undefined, + mesh: { + indices: { value: indices, size: 1 }, + attributes: { + POSITION: { value: positions64High, size: 3 }, + TEXCOORD_0: { value: texCoords, size: 2 }, + }, + }, + positions64Low, + }); + return; + } + // The mesh is lined up with the upper and left edges of the raster. So if // we give the raster the same width and height as the number of pixels in // the image, it'll be omitting the last row and column of pixels. diff --git a/packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts b/packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts index ddbf1f1e..799d1200 100644 --- a/packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts +++ b/packages/deck.gl-raster/src/raster-tileset/bounding-volume-cache.ts @@ -40,10 +40,11 @@ const DEFAULT_MAX_ENTRIES = 65_536; * `(z, x, y, zRange)` for a given tileset descriptor, so it is safe to memoize * across `getTileIndices` calls (i.e. across animation frames). * - * NOTE: this assumes a non-Globe traversal (`project === null`) — the only kind - * `RasterTileNode.getBoundingVolume` currently supports. If Globe-view bounding - * volumes are ever implemented, the cache key will need a viewport/resolution - * component. + * The key is valid only within a single projection mode. A tile's bounding + * volume is computed in a different common space under a GlobeView than under + * Web Mercator, so the cache must be {@link BoundingVolumeCache.clear cleared} + * when the viewport's projection mode changes. `RasterTileset2D` owns the cache + * and does this in `getTileIndices` when it detects a globe↔mercator switch. */ export class BoundingVolumeCache { private entries = new Map(); @@ -83,6 +84,15 @@ export class BoundingVolumeCache { this.entries.set(key, entry); } + /** + * Drop all cached entries. Called by the owner when the viewport's projection + * mode changes (globe↔mercator), since volumes computed under one projection + * are not valid under the other. + */ + clear(): void { + this.entries.clear(); + } + /** * If the cache is over its soft cap, drop least-recently-used entries down to * roughly half of `maxEntries`. No-op when at or under the cap. Call once at diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts index c21cf3fe..fac63439 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts @@ -18,7 +18,7 @@ */ import type { Viewport } from "@deck.gl/core"; -import { _GlobeViewport, assert } from "@deck.gl/core"; +import { _GlobeViewport as GlobeViewport } from "@deck.gl/core"; import { transformBounds } from "@developmentseed/proj"; import { Vector3 } from "@math.gl/core"; import { @@ -97,6 +97,19 @@ const REF_POINTS_9 = REF_POINTS_5.concat([ [0.5, 1], // bottom edge ]); +// For the globe bounding volume: REF_POINTS_9 plus two more points on the +// horizontal centerline (11 points total). The sphere surface bulges most +// between samples along the widest span of a tile, so denser sampling there +// keeps the oriented bounding box from under-enclosing the tile (which would +// false-cull it). This matches upstream deck.gl's densest reference set, used +// there only for the coarsest (whole-world) zoom. We use it for every globe +// tile: a tile never spans more than the whole world, so 11 points always +// suffice, and per-tile cost is paid once thanks to the bounding-volume cache. +const REF_POINTS_11 = REF_POINTS_9.concat([ + [0.25, 0.5], + [0.75, 0.5], +]); + /** semi-major axis of the WGS84 ellipsoid * * EPSG:3857 also uses the WGS84 datum, so this is used for conversions from @@ -289,8 +302,10 @@ export class RasterTileNode { } // Get bounding volume for this tile (translated for frustum culling at - // non-zero worldOffset). - const { boundingVolume } = this.getBoundingVolume( + // non-zero worldOffset). `commonSpaceBounds` is the Web-Mercator-world AABB + // used for the LOD latitude (a worldOffset only shifts X, so latitude is + // unaffected). + const { boundingVolume, commonSpaceBounds } = this.getBoundingVolume( elevationBounds, project, boundingVolumeCache, @@ -328,8 +343,8 @@ export class RasterTileNode { // Only select this tile if no child is visible (prevents overlapping tiles) // "When pitch is low, force selection at maxZ." if (!this.childVisible && this.z >= minZ) { - const metersPerCSSPixel = getMetersPerPixelAtBoundingVolume( - boundingVolume, + const metersPerCSSPixel = getMetersPerPixelAtCommonSpaceBounds( + commonSpaceBounds, viewport.zoom, ); @@ -482,12 +497,10 @@ export class RasterTileNode { zRange: ZRange, project: ((xyz: number[]) => number[]) | null, ): { boundingVolume: OrientedBoundingBox; commonSpaceBounds: Bounds } { - // Case 1: Globe view - need to construct an oriented bounding box from - // reprojected sample points, but also using the `project` param + // Case 1: Globe view — reproject sample points to WGS84 and project them + // onto the globe sphere with the viewport's `project` function. if (project) { - assert(false, "TODO: implement getBoundingVolume in Globe view"); - // Reproject positions to wgs84 instead, then pass them into `project` - // return makeOrientedBoundingBoxFromPoints(refPointPositions); + return this._getGlobeBoundingVolume(project); } // (Future) Case 2: Web Mercator input image, can directly compute AABB in @@ -564,6 +577,59 @@ export class RasterTileNode { commonSpaceBounds, }; } + + /** + * Globe-view bounding volume: reproject the tile's reference points to WGS84, + * project them onto the globe sphere (`project` = `viewport.projectPosition`) + * to build the oriented bounding box used for frustum culling, and separately + * compute a Web-Mercator-world AABB for the `bounds` pre-filter in + * {@link update} (which compares against `wgs84Bounds` in mercator world). + * + * NOTE: elevation is not modeled on globe yet — reference points are sampled + * at the surface (z = 0). Flat rasters only. See + * `dev-docs/specs/2026-05-21-globe-view-design.md`. + */ + private _getGlobeBoundingVolume(project: (xyz: number[]) => number[]): { + boundingVolume: OrientedBoundingBox; + commonSpaceBounds: Bounds; + } { + const tileCorners = this.level.projectedTileCorners(this.x, this.y); + const refPointsWgs84 = sampleReferencePointsInWGS84( + REF_POINTS_11, + tileCorners, + this.descriptor.projectTo4326, + ); + + const refPointPositions: [number, number, number][] = []; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + for (const [lng, lat] of refPointsWgs84) { + const projected = project([lng, lat, 0]); + refPointPositions.push([projected[0]!, projected[1]!, projected[2]!]); + + const [worldX, worldY] = lngLatToWorld([lng, lat]); + if (worldX < minX) { + minX = worldX; + } + if (worldY < minY) { + minY = worldY; + } + if (worldX > maxX) { + maxX = worldX; + } + if (worldY > maxY) { + maxY = worldY; + } + } + + return { + boundingVolume: makeOrientedBoundingBoxFromPoints(refPointPositions), + commonSpaceBounds: [minX, minY, maxX, maxY], + }; + } } /** @@ -650,6 +716,36 @@ function sampleReferencePointsInEPSG3857( return refPointPositions; } +/** + * Sample the selected reference points in WGS84 lng/lat. + * + * Like {@link sampleReferencePointsInEPSG3857}, reference points are `[relX, + * relY]` fractions in `[0, 1]` bilinearly interpolated across the tile's four + * CRS corners, then reprojected to WGS84. Used by the GlobeView bounding-volume + * path, which projects lng/lat onto the sphere rather than rescaling 3857 + * meters into common space. + */ +function sampleReferencePointsInWGS84( + refPoints: [number, number][], + tileCorners: Corners, + projectTo4326: ProjectionFunction, +): [number, number][] { + const { topLeft, topRight, bottomLeft, bottomRight } = tileCorners; + const refPointPositions: [number, number][] = []; + for (const [relX, relY] of refPoints) { + const [geoX, geoY] = bilerpPoint( + topLeft, + topRight, + bottomLeft, + bottomRight, + relX, + relY, + ); + refPointPositions.push(projectTo4326(geoX, geoY)); + } + return refPointPositions; +} + /** * Rescale positions from EPSG:3857 into deck.gl's common space * @@ -812,7 +908,7 @@ export function getTileIndices( // Only define `project` function for Globe viewports, same as upstream const project: ((xyz: number[]) => number[]) | null = - viewport instanceof _GlobeViewport && viewport.resolution + viewport instanceof GlobeViewport && viewport.resolution ? viewport.projectPosition : null; @@ -954,11 +1050,19 @@ function getMetersPerPixel(latitude: number, zoom: number): number { ); } -function getMetersPerPixelAtBoundingVolume( - boundingVolume: OrientedBoundingBox, +function getMetersPerPixelAtCommonSpaceBounds( + commonSpaceBounds: Bounds, zoom: number, ): number { - const [_lng, lat] = worldToLngLat(boundingVolume.center); + const [minX, minY, maxX, maxY] = commonSpaceBounds; + // `commonSpaceBounds` is in Web Mercator world space ([0, 512]) in BOTH the + // mercator and globe paths (the globe path builds it via `lngLatToWorld`), so + // its center maps back to a real latitude. The 3D oriented-bounding-box + // center, by contrast, is in globe common space on a globe and would + // `worldToLngLat` to a garbage latitude (~-89°, near the Mercator + // singularity), making meters-per-pixel far too small so the LOD always + // recursed to the finest level. + const [, lat] = worldToLngLat([(minX + maxX) / 2, (minY + maxY) / 2]); return getMetersPerPixel(lat, zoom); } diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index b291f541..f0670091 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -7,6 +7,7 @@ */ import type { Viewport } from "@deck.gl/core"; +import { _GlobeViewport as GlobeViewport } from "@deck.gl/core"; import type { GeoBoundingBox, _Tileset2DProps as Tileset2DProps, @@ -144,6 +145,12 @@ export class RasterTileset2D extends Tileset2D { private boundingVolumeCache: BoundingVolumeCache; private projectPosition: ProjectionFunction; private unprojectPosition: ProjectionFunction; + /** + * Projection mode of the viewport on the previous `getTileIndices` call. + * `undefined` until the first call. Used to clear {@link boundingVolumeCache} + * on a globe↔mercator switch (volumes are not valid across projection modes). + */ + private lastViewportIsGlobe?: boolean; constructor( opts: Tileset2DProps, @@ -215,6 +222,22 @@ export class RasterTileset2D extends Tileset2D { }): TileIndex[] { const { viewport, minZoom } = opts; + // A tile's bounding volume is computed in a different common space under a + // GlobeView than under Web Mercator, but the cache key is only (z, x, y). + // When the viewport's projection mode flips, drop the stale volumes. This + // mirrors the `project` gate in the tile traversal. (See + // BoundingVolumeCache.) + const isGlobe = Boolean( + viewport instanceof GlobeViewport && viewport.resolution, + ); + if ( + this.lastViewportIsGlobe !== undefined && + this.lastViewportIsGlobe !== isGlobe + ) { + this.boundingVolumeCache.clear(); + } + this.lastViewportIsGlobe = isGlobe; + if (typeof minZoom === "number" && viewport.zoom < minZoom) { return []; } diff --git a/packages/deck.gl-raster/tests/globe-grid-mesh.test.ts b/packages/deck.gl-raster/tests/globe-grid-mesh.test.ts new file mode 100644 index 00000000..abeb95f3 --- /dev/null +++ b/packages/deck.gl-raster/tests/globe-grid-mesh.test.ts @@ -0,0 +1,49 @@ +import type { ReprojectionFns } from "@developmentseed/raster-reproject"; +import { describe, expect, it } from "vitest"; +import { buildUniformGridMesh } from "../src/globe-grid-mesh.js"; + +// Identity reprojection: output position == pixel coordinate, so values are +// easy to assert. +const identityFns: ReprojectionFns = { + forwardTransform: (x, y) => [x, y], + inverseTransform: (x, y) => [x, y], + forwardReproject: (x, y) => [x, y], + inverseReproject: (x, y) => [x, y], +}; + +describe("buildUniformGridMesh", () => { + it("produces an (n+1)^2 vertex grid with n*n*6 indices", () => { + const n = 4; + const { indices, positions64High, positions64Low, texCoords } = + buildUniformGridMesh(identityFns, 257, 257, n); + + const numVerts = (n + 1) * (n + 1); + expect(positions64High.length).toBe(numVerts * 3); + expect(positions64Low.length).toBe(numVerts * 3); + expect(texCoords.length).toBe(numVerts * 2); + expect(indices.length).toBe(n * n * 6); + }); + + it("places texCoords on the unit grid and positions via the reprojection chain", () => { + const n = 2; + const { positions64High, texCoords } = buildUniformGridMesh( + identityFns, + 257, + 257, + n, + ); + + // First vertex: u=0, v=0 → pixel (0,0) → identity → (0,0). + expect(texCoords[0]).toBeCloseTo(0); + expect(texCoords[1]).toBeCloseTo(0); + expect(positions64High[0]).toBeCloseTo(0); + expect(positions64High[1]).toBeCloseTo(0); + + // Last vertex: u=1, v=1 → pixel (256,256) → identity → (256,256). + const last = (n + 1) * (n + 1) - 1; + expect(texCoords[last * 2]).toBeCloseTo(1); + expect(texCoords[last * 2 + 1]).toBeCloseTo(1); + expect(positions64High[last * 3]).toBeCloseTo(256); + expect(positions64High[last * 3 + 1]).toBeCloseTo(256); + }); +}); diff --git a/packages/deck.gl-raster/tests/raster-tileset/bounding-volume-cache-globe.test.ts b/packages/deck.gl-raster/tests/raster-tileset/bounding-volume-cache-globe.test.ts new file mode 100644 index 00000000..4edf609f --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/bounding-volume-cache-globe.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { BoundingVolumeCacheEntry } from "../../src/raster-tileset/bounding-volume-cache.js"; +import { BoundingVolumeCache } from "../../src/raster-tileset/bounding-volume-cache.js"; + +function entry(tag: number): BoundingVolumeCacheEntry { + return { + zRange: [0, 0], + boundingVolume: { tag } as any, + commonSpaceBounds: [0, 0, 1, 1], + }; +} + +// Globe and mercator volumes for the same (z, x, y) live in different common +// spaces; the cache key is only (z, x, y), so the owner clears the cache on a +// projection-mode switch rather than namespacing the key. This exercises the +// clear() primitive that switch relies on. +describe("BoundingVolumeCache.clear", () => { + it("drops all entries", () => { + const cache = new BoundingVolumeCache(); + cache.set(0, 0, 0, entry(1)); + cache.set(1, 0, 0, entry(2)); + expect(cache.size).toBe(2); + + cache.clear(); + + expect(cache.size).toBe(0); + expect(cache.get(0, 0, 0)).toBeUndefined(); + expect(cache.get(1, 0, 0)).toBeUndefined(); + }); +}); diff --git a/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts b/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts new file mode 100644 index 00000000..c55ea8ad --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts @@ -0,0 +1,111 @@ +import { _GlobeViewport as GlobeViewport } from "@deck.gl/core"; +import { describe, expect, it } from "vitest"; +import { getTileIndices } from "../../src/raster-tileset/raster-tile-traversal.js"; +import type { + RasterTilesetDescriptor, + RasterTilesetLevel, +} from "../../src/raster-tileset/tileset-interface.js"; +import type { Bounds, Corners } from "../../src/raster-tileset/types.js"; + +const identity = (x: number, y: number): [number, number] => [x, y]; + +/** Single-tile level covering the lng/lat box [-10, -10, 10, 10]. */ +function makeLevel(metersPerPixel: number): RasterTilesetLevel { + const corners: Corners = { + topLeft: [-10, 10], + topRight: [10, 10], + bottomLeft: [-10, -10], + bottomRight: [10, -10], + }; + return { + matrixWidth: 1, + matrixHeight: 1, + tileWidth: 256, + tileHeight: 256, + metersPerPixel, + projectedTileCorners: () => corners, + tileTransform: () => { + throw new Error("not used"); + }, + crsBoundsToTileRange: () => ({ + minCol: 0, + maxCol: 0, + minRow: 0, + maxRow: 0, + }), + }; +} + +/** Descriptor whose source CRS is WGS84 (identity projections). */ +function makeDescriptor( + metersPerPixelByLevel: number[], +): RasterTilesetDescriptor { + return { + levels: metersPerPixelByLevel.map(makeLevel), + projectTo3857: identity, + projectTo4326: identity, + projectFrom3857: identity, + projectFrom4326: identity, + projectedBounds: [-10, -10, 10, 10], + }; +} + +function makeGlobeViewport(zoom = 1): GlobeViewport { + return new GlobeViewport({ + width: 800, + height: 600, + longitude: 0, + latitude: 0, + zoom, + resolution: 10, + }); +} + +function maxSelectedZ( + descriptor: RasterTilesetDescriptor, + zoom: number, + maxZ: number, +): number { + const indices = getTileIndices(descriptor, { + viewport: makeGlobeViewport(zoom), + maxZ, + zRange: null, + wgs84Bounds: [-10, -10, 10, 10] as Bounds, + pixelRatio: 1, + }); + return Math.max(...indices.map((i) => i.z)); +} + +describe("getTileIndices: GlobeView", () => { + it("selects tiles in a GlobeView without throwing", () => { + const descriptor = makeDescriptor([1.0, 0.4, 0.1]); + const viewport = makeGlobeViewport(); + const indices = getTileIndices(descriptor, { + viewport, + maxZ: 2, + zRange: null, + wgs84Bounds: [-10, -10, 10, 10] as Bounds, + pixelRatio: 1, + }); + expect(indices.length).toBeGreaterThan(0); + }); + + it("picks coarser levels when zoomed out (LOD tracks zoom, not the finest level)", () => { + // metersPerPixel per level, halving from coarse (z0) to fine (z5). At a low + // globe zoom the screen resolution is coarse, so a coarse level suffices; + // zooming in should select progressively finer levels. The bug drove the + // LOD latitude from the 3D OBB center (globe-common space → ~-89° garbage), + // making meters/px far too small so the traversal always recursed to maxZ. + const metersPerPixel = [78000, 39000, 19500, 9750, 4875, 2437]; + const maxZ = metersPerPixel.length - 1; // 5 + const descriptor = makeDescriptor(metersPerPixel); + + const zoomedOut = maxSelectedZ(descriptor, 1, maxZ); + const zoomedIn = maxSelectedZ(descriptor, 6, maxZ); + + // Zoomed out must NOT load the finest level across the globe. + expect(zoomedOut).toBeLessThan(maxZ); + // Zooming in selects strictly finer tiles. + expect(zoomedOut).toBeLessThan(zoomedIn); + }); +}); diff --git a/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-cache.test.ts b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-cache.test.ts index 386f61f7..b87768a7 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-cache.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-cache.test.ts @@ -1,4 +1,7 @@ -import { WebMercatorViewport } from "@deck.gl/core"; +import { + _GlobeViewport as GlobeViewport, + WebMercatorViewport, +} from "@deck.gl/core"; import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; import { describe, expect, it } from "vitest"; import { RasterTileset2D } from "../../src/raster-tileset/raster-tileset-2d.js"; @@ -67,6 +70,17 @@ function makeViewport(): WebMercatorViewport { }); } +function makeGlobeViewport(): GlobeViewport { + return new GlobeViewport({ + longitude: 0, + latitude: 0, + zoom: 1, + width: 100, + height: 100, + resolution: 10, + }); +} + function tilesetProps(): Tileset2DProps { return { getTileData: () => new Promise(() => {}) } as Tileset2DProps; } @@ -116,4 +130,31 @@ describe("RasterTileset2D bounding-volume cache", () => { expected, ); }); + + it("clears the cache when the viewport projection mode switches", () => { + const { descriptor, projectCallCount } = makeCountingDescriptor([ + 1.0, 0.4, 0.1, + ]); + const tileset = new RasterTileset2D(tilesetProps(), descriptor); + const mercator = makeViewport(); + const globe = makeGlobeViewport(); + + tileset.getTileIndices({ viewport: mercator, zRange: null }); + const afterFirst = projectCallCount(); + expect(afterFirst).toBeGreaterThan(0); + + // Second mercator call hits the cache — no new projectTo3857 calls. + tileset.getTileIndices({ viewport: mercator, zRange: null }); + expect(projectCallCount()).toBe(afterFirst); + + // Switching to a globe viewport clears the cache. The globe path projects + // to WGS84 (projectTo4326, uncounted), so the counter is unchanged here… + tileset.getTileIndices({ viewport: globe, zRange: null }); + expect(projectCallCount()).toBe(afterFirst); + + // …but the next mercator call must recompute from scratch (cache empty), + // doubling the projectTo3857 count. + tileset.getTileIndices({ viewport: mercator, zRange: null }); + expect(projectCallCount()).toBe(afterFirst * 2); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0095ea4..74eff44e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,67 @@ importers: specifier: ^8.0.0 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/cog-globe: + dependencies: + '@chakra-ui/react': + specifier: ^3.34.0 + version: 3.35.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@deck.gl/core': + specifier: ^9.3.1 + version: 9.3.2 + '@deck.gl/geo-layers': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@deck.gl/extensions@9.3.2(@deck.gl/core@9.3.2)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))))(@deck.gl/layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))))(@deck.gl/mesh-layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/gltf@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))) + '@deck.gl/layers': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))) + '@deck.gl/mapbox': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@luma.gl/core@9.3.3)(@math.gl/web-mercator@4.1.0) + '@deck.gl/mesh-layers': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/gltf@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)) + '@developmentseed/deck.gl-geotiff': + specifier: workspace:^ + version: link:../../packages/deck.gl-geotiff + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@luma.gl/core': + specifier: ^9.3.3 + version: 9.3.3 + deck.gl-raster-examples-shared: + specifier: workspace:* + version: link:../_shared + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-map-gl: + specifier: ^8.1.1 + version: 8.1.1(maplibre-gl@5.24.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 + vite: + specifier: ^8.0.0 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/dynamical-zarr-ecmwf: dependencies: '@chakra-ui/react':