diff --git a/docs/api-reference/core/deck.md b/docs/api-reference/core/deck.md
index cd22019ee6b..c1677623e74 100644
--- a/docs/api-reference/core/deck.md
+++ b/docs/api-reference/core/deck.md
@@ -773,7 +773,7 @@ Parameters:
* `y` (number) - y position in pixels
* `radius` (number, optional) - radius of tolerance in pixels. Default `0`.
* `layerIds` (string[], optional) - a list of layer ids to query from. If not specified, then all pickable and visible layers are queried.
-* `depth` - Specifies the max number of objects to return. Default `10`.
+* `depth` - Specifies the max number of objects to return. Default `10`. For layers without explicit picking color buffers, only the default depth of 10 unique objects per layer is guaranteed; higher custom depths may return duplicate results for these layers.
* `unproject3D` (boolean, optional) - if `true`, `info.coordinate` will be a 3D point by unprojecting the `x, y` screen coordinates onto the picked geometry. Default `false`.
Returns:
@@ -783,6 +783,7 @@ Returns:
Notes:
* Deep picking is implemented as a sequence of simpler picking operations and can have a performance impact. Should this become a concern, you can use the `depth` parameter to limit the number of matches that can be returned, and thus the maximum number of picking operations.
+* Layers that provide explicit picking color buffers support buffer mutation between picking passes and are not subject to the default-depth unique-object guarantee.
#### `pickObjects` {#pickobjects}
diff --git a/docs/api-reference/core/globe-controller.md b/docs/api-reference/core/globe-controller.md
index 4cb2ad72ed5..5147033b054 100644
--- a/docs/api-reference/core/globe-controller.md
+++ b/docs/api-reference/core/globe-controller.md
@@ -38,9 +38,10 @@ new Deck({
Supports all [Controller options](./controller.md#options) with the following default behavior:
- `dragPan`: default `'pan'` (drag to pan)
-- `dragRotate`: not effective, this view does not currently support rotation
-- `touchRotate`: not effective, this view does not currently support rotation
+- `dragRotate`: shift+drag or right-click drag to change bearing and pitch
+- `touchRotate`: multi-touch rotate to change bearing
- `keyboard`: arrow keys to pan, +/- to zoom
+- `inertia`: when set to a number (milliseconds), the globe continues spinning after a fling gesture with exponential decay
- `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]`
## Custom GlobeController
diff --git a/docs/api-reference/core/globe-view.md b/docs/api-reference/core/globe-view.md
index 24a41fdf67a..b4ebf172a14 100644
--- a/docs/api-reference/core/globe-view.md
+++ b/docs/api-reference/core/globe-view.md
@@ -18,9 +18,7 @@ It's recommended that you read the [Views and Projections guide](../../developer
## Limitations
The goal of `GlobeView` is to provide a generic solution to rendering and navigating data in the 3D space.
-In the initial release, this class mainly addresses the need to render an overview of the entire globe. The following limitations apply, as features are still under development:
-- No support for rotation (`pitch` or `bearing`). The camera always points towards the center of the earth, with north up.
- No high-precision rendering at high zoom levels (> 12). Features at the city-block scale may not be rendered accurately.
- Only supports `'lnglat'` (the default value of the `coordinateSystem` prop).
- Known rendering issues when using multiple views mixing `GlobeView` and `MapView`, or switching between the two.
@@ -72,8 +70,14 @@ To render, `GlobeView` needs to be used together with a `viewState` with the fol
- `longitude` (number) - longitude at the viewport center
- `latitude` (number) - latitude at the viewport center
- `zoom` (number) - zoom level
+- `bearing` (number, optional) - bearing angle in degrees. Default `0` (north up).
+- `pitch` (number, optional) - pitch angle in degrees. `0` looks straight down at the earth. Default `0`.
- `maxZoom` (number, optional) - max zoom level. Default `20`.
- `minZoom` (number, optional) - min zoom level. Default `0`.
+- `maxPitch` (number, optional) - max pitch angle. Default `60`.
+- `minPitch` (number, optional) - min pitch angle. Default `0`.
+
+When `bearing` is `0` (the default), north is always kept pointing up and the globe behaves like a traditional desk globe — horizontal drag changes longitude, vertical drag changes latitude, and the polar axis stays fixed. When the user changes the bearing (via shift+drag or right-click drag), the globe enters free rotation mode where bearing evolves naturally to avoid orientation discontinuities near the poles.
## Controller
diff --git a/docs/api-reference/core/globe-viewport.md b/docs/api-reference/core/globe-viewport.md
index 69f29e1f4ec..7facaed184e 100644
--- a/docs/api-reference/core/globe-viewport.md
+++ b/docs/api-reference/core/globe-viewport.md
@@ -1,6 +1,6 @@
# GlobeViewport (Experimental)
-The `GlobeViewport` class takes globe view states (`latitude`, `longitude`, and `zoom`), and performs projections between world and screen coordinates. It is a helper class for visualizing the earth as a 3D globe.
+The `GlobeViewport` class takes globe view states (`latitude`, `longitude`, `zoom`, `bearing`, and `pitch`), and performs projections between world and screen coordinates. It is a helper class for visualizing the earth as a 3D globe.
## Usage
@@ -25,7 +25,7 @@ viewport.project([-122.45, 37.78]);
## Constructor
```js
-new GlobeViewport({width, height, longitude, latitude, zoom});
+new GlobeViewport({width, height, longitude, latitude, zoom, bearing, pitch});
```
Parameters:
@@ -40,11 +40,13 @@ Parameters:
+ `latitude` (number, optional) - Latitude of the viewport center on map. Default to `0`.
+ `longitude` (number, optional) - Longitude of the viewport center on map. Default to `0`.
+ `zoom` (number, optional) - Map zoom (scale is calculated as `2^zoom`). Default to `11`.
+ + `bearing` (number, optional) - Bearing angle in degrees. Default to `0`.
+ + `pitch` (number, optional) - Pitch angle in degrees. Default to `0`.
+ `altitude` (number, optional) - Altitude of camera, 1 unit equals to the height of the viewport. Default to `1.5`.
projection matrix arguments:
- + `nearZMultiplier` (number, optional) - Scaler for the near plane, 1 unit equals to the height of the viewport. Default to `0.1`.
+ + `nearZMultiplier` (number, optional) - Scaler for the near plane, 1 unit equals to the height of the viewport. Default to `0.01`.
+ `farZMultiplier` (number, optional) - Scaler for the far plane, 1 unit equals to the distance from the camera to the top edge of the screen. Default to `1`.
Remarks:
diff --git a/docs/developer-guide/custom-layers/attribute-management.md b/docs/developer-guide/custom-layers/attribute-management.md
index 6a5f045fe71..300b4dfcd67 100644
--- a/docs/developer-guide/custom-layers/attribute-management.md
+++ b/docs/developer-guide/custom-layers/attribute-management.md
@@ -47,7 +47,7 @@ While most apps rely on their layers to automatically generate appropriate GPU b
While this allows for ultimate performance and control of updates, as well as potential sharing of buffers between layers, the application will need to generate attributes in exactly the format that the layer shaders expect, creating a strong coupling between the application and the layer.
-**Note:** The application can provide some buffers and let others be managed by the layer. As an example management of the `instancePickingColors` buffer is normally left to the layer.
+**Note:** The application can provide some buffers and let others be managed by the layer. Explicit picking color buffers are only needed when the logical picking id differs from the rendered instance id.
## More information
diff --git a/docs/developer-guide/custom-layers/picking.md b/docs/developer-guide/custom-layers/picking.md
index 820fcf6846b..f423cbffc97 100644
--- a/docs/developer-guide/custom-layers/picking.md
+++ b/docs/developer-guide/custom-layers/picking.md
@@ -49,7 +49,17 @@ When other gestures (click, drag, etc.) are detected, deck.gl does not repeat pi
special support is provided for the built-in "picking color" based picking
system, which most layers use.
-To take full control of picking, a layer need to take the following steps:
+The following sections describe common ways to implement custom picking.
+
+### Default Instanced Picking
+
+Instanced layer shaders can derive picking colors from the built-in instance id when each rendered instance maps to one picked object. In GLSL, use `picking_setPickingColorFromInstanceID()` or assign `geometry.pickingColor = picking_getPickingColorFromInstanceID()`. In WGSL, add `@builtin(instance_index)` to the vertex inputs and use `picking_getPickingColorFromIndex(instanceIndex)`.
+
+Add an explicit picking color attribute only when the logical picking id within the current layer is different from the rendered instance id. For example:
+
+* Binary GeoJSON or MVT point sublayers may render local point instances while picking should return a global feature index.
+
+* `PathLayer` tessellates one path into multiple rendered segment or joint instances, so its generated geometry needs explicit picking colors that map back to the source path index instead of each rendered segment's instance id.
### Creating A Picking Color Attribute
diff --git a/docs/developer-guide/custom-layers/primitive-layers.md b/docs/developer-guide/custom-layers/primitive-layers.md
index 0d73c8013aa..49c7866b2a9 100644
--- a/docs/developer-guide/custom-layers/primitive-layers.md
+++ b/docs/developer-guide/custom-layers/primitive-layers.md
@@ -149,6 +149,4 @@ By always using the following shader functions for handling projections and scal
If your layer is instanced (`data` prop is an array and each element is rendered as one primitive), then you may take advantage of the default implementation of the [layer picking methods](../../api-reference/core/layer.md#layer-picking-methods).
-By default, each layer creates an `instancePickingColors` attribute and automatically calculates it using the length of the `data` array.
-
-For custom picking, read about [Implementing Custom Picking](./picking.md#implementing-custom-picking).
+By default, instanced layer shaders can derive picking colors from the built-in instance id. Add an explicit picking color attribute only when the logical picking id within the current layer is different from the rendered instance id. See [Implementing Custom Picking](./picking.md#implementing-custom-picking) for custom picking details and examples.
diff --git a/docs/developer-guide/custom-layers/subclassed-layers.md b/docs/developer-guide/custom-layers/subclassed-layers.md
index c5d5738359a..f4ef3f8051d 100644
--- a/docs/developer-guide/custom-layers/subclassed-layers.md
+++ b/docs/developer-guide/custom-layers/subclassed-layers.md
@@ -206,7 +206,6 @@ attribute vec3 instanceNormals;
attribute vec4 instanceColors;
attribute vec3 instancePositions;
attribute vec3 instancePositions64Low;
-attribute vec3 instancePickingColors;
/* New attribute */
attribute flat instanceRadiusPixels;
@@ -228,7 +227,7 @@ void main(void) {
vColor = vec4(lightColor, instanceColors.a * opacity) / 255.0;
- picking_setPickingColor(instancePickingColors);
+ picking_setPickingColorFromInstanceID();
}
`;
```
diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md
index b166add954b..f6c1b93b500 100644
--- a/docs/upgrade-guide.md
+++ b/docs/upgrade-guide.md
@@ -1,5 +1,20 @@
# Upgrade Guide
+## Upgrading to v9.4
+
+#### `pickMultipleObjects()` pick depth limits
+
+For layers that use built-in shader instance ids instead of explicit picking color buffers, `pickMultipleObjects()` now only guarantees the default `depth` of 10 unique objects per layer. Applications that call `pickMultipleObjects()` with a custom `depth` above the default may receive duplicate results for these layers. Layers with explicit picking color buffers keep their previous buffer-mutation behavior.
+
+### `instancePickingColors` attribute is no longer automatically generated
+
+Most built-in and custom instanced layers now derive picking colors from built-in shader instance ids, meaning the default `instancePickingColors` attribute is no longer automatically generated by deck.gl.
+
+In rare cases, custom WebGL layer shaders may need an update if they explicitly read the `instancePickingColors` attribute.
+
+- In such cases, use the new picking shader helper functions to derive the color from the instance id, for example `picking_setPickingColorFromInstanceID()` in GLSL or `picking_getPickingColorFromIndex(instanceIndex)` in WGSL.
+- However, if the logical picking id is different from the rendered instance id, layers can still continue to register and populate an explicit picking color attribute as before.
+
## Upgrading to v9.3
Upgraded dependencies to [luma.gl v9.3](https://luma.gl/docs/upgrade-guide) and [loaders.gl v4.4](https://loaders.gl/docs/upgrade-guide). Your app may be affected if it contains custom layers.
diff --git a/docs/whats-new.md b/docs/whats-new.md
index 7e845a7356e..2da341753ff 100644
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -8,6 +8,10 @@ This page contains highlights of each deck.gl release. Also check our [vis.gl bl
- Views now support a `parameters` prop for per-view GPU draw state overrides. `GlobeView` uses this to enable back-face culling by default, and applications can override it with `new GlobeView({parameters: {cullMode: 'none'}})`.
+### Performance
+
+- Picking in most instanced layers no longer allocates an `instancePickingColors` attribute buffer, instead using shader builtins `instance_index` / `gl_InstanceID`, reducing memory usage and initialization times.
+
## deck.gl v9.3
Release date: April 13, 2026
diff --git a/examples/website/google-3d-tiles/app.jsx b/examples/website/google-3d-tiles/app.jsx
index 307d1cc7c2e..ae785151d61 100644
--- a/examples/website/google-3d-tiles/app.jsx
+++ b/examples/website/google-3d-tiles/app.jsx
@@ -2,11 +2,11 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
-import React, {useState} from 'react';
+import React, {useState, useMemo} from 'react';
import {scaleLinear} from 'd3-scale';
import {createRoot} from 'react-dom/client';
import {DeckGL} from '@deck.gl/react';
-import {TerrainController} from '@deck.gl/core';
+import {MapView, _GlobeView as GlobeView, TerrainController} from '@deck.gl/core';
import {GeoJsonLayer} from '@deck.gl/layers';
import {Tile3DLayer} from '@deck.gl/geo-layers';
import {DataFilterExtension, _TerrainExtension as TerrainExtension} from '@deck.gl/extensions';
@@ -28,10 +28,10 @@ const INITIAL_VIEW_STATE = {
latitude: 50.089,
longitude: 14.42,
zoom: 16,
- minZoom: 14,
- maxZoom: 18,
- bearing: 90,
- pitch: 60
+ minZoom: 0,
+ maxZoom: 24,
+ bearing: 0,
+ pitch: 0
};
const BUILDING_DATA =
@@ -50,6 +50,8 @@ function getTooltip({object}) {
export default function App({data = TILESET_URL, distance = 0, opacity = 0.2}) {
const [credits, setCredits] = useState('');
+ const [useGlobe, setUseGlobe] = useState(false);
+
const onTraversalComplete = selectedTiles => {
const uniqueCredits = new Set();
selectedTiles.forEach(tile => {
@@ -90,15 +92,42 @@ export default function App({data = TILESET_URL, distance = 0, opacity = 0.2}) {
})
];
+ const view = useMemo(
+ () =>
+ useGlobe
+ ? new GlobeView({id: 'view', controller: true})
+ : new MapView({
+ id: 'view',
+ controller: {type: TerrainController, touchRotate: true, inertia: 500}
+ }),
+ [useGlobe]
+ );
+
return (
+
diff --git a/modules/aggregation-layers/src/common/aggregation-layer.ts b/modules/aggregation-layers/src/common/aggregation-layer.ts
index 9d4ab4f4ade..8eb07ab6d45 100644
--- a/modules/aggregation-layers/src/common/aggregation-layer.ts
+++ b/modules/aggregation-layers/src/common/aggregation-layer.ts
@@ -39,9 +39,7 @@ export default abstract class AggregationLayer<
/** Called when some attributes change, a chance to mark Aggregator as dirty */
abstract onAttributeChange(id: string): void;
- initializeState(): void {
- this.getAttributeManager()!.remove(['instancePickingColors']);
- }
+ initializeState(): void {}
// Extend Layer.updateState to update the Aggregator instance
// returns true if aggregator is changed
diff --git a/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts b/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts
index 11e49390076..6d5970fe88f 100644
--- a/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts
+++ b/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts
@@ -12,7 +12,6 @@ in vec3 normals;
in vec2 instancePositions;
in float instanceElevationValues;
in float instanceColorValues;
-in vec3 instancePickingColors;
uniform sampler2D colorRange;
@@ -30,7 +29,7 @@ vec4 interp(float value, vec2 domain, sampler2D range) {
}
void main(void) {
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
if (isnan(instanceColorValues) ||
instanceColorValues < grid.colorDomain.z ||
diff --git a/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts b/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts
index 88bc07406c8..15f15e62c88 100644
--- a/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts
+++ b/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts
@@ -14,7 +14,6 @@ in vec3 normals;
in vec2 instancePositions;
in float instanceElevationValues;
in float instanceColorValues;
-in vec3 instancePickingColors;
uniform sampler2D colorRange;
@@ -34,7 +33,7 @@ vec4 interp(float value, vec2 domain, sampler2D range) {
}
void main(void) {
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
if (isnan(instanceColorValues) ||
instanceColorValues < hexagon.colorDomain.z ||
diff --git a/modules/aggregation-layers/src/screen-grid-layer/screen-grid-layer-vertex.glsl.ts b/modules/aggregation-layers/src/screen-grid-layer/screen-grid-layer-vertex.glsl.ts
index b03dc2625b2..2d15a00eebd 100644
--- a/modules/aggregation-layers/src/screen-grid-layer/screen-grid-layer-vertex.glsl.ts
+++ b/modules/aggregation-layers/src/screen-grid-layer/screen-grid-layer-vertex.glsl.ts
@@ -10,7 +10,6 @@ export default /* glsl */ `\
in vec2 positions;
in vec2 instancePositions;
in float instanceWeights;
-in vec3 instancePickingColors;
uniform sampler2D colorRange;
@@ -37,6 +36,6 @@ void main(void) {
vColor.a *= layer.opacity;
// Set color to be rendered to picking fbo (also used to check for selection highlight).
- picking_setPickingColor(instancePickingColors);
+ picking_setPickingColorFromInstanceID();
}
`;
diff --git a/modules/carto/src/layers/label-utils.ts b/modules/carto/src/layers/label-utils.ts
index 3c991f8152f..9cae2f9afae 100644
--- a/modules/carto/src/layers/label-utils.ts
+++ b/modules/carto/src/layers/label-utils.ts
@@ -8,6 +8,38 @@ type TileBBox = GeoBoundingBox;
type Properties = BinaryPointFeature['properties'];
type LineInfo = {index: number; length: number};
+/** Property name for server-provided feature bounding box (comma-separated west,south,east,north in world coordinates) */
+export const FEATURE_BBOX_PROP = '_carto_bbox';
+
+function parseFeatureBbox(value: unknown): [number, number, number, number] | null {
+ if (typeof value !== 'string') return null;
+ const parts = value.split(',');
+ if (parts.length !== 4) return null;
+ return parts.map(Number) as [number, number, number, number];
+}
+
+function latToMercatorY(lat: number): number {
+ return Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
+}
+
+/** Convert a WGS84 point to MVT tile-space, where y=0 is north, y=1 is south, linear in Mercator */
+function worldToTile(
+ point: [number, number],
+ geoBbox: TileBBox,
+ tileBbox: TileBBox
+): [number, number] {
+ const xFrac = (point[0] - geoBbox.west) / (geoBbox.east - geoBbox.west);
+ const x = tileBbox.west + xFrac * (tileBbox.east - tileBbox.west);
+
+ const mercY = latToMercatorY(point[1]);
+ const mercNorth = latToMercatorY(geoBbox.north);
+ const mercSouth = latToMercatorY(geoBbox.south);
+ const yFrac = (mercNorth - mercY) / (mercNorth - mercSouth);
+ const y = tileBbox.south + yFrac * (tileBbox.north - tileBbox.south);
+
+ return [x, y];
+}
+
export function createPointsFromLines(
lines: BinaryLineFeature,
uniqueIdProperty?: string
@@ -70,9 +102,16 @@ export function createPointsFromLines(
export function createPointsFromPolygons(
polygons: Required,
tileBbox: TileBBox,
- props: any
+ props: any,
+ geoBbox?: TileBBox
): BinaryPointFeature {
- const {west, south, east, north} = tileBbox;
+ // When feature bounding boxes are provided, use geoBbox for area filtering
+ // as the bbox values are in world coordinates
+ const useBbox = Boolean(
+ geoBbox && polygons.properties.length > 0 && FEATURE_BBOX_PROP in polygons.properties[0]
+ );
+ const boundsBbox = useBbox ? geoBbox! : tileBbox;
+ const {west, south, east, north} = boundsBbox;
const tileArea = (east - west) * (north - south);
const minPolygonArea = tileArea * 0.0001; // 0.01% threshold
@@ -85,6 +124,9 @@ export function createPointsFromPolygons(
polygons.numericProps
);
+ // MVT: tile space is Mercator-projected, need worldToTile conversion
+ const isMVT = useBbox && geoBbox !== tileBbox;
+
// Process each polygon
let pointIndex = 0;
let triangleIndex = 0;
@@ -93,61 +135,80 @@ export function createPointsFromPolygons(
const startIndex = polygons.polygonIndices.value[i];
const endIndex = polygons.polygonIndices.value[i + 1];
- // Skip small polygons
- if (getPolygonArea(polygons, i) < minPolygonArea) {
- continue;
+ let labelPoint: [number, number] | null = null;
+
+ // Determine preferred label position and area filter
+ let preferredPoint: [number, number] | null = null;
+ if (useBbox) {
+ const featureId = polygons.featureIds.value[startIndex];
+ const bbox = parseFeatureBbox(polygons.properties[featureId][FEATURE_BBOX_PROP]);
+ if (bbox) {
+ const [bboxWest, bboxSouth, bboxEast, bboxNorth] = bbox;
+ const bboxArea = (bboxEast - bboxWest) * (bboxNorth - bboxSouth);
+ if (bboxArea >= minPolygonArea) {
+ const center: [number, number] = [(bboxWest + bboxEast) / 2, (bboxSouth + bboxNorth) / 2];
+ if (isPointInBounds(center, geoBbox!)) {
+ preferredPoint = isMVT ? worldToTile(center, geoBbox!, tileBbox) : center;
+ }
+ }
+ }
+ } else {
+ if (getPolygonArea(polygons, i) < minPolygonArea) {
+ continue;
+ }
+ preferredPoint = getPolygonCentroid(polygons, i);
}
- const centroid = getPolygonCentroid(polygons, i);
- let maxArea = -1;
- let largestTriangleCenter: [number, number] = [0, 0];
- let centroidIsInside = false;
+ if (preferredPoint) {
+ // Check if preferred point is inside the polygon, tracking largest triangle as fallback
+ let maxArea = -1;
+ let largestTriangleCenter: [number, number] = [0, 0];
+ let isInside = false;
- // Scan triangles until we find ones that don't belong to this polygon
- while (triangleIndex < polygons.triangles.value.length) {
- const i1 = polygons.triangles.value[triangleIndex];
+ while (triangleIndex < polygons.triangles.value.length) {
+ const i1 = polygons.triangles.value[triangleIndex];
+ if (i1 >= endIndex) break;
- // If we've moved past the current polygon's triangles, break
- if (i1 >= endIndex) {
- break;
- }
+ if (isInside) {
+ triangleIndex += 3;
+ continue;
+ }
+
+ const i2 = polygons.triangles.value[triangleIndex + 1];
+ const i3 = polygons.triangles.value[triangleIndex + 2];
+ const v1 = polygons.positions.value.subarray(
+ i1 * polygons.positions.size,
+ i1 * polygons.positions.size + polygons.positions.size
+ );
+ const v2 = polygons.positions.value.subarray(
+ i2 * polygons.positions.size,
+ i2 * polygons.positions.size + polygons.positions.size
+ );
+ const v3 = polygons.positions.value.subarray(
+ i3 * polygons.positions.size,
+ i3 * polygons.positions.size + polygons.positions.size
+ );
+
+ if (isPointInTriangle(preferredPoint, v1, v2, v3)) {
+ isInside = true;
+ } else {
+ const area = getTriangleArea(v1, v2, v3);
+ if (area > maxArea) {
+ maxArea = area;
+ largestTriangleCenter = [(v1[0] + v2[0] + v3[0]) / 3, (v1[1] + v2[1] + v3[1]) / 3];
+ }
+ }
- // If we've already found a triangle containing the centroid, skip the rest
- if (centroidIsInside) {
triangleIndex += 3;
- continue;
}
- const i2 = polygons.triangles.value[triangleIndex + 1];
- const i3 = polygons.triangles.value[triangleIndex + 2];
- const v1 = polygons.positions.value.subarray(
- i1 * polygons.positions.size,
- i1 * polygons.positions.size + polygons.positions.size
- );
- const v2 = polygons.positions.value.subarray(
- i2 * polygons.positions.size,
- i2 * polygons.positions.size + polygons.positions.size
- );
- const v3 = polygons.positions.value.subarray(
- i3 * polygons.positions.size,
- i3 * polygons.positions.size + polygons.positions.size
- );
-
- if (isPointInTriangle(centroid, v1, v2, v3)) {
- centroidIsInside = true;
- } else {
- const area = getTriangleArea(v1, v2, v3);
- if (area > maxArea) {
- maxArea = area;
- largestTriangleCenter = [(v1[0] + v2[0] + v3[0]) / 3, (v1[1] + v2[1] + v3[1]) / 3];
- }
+ const candidate = isInside ? preferredPoint : largestTriangleCenter;
+ if (isPointInBounds(candidate, tileBbox)) {
+ labelPoint = candidate;
}
-
- triangleIndex += 3;
}
- const labelPoint = centroidIsInside ? centroid : largestTriangleCenter;
- if (isPointInBounds(labelPoint, tileBbox)) {
+ if (labelPoint) {
positions.push(...labelPoint);
const featureId = polygons.featureIds.value[startIndex];
if (extruded) {
diff --git a/modules/carto/src/layers/raster-layer-vertex.glsl.ts b/modules/carto/src/layers/raster-layer-vertex.glsl.ts
index 94eccd3d1ca..824d6e60da9 100644
--- a/modules/carto/src/layers/raster-layer-vertex.glsl.ts
+++ b/modules/carto/src/layers/raster-layer-vertex.glsl.ts
@@ -13,8 +13,6 @@ in float instanceElevations;
in vec4 instanceFillColors;
in vec4 instanceLineColors;
-in vec3 instancePickingColors;
-
// Result
out vec4 vColor;
#ifdef FLAT_SHADING
@@ -67,7 +65,7 @@ void main(void) {
}
}
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
// Cell coordinates centered on origin
vec2 base = positions.xy * scale * strokeOffsetRatio * column.coverage * shouldRender;
diff --git a/modules/carto/src/layers/vector-tile-layer.ts b/modules/carto/src/layers/vector-tile-layer.ts
index 82ccbff6399..b10a6c8a36c 100644
--- a/modules/carto/src/layers/vector-tile-layer.ts
+++ b/modules/carto/src/layers/vector-tile-layer.ts
@@ -217,7 +217,8 @@ export default class VectorTileLayer<
labelData.points = createPointsFromPolygons(
props.data.polygons,
this.state.mvt ? MVT_BBOX : tileBbox,
- props
+ props,
+ tileBbox
);
}
diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts
index 60532e9ce3b..8993b2ffee7 100644
--- a/modules/core/src/controllers/globe-controller.ts
+++ b/modules/core/src/controllers/globe-controller.ts
@@ -3,16 +3,23 @@
// Copyright (c) vis.gl contributors
import {clamp} from '@math.gl/core';
-import Controller, {ControllerProps} from './controller';
+import Controller from './controller';
import {MapState, MapStateProps} from './map-controller';
import type {MapStateInternal} from './map-controller';
import {mod} from '../utils/math-utils';
import LinearInterpolator from '../transitions/linear-interpolator';
import {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
-
+import {
+ Globe,
+ type CameraFrame,
+ GLOBE_INERTIA_EASING,
+ GlobeInertiaInterpolator
+} from '../viewports/globe-utils';
import {MAX_LATITUDE} from '@math.gl/web-mercator';
+import type {MjolnirGestureEvent} from 'mjolnir.js';
+
const DEGREES_TO_RADIANS = Math.PI / 180;
const RADIANS_TO_DEGREES = 180 / Math.PI;
@@ -21,6 +28,7 @@ function degreesToPixels(angle: number, zoom: number = 0): number {
const size = GLOBE_RADIUS * 2 * Math.sin(radians / 2);
return size * Math.pow(2, zoom);
}
+
function pixelsToDegrees(pixels: number, zoom: number = 0): number {
const size = pixels / Math.pow(2, zoom);
const radians = Math.asin(Math.min(1, size / GLOBE_RADIUS / 2)) * 2;
@@ -29,6 +37,10 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number {
type GlobeStateInternal = MapStateInternal & {
startPanPos?: [number, number];
+ startPanCameraFrame?: CameraFrame;
+ startPanAngularRate?: number;
+ /** When true, bearing is held fixed during pan (north stays up) */
+ startPanLockBearing?: boolean;
};
class GlobeState extends MapState {
@@ -38,54 +50,113 @@ class GlobeState extends MapState {
makeViewport: (props: Record) => any;
}
) {
- const {startPanPos, ...mapStateOptions} = options;
- mapStateOptions.normalize = false; // disable MapState default normalization
+ const {
+ startPanPos,
+ startPanCameraFrame,
+ startPanAngularRate,
+ startPanLockBearing,
+ ...mapStateOptions
+ } = options;
+ mapStateOptions.normalize = false;
super(mapStateOptions);
- if (startPanPos !== undefined) {
- (this as any)._state.startPanPos = startPanPos;
- }
+ const s = (this as any)._state;
+ if (startPanPos !== undefined) s.startPanPos = startPanPos;
+ if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame;
+ if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate;
+ if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing;
}
panStart({pos}: {pos: [number, number]}): GlobeState {
- const {latitude, longitude, zoom} = this.getViewportProps();
+ const {latitude, longitude, zoom, bearing = 0} = this.getViewportProps();
+ const cameraFrame = Globe.cameraFrame(longitude, latitude, bearing);
+ const lockBearing = Math.abs(bearing) < 1;
+
+ if (lockBearing) {
+ // Override horizontal axis to polar so north stays up.
+ // Boost rate by 1/cos(lat) to compensate for smaller longitude
+ // circles near the poles, capped at 4x.
+ cameraFrame.axisHorizontal = [0, 0, 1];
+ }
+
+ // Radians of arc per pixel, derived from zoom scale
+ const scale = Math.pow(2, zoom - zoomAdjust(latitude, true));
+ const angularRate = (0.25 / scale) * DEGREES_TO_RADIANS;
+
return this._getUpdatedState({
- startPanLngLat: [longitude, latitude],
startPanPos: pos,
+ startPanCameraFrame: cameraFrame,
+ startPanAngularRate: angularRate,
+ startPanLockBearing: lockBearing,
startZoom: zoom
}) as GlobeState;
}
pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): GlobeState {
const state = this.getState() as GlobeStateInternal;
- const startPanLngLat = state.startPanLngLat || this._unproject(startPos);
- if (!startPanLngLat) return this;
- const startZoom = state.startZoom ?? this.getViewportProps().zoom;
const startPanPos = state.startPanPos || startPos;
+ if (!startPanPos) return this;
+
+ const frame = state.startPanCameraFrame;
+ const rate = state.startPanAngularRate;
+ const startZoom = state.startZoom ?? this.getViewportProps().zoom;
+ if (!frame || !rate) {
+ return this;
+ }
+
+ const dx = startPanPos[0] - pos[0];
+ const dy = startPanPos[1] - pos[1];
+
+ let hAngle = dx * rate;
+ let vAngle = -dy * rate;
+ const locked = state.startPanLockBearing;
+
+ if (locked) {
+ // Boost horizontal rate by 1/cos(lat) for the polar axis, capped at 4x
+ const cosLat = Math.cos(frame.latitude * DEGREES_TO_RADIANS);
+ hAngle = (dx * rate) / Math.max(cosLat, 0.25);
+ // Clamp vertical angle to prevent crossing the poles
+ const maxUp = (MAX_LATITUDE - frame.latitude) * DEGREES_TO_RADIANS;
+ const maxDown = -(MAX_LATITUDE + frame.latitude) * DEGREES_TO_RADIANS;
+ vAngle = clamp(vAngle, maxDown, maxUp);
+ }
+
+ const rotated = Globe.rotateFrame(frame, hAngle, vAngle, locked);
+ const zoom = startZoom + zoomAdjust(rotated.latitude, true) - zoomAdjust(frame.latitude, true);
- const coords = [startPanLngLat[0], startPanLngLat[1], startZoom];
- const viewport = this.makeViewport(this.getViewportProps());
- const newProps = viewport.panByPosition(coords, pos, startPanPos);
- return this._getUpdatedState(newProps) as GlobeState;
+ return this._getUpdatedState({
+ longitude: rotated.longitude,
+ latitude: rotated.latitude,
+ bearing: rotated.bearing,
+ zoom
+ }) as GlobeState;
}
panEnd(): GlobeState {
return this._getUpdatedState({
- startPanLngLat: null,
startPanPos: null,
+ startPanCameraFrame: null,
+ startPanAngularRate: null,
+ startPanLockBearing: null,
startZoom: null
}) as GlobeState;
}
zoom({scale}: {scale: number}): MapState {
- // In Globe view zoom does not take into account the mouse position
const startZoom = this.getState().startZoom || this.getViewportProps().zoom;
const zoom = startZoom + Math.log2(scale);
return this._getUpdatedState({zoom});
}
+ _panFromCenter(offset: [number, number]): GlobeState {
+ const {width, height} = this.getViewportProps();
+ const center: [number, number] = [width / 2, height / 2];
+ return this.panStart({pos: center})
+ .pan({pos: [center[0] + offset[0], center[1] + offset[1]]})
+ .panEnd();
+ }
+
applyConstraints(props: Required): Required {
- // Ensure zoom is within specified range
const {longitude, latitude, maxBounds} = props;
props.zoom = this._constrainZoom(props.zoom, props);
@@ -93,19 +164,23 @@ class GlobeState extends MapState {
if (longitude < -180 || longitude > 180) {
props.longitude = mod(longitude + 180, 360) - 180;
}
- props.latitude = clamp(latitude, -MAX_LATITUDE, MAX_LATITUDE);
+ props.latitude = clamp(latitude, -90, 90);
+
+ if (props.bearing < -180 || props.bearing > 180) {
+ props.bearing = mod(props.bearing + 180, 360) - 180;
+ }
+ props.pitch = clamp(props.pitch, props.minPitch, props.maxPitch);
+
if (maxBounds) {
props.longitude = clamp(props.longitude, maxBounds[0][0], maxBounds[1][0]);
props.latitude = clamp(props.latitude, maxBounds[0][1], maxBounds[1][1]);
}
if (maxBounds) {
- // calculate center and zoom ranges at pitch=0 and bearing=0
- // to maintain visual stability when rotating
const effectiveZoom = props.zoom - zoomAdjust(latitude);
const lngSpan = maxBounds[1][0] - maxBounds[0][0];
const latSpan = maxBounds[1][1] - maxBounds[0][1];
- if (latSpan > 0 && latSpan < MAX_LATITUDE * 2) {
+ if (latSpan > 0 && latSpan < 180) {
const halfHeightDegrees =
Math.min(pixelsToDegrees(props.height, effectiveZoom), latSpan) / 2;
props.latitude = clamp(
@@ -131,7 +206,7 @@ class GlobeState extends MapState {
}
}
if (props.latitude !== latitude) {
- props.zoom += zoomAdjust(props.latitude) - zoomAdjust(latitude);
+ props.zoom += zoomAdjust(props.latitude, true) - zoomAdjust(latitude, true);
}
return props;
@@ -139,20 +214,18 @@ class GlobeState extends MapState {
_constrainZoom(zoom: number, props?: Required): number {
props ||= this.getViewportProps();
- const {latitude, maxZoom, maxBounds} = props;
+ const {maxZoom, maxBounds} = props;
let {minZoom} = props;
- const ZOOM0 = zoomAdjust(0);
- const zoomAdjustment = zoomAdjust(latitude) - ZOOM0;
const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
if (shouldApplyMaxBounds) {
const minLatitude = maxBounds[0][1];
const maxLatitude = maxBounds[1][1];
- // latitude at which the bounding box is the widest
const fitLatitude =
Math.sign(minLatitude) === Math.sign(maxLatitude)
? Math.min(Math.abs(minLatitude), Math.abs(maxLatitude))
: 0;
+ const ZOOM0 = zoomAdjust(0);
const w =
degreesToPixels(maxBounds[1][0] - maxBounds[0][0]) *
Math.cos(fitLatitude * DEGREES_TO_RADIANS);
@@ -166,6 +239,7 @@ class GlobeState extends MapState {
if (minZoom > maxZoom) minZoom = maxZoom;
}
+ const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true);
return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment);
}
}
@@ -175,16 +249,127 @@ export default class GlobeController extends Controller {
transition = {
transitionDuration: 300,
- transitionInterpolator: new LinearInterpolator(['longitude', 'latitude', 'zoom'])
+ transitionInterpolator: new LinearInterpolator({
+ transitionProps: {
+ compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'],
+ required: ['longitude', 'latitude', 'zoom']
+ }
+ })
};
dragMode: 'pan' | 'rotate' = 'pan';
- setProps(props: ControllerProps) {
- super.setProps(props);
+ // Ring buffer tracking globe position during pan for inertia velocity
+ private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = [];
+
+ protected _onPanStart(event: MjolnirGestureEvent): boolean {
+ this._panHistory = [];
+ return super._onPanStart(event);
+ }
+
+ protected _onPanMove(event: MjolnirGestureEvent): boolean {
+ if (!this.dragPan) {
+ return false;
+ }
+ const pos = this.getCenter(event);
+ const newControllerState = this.controllerState.pan({pos});
+ this.updateViewport(
+ newControllerState,
+ {transitionDuration: 0},
+ {
+ isDragging: true,
+ isPanning: true
+ }
+ );
+
+ const {longitude, latitude} = newControllerState.getViewportProps();
+ this._panHistory.push({longitude, latitude, timestamp: Date.now()});
+ if (this._panHistory.length > 5) {
+ this._panHistory.shift();
+ }
+
+ return true;
+ }
+
+ protected _onPanMoveEnd(event: MjolnirGestureEvent): boolean {
+ const {inertia} = this;
+ if (this.dragPan && inertia && this._panHistory.length >= 2) {
+ const first = this._panHistory[0];
+ const last = this._panHistory[this._panHistory.length - 1];
+ const dt = last.timestamp - first.timestamp;
+
+ if (dt > 0) {
+ const viewportProps = this.controllerState.getViewportProps();
+ const state = this.controllerState.getState() as GlobeStateInternal;
+
+ // Compute velocity from the actual positions the globe was at
+ const angularDistance = Globe.angularDistance(first, last);
+ const angularVelocity = angularDistance / dt;
+
+ if (angularVelocity > 1e-6) {
+ const totalAngle = (angularVelocity * inertia) / 2;
+ let interpolator: GlobeInertiaInterpolator;
+ let endLng: number;
+ let endLat: number;
+
+ if (state.startPanLockBearing) {
+ // Decompose into lng/lat velocity and extrapolate linearly
+ let dLng = last.longitude - first.longitude;
+ if (dLng > 180) dLng -= 360;
+ else if (dLng < -180) dLng += 360;
+ const dLat = last.latitude - first.latitude;
+ const vLng = dLng / dt;
+ const vLat = dLat / dt;
+ endLng = viewportProps.longitude + (vLng * inertia) / 2;
+ endLat = clamp(viewportProps.latitude + (vLat * inertia) / 2, -90, 90);
+
+ interpolator = new GlobeInertiaInterpolator({targetLongitude: endLng});
+ } else {
+ // Free bearing — use single-axis rotation to maintain
+ // constant spin direction with up vector tracking.
+ const axis = Globe.greatCircleAxis(first, last);
+ const currentFrame = Globe.cameraFrame(
+ viewportProps.longitude,
+ viewportProps.latitude,
+ viewportProps.bearing || 0
+ );
+ const endFrame = Globe.rotateFrame(
+ {...currentFrame, axisHorizontal: axis},
+ totalAngle,
+ 0
+ );
+ endLng = endFrame.longitude;
+ endLat = clamp(endFrame.latitude, -90, 90);
+ interpolator = new GlobeInertiaInterpolator({axis, totalAngle});
+ }
+
+ const newControllerState = this.controllerState.panEnd();
+ this.updateViewport(
+ newControllerState,
+ {
+ transitionInterpolator: interpolator,
+ transitionDuration: inertia,
+ transitionEasing: GLOBE_INERTIA_EASING,
+ longitude: endLng,
+ latitude: endLat
+ },
+ {
+ isDragging: false,
+ isPanning: true
+ }
+ );
+ this._panHistory = [];
+ return true;
+ }
+ }
+ }
- // TODO - support pitching?
- this.dragRotate = false;
- this.touchRotate = false;
+ this._panHistory = [];
+ const newControllerState = this.controllerState.panEnd();
+ this.updateViewport(newControllerState, null, {
+ isDragging: false,
+ isPanning: false
+ });
+ return true;
}
}
diff --git a/modules/core/src/lib/layer-state.ts b/modules/core/src/lib/layer-state.ts
index f71a5ae8020..2d2a0df9471 100644
--- a/modules/core/src/lib/layer-state.ts
+++ b/modules/core/src/lib/layer-state.ts
@@ -40,6 +40,11 @@ export default class LayerState extends ComponentState extends ComponentState extends Component<
const {pickingColors, instancePickingColors} = this.getAttributeManager().attributes;
const colors = pickingColors || instancePickingColors;
if (!colors) {
+ if (this.internalState) {
+ disablePickingIndex(this.internalState.disabledPickingIndices, objectIndex);
+ }
return;
}
@@ -851,6 +855,9 @@ export default abstract class Layer extends Component<
const {pickingColors, instancePickingColors} = this.getAttributeManager().attributes;
const colors = pickingColors || instancePickingColors;
if (!colors) {
+ if (this.internalState) {
+ this.internalState.disabledPickingIndices.length = 0;
+ }
return;
}
// The picking color cache may have been freed and then reallocated. This ensures we read from the currently allocated cache.
@@ -873,22 +880,6 @@ export default abstract class Layer extends Component<
const attributeManager = this._getAttributeManager();
- if (attributeManager) {
- // All instanced layers get instancePickingColors attribute by default
- // Their shaders can use it to render a picking scene
- // TODO - this slightly slows down non instanced layers
- attributeManager.addInstanced({
- instancePickingColors: {
- type: 'uint8',
- size: 4,
- noAlloc: true,
- // Updaters are always called with `this` pointing to the layer
- // eslint-disable-next-line @typescript-eslint/unbound-method
- update: this.calculateInstancePickingColors
- }
- });
- }
-
this.internalState = new LayerState({
attributeManager,
layer: this
diff --git a/modules/core/src/passes/layers-pass.ts b/modules/core/src/passes/layers-pass.ts
index ee81eeb3627..90b166f2a81 100644
--- a/modules/core/src/passes/layers-pass.ts
+++ b/modules/core/src/passes/layers-pass.ts
@@ -455,6 +455,15 @@ export default class LayersPass extends Pass {
}
}
+ // Ensure all default shader modules have an entry so their getUniforms is called.
+ // Without this, default modules added by effects (e.g. terrain) may not get their
+ // bindings set when rendered in passes that don't include those effects (e.g. mask pass).
+ for (const module of layer.context.defaultShaderModules) {
+ if (!(module.name in shaderModuleProps)) {
+ shaderModuleProps[module.name] = {};
+ }
+ }
+
return mergeModuleParameters(
shaderModuleProps,
this.getShaderModuleProps(layer, effects, shaderModuleProps),
diff --git a/modules/core/src/passes/pick-layers-pass.ts b/modules/core/src/passes/pick-layers-pass.ts
index 8917e647bc2..e952ed8399e 100644
--- a/modules/core/src/passes/pick-layers-pass.ts
+++ b/modules/core/src/passes/pick-layers-pass.ts
@@ -129,7 +129,8 @@ export default class PickLayersPass extends LayersPass {
return {
picking: {
isActive: 1,
- isAttribute: this.pickZ
+ isAttribute: this.pickZ,
+ disabledPickingIndices: layer.internalState?.disabledPickingIndices
},
lighting: {enabled: false}
};
diff --git a/modules/core/src/shaderlib/misc/geometry.ts b/modules/core/src/shaderlib/misc/geometry.ts
index 5b35e963c4c..429838c1be1 100644
--- a/modules/core/src/shaderlib/misc/geometry.ts
+++ b/modules/core/src/shaderlib/misc/geometry.ts
@@ -63,7 +63,8 @@ ${defines}
struct FragmentGeometry {
vec2 uv;
-} geometry;
+};
+FragmentGeometry geometry;
float smoothedge(float edge, float x) {
return smoothstep(edge - SMOOTH_EDGE_RADIUS, edge + SMOOTH_EDGE_RADIUS, x);
diff --git a/modules/core/src/shaderlib/picking/picking.ts b/modules/core/src/shaderlib/picking/picking.ts
index 81ce839f7d6..942433585b6 100644
--- a/modules/core/src/shaderlib/picking/picking.ts
+++ b/modules/core/src/shaderlib/picking/picking.ts
@@ -3,6 +3,78 @@
// Copyright (c) vis.gl contributors
import {picking} from '@luma.gl/shadertools';
+import log from '../../utils/log';
+
+export const PICKING_MAX_DISABLED_INDICES = 10;
+
+export function disablePickingIndex(disabledPickingIndices: number[], objectIndex: number): void {
+ if (disabledPickingIndices.length === PICKING_MAX_DISABLED_INDICES) {
+ log.warn(
+ `pickMultipleObjects can only exclude ${PICKING_MAX_DISABLED_INDICES} previously picked objects for layers without picking color buffers`
+ )();
+ } else {
+ disabledPickingIndices.push(objectIndex);
+ }
+}
+
+const pickingUniformsGLSL = /* glsl */ `\
+ float disabledPickingIndexCount;
+ vec4 disabledPickingIndices0;
+ vec4 disabledPickingIndices1;
+ vec4 disabledPickingIndices2;
+`;
+
+function addPickingUniformsGLSL(source: string): string {
+ return source.replace(
+ ' vec4 highlightColor;\n} picking;',
+ ` vec4 highlightColor;\n${pickingUniformsGLSL}} picking;`
+ );
+}
+
+function packDisabledPickingIndices(disabledPickingIndices: number[], startIndex: number) {
+ return [
+ disabledPickingIndices[startIndex] || 0,
+ disabledPickingIndices[startIndex + 1] || 0,
+ disabledPickingIndices[startIndex + 2] || 0,
+ disabledPickingIndices[startIndex + 3] || 0
+ ];
+}
+
+const pickingHelpersGLSL = /* glsl */ `\
+vec3 picking_getPickingColorFromIndex(float objectIndex) {
+ if (objectIndex < 0.0 || objectIndex > 16777214.0) {
+ return vec3(0.0);
+ }
+
+ for (int i = 0; i < ${PICKING_MAX_DISABLED_INDICES}; i++) {
+ if (float(i) >= picking.disabledPickingIndexCount) {
+ break;
+ }
+ vec4 disabledIndices = i < 4
+ ? picking.disabledPickingIndices0
+ : (i < 8 ? picking.disabledPickingIndices1 : picking.disabledPickingIndices2);
+ float disabledIndex = disabledIndices[i - (i / 4) * 4];
+ if (disabledIndex == objectIndex) {
+ return vec3(0.0);
+ }
+ }
+
+ float encodedIndex = objectIndex + 1.0;
+ return vec3(
+ mod(encodedIndex, 256.0),
+ mod(floor(encodedIndex / 256.0), 256.0),
+ mod(floor(encodedIndex / 65536.0), 256.0)
+ );
+}
+
+vec3 picking_getPickingColorFromInstanceID() {
+ return picking_getPickingColorFromIndex(float(gl_InstanceID));
+}
+
+void picking_setPickingColorFromInstanceID() {
+ picking_setPickingColor(picking_getPickingColorFromInstanceID());
+}
+`;
const sourceWGSL = /* wgsl */ `\
struct pickingUniforms {
@@ -12,6 +84,10 @@ struct pickingUniforms {
useByteColors: f32,
highlightedObjectColor: vec3,
highlightColor: vec4,
+ disabledPickingIndexCount: f32,
+ disabledPickingIndices0: vec4,
+ disabledPickingIndices1: vec4,
+ disabledPickingIndices2: vec4,
};
@group(0) @binding(auto) var picking: pickingUniforms;
@@ -31,12 +107,65 @@ fn picking_isColorZero(color: vec3) -> bool {
fn picking_isColorValid(color: vec3) -> bool {
return dot(color, vec3(1.0)) > 0.00001;
}
+
+fn picking_getPickingColorFromIndex(objectIndex: u32) -> vec3 {
+ if (objectIndex > 16777214u) {
+ return vec3(0.0);
+ }
+
+ for (var i = 0; i < ${PICKING_MAX_DISABLED_INDICES}; i = i + 1) {
+ if (f32(i) >= picking.disabledPickingIndexCount) {
+ break;
+ }
+ let disabledIndices = select(
+ picking.disabledPickingIndices2,
+ select(picking.disabledPickingIndices1, picking.disabledPickingIndices0, i < 4),
+ i < 8
+ );
+ let disabledIndex = disabledIndices[i % 4];
+ if (disabledIndex == f32(objectIndex)) {
+ return vec3(0.0);
+ }
+ }
+
+ let encodedIndex = objectIndex + 1u;
+ return vec3(
+ f32(encodedIndex % 256u),
+ f32((encodedIndex / 256u) % 256u),
+ f32((encodedIndex / 65536u) % 256u)
+ ) / 255.0;
+}
`;
export default {
...picking,
+ vs: `${addPickingUniformsGLSL(picking.vs)}\n${pickingHelpersGLSL}`,
+ fs: addPickingUniformsGLSL(picking.fs),
source: sourceWGSL,
- defaultUniforms: {...picking.defaultUniforms, useByteColors: true},
+ uniformTypes: {
+ ...picking.uniformTypes,
+ disabledPickingIndexCount: 'f32',
+ disabledPickingIndices0: 'vec4',
+ disabledPickingIndices1: 'vec4',
+ disabledPickingIndices2: 'vec4'
+ },
+ defaultUniforms: {
+ ...picking.defaultUniforms,
+ useByteColors: true,
+ disabledPickingIndexCount: 0,
+ disabledPickingIndices0: [0, 0, 0, 0],
+ disabledPickingIndices1: [0, 0, 0, 0],
+ disabledPickingIndices2: [0, 0, 0, 0]
+ },
+ getUniforms(props, prevUniforms) {
+ const uniforms = picking.getUniforms(props, prevUniforms) as any;
+ const disabledPickingIndices = props.disabledPickingIndices || [];
+ uniforms.disabledPickingIndexCount = disabledPickingIndices.length;
+ uniforms.disabledPickingIndices0 = packDisabledPickingIndices(disabledPickingIndices, 0);
+ uniforms.disabledPickingIndices1 = packDisabledPickingIndices(disabledPickingIndices, 4);
+ uniforms.disabledPickingIndices2 = packDisabledPickingIndices(disabledPickingIndices, 8);
+ return uniforms;
+ },
inject: {
'vs:DECKGL_FILTER_GL_POSITION': `
// for picking depth values
diff --git a/modules/core/src/shaderlib/project/project.glsl.ts b/modules/core/src/shaderlib/project/project.glsl.ts
index a06f6080a5f..93139446cd0 100644
--- a/modules/core/src/shaderlib/project/project.glsl.ts
+++ b/modules/core/src/shaderlib/project/project.glsl.ts
@@ -72,7 +72,7 @@ float project_size() {
// Adjust by 1 / cos(latitude)
// If geometry.position (vertex in common space) is populated, use it
// Otherwise use geometry.worldPosition (anchor in world space)
-
+
if (geometry.position.w == 0.0) {
return project_size_at_latitude(geometry.worldPosition.y);
}
@@ -80,7 +80,7 @@ float project_size() {
// latitude from common y: 2.0 * (atan(exp(y / TILE_SIZE * 2.0 * PI - PI)) - PI / 4.0)
// Taylor series of 1 / cos(latitude)
// Max error < 0.003
-
+
float y = geometry.position.y / TILE_SIZE * 2.0 - 1.0;
float y2 = y * y;
float y4 = y2 * y2;
@@ -208,6 +208,12 @@ vec4 project_position(vec4 position, vec3 position64Low) {
position_world.w
);
}
+ if (project.coordinateSystem == COORDINATE_SYSTEM_METER_OFFSETS) {
+ mat3 enuMatrix = project_get_orientation_matrix(project.commonOrigin);
+ float metersToCommon = GLOBE_RADIUS / EARTH_RADIUS;
+ vec3 offsetCommon = (enuMatrix * vec3(-position_world.xy, position_world.z)) * metersToCommon;
+ return vec4(project.commonOrigin + offsetCommon, position_world.w);
+ }
}
if (project.projectionMode == PROJECTION_MODE_WEB_MERCATOR_AUTO_OFFSET) {
if (project.coordinateSystem == COORDINATE_SYSTEM_LNGLAT) {
diff --git a/modules/core/src/shaderlib/project/project.wgsl.ts b/modules/core/src/shaderlib/project/project.wgsl.ts
index c14a7c6da45..db22f291b34 100644
--- a/modules/core/src/shaderlib/project/project.wgsl.ts
+++ b/modules/core/src/shaderlib/project/project.wgsl.ts
@@ -229,6 +229,12 @@ fn project_position_vec4_f64(position: vec4, position64Low: vec3) -> v
position_world.w
);
}
+ if (project.coordinateSystem == COORDINATE_SYSTEM_METER_OFFSETS) {
+ let enuMatrix = project_get_orientation_matrix(project.commonOrigin);
+ let metersToCommon = GLOBE_RADIUS / EARTH_RADIUS;
+ let offsetCommon = (enuMatrix * vec3(-position_world.x, -position_world.y, position_world.z)) * metersToCommon;
+ return vec4(project.commonOrigin + offsetCommon, position_world.w);
+ }
}
if (project.projectionMode == PROJECTION_MODE_WEB_MERCATOR_AUTO_OFFSET) {
if (project.coordinateSystem == COORDINATE_SYSTEM_LNGLAT) {
diff --git a/modules/core/src/shaderlib/project/viewport-uniforms.ts b/modules/core/src/shaderlib/project/viewport-uniforms.ts
index 07ca79251ea..34a94ad6666 100644
--- a/modules/core/src/shaderlib/project/viewport-uniforms.ts
+++ b/modules/core/src/shaderlib/project/viewport-uniforms.ts
@@ -359,5 +359,22 @@ function calculateViewportUniforms({
}
}
+ // For GLOBE + METER_OFFSETS, precompute the globe-space position of
+ // coordinateOrigin into commonOrigin. The shader derives the ENU frame from
+ // this direction via project_get_orientation_matrix, avoiding per-vertex trig.
+ if (viewport.projectionMode === PROJECTION_MODE.GLOBE && coordinateSystem === 'meter-offsets') {
+ const EARTH_RADIUS = 6370972;
+ const GLOBE_RADIUS = 256;
+ const lambda = (coordinateOrigin[0] * Math.PI) / 180;
+ const phi = (coordinateOrigin[1] * Math.PI) / 180;
+ const cosPhi = Math.cos(phi);
+ const D = ((coordinateOrigin[2] || 0) / EARTH_RADIUS + 1.0) * GLOBE_RADIUS;
+ uniforms.commonOrigin = [
+ Math.sin(lambda) * cosPhi * D,
+ -Math.cos(lambda) * cosPhi * D,
+ Math.sin(phi) * D
+ ];
+ }
+
return uniforms;
}
diff --git a/modules/core/src/viewports/globe-utils.ts b/modules/core/src/viewports/globe-utils.ts
new file mode 100644
index 00000000000..9c4bc42ba07
--- /dev/null
+++ b/modules/core/src/viewports/globe-utils.ts
@@ -0,0 +1,235 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {clamp, vec3, Quaternion} from '@math.gl/core';
+import TransitionInterpolator from '../transitions/transition-interpolator';
+import {zoomAdjust} from './globe-viewport';
+
+const DEGREES_TO_RADIANS = Math.PI / 180;
+const RADIANS_TO_DEGREES = 180 / Math.PI;
+
+type Vec3 = number[];
+
+export type CameraFrame = {
+ /** Unit-sphere position */
+ position: Vec3;
+ /** Camera up direction (tangent to sphere) */
+ up: Vec3;
+ /** Rotation axis for horizontal drag */
+ axisHorizontal: Vec3;
+ /** Rotation axis for vertical drag */
+ axisVertical: Vec3;
+ /** Longitude in degrees */
+ longitude: number;
+ /** Latitude in degrees */
+ latitude: number;
+ /** Bearing in degrees */
+ bearing: number;
+};
+
+/**
+ * Static utility methods for sphere geometry on the unit globe.
+ * Used by GlobeState and the globe inertia interpolators.
+ */
+export class Globe {
+ /** Convert (lng, lat) in degrees to a unit-sphere position */
+ static toPosition(lng: number, lat: number): Vec3 {
+ const phi = lat * DEGREES_TO_RADIANS;
+ const lam = lng * DEGREES_TO_RADIANS;
+ const cp = Math.cos(phi);
+ return [cp * Math.cos(lam), cp * Math.sin(lam), Math.sin(phi)];
+ }
+
+ /** Convert a unit-sphere position to [lng, lat] in degrees */
+ static toLngLat(v: Vec3): [number, number] {
+ return [
+ Math.atan2(v[1], v[0]) * RADIANS_TO_DEGREES,
+ Math.asin(clamp(v[2], -1, 1)) * RADIANS_TO_DEGREES
+ ];
+ }
+
+ /** North and East tangent vectors at a given (lng, lat) */
+ static tangentBasis(lng: number, lat: number): {N: Vec3; E: Vec3} {
+ const phi = lat * DEGREES_TO_RADIANS;
+ const lam = lng * DEGREES_TO_RADIANS;
+ const sp = Math.sin(phi);
+ const cp = Math.cos(phi);
+ const sl = Math.sin(lam);
+ const cl = Math.cos(lam);
+ return {
+ N: [-sp * cl, -sp * sl, cp],
+ E: [-sl, cl, 0]
+ };
+ }
+
+ /** Camera "up" direction on the unit sphere for a given bearing */
+ static upVector(lng: number, lat: number, bearing: number): Vec3 {
+ const {N, E} = Globe.tangentBasis(lng, lat);
+ const b = bearing * DEGREES_TO_RADIANS;
+ const cb = Math.cos(b);
+ const sb = Math.sin(b);
+ return [N[0] * cb + E[0] * sb, N[1] * cb + E[1] * sb, N[2] * cb + E[2] * sb];
+ }
+
+ /** Bearing (degrees) from a camera up vector at a given lng/lat */
+ static bearing(upVector: Vec3, lng: number, lat: number): number {
+ const {N, E} = Globe.tangentBasis(lng, lat);
+ return Math.atan2(vec3.dot(upVector, E), vec3.dot(upVector, N)) * RADIANS_TO_DEGREES;
+ }
+
+ /** Camera frame for panning at a given position/bearing */
+ static cameraFrame(lng: number, lat: number, bearing: number): CameraFrame {
+ const position = Globe.toPosition(lng, lat);
+ const up = Globe.upVector(lng, lat, bearing);
+ const {N, E} = Globe.tangentBasis(lng, lat);
+ const b = bearing * DEGREES_TO_RADIANS;
+ const cb = Math.cos(b);
+ const sb = Math.sin(b);
+ const right: Vec3 = [E[0] * cb - N[0] * sb, E[1] * cb - N[1] * sb, E[2] * cb - N[2] * sb];
+ return {
+ position,
+ up,
+ axisHorizontal: vec3.cross([], position, right),
+ axisVertical: vec3.cross([], position, up),
+ longitude: lng,
+ latitude: lat,
+ bearing
+ };
+ }
+
+ /** Angular distance in radians between two lng/lat points (great circle arc) */
+ static angularDistance(
+ a: {longitude: number; latitude: number},
+ b: {longitude: number; latitude: number}
+ ): number {
+ const pa = Globe.toPosition(a.longitude, a.latitude);
+ const pb = Globe.toPosition(b.longitude, b.latitude);
+ return Math.acos(clamp(vec3.dot(pa, pb), -1, 1));
+ }
+
+ /** Normalized rotation axis of the great circle between two lng/lat points */
+ static greatCircleAxis(
+ a: {longitude: number; latitude: number},
+ b: {longitude: number; latitude: number}
+ ): Vec3 {
+ const pa = Globe.toPosition(a.longitude, a.latitude);
+ const pb = Globe.toPosition(b.longitude, b.latitude);
+ return vec3.normalize([], vec3.cross([], pa, pb));
+ }
+
+ /** Rotate a vector around a unit axis by an angle (radians) using quaternions */
+ static rotate(v: Vec3, axis: Vec3, angle: number): Vec3 {
+ const q = new Quaternion().fromAxisRotation(axis, angle);
+ return vec3.transformQuat([], v, q) as Vec3;
+ }
+
+ /**
+ * Rotate a camera frame by horizontal/vertical angles (radians).
+ * Returns a new frame with updated position, up, longitude, latitude,
+ * and bearing. If lockBearing is true, bearing is forced to 0.
+ */
+ static rotateFrame(
+ frame: CameraFrame,
+ horizontalAngle: number,
+ verticalAngle: number,
+ lockBearing?: boolean
+ ): CameraFrame {
+ let position = Globe.rotate(frame.position, frame.axisHorizontal, horizontalAngle);
+ position = Globe.rotate(position, frame.axisVertical, verticalAngle);
+ let up = Globe.rotate(frame.up, frame.axisHorizontal, horizontalAngle);
+ up = Globe.rotate(up, frame.axisVertical, verticalAngle);
+
+ const [longitude, latitude] = Globe.toLngLat(position);
+ const b = lockBearing ? 0 : Globe.bearing(up, longitude, latitude);
+
+ return {
+ ...frame, // preserve axes
+ position,
+ up,
+ longitude,
+ latitude,
+ bearing: b
+ };
+ }
+}
+
+// Exponential decay easing — models viscous friction on a spinning sphere.
+const INERTIA_DECAY = 5;
+const INERTIA_NORM = 1 / (1 - Math.exp(-INERTIA_DECAY));
+export const GLOBE_INERTIA_EASING = (t: number) =>
+ (1 - Math.exp(-INERTIA_DECAY * t)) * INERTIA_NORM;
+
+/**
+ * Inertia interpolator for the globe. Two modes:
+ * - Linear (bearing locked): lerps lng/lat, preserves raw target longitude
+ * to avoid antimeridian reversal.
+ * - Rotation (free bearing): rigid body spin around a fixed axis with
+ * bearing tracked via the up vector.
+ */
+export class GlobeInertiaInterpolator extends TransitionInterpolator {
+ private _mode: 'linear' | 'rotation';
+ private _targetLongitude?: number;
+ private _axis?: number[];
+ private _totalAngle?: number;
+ private _startFrame!: CameraFrame;
+ private _startZoom!: number;
+
+ constructor(opts: {targetLongitude: number} | {axis: number[]; totalAngle: number}) {
+ const isRotation = 'axis' in opts;
+ super({
+ compare: ['longitude', 'latitude'],
+ extract: isRotation
+ ? ['longitude', 'latitude', 'zoom', 'bearing']
+ : ['longitude', 'latitude', 'zoom'],
+ required: ['longitude', 'latitude']
+ });
+ if (isRotation) {
+ this._mode = 'rotation';
+ this._axis = opts.axis;
+ this._totalAngle = opts.totalAngle;
+ } else {
+ this._mode = 'linear';
+ this._targetLongitude = opts.targetLongitude;
+ }
+ }
+
+ initializeProps(
+ startProps: Record,
+ endProps: Record
+ ): {start: Record; end: Record} {
+ const result = super.initializeProps(startProps, endProps);
+ this._startZoom = startProps.zoom;
+ if (this._mode === 'rotation') {
+ this._startFrame = {
+ ...Globe.cameraFrame(startProps.longitude, startProps.latitude, startProps.bearing || 0),
+ axisHorizontal: this._axis!
+ };
+ } else {
+ result.end.longitude = this._targetLongitude;
+ }
+ return result;
+ }
+
+ interpolateProps(
+ startProps: Record,
+ endProps: Record,
+ t: number
+ ): Record {
+ if (this._mode === 'rotation') {
+ const {longitude, latitude, bearing} = Globe.rotateFrame(
+ this._startFrame,
+ this._totalAngle! * t,
+ 0
+ );
+ const zoom =
+ this._startZoom + zoomAdjust(latitude, true) - zoomAdjust(this._startFrame.latitude, true);
+ return {bearing, longitude, latitude, zoom};
+ }
+ const longitude = startProps.longitude + (endProps.longitude - startProps.longitude) * t;
+ const latitude = startProps.latitude + (endProps.latitude - startProps.latitude) * t;
+ const zoom =
+ this._startZoom + zoomAdjust(latitude, true) - zoomAdjust(startProps.latitude, true);
+ return {longitude, latitude, zoom};
+ }
+}
diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts
index 578d49530ce..55e37122bbb 100644
--- a/modules/core/src/viewports/globe-viewport.ts
+++ b/modules/core/src/viewports/globe-viewport.ts
@@ -6,7 +6,6 @@ import {Matrix4} from '@math.gl/core';
import Viewport from './viewport';
import {PROJECTION_MODE} from '../lib/constants';
import {altitudeToFovy, fovyToAltitude} from '@math.gl/web-mercator';
-import {MAX_LATITUDE} from '@math.gl/web-mercator';
import {vec3, vec4} from '@math.gl/core';
@@ -14,6 +13,7 @@ const DEGREES_TO_RADIANS = Math.PI / 180;
const RADIANS_TO_DEGREES = 180 / Math.PI;
const EARTH_RADIUS = 6370972;
export const GLOBE_RADIUS = 256;
+import {MAX_LATITUDE} from '@math.gl/web-mercator';
function getDistanceScales() {
const unitsPerMeter = GLOBE_RADIUS / EARTH_RADIUS;
@@ -44,6 +44,10 @@ export type GlobeViewportOptions = {
longitude?: number;
/** Latitude in degrees */
latitude?: number;
+ /** Bearing in degrees. Default `0` */
+ bearing?: number;
+ /** Pitch in degrees. Default `0` */
+ pitch?: number;
/** Camera altitude relative to the viewport height, used to control the FOV. Default `1.5` */
altitude?: number;
/* Meter offsets of the viewport center from lng, lat, elevation */
@@ -71,12 +75,16 @@ export default class GlobeViewport extends Viewport {
longitude: number;
latitude: number;
+ bearing: number;
+ pitch: number;
fovy: number;
resolution: number;
constructor(opts: GlobeViewportOptions = {}) {
const {
longitude = 0,
+ bearing = 0,
+ pitch = 0,
zoom = 0,
// Matches Maplibre defaults
// https://github.com/maplibre/maplibre-gl-js/blob/f8ab4b48d59ab8fe7b068b102538793bbdd4c848/src/geo/projection/globe_transform.ts#L632-L633
@@ -87,8 +95,8 @@ export default class GlobeViewport extends Viewport {
let {latitude = 0, height, altitude = 1.5, fovy} = opts;
- // Clamp to web mercator limit to prevent bad inputs
- latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
+ // Clamp to valid range
+ latitude = Math.max(Math.min(latitude, 90), -90);
height = height || 1;
if (fovy) {
@@ -99,15 +107,34 @@ export default class GlobeViewport extends Viewport {
// Exagerate distance by latitude to match the Web Mercator distortion
// The goal is that globe and web mercator projection results converge at high zoom
// https://github.com/maplibre/maplibre-gl-js/blob/f8ab4b48d59ab8fe7b068b102538793bbdd4c848/src/geo/projection/globe_transform.ts#L575-L577
- const scale = Math.pow(2, zoom - zoomAdjust(latitude));
+ // Cap latitude for scale calculation to avoid the singularity at the poles
+ // where cos(90°)=0 → scale→∞. GlobeController applies the same cap when
+ // compensating zoom during pan (MAX_LATITUDE).
+ const scaleLatitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
+ const scale = Math.pow(2, zoom - zoomAdjust(scaleLatitude));
+ // Adjust far plane for pitch — tilted camera can see further across the globe
+ const pitchRadians = pitch * DEGREES_TO_RADIANS;
const nearZ = opts.nearZ ?? nearZMultiplier;
- const farZ = opts.farZ ?? (altitude + (GLOBE_RADIUS * 2 * scale) / height) * farZMultiplier;
+ const farZ =
+ opts.farZ ??
+ (altitude + (GLOBE_RADIUS * 2 * scale) / height / Math.max(Math.cos(pitchRadians), 0.1)) *
+ farZMultiplier;
// Calculate view matrix
- const viewMatrix = new Matrix4().lookAt({eye: [0, -altitude, 0], up: [0, 0, 1]});
- viewMatrix.rotateX(latitude * DEGREES_TO_RADIANS);
- viewMatrix.rotateZ(-longitude * DEGREES_TO_RADIANS);
- viewMatrix.scale(scale / height);
+ // The lookAt places the camera along -Y looking toward origin.
+ // After the globe rotation (Rx(lat) * Rz(-lng)), the surface normal at the target
+ // aligns with -Y, East with +X, and North with +Z.
+ const viewMatrix = new Matrix4()
+ .lookAt({eye: [0, -altitude, 0], up: [0, 0, 1]})
+ // Pitch: tilt the camera away from straight-down
+ .rotateX(-pitchRadians)
+ // Bearing: rotate around the surface normal.
+ // Negative sign matches the WebMercator convention (bearing > 0 = clockwise from North).
+ .rotateY(-bearing * DEGREES_TO_RADIANS)
+ // Globe orientation: position the target's surface at the top
+ .rotateX(latitude * DEGREES_TO_RADIANS)
+ .rotateZ(-longitude * DEGREES_TO_RADIANS)
+ .scale(scale / height);
super({
...opts,
@@ -131,6 +158,8 @@ export default class GlobeViewport extends Viewport {
this.scale = scale;
this.latitude = latitude;
this.longitude = longitude;
+ this.bearing = bearing;
+ this.pitch = pitch;
this.fovy = fovy;
this.resolution = resolution;
}
@@ -249,14 +278,17 @@ export default class GlobeViewport extends Viewport {
const longitude = startLng + rotationSpeed * (startPixel[0] - pixel[0]);
let latitude = startLat - rotationSpeed * (startPixel[1] - pixel[1]);
- latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
+ latitude = Math.max(Math.min(latitude, 90), -90);
const out = {longitude, latitude, zoom: startZoom - zoomAdjust(startLat)};
out.zoom += zoomAdjust(out.latitude);
return out;
}
}
-export function zoomAdjust(latitude: number): number {
+export function zoomAdjust(latitude: number, clampToPoles?: boolean): number {
+ if (clampToPoles) {
+ latitude = Math.max(Math.min(latitude, MAX_LATITUDE), -MAX_LATITUDE);
+ }
const scaleAdjust = Math.PI * Math.cos((latitude * Math.PI) / 180);
return Math.log2(scaleAdjust);
}
diff --git a/modules/extensions/src/mask/mask-effect.ts b/modules/extensions/src/mask/mask-effect.ts
index 6680fd2ce4a..66299a2f9a3 100644
--- a/modules/extensions/src/mask/mask-effect.ts
+++ b/modules/extensions/src/mask/mask-effect.ts
@@ -70,6 +70,7 @@ export default class MaskEffect implements Effect {
viewports,
onViewportActive,
views,
+ effects,
isPicking
}: PreRenderOptions): MaskPreRenderStats {
let didRender = false;
@@ -103,6 +104,7 @@ export default class MaskEffect implements Effect {
layerFilter,
onViewportActive,
views,
+ effects,
viewport,
viewportChanged
});
@@ -120,12 +122,14 @@ export default class MaskEffect implements Effect {
layerFilter,
onViewportActive,
views,
+ effects,
viewport,
viewportChanged
}: {
layerFilter: PreRenderOptions['layerFilter'];
onViewportActive: PreRenderOptions['onViewportActive'];
views: PreRenderOptions['views'];
+ effects: PreRenderOptions['effects'];
viewport: Viewport;
viewportChanged: boolean;
}
@@ -190,6 +194,7 @@ export default class MaskEffect implements Effect {
viewports: maskViewport ? [maskViewport] : [],
onViewportActive,
views,
+ effects,
shaderModuleProps: {
project: {
devicePixelRatio: 1
diff --git a/modules/extensions/src/terrain/shader-module.ts b/modules/extensions/src/terrain/shader-module.ts
index 447bac3359f..163b13e638a 100644
--- a/modules/extensions/src/terrain/shader-module.ts
+++ b/modules/extensions/src/terrain/shader-module.ts
@@ -119,7 +119,13 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
},
// eslint-disable-next-line complexity
getUniforms: (opts: Partial = {}) => {
- if ('dummyHeightMap' in opts) {
+ if (!opts.dummyHeightMap) {
+ // TerrainEffect has not provided props (e.g. not set up yet, or this pass
+ // doesn't include the terrain effect in its effects list)
+ return {};
+ }
+
+ if ('terrainSkipRender' in opts || 'drawToTerrainHeightMap' in opts) {
const {
drawToTerrainHeightMap,
heightMap,
@@ -134,7 +140,7 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
let mode: number = terrainSkipRender ? TERRAIN_MODE.SKIP : TERRAIN_MODE.NONE;
// height map if case USE_HEIGHT_MAP, terrain cover if USE_COVER, otherwise empty
- let sampler: Texture | undefined = dummyHeightMap as Texture;
+ let sampler: Texture = dummyHeightMap;
// height map bounds if case USE_HEIGHT_MAP, terrain cover bounds if USE_COVER, otherwise null
let bounds: number[] | null = null;
if (drawToTerrainHeightMap) {
@@ -149,19 +155,17 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
const fbo = opts.isPicking
? terrainCover.getPickingFramebuffer()
: terrainCover.getRenderFramebuffer();
- sampler = fbo?.colorAttachments[0].texture;
+ const coverTexture = fbo?.colorAttachments[0].texture;
if (opts.isPicking) {
mode = TERRAIN_MODE.SKIP;
}
- if (sampler) {
+ if (coverTexture) {
+ sampler = coverTexture;
mode = mode === TERRAIN_MODE.SKIP ? TERRAIN_MODE.USE_COVER_ONLY : TERRAIN_MODE.USE_COVER;
bounds = terrainCover.bounds;
- } else {
- sampler = dummyHeightMap!;
- if (opts.isPicking && !terrainSkipRender) {
- // terrain+draw layer without cover FBO: render own picking colors
- mode = TERRAIN_MODE.NONE;
- }
+ } else if (opts.isPicking && !terrainSkipRender) {
+ // terrain+draw layer without cover FBO: render own picking colors
+ mode = TERRAIN_MODE.NONE;
}
}
@@ -180,7 +184,14 @@ if ((terrain.mode == TERRAIN_MODE_USE_COVER) || (terrain.mode == TERRAIN_MODE_US
: [0, 0, 0, 0]
};
}
- return {};
+ // No terrain-specific props provided but dummyHeightMap is available
+ // (e.g. non-terrain render pass where the effect still provides props).
+ // Provide the dummy texture to satisfy the terrain_map binding.
+ return {
+ mode: TERRAIN_MODE.NONE,
+ terrain_map: opts.dummyHeightMap,
+ bounds: [0, 0, 0, 0]
+ };
},
uniformTypes: {
mode: 'f32',
diff --git a/modules/extensions/src/terrain/terrain-effect.ts b/modules/extensions/src/terrain/terrain-effect.ts
index 4567abfeb5b..903596e5633 100644
--- a/modules/extensions/src/terrain/terrain-effect.ts
+++ b/modules/extensions/src/terrain/terrain-effect.ts
@@ -38,6 +38,7 @@ export class TerrainEffect implements Effect {
height: 1,
data: new Uint8Array([0, 0, 0, 0])
});
+
this.terrainPass = new TerrainPass(device, {id: 'terrain'});
this.terrainPickingPass = new TerrainPickingPass(device, {id: 'terrain-picking'});
@@ -83,13 +84,29 @@ export class TerrainEffect implements Effect {
}
const drapeLayers = layers.filter(l => l.state.terrainDrawMode === 'drape');
- this._updateTerrainCovers(terrainLayers, drapeLayers, viewport, opts);
+ // Filter out the terrain effect itself to avoid feedback loops when rendering terrain covers
+ // (the terrain cover FBO would be both read and written to). Other effects like MaskEffect
+ // need to be passed through so they can apply to draped layers.
+ const nonTerrainEffects = opts.effects?.filter(e => e !== this);
+ this._updateTerrainCovers(terrainLayers, drapeLayers, viewport, {
+ ...opts,
+ effects: nonTerrainEffects
+ });
}
getShaderModuleProps(
layer: Layer,
otherShaderModuleProps: Record
- ): {terrain: TerrainModuleProps} {
+ ): {terrain: Partial} {
+ // Mask layers need the terrain_map binding satisfied but shouldn't use terrain features
+ if (layer.props.operation.includes('mask')) {
+ return {
+ terrain: {
+ dummyHeightMap: this.dummyHeightMap!
+ }
+ };
+ }
+
const {terrainDrawMode} = layer.state;
const terrainCover = this.isDrapingEnabled ? (this.terrainCovers.get(layer.id) ?? null) : null;
diff --git a/modules/extensions/src/terrain/terrain-pass.ts b/modules/extensions/src/terrain/terrain-pass.ts
index 37ccd5fa936..209d2fe1491 100644
--- a/modules/extensions/src/terrain/terrain-pass.ts
+++ b/modules/extensions/src/terrain/terrain-pass.ts
@@ -73,7 +73,6 @@ export class TerrainPass extends LayersPass {
target,
pass: `terrain-cover-${terrainCover.id}`,
layers,
- effects: [],
viewports: [viewport],
clearColor: [0, 0, 0, 0]
});
diff --git a/modules/extensions/src/terrain/terrain-picking-pass.ts b/modules/extensions/src/terrain/terrain-picking-pass.ts
index da3a14abba6..1bcd5194711 100644
--- a/modules/extensions/src/terrain/terrain-picking-pass.ts
+++ b/modules/extensions/src/terrain/terrain-picking-pass.ts
@@ -69,7 +69,6 @@ export class TerrainPickingPass extends PickLayersPass {
pickingFBO: target,
pass: `terrain-cover-picking-${terrainCover.id}`,
layers,
- effects: [],
viewports: [viewport],
// Disable the default culling because TileLayer would cull sublayers based on the screen viewport,
// not the viewport of the terrain cover. Culling is already done by `terrainCover.filterLayers`
diff --git a/modules/geo-layers/src/mesh-layer/mesh-layer-vertex.glsl.ts b/modules/geo-layers/src/mesh-layer/mesh-layer-vertex.glsl.ts
index 1ca6c81f11e..332bdf8968d 100644
--- a/modules/geo-layers/src/mesh-layer/mesh-layer-vertex.glsl.ts
+++ b/modules/geo-layers/src/mesh-layer/mesh-layer-vertex.glsl.ts
@@ -15,7 +15,6 @@ in vec3 featureIdsPickingColors;
// Instance attributes
in vec4 instanceColors;
-in vec3 instancePickingColors;
in vec3 instanceModelMatrixCol0;
in vec3 instanceModelMatrixCol1;
in vec3 instanceModelMatrixCol2;
@@ -43,7 +42,7 @@ void main(void) {
if (mesh.pickFeatureIds) {
geometry.pickingColor = featureIdsPickingColors;
} else {
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
}
mat3 instanceModelMatrix = mat3(instanceModelMatrixCol0, instanceModelMatrixCol1, instanceModelMatrixCol2);
diff --git a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts
index 32406256cbc..8375a165c28 100644
--- a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts
+++ b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts
@@ -10,6 +10,7 @@ import type {Layer} from '@deck.gl/core';
export type TileLoadDataProps = {
requestScheduler: RequestScheduler;
getData: (props: TileLoadProps) => Promise;
+ getPriority: (tile: Tile2DHeader) => number;
onLoad: (tile: Tile2DHeader) => void;
onError: (error: any, tile: Tile2DHeader) => void;
};
@@ -106,6 +107,7 @@ export class Tile2DHeader {
/* eslint-disable max-statements */
private async _loadData({
getData,
+ getPriority,
requestScheduler,
onLoad,
onError
@@ -116,10 +118,8 @@ export class Tile2DHeader {
this._abortController = new AbortController();
const {signal} = this._abortController;
- // @ts-expect-error (2345) Argument of type '(tile: any) => 1 | -1' is not assignable ...
- const requestToken = await requestScheduler.scheduleRequest(this, tile => {
- return tile.isSelected ? 1 : -1;
- });
+ // @ts-expect-error (2345) loaders.gl's RequestScheduler callback type is too narrow.
+ const requestToken = await requestScheduler.scheduleRequest(this, getPriority);
if (!requestToken) {
this._isCancelled = true;
diff --git a/modules/geo-layers/src/tileset-2d/tileset-2d.ts b/modules/geo-layers/src/tileset-2d/tileset-2d.ts
index e0752ec13a8..d4d72164f3f 100644
--- a/modules/geo-layers/src/tileset-2d/tileset-2d.ts
+++ b/modules/geo-layers/src/tileset-2d/tileset-2d.ts
@@ -437,6 +437,43 @@ export class Tileset2D {
private _getCullBounds = memoize(getCullBounds);
+ private _getRequestPriority(tile: Tile2DHeader): number {
+ // RequestScheduler loads lower priority values first.
+ const distance = this._getTileDistanceSquared(tile);
+ if (tile.isSelected) {
+ return distance;
+ }
+ if (tile.isVisible) {
+ return 1e6 + distance;
+ }
+ return -1;
+ }
+
+ private _getTileDistanceSquared(tile: Tile2DHeader): number {
+ const {width, height} = this._viewport || {};
+ if (!this._viewport || !width || !height) {
+ return 0;
+ }
+
+ const {bbox} = tile;
+ const center =
+ 'west' in bbox
+ ? ([(bbox.west + bbox.east) / 2, (bbox.south + bbox.north) / 2] as [number, number])
+ : ([(bbox.left + bbox.right) / 2, (bbox.top + bbox.bottom) / 2] as [number, number]);
+
+ try {
+ const [x, y] = this._viewport.project(center);
+ if (Number.isFinite(x) && Number.isFinite(y)) {
+ const dx = x - width / 2;
+ const dy = y - height / 2;
+ return dx * dx + dy * dy;
+ }
+ } catch {
+ // Some viewport/tile combinations are not projectable. Keep them valid but lowest priority.
+ }
+ return Number.MAX_SAFE_INTEGER;
+ }
+
private _pruneRequests(): void {
const {maxRequests = 0} = this.opts;
@@ -542,6 +579,7 @@ export class Tileset2D {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
tile.loadData({
getData: this.opts.getTileData,
+ getPriority: this._getRequestPriority.bind(this),
requestScheduler: this._requestScheduler,
onLoad: this.onTileLoad,
onError: this.opts.onTileError
diff --git a/modules/layers/src/arc-layer/arc-layer-vertex.glsl.ts b/modules/layers/src/arc-layer/arc-layer-vertex.glsl.ts
index 6d1fcd4872e..bf9bf8b6334 100644
--- a/modules/layers/src/arc-layer/arc-layer-vertex.glsl.ts
+++ b/modules/layers/src/arc-layer/arc-layer-vertex.glsl.ts
@@ -12,7 +12,6 @@ in vec3 instanceSourcePositions;
in vec3 instanceSourcePositions64Low;
in vec3 instanceTargetPositions;
in vec3 instanceTargetPositions64Low;
-in vec3 instancePickingColors;
in float instanceWidths;
in float instanceHeights;
in float instanceTilts;
@@ -131,7 +130,7 @@ void main(void) {
uv = vec2(segmentRatio, segmentSide);
geometry.uv = uv;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
vec4 curr;
vec4 next;
diff --git a/modules/layers/src/bitmap-layer/bitmap-layer.ts b/modules/layers/src/bitmap-layer/bitmap-layer.ts
index b2ac4ea20d3..3f5c7be75a6 100644
--- a/modules/layers/src/bitmap-layer/bitmap-layer.ts
+++ b/modules/layers/src/bitmap-layer/bitmap-layer.ts
@@ -135,7 +135,6 @@ export default class BitmapLayer extends Layer<
initializeState() {
const attributeManager = this.getAttributeManager()!;
- attributeManager.remove(['instancePickingColors']);
const noAlloc = true;
attributeManager.add({
diff --git a/modules/layers/src/column-layer/column-layer-vertex.glsl.ts b/modules/layers/src/column-layer/column-layer-vertex.glsl.ts
index 69503867dd0..a09ffab50fc 100644
--- a/modules/layers/src/column-layer/column-layer-vertex.glsl.ts
+++ b/modules/layers/src/column-layer/column-layer-vertex.glsl.ts
@@ -16,8 +16,6 @@ in vec4 instanceFillColors;
in vec4 instanceLineColors;
in float instanceStrokeWidths;
-in vec3 instancePickingColors;
-
// Result
out vec4 vColor;
#ifdef FLAT_SHADING
@@ -56,7 +54,7 @@ void main(void) {
float shouldRender = float(color.a > 0.0 && instanceElevations >= 0.0);
float dotRadius = column.radius * column.coverage * shouldRender;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
// project center of column
vec3 centroidPosition = vec3(instancePositions.xy, instancePositions.z + elevation);
diff --git a/modules/layers/src/icon-layer/icon-layer-vertex.glsl.ts b/modules/layers/src/icon-layer/icon-layer-vertex.glsl.ts
index 3653346754e..a34693ebd07 100644
--- a/modules/layers/src/icon-layer/icon-layer-vertex.glsl.ts
+++ b/modules/layers/src/icon-layer/icon-layer-vertex.glsl.ts
@@ -13,7 +13,9 @@ in vec3 instancePositions64Low;
in float instanceSizes;
in float instanceAngles;
in vec4 instanceColors;
+#ifdef USE_INSTANCE_PICKING_COLORS
in vec3 instancePickingColors;
+#endif
in vec4 instanceIconFrames;
in float instanceColorModes;
in vec2 instanceOffsets;
@@ -35,7 +37,11 @@ vec2 rotate_by_angle(vec2 vertex, float angle) {
void main(void) {
geometry.worldPosition = instancePositions;
geometry.uv = positions;
+#ifdef USE_INSTANCE_PICKING_COLORS
geometry.pickingColor = instancePickingColors;
+#else
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
+#endif
uv = positions;
vec2 iconSize = instanceIconFrames.zw;
diff --git a/modules/layers/src/icon-layer/icon-layer.ts b/modules/layers/src/icon-layer/icon-layer.ts
index fa170a52dbe..1df6e34ed57 100644
--- a/modules/layers/src/icon-layer/icon-layer.ts
+++ b/modules/layers/src/icon-layer/icon-layer.ts
@@ -9,7 +9,7 @@ import {Model, Geometry} from '@luma.gl/engine';
import {iconUniforms, IconProps} from './icon-layer-uniforms';
import vs from './icon-layer-vertex.glsl';
import fs from './icon-layer-fragment.glsl';
-import {shaderWGSL as source} from './icon-layer.wgsl';
+import {getShaderWGSL} from './icon-layer.wgsl';
import IconManager from './icon-manager';
import type {
@@ -140,7 +140,16 @@ export default class IconLayer extends
};
getShaders() {
- return super.getShaders({vs, fs, source, modules: [project32, color, picking, iconUniforms]});
+ const useInstancePickingColors = Boolean(
+ (this.props.data as any)?.attributes?.instancePickingColors
+ );
+ return super.getShaders({
+ vs,
+ fs,
+ source: getShaderWGSL(useInstancePickingColors),
+ defines: useInstancePickingColors ? {USE_INSTANCE_PICKING_COLORS: true} : {},
+ modules: [project32, color, picking, iconUniforms]
+ });
}
initializeState() {
@@ -203,7 +212,16 @@ export default class IconLayer extends
size: 2,
transition: true,
accessor: 'getPixelOffset'
- }
+ },
+ ...((this.props.data as any)?.attributes?.instancePickingColors
+ ? {
+ instancePickingColors: {
+ size: 4,
+ type: 'uint8',
+ noAlloc: true
+ }
+ }
+ : {})
});
/* eslint-enable max-len */
}
diff --git a/modules/layers/src/icon-layer/icon-layer.wgsl.ts b/modules/layers/src/icon-layer/icon-layer.wgsl.ts
index 4e073d1f771..f02651acf65 100644
--- a/modules/layers/src/icon-layer/icon-layer.wgsl.ts
+++ b/modules/layers/src/icon-layer/icon-layer.wgsl.ts
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
-export const shaderWGSL = /* wgsl */ `\
+const shaderWGSL = /* wgsl */ `\
struct IconUniforms {
sizeScale: f32,
iconsTextureDim: vec2,
@@ -27,6 +27,7 @@ fn rotate_by_angle(vertex: vec2, angle_deg: f32) -> vec2 {
}
struct Attributes {
+ @builtin(instance_index) instanceIndex : u32,
@location(0) positions: vec2,
@location(1) instancePositions: vec3,
@@ -34,11 +35,11 @@ struct Attributes {
@location(3) instanceSizes: f32,
@location(4) instanceAngles: f32,
@location(5) instanceColors: vec4,
- @location(6) instancePickingColors: vec3,
- @location(7) instanceIconFrames: vec4,
- @location(8) instanceColorModes: f32,
- @location(9) instanceOffsets: vec2,
- @location(10) instancePixelOffset: vec2,
+ @location(6) instanceIconFrames: vec4,
+ @location(7) instanceColorModes: f32,
+ @location(8) instanceOffsets: vec2,
+ @location(9) instancePixelOffset: vec2,
+ PICKING_COLOR_ATTRIBUTE
};
struct Varyings {
@@ -56,7 +57,7 @@ fn vertexMain(inp: Attributes) -> Varyings {
// write geometry fields used by filters + FS
geometry.worldPosition = inp.instancePositions;
geometry.uv = inp.positions;
- geometry.pickingColor = inp.instancePickingColors;
+ geometry.pickingColor = PICKING_COLOR_VALUE;
var outp: Varyings;
outp.uv = inp.positions;
@@ -103,7 +104,7 @@ fn vertexMain(inp: Attributes) -> Varyings {
// DECKGL_FILTER_COLOR(outp.vColor, geometry);
outp.vColorMode = inp.instanceColorModes;
- outp.pickingColor = inp.instancePickingColors;
+ outp.pickingColor = geometry.pickingColor;
return outp;
}
@@ -153,3 +154,19 @@ fn fragmentMain(inp: Varyings) -> @location(0) vec4 {
return fragColor;
}
`;
+
+export function getShaderWGSL(useInstancePickingColors: boolean): string {
+ return shaderWGSL
+ .replace(
+ 'PICKING_COLOR_ATTRIBUTE',
+ useInstancePickingColors ? '@location(10) instancePickingColors: vec4,' : ''
+ )
+ .replace(
+ 'PICKING_COLOR_VALUE',
+ useInstancePickingColors
+ ? 'inp.instancePickingColors.rgb'
+ : 'picking_getPickingColorFromIndex(inp.instanceIndex)'
+ );
+}
+
+export {shaderWGSL};
diff --git a/modules/layers/src/line-layer/line-layer-vertex.glsl.ts b/modules/layers/src/line-layer/line-layer-vertex.glsl.ts
index e193ff2a1f0..d07e21edca4 100644
--- a/modules/layers/src/line-layer/line-layer-vertex.glsl.ts
+++ b/modules/layers/src/line-layer/line-layer-vertex.glsl.ts
@@ -12,7 +12,6 @@ in vec3 instanceTargetPositions;
in vec3 instanceSourcePositions64Low;
in vec3 instanceTargetPositions64Low;
in vec4 instanceColors;
-in vec3 instancePickingColors;
in float instanceWidths;
out vec4 vColor;
@@ -75,7 +74,7 @@ void main(void) {
geometry.position = mix(source_commonspace, target_commonspace, segmentIndex);
uv = positions.xy;
geometry.uv = uv;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
// Multiply out width and clamp to limits
float widthPixels = clamp(
diff --git a/modules/layers/src/line-layer/line-layer.wgsl.ts b/modules/layers/src/line-layer/line-layer.wgsl.ts
index eee0fb82139..1e397d9b2b0 100644
--- a/modules/layers/src/line-layer/line-layer.wgsl.ts
+++ b/modules/layers/src/line-layer/line-layer.wgsl.ts
@@ -63,14 +63,14 @@ struct Varyings {
@vertex
fn vertexMain(
+ @builtin(instance_index) instanceIndex: u32,
@location(0) positions: vec3,
@location(1) instanceSourcePositions: vec3,
@location(2) instanceTargetPositions: vec3,
@location(3) instanceSourcePositions64Low: vec3,
@location(4) instanceTargetPositions64Low: vec3,
@location(5) instanceColors: vec4,
- @location(6) instancePickingColors: vec3,
- @location(7) instanceWidths: f32
+ @location(6) instanceWidths: f32
) -> Varyings {
geometry.worldPosition = instanceSourcePositions;
geometry.worldPositionAlt = instanceTargetPositions;
@@ -117,7 +117,7 @@ fn vertexMain(
geometry.position = source_commonspace + segmentIndex * (target_commonspace - source_commonspace);
let uv: vec2 = positions.xy;
geometry.uv = uv;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromIndex(instanceIndex);
// Determine width in pixels.
let widthPixels: f32 = clamp(
@@ -144,7 +144,7 @@ fn vertexMain(
output.gl_Position = finalPosition;
output.vColor = vColor;
output.uv = uv;
- output.pickingColor = instancePickingColors;
+ output.pickingColor = geometry.pickingColor;
return output;
}
diff --git a/modules/layers/src/point-cloud-layer/point-cloud-layer-vertex.glsl.ts b/modules/layers/src/point-cloud-layer/point-cloud-layer-vertex.glsl.ts
index 6f0b77508a3..f254286fb05 100644
--- a/modules/layers/src/point-cloud-layer/point-cloud-layer-vertex.glsl.ts
+++ b/modules/layers/src/point-cloud-layer/point-cloud-layer-vertex.glsl.ts
@@ -11,7 +11,6 @@ in vec3 instanceNormals;
in vec4 instanceColors;
in vec3 instancePositions;
in vec3 instancePositions64Low;
-in vec3 instancePickingColors;
out vec4 vColor;
out vec2 unitPosition;
@@ -23,7 +22,7 @@ void main(void) {
// position on the containing square in [-1, 1] space
unitPosition = positions.xy;
geometry.uv = unitPosition;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
// Find the center of the point and add the current vertex
vec3 offset = vec3(positions.xy * project_size_to_pixel(pointCloud.radiusPixels, pointCloud.sizeUnits), 0.0);
diff --git a/modules/layers/src/point-cloud-layer/point-cloud-layer.wgsl.ts b/modules/layers/src/point-cloud-layer/point-cloud-layer.wgsl.ts
index 319b37a9016..23a00ab798f 100644
--- a/modules/layers/src/point-cloud-layer/point-cloud-layer.wgsl.ts
+++ b/modules/layers/src/point-cloud-layer/point-cloud-layer.wgsl.ts
@@ -15,15 +15,13 @@ struct ConstantAttributes {
instanceNormals: vec3,
instanceColors: vec4,
instancePositions: vec3,
- instancePositions64Low: vec3,
- instancePickingColors: vec3
+ instancePositions64Low: vec3
};
const constants = ConstantAttributes(
vec3(1.0, 0.0, 0.0),
vec4(0.0, 0.0, 0.0, 1.0),
vec3(0.0),
- vec3(0.0),
vec3(0.0)
);
@@ -34,8 +32,7 @@ struct Attributes {
@location(1) instancePositions: vec3,
@location(2) instancePositions64Low: vec3,
@location(3) instanceNormals: vec3,
- @location(4) instanceColors: vec4,
- @location(5) instancePickingColors: vec3
+ @location(4) instanceColors: vec4
};
struct Varyings {
@@ -62,7 +59,7 @@ fn vertexMain(attributes: Attributes) -> Varyings {
// position on the containing square in [-1, 1] space
varyings.unitPosition = attributes.positions.xy;
geometry.uv = varyings.unitPosition;
- geometry.pickingColor = attributes.instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromIndex(attributes.instanceIndex);
// Find the center of the point and add the current vertex
let offset = vec3(
@@ -84,7 +81,7 @@ fn vertexMain(attributes: Attributes) -> Varyings {
// Apply opacity to instance color, or return instance picking color
varyings.vColor = vec4(lightColor, attributes.instanceColors.a * layer.opacity);
// DECKGL_FILTER_COLOR(vColor, geometry);
- varyings.pickingColor = attributes.instancePickingColors;
+ varyings.pickingColor = geometry.pickingColor;
return varyings;
}
diff --git a/modules/layers/src/scatterplot-layer/scatterplot-layer-vertex.glsl.ts b/modules/layers/src/scatterplot-layer/scatterplot-layer-vertex.glsl.ts
index c027d404e78..c159a33d125 100644
--- a/modules/layers/src/scatterplot-layer/scatterplot-layer-vertex.glsl.ts
+++ b/modules/layers/src/scatterplot-layer/scatterplot-layer-vertex.glsl.ts
@@ -14,7 +14,9 @@ in float instanceRadius;
in float instanceLineWidths;
in vec4 instanceFillColors;
in vec4 instanceLineColors;
+#ifdef USE_INSTANCE_PICKING_COLORS
in vec3 instancePickingColors;
+#endif
in vec2 instancePixelOffset;
out vec4 vFillColor;
@@ -47,7 +49,11 @@ void main(void) {
// position on the containing square in [-1, 1] space
unitPosition = edgePadding * positions.xy;
geometry.uv = unitPosition;
+#ifdef USE_INSTANCE_PICKING_COLORS
geometry.pickingColor = instancePickingColors;
+#else
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
+#endif
innerUnitRadius = 1.0 - scatterplot.stroked * lineWidthPixels / outerRadiusPixels;
diff --git a/modules/layers/src/scatterplot-layer/scatterplot-layer.ts b/modules/layers/src/scatterplot-layer/scatterplot-layer.ts
index bdc40cad702..5736abc9d0b 100644
--- a/modules/layers/src/scatterplot-layer/scatterplot-layer.ts
+++ b/modules/layers/src/scatterplot-layer/scatterplot-layer.ts
@@ -8,7 +8,7 @@ import {Model, Geometry} from '@luma.gl/engine';
import {scatterplotUniforms, ScatterplotProps} from './scatterplot-layer-uniforms';
import vs from './scatterplot-layer-vertex.glsl';
import fs from './scatterplot-layer-fragment.glsl';
-import source from './scatterplot-layer.wgsl';
+import {getShaderWGSL} from './scatterplot-layer.wgsl';
import type {
LayerProps,
@@ -176,15 +176,27 @@ export default class ScatterplotLayer
};
getShaders() {
+ const useInstancePickingColors = Boolean(
+ (this.props.data as any)?.attributes?.instancePickingColors
+ );
return super.getShaders({
vs,
fs,
- source,
+ source: getShaderWGSL(useInstancePickingColors),
+ defines: useInstancePickingColors ? {USE_INSTANCE_PICKING_COLORS: true} : {},
modules: [project32, color, picking, scatterplotUniforms]
});
}
initializeState() {
+ const attributes: Record = {};
+ if ((this.props.data as any)?.attributes?.instancePickingColors) {
+ attributes.instancePickingColors = {
+ size: 4,
+ type: 'uint8',
+ noAlloc: true
+ };
+ }
this.getAttributeManager()!.addInstanced({
instancePositions: {
size: 3,
@@ -223,7 +235,8 @@ export default class ScatterplotLayer
size: 2,
transition: true,
accessor: 'getPixelOffset'
- }
+ },
+ ...attributes
});
}
diff --git a/modules/layers/src/scatterplot-layer/scatterplot-layer.wgsl.ts b/modules/layers/src/scatterplot-layer/scatterplot-layer.wgsl.ts
index eb74dfa472c..97b1ed83238 100644
--- a/modules/layers/src/scatterplot-layer/scatterplot-layer.wgsl.ts
+++ b/modules/layers/src/scatterplot-layer/scatterplot-layer.wgsl.ts
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
-export default /* wgsl */ `\
+const shaderWGSL = /* wgsl */ `\
// Main shaders
struct ScatterplotUniforms {
@@ -27,7 +27,6 @@ struct ConstantAttributeUniforms {
instanceLineWidths: f32,
instanceFillColors: vec4,
instanceLineColors: vec4,
- instancePickingColors: vec3,
instancePixelOffset: vec2,
instancePositionsConstant: i32,
@@ -36,7 +35,6 @@ struct ConstantAttributeUniforms {
instanceLineWidthsConstant: i32,
instanceFillColorsConstant: i32,
instanceLineColorsConstant: i32,
- instancePickingColorsConstant: i32,
instancePixelOffsetConstant: i32
};
@@ -49,7 +47,6 @@ struct ConstantAttributes {
instanceLineWidths: f32,
instanceFillColors: vec4,
instanceLineColors: vec4,
- instancePickingColors: vec3,
instancePixelOffset: vec2
};
@@ -60,7 +57,6 @@ const constants = ConstantAttributes(
0.0,
vec4(0.0, 0.0, 0.0, 1.0),
vec4(0.0, 0.0, 0.0, 1.0),
- vec3(0.0),
vec2(0.0)
);
@@ -74,8 +70,8 @@ struct Attributes {
@location(4) instanceLineWidths: f32,
@location(5) instanceFillColors: vec4,
@location(6) instanceLineColors: vec4,
- @location(7) instancePickingColors: vec3,
- @location(8) instancePixelOffset: vec2
+ @location(7) instancePixelOffset: vec2,
+ PICKING_COLOR_ATTRIBUTE
};
struct Varyings {
@@ -125,7 +121,7 @@ fn vertexMain(attributes: Attributes) -> Varyings {
// position on the containing square in [-1, 1] space
varyings.unitPosition = edgePadding * attributes.positions.xy;
geometry.uv = varyings.unitPosition;
- geometry.pickingColor = attributes.instancePickingColors;
+ geometry.pickingColor = PICKING_COLOR_VALUE;
varyings.innerUnitRadius = 1.0 - scatterplot.stroked * lineWidthPixels / varyings.outerRadiusPixels;
@@ -150,7 +146,7 @@ fn vertexMain(attributes: Attributes) -> Varyings {
// DECKGL_FILTER_COLOR(varyings.vFillColor, geometry);
varyings.vLineColor = vec4(attributes.instanceLineColors.rgb, attributes.instanceLineColors.a * layer.opacity);
// DECKGL_FILTER_COLOR(varyings.vLineColor, geometry);
- varyings.pickingColor = attributes.instancePickingColors;
+ varyings.pickingColor = geometry.pickingColor;
return varyings;
}
@@ -227,3 +223,19 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 {
// return vec4(0, 0, 1, 1);
}
`;
+
+export function getShaderWGSL(useInstancePickingColors: boolean): string {
+ return shaderWGSL
+ .replace(
+ 'PICKING_COLOR_ATTRIBUTE',
+ useInstancePickingColors ? '@location(8) instancePickingColors: vec4,' : ''
+ )
+ .replace(
+ 'PICKING_COLOR_VALUE',
+ useInstancePickingColors
+ ? 'attributes.instancePickingColors.rgb'
+ : 'picking_getPickingColorFromIndex(attributes.instanceIndex)'
+ );
+}
+
+export default shaderWGSL;
diff --git a/modules/layers/src/solid-polygon-layer/solid-polygon-layer.ts b/modules/layers/src/solid-polygon-layer/solid-polygon-layer.ts
index 821f2da3c6e..700a97bfd43 100644
--- a/modules/layers/src/solid-polygon-layer/solid-polygon-layer.ts
+++ b/modules/layers/src/solid-polygon-layer/solid-polygon-layer.ts
@@ -183,8 +183,6 @@ export default class SolidPolygonLayer
const attributeManager = this.getAttributeManager()!;
const noAlloc = true;
- attributeManager.remove(['instancePickingColors']);
-
/* eslint-disable max-len */
attributeManager.add({
indices: {
diff --git a/modules/layers/src/text-layer/text-background-layer/text-background-layer-vertex.glsl.ts b/modules/layers/src/text-layer/text-background-layer/text-background-layer-vertex.glsl.ts
index 12852e714d7..d112b9d4840 100644
--- a/modules/layers/src/text-layer/text-background-layer/text-background-layer-vertex.glsl.ts
+++ b/modules/layers/src/text-layer/text-background-layer/text-background-layer-vertex.glsl.ts
@@ -18,7 +18,6 @@ in vec2 instancePixelOffsets;
in float instanceLineWidths;
in vec4 instanceFillColors;
in vec4 instanceLineColors;
-in vec3 instancePickingColors;
out vec4 vFillColor;
out vec4 vLineColor;
@@ -37,7 +36,7 @@ vec2 rotate_by_angle(vec2 vertex, float angle) {
void main(void) {
geometry.worldPosition = instancePositions;
geometry.uv = positions;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
uv = positions;
vLineWidth = instanceLineWidths;
diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts
index 6921e9a51a7..fa94b0f60d1 100644
--- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts
+++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts
@@ -11,7 +11,6 @@ export default `\
in vec3 instancePositions;
in vec3 instancePositions64Low;
in vec4 instanceColors;
-in vec3 instancePickingColors;
in vec3 instanceModelMatrixCol0;
in vec3 instanceModelMatrixCol1;
in vec3 instanceModelMatrixCol2;
@@ -46,7 +45,7 @@ void main(void) {
#endif
geometry.worldPosition = instancePositions;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
mat3 instanceModelMatrix = mat3(instanceModelMatrixCol0, instanceModelMatrixCol1, instanceModelMatrixCol2);
diff --git a/modules/mesh-layers/src/simple-mesh-layer/simple-mesh-layer-vertex.glsl.ts b/modules/mesh-layers/src/simple-mesh-layer/simple-mesh-layer-vertex.glsl.ts
index accd443e35f..3a310142ad8 100644
--- a/modules/mesh-layers/src/simple-mesh-layer/simple-mesh-layer-vertex.glsl.ts
+++ b/modules/mesh-layers/src/simple-mesh-layer/simple-mesh-layer-vertex.glsl.ts
@@ -15,7 +15,6 @@ in vec2 texCoords;
in vec3 instancePositions;
in vec3 instancePositions64Low;
in vec4 instanceColors;
-in vec3 instancePickingColors;
in vec3 instanceModelMatrixCol0;
in vec3 instanceModelMatrixCol1;
in vec3 instanceModelMatrixCol2;
@@ -31,7 +30,7 @@ out vec4 vColor;
void main(void) {
geometry.worldPosition = instancePositions;
geometry.uv = texCoords;
- geometry.pickingColor = instancePickingColors;
+ geometry.pickingColor = picking_getPickingColorFromInstanceID();
vTexCoord = texCoords;
cameraPosition = project.cameraPosition;
diff --git a/test/apps/globe/app.js b/test/apps/globe/app.js
index 6361407fa72..f9295b9a336 100644
--- a/test/apps/globe/app.js
+++ b/test/apps/globe/app.js
@@ -4,6 +4,8 @@
import {Deck, _GlobeView as GlobeView} from '@deck.gl/core';
import {GeoJsonLayer, ArcLayer, ColumnLayer, BitmapLayer, PathLayer} from '@deck.gl/layers';
+import {ResetViewWidget as _ResetViewWidget} from '@deck.gl/widgets';
+import '@deck.gl/widgets/stylesheet.css';
// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
const COUNTRIES =
@@ -16,7 +18,8 @@ const WORLD_MAP = './map.jpg';
const INITIAL_VIEW_STATE = {
latitude: 51.47,
longitude: 0.45,
- zoom: 0
+ minZoom: 1,
+ zoom: 1
};
const GRATICULES = getGraticules(30);
@@ -24,7 +27,7 @@ const GRATICULES = getGraticules(30);
export const deck = new Deck({
views: new GlobeView(),
initialViewState: INITIAL_VIEW_STATE,
- controller: {minZoom: -2},
+ controller: {inertia: 500},
parameters: {
cull: true
},
@@ -118,3 +121,42 @@ function getGraticules(resolution) {
// For automated test cases
/* global document */
document.body.style.margin = '0px';
+
+// Debug overlay
+const overlay = document.createElement('div');
+Object.assign(overlay.style, {
+ position: 'fixed',
+ top: '10px',
+ left: '10px',
+ background: 'rgba(0,0,0,0.7)',
+ color: '#fff',
+ padding: '8px 12px',
+ fontFamily: 'monospace',
+ fontSize: '13px',
+ borderRadius: '4px',
+ zIndex: '1000',
+ pointerEvents: 'none',
+ lineHeight: '1.6',
+ whiteSpace: 'pre'
+});
+document.body.appendChild(overlay);
+
+function updateOverlay(vs) {
+ const {longitude = 0, latitude = 0, zoom = 0, bearing = 0, pitch = 0} = vs;
+ overlay.textContent =
+ `lat: ${latitude.toFixed(2)} lng: ${longitude.toFixed(2)}\n` +
+ `zoom: ${zoom.toFixed(2)} bearing: ${bearing.toFixed(2)} pitch: ${pitch.toFixed(2)}`;
+}
+updateOverlay(INITIAL_VIEW_STATE);
+
+deck.setProps({
+ widgets: [
+ new _ResetViewWidget({
+ placement: 'top-right',
+ initialViewState: {...INITIAL_VIEW_STATE, transitionDuration: 300}
+ })
+ ],
+ onViewStateChange: ({viewState}) => {
+ updateOverlay(viewState);
+ }
+});
diff --git a/test/modules/carto/layers/label-utils.spec.ts b/test/modules/carto/layers/label-utils.spec.ts
index f37a5f3da7e..accd5574572 100644
--- a/test/modules/carto/layers/label-utils.spec.ts
+++ b/test/modules/carto/layers/label-utils.spec.ts
@@ -3,7 +3,11 @@
// Copyright (c) vis.gl contributors
import {test, expect} from 'vitest';
-import {createPointsFromLines, createPointsFromPolygons} from '@deck.gl/carto/layers/label-utils';
+import {
+ createPointsFromLines,
+ createPointsFromPolygons,
+ FEATURE_BBOX_PROP
+} from '@deck.gl/carto/layers/label-utils';
import type {BinaryFeatureCollection} from '@loaders.gl/schema';
test('createPointsFromLines', () => {
@@ -474,3 +478,149 @@ test('createPointsFromLines - mixed uniqueIdProperty', () => {
{name: 'line3'}
]);
});
+
+// Helper to create polygon data with optional feature bounding box property
+function createPolygonWithBbox(
+ positions: number[],
+ triangles: number[],
+ bbox?: [number, number, number, number]
+) {
+ const numVertices = positions.length / 2;
+ const properties: Record[] = [{name: 'polygon1'}];
+ if (bbox) {
+ properties[0][FEATURE_BBOX_PROP] = bbox.join(',');
+ }
+ return {
+ type: 'Polygon' as const,
+ positions: {value: new Float32Array(positions), size: 2 as const},
+ polygonIndices: {value: new Uint32Array([0, numVertices]), size: 1 as const},
+ primitivePolygonIndices: {value: new Uint32Array([numVertices]), size: 1 as const},
+ triangles: {value: new Uint32Array(triangles), size: 1 as const},
+ featureIds: {value: new Uint32Array(numVertices).fill(0), size: 1 as const},
+ globalFeatureIds: {value: new Uint32Array(numVertices).fill(100), size: 1 as const},
+ numericProps: {},
+ properties,
+ fields: []
+ };
+}
+
+test('createPointsFromPolygons - uses feature bbox when provided', () => {
+ // Polygon clipped to tile [0,1] but full geometry bbox spans [-1, -1, 2, 2]
+ const polygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [-1, -1, 2, 2]
+ );
+
+ const tileBbox = {west: -1, south: -1, east: 2, north: 2};
+ const geoBbox = tileBbox;
+
+ const result = createPointsFromPolygons(polygons, tileBbox, {extruded: false}, geoBbox);
+
+ expect(result.positions.value.length, 'creates one label point').toBe(2);
+ // Center of bbox [-1,-1,2,2] = [0.5, 0.5]
+ expect(Array.from(result.positions.value), 'correct bbox center position').toEqual([0.5, 0.5]);
+ expect(result.properties[0].name, 'correct properties').toBe('polygon1');
+});
+
+test('createPointsFromPolygons - bbox with MVT coordinate conversion', () => {
+ // Simulate MVT tile: positions in [0,1] space, bbox in world coords
+ const polygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [-10, 40, -9, 41]
+ );
+
+ const mvtBbox = {west: 0, east: 1, south: 0, north: 1};
+ const geoBbox = {west: -10, south: 40, east: -9, north: 41};
+
+ const result = createPointsFromPolygons(polygons, mvtBbox, {extruded: false}, geoBbox);
+
+ expect(result.positions.value.length, 'creates one label point').toBe(2);
+ // Center of bbox in world = [-9.5, 40.5], converted to [0,1] tile space
+ expect(result.positions.value[0], 'correct x in tile coords').toBeCloseTo(0.5);
+ // y ~0.5 (slight Mercator offset for 1° span at lat 40)
+ expect(result.positions.value[1], 'correct y in tile coords').toBeCloseTo(0.5, 1);
+});
+
+test('createPointsFromPolygons - MVT y-axis: y=0 is north, y=1 is south', () => {
+ const mvtBbox = {west: 0, east: 1, south: 0, north: 1};
+ const geoBbox = {west: -120, south: 30, east: -110, north: 50};
+
+ // Feature near the NORTH edge of the tile
+ const northPolygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [-116, 48, -114, 50]
+ );
+ const northResult = createPointsFromPolygons(northPolygons, mvtBbox, {extruded: false}, geoBbox);
+ expect(northResult.positions.value.length, 'creates label').toBe(2);
+ expect(northResult.positions.value[1], 'north feature has small y').toBeLessThan(0.15);
+
+ // Feature near the SOUTH edge of the tile
+ const southPolygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [-116, 30, -114, 32]
+ );
+ const southResult = createPointsFromPolygons(southPolygons, mvtBbox, {extruded: false}, geoBbox);
+ expect(southResult.positions.value.length, 'creates label').toBe(2);
+ expect(southResult.positions.value[1], 'south feature has large y').toBeGreaterThan(0.85);
+});
+
+test('createPointsFromPolygons - MVT worldToTile uses Mercator projection', () => {
+ // Use a tile spanning a large latitude range where Mercator distortion is significant
+ const mvtBbox = {west: 0, east: 1, south: 0, north: 1};
+ const geoBbox = {west: 0, south: 0, east: 10, north: 60};
+
+ // Geographic midpoint lat=30, but Mercator midpoint is ~26.6° due to polar stretching
+ // So the geographic midpoint should map to y < 0.5 (closer to north/0)
+ const midPolygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [4, 29, 6, 31] // center at [5, 30]
+ );
+ const result = createPointsFromPolygons(midPolygons, mvtBbox, {extruded: false}, geoBbox);
+ expect(result.positions.value.length, 'creates label').toBe(2);
+ // With Mercator, lat 30 is south of the Mercator midpoint (~26.6°) so y > 0.5
+ expect(result.positions.value[1], 'Mercator shifts y away from 0.5').toBeGreaterThan(0.55);
+});
+
+test('createPointsFromPolygons - bbox center outside tile is filtered', () => {
+ // Feature bbox center is at [5, 5], outside tile [-1,-1,2,2]
+ const polygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [4, 4, 6, 6]
+ );
+
+ const tileBbox = {west: -1, south: -1, east: 2, north: 2};
+ const result = createPointsFromPolygons(polygons, tileBbox, {extruded: false}, tileBbox);
+
+ expect(result.positions.value.length, 'no label for out-of-bounds bbox center').toBe(0);
+});
+
+test('createPointsFromPolygons - tiny bbox feature is filtered', () => {
+ // Feature bbox area is tiny relative to tile
+ const polygons = createPolygonWithBbox(
+ [0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
+ [0, 1, 2, 0, 2, 3],
+ [0, 0, 0.001, 0.001]
+ );
+
+ const tileBbox = {west: -1, south: -1, east: 2, north: 2};
+ const result = createPointsFromPolygons(polygons, tileBbox, {extruded: false}, tileBbox);
+
+ expect(result.positions.value.length, 'no label for tiny feature').toBe(0);
+});
+
+test('createPointsFromPolygons - falls back without bbox props', () => {
+ // No bbox props - should use existing centroid logic
+ const polygons = createPolygonWithBbox([0, 0, 1, 0, 1, 1, 0, 1, 0, 0], [0, 1, 2, 0, 2, 3]);
+
+ const tileBbox = {west: -1, south: -1, east: 2, north: 2};
+ const result = createPointsFromPolygons(polygons, tileBbox, {extruded: false}, tileBbox);
+
+ expect(result.positions.value.length, 'creates label from geometry').toBe(2);
+ expect(Array.from(result.positions.value), 'centroid from positions').toEqual([0.5, 0.5]);
+});
diff --git a/test/modules/core/lib/layer-extension.spec.ts b/test/modules/core/lib/layer-extension.spec.ts
index 9d81de7e4b5..c3ff0ff8878 100644
--- a/test/modules/core/lib/layer-extension.spec.ts
+++ b/test/modules/core/lib/layer-extension.spec.ts
@@ -94,7 +94,7 @@ test('LayerExtension', () => {
expect(MockExtension.finalizeCalled, 'finalizeState called').toBe(0);
const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- expect(instancePickingColors.state.constant, 'picking buffer is disabled').toBeTruthy();
+ expect(instancePickingColors, 'default picking buffer is not registered').toBeUndefined();
}
},
{
@@ -117,7 +117,7 @@ test('LayerExtension', () => {
expect(MockExtension.finalizeCalled, 'finalizeState not called').toBe(0);
const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- expect(instancePickingColors.state.constant, 'picking buffer is enabled').toBeFalsy();
+ expect(instancePickingColors, 'extension does not force picking buffer').toBeUndefined();
}
},
{
diff --git a/test/modules/core/lib/layer.spec.ts b/test/modules/core/lib/layer.spec.ts
index 843408d76d2..37ff959a396 100644
--- a/test/modules/core/lib/layer.spec.ts
+++ b/test/modules/core/lib/layer.spec.ts
@@ -469,7 +469,7 @@ test('Layer#uniformTransitions', () => {
testLayer({Layer: TestLayer, timeline, testCases, onError: err => expect(err).toBeFalsy()});
});
-test('Layer#calculateInstancePickingColors', () => {
+test('Layer#builtin instance picking does not allocate instancePickingColors', () => {
const testCases = [
{
props: {
@@ -477,13 +477,7 @@ test('Layer#calculateInstancePickingColors', () => {
},
onAfterUpdate: ({layer}) => {
const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- expect(
- instancePickingColors.state.constant,
- 'instancePickingColors is set to constant'
- ).toBeTruthy();
- expect(instancePickingColors.value, 'instancePickingColors is set to constant').toEqual([
- 0, 0, 0, 0
- ]);
+ expect(instancePickingColors, 'instancePickingColors is not registered').toBeUndefined();
}
},
{
@@ -492,28 +486,7 @@ test('Layer#calculateInstancePickingColors', () => {
},
onAfterUpdate: ({layer}) => {
const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- expect(
- instancePickingColors.state.constant,
- 'instancePickingColors is enabled'
- ).toBeFalsy();
- expect(
- instancePickingColors.value.subarray(0, 8),
- 'instancePickingColors is populated'
- ).toEqual([1, 0, 0, 0, 2, 0, 0, 0]);
- }
- },
- {
- updateProps: {
- data: new Array(3).fill(0),
- // If a layer has been pickable once, picking colors attribute is always populated
- pickable: false
- },
- onAfterUpdate: ({layer}) => {
- const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- expect(
- instancePickingColors.value.subarray(0, 12),
- 'instancePickingColors is populated'
- ).toEqual([1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0]);
+ expect(instancePickingColors, 'instancePickingColors remains absent').toBeUndefined();
}
},
{
@@ -522,28 +495,15 @@ test('Layer#calculateInstancePickingColors', () => {
},
onBeforeUpdate: ({layer}) => {
layer.disablePickingIndex(1);
+ expect(layer.internalState.disabledPickingIndices, 'disabled index is tracked').toEqual([
+ 1
+ ]);
layer.restorePickingColors();
},
onAfterUpdate: ({layer}) => {
- const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- expect(
- instancePickingColors.value.subarray(0, 12),
- 'instancePickingColors is populated'
- ).toEqual([1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0]);
- }
- },
- {
- updateProps: {
- data: new Array(2 ** 24 + 100).fill(0),
- pickable: true
- },
- onAfterUpdate: ({layer}) => {
- const {instancePickingColors} = layer.getAttributeManager().getAttributes();
- const {length} = instancePickingColors.value;
- expect(
- length,
- `no over allocation for instancePickingColors buffer after 2**24 elements`
- ).toEqual((2 ** 24 + 100) * 4);
+ expect(layer.internalState.disabledPickingIndices, 'disabled indices are restored').toEqual(
+ []
+ );
}
}
];
diff --git a/test/modules/core/shaderlib/picking.spec.ts b/test/modules/core/shaderlib/picking.spec.ts
index d89fcc56dbf..35e15f67137 100644
--- a/test/modules/core/shaderlib/picking.spec.ts
+++ b/test/modules/core/shaderlib/picking.spec.ts
@@ -20,7 +20,11 @@ test('picking#wgsl uniform layout matches luma module contract', () => {
'isHighlightActive',
'useByteColors',
'highlightedObjectColor',
- 'highlightColor'
+ 'highlightColor',
+ 'disabledPickingIndexCount',
+ 'disabledPickingIndices0',
+ 'disabledPickingIndices1',
+ 'disabledPickingIndices2'
]);
});
diff --git a/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts b/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts
index 578ea3b595d..a7a7e2124a1 100644
--- a/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts
+++ b/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts
@@ -6,6 +6,8 @@ import {test, expect} from 'vitest';
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers';
import {RequestScheduler} from '@loaders.gl/loader-utils';
+const getPriority = tile => (tile.isSelected ? 1 : -1);
+
test('Tile2DHeader', async () => {
let onTileLoadCalled = false;
let onTileErrorCalled = false;
@@ -14,6 +16,7 @@ test('Tile2DHeader', async () => {
let tile2d = new Tile2DHeader({});
await tile2d.loadData({
requestScheduler,
+ getPriority,
getData: () => 'loaded data',
onLoad: () => (onTileLoadCalled = true),
onError: () => (onTileErrorCalled = true)
@@ -26,6 +29,7 @@ test('Tile2DHeader', async () => {
tile2d = new Tile2DHeader({});
await tile2d.loadData({
requestScheduler,
+ getPriority,
getData: () => {
throw new Error('getTileData error');
},
@@ -44,6 +48,7 @@ test('Tile2DHeader#Cancel request if not selected', async () => {
const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1});
const opts = {
requestScheduler,
+ getPriority,
getData: () => tileRequestCount++,
onLoad: () => onTileLoadCalled++,
onError: () => onTileErrorCalled++
@@ -66,6 +71,35 @@ test('Tile2DHeader#Cancel request if not selected', async () => {
expect(onTileLoadCalled === 1 && onTileErrorCalled === 0, 'Callbacks invoked').toBeTruthy();
});
+test('Tile2DHeader#request priority', async () => {
+ const requestOrder: string[] = [];
+ const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1});
+ const opts = {
+ requestScheduler,
+ getPriority: tile => (tile.id === 'center' ? 0 : 10),
+ getData: ({id}) => {
+ requestOrder.push(id);
+ return id;
+ },
+ onLoad: () => {},
+ onError: () => {}
+ };
+
+ const edgeTile = new Tile2DHeader({});
+ edgeTile.id = 'edge';
+ edgeTile.isSelected = true;
+ const centerTile = new Tile2DHeader({});
+ centerTile.id = 'center';
+ centerTile.isSelected = true;
+
+ const edgeLoader = edgeTile.loadData(opts);
+ const centerLoader = centerTile.loadData(opts);
+ await edgeLoader;
+ await centerLoader;
+
+ expect(requestOrder, 'lower request priority values load first').toEqual(['center', 'edge']);
+});
+
test('Tile2DHeader#abort', async () => {
const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1});
let onTileLoadCalled = false;
@@ -73,6 +107,7 @@ test('Tile2DHeader#abort', async () => {
const opts = {
requestScheduler,
+ getPriority,
getData: () => null,
onLoad: () => (onTileLoadCalled = true),
onError: () => (onTileErrorCalled = true)
@@ -104,6 +139,7 @@ test('Tile2DHeader#reload', async () => {
let onTileErrorCalled = 0;
const opts = {
requestScheduler,
+ getPriority,
onLoad: () => onTileLoadCalled++,
onError: () => onTileErrorCalled++
};