From 3a9c86d2c2caddbd9297fc8e02da8b560ddfb7fa Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 12:39:02 -0400 Subject: [PATCH 01/13] docs: Add globe view rendering-prototype spec Scopes a working globe-view prototype (globe tile-selection bounding volume, lng/lat-direct render path via a shader unification, cog-globe and zarr-globe examples over MapLibre globe, throwaway anti-faceting scaffold) while deliberately carving out spherical-reprojection correctness as a follow-up design. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-21-globe-view-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 dev-docs/specs/2026-05-21-globe-view-design.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. From d4083821d93cab3c240b16202388d08bcdedfcff Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 13:09:29 -0400 Subject: [PATCH 02/13] feat: Implement globe-view tile-selection bounding volume Replaces the assert(false) globe stub in computeBoundingVolume with an oriented bounding box built from WGS84 reference points projected onto the globe sphere via viewport.projectPosition. Makes the bounding-volume cache key projection-aware so globe and mercator volumes don't collide. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../raster-tileset/bounding-volume-cache.ts | 35 ++++-- .../raster-tileset/raster-tile-traversal.ts | 108 ++++++++++++++++-- .../bounding-volume-cache-globe.test.ts | 26 +++++ .../raster-tileset/globe-traversal.test.ts | 77 +++++++++++++ 4 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 packages/deck.gl-raster/tests/raster-tileset/bounding-volume-cache-globe.test.ts create mode 100644 packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts 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..e4f52c25 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,10 @@ 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 cache key namespaces by projection mode (`globe`): a tile's bounding + * volume in a GlobeView is computed in a different common space than its Web + * Mercator counterpart, so globe and mercator entries for the same `(z, x, y)` + * are stored under distinct keys and never collide. */ export class BoundingVolumeCache { private entries = new Map(); @@ -63,9 +63,18 @@ export class BoundingVolumeCache { /** * Look up the cached bounding volume for tile `(z, x, y)`. On a hit the entry * is marked most-recently-used. Returns `undefined` on a miss. + * + * `globe` selects the projection-mode namespace: a tile's bounding volume in + * a GlobeView lives in a different common space than its Web Mercator + * counterpart, so the two must never collide in the cache. */ - get(z: number, x: number, y: number): BoundingVolumeCacheEntry | undefined { - const key = `${z}/${x}/${y}`; + get( + z: number, + x: number, + y: number, + globe = false, + ): BoundingVolumeCacheEntry | undefined { + const key = this.makeKey(z, x, y, globe); const entry = this.entries.get(key); if (entry === undefined) { return undefined; @@ -77,12 +86,22 @@ export class BoundingVolumeCache { } /** Store the bounding volume for tile `(z, x, y)` as most-recently-used. */ - set(z: number, x: number, y: number, entry: BoundingVolumeCacheEntry): void { - const key = `${z}/${x}/${y}`; + set( + z: number, + x: number, + y: number, + entry: BoundingVolumeCacheEntry, + globe = false, + ): void { + const key = this.makeKey(z, x, y, globe); this.entries.delete(key); this.entries.set(key, entry); } + private makeKey(z: number, x: number, y: number, globe: boolean): string { + return `${z}/${x}/${y}/${globe ? "g" : "m"}`; + } + /** * 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 98b4b01d..10bc8464 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 } from "@deck.gl/core"; import { transformBounds } from "@developmentseed/proj"; import type { OrientedBoundingBox } from "@math.gl/culling"; import { @@ -389,12 +389,23 @@ export class RasterTileNode { project: ((xyz: number[]) => number[]) | null, boundingVolumeCache: BoundingVolumeCache, ): { boundingVolume: OrientedBoundingBox; commonSpaceBounds: Bounds } { - const hit = boundingVolumeCache.get(this.z, this.x, this.y); + const hit = boundingVolumeCache.get( + this.z, + this.x, + this.y, + project !== null, + ); if (hit && hit.zRange[0] === zRange[0] && hit.zRange[1] === zRange[1]) { return hit; } const result = this.computeBoundingVolume(zRange, project); - boundingVolumeCache.set(this.z, this.x, this.y, { zRange, ...result }); + boundingVolumeCache.set( + this.z, + this.x, + this.y, + { zRange, ...result }, + project !== null, + ); return result; } @@ -409,12 +420,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 @@ -491,6 +500,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_9, + 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], + }; + } } /** @@ -577,6 +639,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 * 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..068f675f --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/bounding-volume-cache-globe.test.ts @@ -0,0 +1,26 @@ +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], + }; +} + +describe("BoundingVolumeCache: globe vs mercator keys", () => { + it("does not let a globe entry collide with a mercator entry at the same z/x/y", () => { + const cache = new BoundingVolumeCache(); + const mercator = entry(1); + const globe = entry(2); + + cache.set(0, 0, 0, mercator); // default globe = false + cache.set(0, 0, 0, globe, true); + + expect(cache.get(0, 0, 0)).toBe(mercator); + expect(cache.get(0, 0, 0, true)).toBe(globe); + expect(cache.size).toBe(2); + }); +}); 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..4a9b94e3 --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts @@ -0,0 +1,77 @@ +import { _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(): _GlobeViewport { + return new _GlobeViewport({ + width: 200, + height: 200, + longitude: 0, + latitude: 0, + zoom: 1, + resolution: 10, + }); +} + +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); + }); +}); From 181e8bbb955eee8fc2d1d10f37b378f5cb1e91fb Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 13:59:26 -0400 Subject: [PATCH 03/13] refactor: Address globe bounding-volume review feedback - Drop the projection-namespaced cache key; instead RasterTileset2D clears the BoundingVolumeCache when the viewport projection mode switches (globe<->mercator). Simpler z/x/y key; adds a clear() method. - Always use REF_POINTS_11 for the globe bounding volume. Our descriptor z is an internal overview index, not web-mercator zoom, so upstream's zoom-keyed point count doesn't transfer; a tile never spans more than the whole world, so 11 points always suffice and the cache makes the per-tile cost one-time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../raster-tileset/bounding-volume-cache.ts | 41 ++++++++----------- .../raster-tileset/raster-tile-traversal.ts | 30 +++++++------- .../src/raster-tileset/raster-tileset-2d.ts | 23 +++++++++++ .../bounding-volume-cache-globe.test.ts | 22 ++++++---- .../raster-tileset-2d-cache.test.ts | 40 +++++++++++++++++- 5 files changed, 107 insertions(+), 49 deletions(-) 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 e4f52c25..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). * - * The cache key namespaces by projection mode (`globe`): a tile's bounding - * volume in a GlobeView is computed in a different common space than its Web - * Mercator counterpart, so globe and mercator entries for the same `(z, x, y)` - * are stored under distinct keys and never collide. + * 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(); @@ -63,18 +64,9 @@ export class BoundingVolumeCache { /** * Look up the cached bounding volume for tile `(z, x, y)`. On a hit the entry * is marked most-recently-used. Returns `undefined` on a miss. - * - * `globe` selects the projection-mode namespace: a tile's bounding volume in - * a GlobeView lives in a different common space than its Web Mercator - * counterpart, so the two must never collide in the cache. */ - get( - z: number, - x: number, - y: number, - globe = false, - ): BoundingVolumeCacheEntry | undefined { - const key = this.makeKey(z, x, y, globe); + get(z: number, x: number, y: number): BoundingVolumeCacheEntry | undefined { + const key = `${z}/${x}/${y}`; const entry = this.entries.get(key); if (entry === undefined) { return undefined; @@ -86,20 +78,19 @@ export class BoundingVolumeCache { } /** Store the bounding volume for tile `(z, x, y)` as most-recently-used. */ - set( - z: number, - x: number, - y: number, - entry: BoundingVolumeCacheEntry, - globe = false, - ): void { - const key = this.makeKey(z, x, y, globe); + set(z: number, x: number, y: number, entry: BoundingVolumeCacheEntry): void { + const key = `${z}/${x}/${y}`; this.entries.delete(key); this.entries.set(key, entry); } - private makeKey(z: number, x: number, y: number, globe: boolean): string { - return `${z}/${x}/${y}/${globe ? "g" : "m"}`; + /** + * 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(); } /** 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 10bc8464..2cacfa64 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 @@ -89,6 +89,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 @@ -389,23 +402,12 @@ export class RasterTileNode { project: ((xyz: number[]) => number[]) | null, boundingVolumeCache: BoundingVolumeCache, ): { boundingVolume: OrientedBoundingBox; commonSpaceBounds: Bounds } { - const hit = boundingVolumeCache.get( - this.z, - this.x, - this.y, - project !== null, - ); + const hit = boundingVolumeCache.get(this.z, this.x, this.y); if (hit && hit.zRange[0] === zRange[0] && hit.zRange[1] === zRange[1]) { return hit; } const result = this.computeBoundingVolume(zRange, project); - boundingVolumeCache.set( - this.z, - this.x, - this.y, - { zRange, ...result }, - project !== null, - ); + boundingVolumeCache.set(this.z, this.x, this.y, { zRange, ...result }); return result; } @@ -518,7 +520,7 @@ export class RasterTileNode { } { const tileCorners = this.level.projectedTileCorners(this.x, this.y); const refPointsWgs84 = sampleReferencePointsInWGS84( - REF_POINTS_9, + REF_POINTS_11, tileCorners, this.descriptor.projectTo4326, ); 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..150fe7b1 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 } 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/raster-tileset/bounding-volume-cache-globe.test.ts b/packages/deck.gl-raster/tests/raster-tileset/bounding-volume-cache-globe.test.ts index 068f675f..4edf609f 100644 --- 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 @@ -10,17 +10,21 @@ function entry(tag: number): BoundingVolumeCacheEntry { }; } -describe("BoundingVolumeCache: globe vs mercator keys", () => { - it("does not let a globe entry collide with a mercator entry at the same z/x/y", () => { +// 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(); - const mercator = entry(1); - const globe = entry(2); + cache.set(0, 0, 0, entry(1)); + cache.set(1, 0, 0, entry(2)); + expect(cache.size).toBe(2); - cache.set(0, 0, 0, mercator); // default globe = false - cache.set(0, 0, 0, globe, true); + cache.clear(); - expect(cache.get(0, 0, 0)).toBe(mercator); - expect(cache.get(0, 0, 0, true)).toBe(globe); - expect(cache.size).toBe(2); + 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/raster-tileset-2d-cache.test.ts b/packages/deck.gl-raster/tests/raster-tileset/raster-tileset-2d-cache.test.ts index 386f61f7..ddb661e0 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,4 @@ -import { WebMercatorViewport } from "@deck.gl/core"; +import { _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 +67,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 +127,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); + }); }); From 81be3e1d047a1a8e50df64abd6b7604012c4d47c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 14:09:04 -0400 Subject: [PATCH 04/13] style: Alias _GlobeViewport import as GlobeViewport Follow the repo convention of aliasing deck.gl's underscore-prefixed exports (cf. _Tileset2D as Tileset2D). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/raster-tileset/raster-tile-traversal.ts | 4 ++-- .../src/raster-tileset/raster-tileset-2d.ts | 4 ++-- .../tests/raster-tileset/globe-traversal.test.ts | 6 +++--- .../tests/raster-tileset/raster-tileset-2d-cache.test.ts | 9 ++++++--- 4 files changed, 13 insertions(+), 10 deletions(-) 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 2cacfa64..cfa912bf 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 } from "@deck.gl/core"; +import { _GlobeViewport as GlobeViewport } from "@deck.gl/core"; import { transformBounds } from "@developmentseed/proj"; import type { OrientedBoundingBox } from "@math.gl/culling"; import { @@ -833,7 +833,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; 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 150fe7b1..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,7 +7,7 @@ */ import type { Viewport } from "@deck.gl/core"; -import { _GlobeViewport } from "@deck.gl/core"; +import { _GlobeViewport as GlobeViewport } from "@deck.gl/core"; import type { GeoBoundingBox, _Tileset2DProps as Tileset2DProps, @@ -228,7 +228,7 @@ export class RasterTileset2D extends Tileset2D { // mirrors the `project` gate in the tile traversal. (See // BoundingVolumeCache.) const isGlobe = Boolean( - viewport instanceof _GlobeViewport && viewport.resolution, + viewport instanceof GlobeViewport && viewport.resolution, ); if ( this.lastViewportIsGlobe !== undefined && 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 index 4a9b94e3..52c49176 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts @@ -1,4 +1,4 @@ -import { _GlobeViewport } from "@deck.gl/core"; +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 { @@ -50,8 +50,8 @@ function makeDescriptor( }; } -function makeGlobeViewport(): _GlobeViewport { - return new _GlobeViewport({ +function makeGlobeViewport(): GlobeViewport { + return new GlobeViewport({ width: 200, height: 200, longitude: 0, 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 ddb661e0..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 { _GlobeViewport, 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,8 +70,8 @@ function makeViewport(): WebMercatorViewport { }); } -function makeGlobeViewport(): _GlobeViewport { - return new _GlobeViewport({ +function makeGlobeViewport(): GlobeViewport { + return new GlobeViewport({ longitude: 0, latitude: 0, zoom: 1, From 037f178e8153117e1929a4016cf3a3a55a9220bb Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 14:24:09 -0400 Subject: [PATCH 05/13] feat: Unify MeshTextureLayer vertex shader to a single projection path Collapse SimpleMeshLayer's composeModelMatrix branch to one direct-projection path. MeshTextureLayer always draws one identity mesh at the origin, so projecting the mesh vertex directly is correct for both cartesian (Web Mercator) and lnglat (GlobeView). Fixes the globe path, which previously hit the meters-offset branch and ran project_size on lng/lat degrees. Behavior-preserving for mercator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mesh-layer/mesh-layer-vertex.glsl.ts | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) 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..78199438 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,18 @@ 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); - } + // Single direct-projection path (see header note #3). instancePositions and + // instancePositions64Low are [0,0,0] for MeshTextureLayer, so this projects + // the mesh vertex pos directly, carrying its fp64 low part. Correct for + // cartesian (common-space, Web Mercator) and lnglat (degrees, GlobeView). + 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); From 0f3691b6d9c8d0f480c2a98dab3f6bb16c96e2d0 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 14:24:09 -0400 Subject: [PATCH 06/13] feat: Unify MeshTextureLayer vertex shader to a single projection path Collapse SimpleMeshLayer's composeModelMatrix branch to one direct-projection path. MeshTextureLayer always draws one identity mesh at the origin, so projecting the mesh vertex directly is correct for both cartesian (Web Mercator) and lnglat (GlobeView). Fixes the globe path, which previously hit the meters-offset branch and ran project_size on lng/lat degrees. Behavior-preserving for mercator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mesh-layer/mesh-layer-vertex.glsl.ts | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) 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); From 6d1a814ee75d90f17ceaceecfa588af87ae2f2ed Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 15:32:43 -0400 Subject: [PATCH 07/13] move comment --- packages/deck.gl-raster/src/raster-layer.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts index 9398d994..448814ed 100644 --- a/packages/deck.gl-raster/src/raster-layer.ts +++ b/packages/deck.gl-raster/src/raster-layer.ts @@ -202,14 +202,8 @@ export class RasterLayer extends CompositeLayer { maxError = DEFAULT_MAX_ERROR, } = this.props; - // 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. + // TEMPORARY GLOBE VIEW HACK: // - // To account for this, we add 1 to both width and height when generating - // the mesh. This also solves obvious gaps in between neighboring tiles in - // the COGLayer. - // 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 @@ -233,6 +227,13 @@ export class RasterLayer extends CompositeLayer { 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. + // + // To account for this, we add 1 to both width and height when generating + // the mesh. This also solves obvious gaps in between neighboring tiles in + // the COGLayer. const reprojector = new RasterReprojector( reprojectionFns, width + 1, From 9fc5140b90b1a1f34585bd4efd520b77543c9a63 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 15:40:33 -0400 Subject: [PATCH 08/13] feat: Add cog-globe example (COG on a MapLibre globe) Standalone example rendering an EPSG:4326 COG on MapLibre's globe projection (projection="globe") with deck.gl interleaved. Exercises the globe tile-selection, lng/lat render path, and uniform-grid scaffold end-to-end. fitBounds frames the data wherever the COG sits. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/cog-globe/README.md | 11 +++++ examples/cog-globe/index.html | 22 ++++++++++ examples/cog-globe/package.json | 35 +++++++++++++++ examples/cog-globe/src/App.tsx | 65 ++++++++++++++++++++++++++++ examples/cog-globe/src/main.tsx | 12 +++++ examples/cog-globe/src/vite-env.d.ts | 1 + examples/cog-globe/tsconfig.json | 4 ++ examples/cog-globe/vite.config.ts | 11 +++++ pnpm-lock.yaml | 61 ++++++++++++++++++++++++++ 9 files changed, 222 insertions(+) create mode 100644 examples/cog-globe/README.md create mode 100644 examples/cog-globe/index.html create mode 100644 examples/cog-globe/package.json create mode 100644 examples/cog-globe/src/App.tsx create mode 100644 examples/cog-globe/src/main.tsx create mode 100644 examples/cog-globe/src/vite-env.d.ts create mode 100644 examples/cog-globe/tsconfig.json create mode 100644 examples/cog-globe/vite.config.ts diff --git a/examples/cog-globe/README.md b/examples/cog-globe/README.md new file mode 100644 index 00000000..9440b017 --- /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 --filter @developmentseed/deck.gl-raster build +pnpm --filter @developmentseed/deck.gl-geotiff build +pnpm --filter deck.gl-cog-globe-example 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..1f1551cc --- /dev/null +++ b/examples/cog-globe/src/App.tsx @@ -0,0 +1,65 @@ +import { Text } from "@chakra-ui/react"; +import { COGLayer } from "@developmentseed/deck.gl-geotiff"; +import { ControlPanel, DeckGlOverlay } from "deck.gl-raster-examples-shared"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { useRef } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import { Map as MaplibreMap } from "react-map-gl/maplibre"; + +// EPSG:4326 (geodetic) COG. Exercises the COGLayer path on the globe; the +// identity 4326→4326 reprojection is the strongest faceting stress test for the +// globe grid scaffold. Swap for any global 4326 COG. (The zarr-globe example +// provides genuinely whole-globe coverage.) +const COG_URL = + "https://s2downloads.eox.at/demo/EOxCloudless/2020/rgb_corrected_geodetic/3/0/0.tif"; + +export default function App() { + const mapRef = useRef(null); + + const cogLayer = new COGLayer({ + id: "cog-layer", + geotiff: COG_URL, + onGeoTIFFLoad: (_tiff, options) => { + const { west, south, east, north } = options.geographicBounds; + mapRef.current?.fitBounds( + [ + [west, south], + [east, north], + ], + { padding: 40, duration: 1000 }, + ); + }, + // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps + // doesn't know about it. + beforeId: "boundary_country_outline", + }); + + return ( +
+ + + + + + + Renders a Cloud-Optimized GeoTIFF on a 3D globe using MapLibre's globe + projection with deck.gl interleaved rendering. + + +
+ ); +} 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/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': From 86a0fd95f4aa48852dc529625ffb3bcbe65735b2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 16:11:38 -0400 Subject: [PATCH 09/13] fix: Resolve globe z-fighting with depthCompare + back-face culling In globe mode the raster mesh is coplanar with MapLibre's basemap sphere and z-fights in the shared interleaved depth buffer. A depth bias does not help with maplibre's globe depth encoding; instead skip depth comparison (depthCompare: "always") and occlude the far hemisphere with back-face culling (cullMode: "back" for this grid's winding). Approach per visgl/deck.gl#9592. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deck.gl-raster/src/raster-layer.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts index 448814ed..d8407e8c 100644 --- a/packages/deck.gl-raster/src/raster-layer.ts +++ b/packages/deck.gl-raster/src/raster-layer.ts @@ -330,6 +330,8 @@ export class RasterLayer extends CompositeLayer { return null; } + const isGlobe = this.context?.viewport?.resolution !== undefined; + const meshLayer = new MeshTextureLayer( this.getSubLayerProps({ id: "raster", @@ -340,6 +342,16 @@ export class RasterLayer extends CompositeLayer { mesh, // We give a white color to turn off color mixing with the texture. getColor: [255, 255, 255], + // On a globe the mesh is coplanar with MapLibre's basemap sphere and + // they share the interleaved depth buffer, which z-fights. A depth bias + // (polygon offset) does not help with maplibre's globe depth encoding; + // the fix (see visgl/deck.gl#9592) is to skip depth comparison entirely + // and instead occlude the far side of the globe with back-face culling. + // For this grid's winding, `back` culls the far hemisphere; `front` + // culls the near (visible) side instead. + ...(isGlobe + ? { parameters: { depthCompare: "always", cullMode: "back" } } + : {}), }), ); From 360ceeab240a715ca3860d7ee6c2649f27021d48 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 16:17:59 -0400 Subject: [PATCH 10/13] feat: Add COG source selector to cog-globe example Mirror cog-basic's source list + selector (and debug controls) so every COG source can be tested on the globe. fitBounds reframes on each switch. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/cog-globe/src/App.tsx | 125 +++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 13 deletions(-) diff --git a/examples/cog-globe/src/App.tsx b/examples/cog-globe/src/App.tsx index 1f1551cc..0f789766 100644 --- a/examples/cog-globe/src/App.tsx +++ b/examples/cog-globe/src/App.tsx @@ -1,25 +1,100 @@ -import { Text } from "@chakra-ui/react"; +import { NativeSelect, Text } from "@chakra-ui/react"; import { COGLayer } from "@developmentseed/deck.gl-geotiff"; -import { ControlPanel, DeckGlOverlay } from "deck.gl-raster-examples-shared"; +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 { useRef } from "react"; +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"; -// EPSG:4326 (geodetic) COG. Exercises the COGLayer path on the globe; the -// identity 4326→4326 reprojection is the strongest faceting stress test for the -// globe grid scaffold. Swap for any global 4326 COG. (The zarr-globe example -// provides genuinely whole-globe coverage.) -const COG_URL = - "https://s2downloads.eox.at/demo/EOxCloudless/2020/rgb_corrected_geodetic/3/0/0.tif"; +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: COG_URL, - onGeoTIFFLoad: (_tiff, options) => { + 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( [ @@ -56,9 +131,33 @@ export default function App() { sourcePath="examples/cog-globe" > - Renders a Cloud-Optimized GeoTIFF on a 3D globe using MapLibre's globe - projection with deck.gl interleaved rendering. + 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} + ); From 8bd5e5f841a1d689fa852263d5ea469cfc8f2b0f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 16:33:49 -0400 Subject: [PATCH 11/13] fix: Correct globe LOD by deriving latitude from common-space bounds The LOD meters-per-pixel read the latitude from the 3D oriented bounding box center, which on a globe is in globe common space (y far outside the Web Mercator world range), so worldToLngLat returned ~-89deg and cos(lat) made meters-per-pixel 10-270x too small. devicePixelsPerSource was then always >>1, so the traversal always recursed to the finest level -- loading the finest tiles across the whole globe when zoomed out. Drive the latitude from commonSpaceBounds instead, which is in Web Mercator world space in both the mercator and globe paths. Adds a regression test asserting LOD tracks zoom on a globe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../raster-tileset/raster-tile-traversal.ts | 18 +++++--- .../raster-tileset/globe-traversal.test.ts | 42 +++++++++++++++++-- 2 files changed, 51 insertions(+), 9 deletions(-) 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 cfa912bf..19240f72 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 @@ -307,8 +307,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, ); @@ -934,11 +934,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/tests/raster-tileset/globe-traversal.test.ts b/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts index 52c49176..c55ea8ad 100644 --- a/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts +++ b/packages/deck.gl-raster/tests/raster-tileset/globe-traversal.test.ts @@ -50,17 +50,32 @@ function makeDescriptor( }; } -function makeGlobeViewport(): GlobeViewport { +function makeGlobeViewport(zoom = 1): GlobeViewport { return new GlobeViewport({ - width: 200, - height: 200, + width: 800, + height: 600, longitude: 0, latitude: 0, - zoom: 1, + 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]); @@ -74,4 +89,23 @@ describe("getTileIndices: GlobeView", () => { }); 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); + }); }); From cb59a77607ad3e3e58410fdef4c559780ac998d5 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 21 May 2026 16:48:39 -0400 Subject: [PATCH 12/13] docs: Document the globe-view render path in coordinate-systems.md Adds a "Globe view (prototype)" section: lnglat-direct rendering, the two-coordinate-space tile selection, the LOD latitude gotcha (+ limb foreshortening caveat), the depthCompare/cullMode z-fighting recipe, and the throwaway anti-faceting grid. Links the globe-view spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- dev-docs/coordinate-systems.md | 87 +++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) 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) — From de718e01a59353c73e3dfd93011f9d3175267276 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 22 May 2026 11:49:29 -0400 Subject: [PATCH 13/13] refactor: Move globe z-fighting parameters from library to app Per review: the depthCompare/cullMode recipe for z-fighting against the basemap depends on the compositing context (MapLibre interleaved globe handedness vs a standalone _GlobeView), so it's an app decision, not a library one. RasterLayer no longer injects it; deck.gl forwards the `parameters` prop from COGLayer down to the mesh via getSubLayerProps, and the cog-globe example sets it. Also simplify the cog-globe README. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/cog-globe/README.md | 6 +++--- examples/cog-globe/src/App.tsx | 8 ++++++++ packages/deck.gl-raster/src/raster-layer.ts | 12 ------------ 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/examples/cog-globe/README.md b/examples/cog-globe/README.md index 9440b017..dd9af7d8 100644 --- a/examples/cog-globe/README.md +++ b/examples/cog-globe/README.md @@ -5,7 +5,7 @@ Renders a Cloud-Optimized GeoTIFF on a 3D globe using MapLibre's ```bash pnpm install -pnpm --filter @developmentseed/deck.gl-raster build -pnpm --filter @developmentseed/deck.gl-geotiff build -pnpm --filter deck.gl-cog-globe-example dev +pnpm build +cd examples/cog-globe +pnpm dev ``` diff --git a/examples/cog-globe/src/App.tsx b/examples/cog-globe/src/App.tsx index 0f789766..e62fcfa4 100644 --- a/examples/cog-globe/src/App.tsx +++ b/examples/cog-globe/src/App.tsx @@ -104,6 +104,14 @@ export default function App() { { 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", diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts index d8407e8c..448814ed 100644 --- a/packages/deck.gl-raster/src/raster-layer.ts +++ b/packages/deck.gl-raster/src/raster-layer.ts @@ -330,8 +330,6 @@ export class RasterLayer extends CompositeLayer { return null; } - const isGlobe = this.context?.viewport?.resolution !== undefined; - const meshLayer = new MeshTextureLayer( this.getSubLayerProps({ id: "raster", @@ -342,16 +340,6 @@ export class RasterLayer extends CompositeLayer { mesh, // We give a white color to turn off color mixing with the texture. getColor: [255, 255, 255], - // On a globe the mesh is coplanar with MapLibre's basemap sphere and - // they share the interleaved depth buffer, which z-fights. A depth bias - // (polygon offset) does not help with maplibre's globe depth encoding; - // the fix (see visgl/deck.gl#9592) is to skip depth comparison entirely - // and instead occlude the far side of the globe with back-face culling. - // For this grid's winding, `back` culls the far hemisphere; `front` - // culls the near (visible) side instead. - ...(isGlobe - ? { parameters: { depthCompare: "always", cullMode: "back" } } - : {}), }), );