From 551ba956cf67f722695ec71f8a58d6ff6547d1a6 Mon Sep 17 00:00:00 2001 From: Harel M Date: Thu, 12 Dec 2024 12:03:49 +0200 Subject: [PATCH] Facilitate for globe transition expression refactoring (#5139) * Initial commit to move the logic of transition out to a new file * Remove one line method * Fix build test * Fix projection data bad refactoring * Fix render and size tests * Fix failing unit test * Uncomment unit tests * Fix typo, remove unneeded details provider initialization. Added current transfrom getter * Fix typo * Add back error correction for latitude. * Refactors the `Projection` classed only (#5163) * This refactors the projection class only, without touching other parts of the code. * remove mercator from the code, revert more changes * Refactor camera helper to split the logic for vertical-perspective and mercator (#5162) * Refactor camera helper * Use the helper in the factory. * Fix build test * Fix according to code review * Use `Transitionalable` infrastructure for transfrom globeness changes. (#5164) * Remove duplicate code * Fix lint, add some methods for original branch * Add projection definition in factory * Add has transition to projection * Fix transition value * Remove isRenderingDirty and use hasTransition when needed * Remove the last part of the animation handling in the transform class * More clean-up * Remove some "TODO"s. * Remove reference to globe projection in globe transform * Rename newFrame with recalculateCache * Remove unneeded ifs * Add support for arbitrary projection definitions * Uses mercator matrix when globe is using mercator transform * Improve handling of fog martix in globe transform --------- Co-authored-by: Isaac Besora Vilardaga * Globe custom layer fixes (#5150) * Simplify custom layer ProjectionData code in mercator transform * Fix globe tiles example * Fix globe projection shader * Add custom layer 3D model after globe->mercator transition render test * Fix custom layer 3D models not rendering when globe transitions to mercator * Move camera to center distance to helper # Conflicts: # src/geo/projection/globe_transform.ts # src/geo/projection/mercator_transform.ts # src/geo/transform_helper.ts # src/geo/transform_interface.ts * Synchronize globe+mercator near and far Z to prevent 3D model disappearing during projection transition * Expose getProjectionData and getMatrixForModel for custom layers, don't use internal API in examples * VerticalPerspectiveTransform should have a valid getProjectionDataForCustomLayer implementation * Add changelog entry * Fix missing docs * Fix docs again * Review feedback * Move nearZfarZoverride to transform helper * Update build size * Review feedback * Change near/far Z override API * Custom layers use internal API * Fix globe custom tiles example * Update build size * Fix typo --------- Co-authored-by: HarelM * Final removal of TODOs and some minor clean up * Update CHANGELOG.md --------- Co-authored-by: Isaac Besora Vilardaga Co-authored-by: Jakub Pelc <57600346+kubapelc@users.noreply.github.com> --- CHANGELOG.md | 3 + src/geo/projection/camera_helper.ts | 72 +- src/geo/projection/covering_tiles.test.ts | 22 +- src/geo/projection/covering_tiles.ts | 10 +- .../covering_tiles_details_provider.ts | 5 + src/geo/projection/globe_camera_helper.ts | 475 +-------- .../globe_covering_tiles_details_provider.ts | 15 +- src/geo/projection/globe_projection.ts | 139 +++ src/geo/projection/globe_transform.test.ts | 100 +- src/geo/projection/globe_transform.ts | 999 +++--------------- src/geo/projection/globe_utils.ts | 5 +- src/geo/projection/mercator_camera_helper.ts | 82 +- ...ercator_covering_tiles_details_provider.ts | 4 + .../{mercator.ts => mercator_projection.ts} | 25 +- src/geo/projection/mercator_transform.ts | 222 ++-- src/geo/projection/mercator_utils.test.ts | 38 +- src/geo/projection/mercator_utils.ts | 62 +- src/geo/projection/projection.ts | 40 +- src/geo/projection/projection_data.ts | 4 + src/geo/projection/projection_factory.ts | 47 +- .../vertical_perspective_camera_helper.ts | 454 ++++++++ ....ts => vertical_perspective_projection.ts} | 92 +- .../vertical_perspective_transform.ts | 991 +++++++++++++++++ src/geo/transform_helper.test.ts | 33 +- src/geo/transform_helper.ts | 152 ++- src/geo/transform_interface.ts | 71 +- src/render/draw_custom.test.ts | 2 +- src/render/draw_symbol.test.ts | 2 +- src/render/painter.ts | 4 +- src/shaders/_projection_globe.vertex.glsl | 40 +- src/source/source_cache.ts | 2 +- src/style/style.ts | 9 +- src/ui/camera.test.ts | 7 +- src/ui/camera.ts | 7 +- src/ui/control/navigation_control.ts | 1 - src/ui/handler/scroll_zoom.ts | 5 +- src/ui/map.ts | 16 +- src/ui/map_tests/map_events.test.ts | 8 +- src/ui/map_tests/map_resize.test.ts | 2 +- src/util/primitives/aabb_cache.test.ts | 10 +- src/util/primitives/aabb_cache.ts | 2 +- src/util/test/util.ts | 12 - src/util/util.test.ts | 10 +- src/util/util.ts | 12 + test/bench/benchmarks/covering_tiles_globe.ts | 11 +- test/bench/benchmarks/symbol_collision_box.ts | 5 +- test/build/min.test.ts | 2 +- test/examples/add-3d-model.html | 2 +- test/examples/globe-3d-model.html | 2 +- test/examples/globe-custom-tiles.html | 4 +- .../custom/tent-3d-globe-zoomed/expected.png | Bin 0 -> 341 bytes .../custom/tent-3d-globe-zoomed/style.json | 83 ++ .../terrain/fog-sky-blend-globe/expected.png | Bin 11103 -> 10867 bytes .../terrain/fog-sky-blend-globe/style.json | 4 +- 54 files changed, 2469 insertions(+), 1957 deletions(-) create mode 100644 src/geo/projection/globe_projection.ts rename src/geo/projection/{mercator.ts => mercator_projection.ts} (90%) create mode 100644 src/geo/projection/vertical_perspective_camera_helper.ts rename src/geo/projection/{globe.ts => vertical_perspective_projection.ts} (78%) create mode 100644 src/geo/projection/vertical_perspective_transform.ts create mode 100644 test/integration/render/tests/projection/globe/custom/tent-3d-globe-zoomed/expected.png create mode 100644 test/integration/render/tests/projection/globe/custom/tent-3d-globe-zoomed/style.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e37e34d47c..0602ba242c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ ## main ### ✨ Features and improvements +- Add support for projection type expression as part of a refactoring of the transfrom and projection classes ([#5139](https://github.com/maplibre/maplibre-gl-js/pull/5139)) - _...Add new stuff here..._ ### 🐞 Bug fixes +- Fix globe custom layers being supplied incorrect matrices after projection transition to mercator ([#5150](https://github.com/maplibre/maplibre-gl-js/pull/5150)) +- Fix custom 3D models disappearing during projection transition ([#5150](https://github.com/maplibre/maplibre-gl-js/pull/5150)) - _...Add new stuff here..._ ## 5.0.0-pre.9 diff --git a/src/geo/projection/camera_helper.ts b/src/geo/projection/camera_helper.ts index 6887af51eb..7090d139c5 100644 --- a/src/geo/projection/camera_helper.ts +++ b/src/geo/projection/camera_helper.ts @@ -1,12 +1,13 @@ -import type Point from '@mapbox/point-geometry'; +import Point from '@mapbox/point-geometry'; import {type IReadonlyTransform, type ITransform} from '../transform_interface'; import {type LngLat, type LngLatLike} from '../lng_lat'; import {type CameraForBoundsOptions, type PointLike} from '../../ui/camera'; import {type PaddingOptions} from '../edge_insets'; import {type LngLatBounds} from '../lng_lat_bounds'; -import {getRollPitchBearing, type RollPitchBearing, rollPitchBearingToQuat, warnOnce} from '../../util/util'; +import {degreesToRadians, getRollPitchBearing, type RollPitchBearing, rollPitchBearingToQuat, scaleZoom, warnOnce, zoomScale} from '../../util/util'; import {quat} from 'gl-matrix'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; +import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils'; export type MapControlsDeltas = { panDelta: Point; @@ -151,3 +152,70 @@ export function updateRotation(args: UpdateRotationArgs) { args.tr.setBearing(interpolates.number(args.startEulerAngles.bearing, args.endEulerAngles.bearing, args.k)); } } + +export function cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult { + const edgePadding = tr.padding; + + // Consider all corners of the rotated bounding box derived from the given points + // when find the camera position that fits the given points. + + const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest()); + const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast()); + const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast()); + const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest()); + + const bearingRadians = degreesToRadians(-bearing); + + const nwRotatedWorld = nwWorld.rotate(bearingRadians); + const neRotatedWorld = neWorld.rotate(bearingRadians); + const seRotatedWorld = seWorld.rotate(bearingRadians); + const swRotatedWorld = swWorld.rotate(bearingRadians); + + const upperRight = new Point( + Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), + Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) + ); + + const lowerLeft = new Point( + Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), + Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) + ); + + // Calculate zoom: consider the original bbox and padding. + const size = upperRight.sub(lowerLeft); + + const availableWidth = (tr.width - (edgePadding.left + edgePadding.right + padding.left + padding.right)); + const availableHeight = (tr.height - (edgePadding.top + edgePadding.bottom + padding.top + padding.bottom)); + const scaleX = availableWidth / size.x; + const scaleY = availableHeight / size.y; + + if (scaleY < 0 || scaleX < 0) { + cameraBoundsWarning(); + return undefined; + } + + const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); + + // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. + const offset = Point.convert(options.offset); + const paddingOffsetX = (padding.left - padding.right) / 2; + const paddingOffsetY = (padding.top - padding.bottom) / 2; + const paddingOffset = new Point(paddingOffsetX, paddingOffsetY); + const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing)); + const offsetAtInitialZoom = offset.add(rotatedPaddingOffset); + const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom)); + + const center = unprojectFromWorldCoordinates( + tr.worldSize, + // either world diagonal can be used (NW-SE or NE-SW) + nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom) + ); + + const result = { + center, + zoom, + bearing + }; + + return result; +} \ No newline at end of file diff --git a/src/geo/projection/covering_tiles.test.ts b/src/geo/projection/covering_tiles.test.ts index b13d64df91..182b5948a9 100644 --- a/src/geo/projection/covering_tiles.test.ts +++ b/src/geo/projection/covering_tiles.test.ts @@ -1,25 +1,21 @@ import {beforeEach, describe, expect, test} from 'vitest'; import {GlobeTransform} from './globe_transform'; -import {globeConstants, type GlobeProjection} from './globe'; -import {getGlobeProjectionMock} from '../../util/test/util'; import {LngLat} from '../lng_lat'; import {coveringTiles, coveringZoomLevel, type CoveringZoomOptions} from './covering_tiles'; import {OverscaledTileID} from '../../source/tile_id'; import {MercatorTransform} from './mercator_transform'; +import {globeConstants} from './vertical_perspective_projection'; describe('coveringTiles', () => { describe('globe', () => { - let globeProjectionMock: GlobeProjection; beforeEach(() => { - globeProjectionMock = getGlobeProjectionMock(); // Force faster animations so we can use shorter sleeps when testing them - globeConstants.globeTransitionTimeSeconds = 0.1; globeConstants.errorTransitionTimeSeconds = 0.1; }); test('zoomed out', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(0.0, 0.0)); transform.setZoom(-1); @@ -34,7 +30,7 @@ describe('coveringTiles', () => { }); test('zoomed in', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(-0.02, 0.01)); transform.setZoom(3); @@ -52,7 +48,7 @@ describe('coveringTiles', () => { }); test('zoomed in 512x512', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(512, 512); transform.setCenter(new LngLat(-0.02, 0.01)); transform.setZoom(3); @@ -78,7 +74,7 @@ describe('coveringTiles', () => { }); test('pitched', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(-0.002, 0.001)); transform.setZoom(8); @@ -98,7 +94,7 @@ describe('coveringTiles', () => { }); test('pitched+rotated', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(-0.002, 0.001)); transform.setZoom(8); @@ -123,7 +119,7 @@ describe('coveringTiles', () => { }); test('antimeridian1', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(179.99, -0.001)); transform.setZoom(5); @@ -141,7 +137,7 @@ describe('coveringTiles', () => { }); test('antimeridian2', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(-179.99, 0.001)); transform.setZoom(5); @@ -159,7 +155,7 @@ describe('coveringTiles', () => { }); test('zoom < 0', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(128, 128); transform.setCenter(new LngLat(0.0, 80.0)); transform.setZoom(-0.5); diff --git a/src/geo/projection/covering_tiles.ts b/src/geo/projection/covering_tiles.ts index 7ce9de8797..70796f0d5d 100644 --- a/src/geo/projection/covering_tiles.ts +++ b/src/geo/projection/covering_tiles.ts @@ -1,13 +1,13 @@ import {OverscaledTileID} from '../../source/tile_id'; import {vec2, type vec4} from 'gl-matrix'; -import {type IReadonlyTransform} from '../transform_interface'; import {MercatorCoordinate} from '../mercator_coordinate'; -import {scaleZoom} from '../transform_helper'; -import {clamp, degreesToRadians} from '../../util/util'; -import {type Terrain} from '../../render/terrain'; -import {type Frustum} from '../../util/primitives/frustum'; +import {clamp, degreesToRadians, scaleZoom} from '../../util/util'; import {type Aabb, IntersectionResult} from '../../util/primitives/aabb'; +import type {IReadonlyTransform} from '../transform_interface'; +import type {Terrain} from '../../render/terrain'; +import type {Frustum} from '../../util/primitives/frustum'; + type CoveringTilesResult = { tileID: OverscaledTileID; distanceSq: number; diff --git a/src/geo/projection/covering_tiles_details_provider.ts b/src/geo/projection/covering_tiles_details_provider.ts index 344f129dc1..9c962433c7 100644 --- a/src/geo/projection/covering_tiles_details_provider.ts +++ b/src/geo/projection/covering_tiles_details_provider.ts @@ -36,4 +36,9 @@ export interface CoveringTilesDetailsProvider { * Whether to allow world copies to be rendered. */ allowWorldCopies: () => boolean; + + /** + * Prepare cache for the next frame. + */ + recalculateCache(): void; } diff --git a/src/geo/projection/globe_camera_helper.ts b/src/geo/projection/globe_camera_helper.ts index a7ddcab4e9..0ad9f18580 100644 --- a/src/geo/projection/globe_camera_helper.ts +++ b/src/geo/projection/globe_camera_helper.ts @@ -1,17 +1,14 @@ -import Point from '@mapbox/point-geometry'; -import {type IReadonlyTransform, type ITransform} from '../transform_interface'; -import {cameraBoundsWarning, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs} from './camera_helper'; -import {type GlobeProjection} from './globe'; -import {LngLat, type LngLatLike} from '../lng_lat'; import {MercatorCameraHelper} from './mercator_camera_helper'; -import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils'; -import {clamp, createVec3f64, differenceOfAnglesDegrees, remapSaturate, rollPitchBearingEqual, warnOnce} from '../../util/util'; -import {type mat4, vec3} from 'gl-matrix'; -import {MAX_VALID_LATITUDE, normalizeCenter, scaleZoom, zoomScale} from '../transform_helper'; -import {type CameraForBoundsOptions} from '../../ui/camera'; -import {type LngLatBounds} from '../lng_lat_bounds'; -import {type PaddingOptions} from '../edge_insets'; -import {interpolates} from '@maplibre/maplibre-gl-style-spec'; +import {VerticalPerspectiveCameraHelper} from './vertical_perspective_camera_helper'; + +import type Point from '@mapbox/point-geometry'; +import type {CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper'; +import type {LngLat, LngLatLike} from '../lng_lat'; +import type {IReadonlyTransform, ITransform} from '../transform_interface'; +import type {GlobeProjection} from './globe_projection'; +import type {CameraForBoundsOptions} from '../../ui/camera'; +import type {LngLatBounds} from '../lng_lat_bounds'; +import type {PaddingOptions} from '../edge_insets'; /** * @internal @@ -19,475 +16,51 @@ import {interpolates} from '@maplibre/maplibre-gl-style-spec'; export class GlobeCameraHelper implements ICameraHelper { private _globe: GlobeProjection; private _mercatorCameraHelper: MercatorCameraHelper; + private _verticalPerspectiveCameraHelper: VerticalPerspectiveCameraHelper; constructor(globe: GlobeProjection) { this._globe = globe; this._mercatorCameraHelper = new MercatorCameraHelper(); + this._verticalPerspectiveCameraHelper = new VerticalPerspectiveCameraHelper(); } get useGlobeControls(): boolean { return this._globe.useGlobeRendering; } + get currentHelper(): ICameraHelper { + return this.useGlobeControls ? this._verticalPerspectiveCameraHelper : this._mercatorCameraHelper; + } + handlePanInertia(pan: Point, transform: IReadonlyTransform): { easingCenter: LngLat; easingOffset: Point; } { - if (!this.useGlobeControls) { - return this._mercatorCameraHelper.handlePanInertia(pan, transform); - } - - const panCenter = computeGlobePanCenter(pan, transform); - if (Math.abs(panCenter.lng - transform.center.lng) > 180) { - // If easeTo target would be over 180° distant, the animation would move - // in the opposite direction that what the user intended. - // Thus we clamp the movement to 179.5°. - panCenter.lng = transform.center.lng + 179.5 * Math.sign(panCenter.lng - transform.center.lng); - } - return { - easingCenter: panCenter, - easingOffset: new Point(0, 0), - }; + return this.currentHelper.handlePanInertia(pan, transform); } handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { - if (!this.useGlobeControls) { - this._mercatorCameraHelper.handleMapControlsRollPitchBearingZoom(deltas, tr); - return; - } - - const zoomPixel = deltas.around; - const zoomLoc = tr.screenPointToLocation(zoomPixel); - - if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta); - if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta); - if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta); - const oldZoomPreZoomDelta = tr.zoom; - if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta); - const actualZoomDelta = tr.zoom - oldZoomPreZoomDelta; - - if (actualZoomDelta === 0) { - return; - } - - // Problem: `setLocationAtPoint` for globe works when it is called a single time, but is a little glitchy in practice when used repeatedly for zooming. - // - `setLocationAtPoint` repeatedly called at a location behind a pole will eventually glitch out - // - `setLocationAtPoint` at location the longitude of which is more than 90° different from current center will eventually glitch out - // But otherwise works fine at higher zooms, or when the target is somewhat near the current map center. - // Solution: use a heuristic zooming in the problematic cases and interpolate to `setLocationAtPoint` when possible. - - // Magic numbers that control: - // - when zoom movement slowing starts for cursor not on globe (avoid unnatural map movements) - // - when we interpolate from exact zooming to heuristic zooming based on longitude difference of target location to current center - // - when we interpolate from exact zooming to heuristic zooming based on globe being too small on screen - // - when zoom movement slowing starts for globe being too small on viewport (avoids unnatural/unwanted map movements when map is zoomed out a lot) - const raySurfaceDistanceForSlowingStart = 0.3; // Zoom movement slowing will start when the planet surface to ray distance is greater than this number (globe radius is 1, so 0.3 is ~2000km form the surface). - const slowingMultiplier = 0.5; // The lower this value, the slower will the "zoom movement slowing" occur. - const interpolateToHeuristicStartLng = 45; // When zoom location longitude is this many degrees away from map center, we start interpolating from exact zooming to heuristic zooming. - const interpolateToHeuristicEndLng = 85; // Longitude difference at which interpolation to heuristic zooming ends. - const interpolateToHeuristicExponent = 0.25; // Makes interpolation smoother. - const interpolateToHeuristicStartRadius = 0.75; // When globe is this many times larger than the smaller viewport dimension, we start interpolating from exact zooming to heuristic zooming. - const interpolateToHeuristicEndRadius = 0.35; // Globe size at which interpolation to heuristic zooming ends. - const slowingRadiusStart = 0.9; // If globe is this many times larger than the smaller viewport dimension, start inhibiting map movement while zooming - const slowingRadiusStop = 0.5; - const slowingRadiusSlowFactor = 0.25; // How much is movement slowed when globe is too small - - const dLngRaw = differenceOfAnglesDegrees(tr.center.lng, zoomLoc.lng); - const dLng = dLngRaw / (Math.abs(dLngRaw / 180) + 1.0); // This gradually reduces the amount of longitude change if the zoom location is very far, eg. on the other side of the pole (possible when looking at a pole). - const dLat = differenceOfAnglesDegrees(tr.center.lat, zoomLoc.lat); - - // Slow zoom movement down if the mouse ray is far from the planet. - const rayDirection = tr.getRayDirectionFromPixel(zoomPixel); - const rayOrigin = tr.cameraPosition; - const distanceToClosestPoint = vec3.dot(rayOrigin, rayDirection) * -1; // Globe center relative to ray origin is equal to -rayOrigin and rayDirection is normalized, thus we want to compute dot(-rayOrigin, rayDirection). - const closestPoint = createVec3f64(); - vec3.add(closestPoint, rayOrigin, [ - rayDirection[0] * distanceToClosestPoint, - rayDirection[1] * distanceToClosestPoint, - rayDirection[2] * distanceToClosestPoint - ]); - const distanceFromSurface = vec3.length(closestPoint) - 1; - const distanceFactor = Math.exp(-Math.max(distanceFromSurface - raySurfaceDistanceForSlowingStart, 0) * slowingMultiplier); - - // Slow zoom movement down if the globe is too small on viewport - const radius = getGlobeRadiusPixels(tr.worldSize, tr.center.lat) / Math.min(tr.width, tr.height); // Radius relative to larger viewport dimension - const radiusFactor = remapSaturate(radius, slowingRadiusStart, slowingRadiusStop, 1.0, slowingRadiusSlowFactor); - - // Compute how much to move towards the zoom location - const factor = (1.0 - zoomScale(-actualZoomDelta)) * Math.min(distanceFactor, radiusFactor); - - const oldCenterLat = tr.center.lat; - const oldZoom = tr.zoom; - const heuristicCenter = new LngLat( - tr.center.lng + dLng * factor, - clamp(tr.center.lat + dLat * factor, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE) - ); - - // Now compute the map center exact zoom - tr.setLocationAtPoint(zoomLoc, zoomPixel); - const exactCenter = tr.center; - - // Interpolate between exact zooming and heuristic zooming depending on the longitude difference between current center and zoom location. - const interpolationFactorLongitude = remapSaturate(Math.abs(dLngRaw), interpolateToHeuristicStartLng, interpolateToHeuristicEndLng, 0, 1); - const interpolationFactorRadius = remapSaturate(radius, interpolateToHeuristicStartRadius, interpolateToHeuristicEndRadius, 0, 1); - const heuristicFactor = Math.pow(Math.max(interpolationFactorLongitude, interpolationFactorRadius), interpolateToHeuristicExponent); - - const lngExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lng, heuristicCenter.lng); - const latExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lat, heuristicCenter.lat); - - tr.setCenter(new LngLat( - exactCenter.lng + lngExactToHeuristic * heuristicFactor, - exactCenter.lat + latExactToHeuristic * heuristicFactor - ).wrap()); - tr.setZoom(oldZoom + getZoomAdjustment(oldCenterLat, tr.center.lat)); + return this.currentHelper.handleMapControlsRollPitchBearingZoom(deltas, tr); } handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void { - if (!this.useGlobeControls) { - this._mercatorCameraHelper.handleMapControlsPan(deltas, tr, preZoomAroundLoc); - return; - } - - if (!deltas.panDelta) { - return; - } - - // These are actually very similar to mercator controls, and should converge to them at high zooms. - // We avoid using the "grab a place and move it around" approach from mercator here, - // since it is not a very pleasant way to pan a globe. - const oldLat = tr.center.lat; - const oldZoom = tr.zoom; - tr.setCenter(computeGlobePanCenter(deltas.panDelta, tr).wrap()); - // Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time - tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat)); + this.currentHelper.handleMapControlsPan(deltas, tr, preZoomAroundLoc); } cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult { - const result = this._mercatorCameraHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr); - - if (!this.useGlobeControls) { - return result; - } - - // If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds. - - // Get clip space bounds including padding - const xLeft = (padding.left) / tr.width * 2.0 - 1.0; - const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0; - const yTop = (padding.top) / tr.height * -2.0 + 1.0; - const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0; - - // Get camera bounds - const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0; - const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest(); - const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast(); - - const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north... - const latSouth = Math.min(bounds.getNorth(), bounds.getSouth()); - - // Additional vectors will be tested for the rectangle midpoints - const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5; - const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5; - - // Obtain a globe projection matrix that does not include pitch (unsupported) - const clonedTr = tr.clone(); - clonedTr.setCenter(result.center); - clonedTr.setBearing(result.bearing); - clonedTr.setPitch(0); - clonedTr.setRoll(0); - clonedTr.setZoom(result.zoom); - const matrix = clonedTr.modelViewProjectionMatrix; - - // Vectors to test - the bounds' corners and edge midpoints - const testVectors = [ - angularCoordinatesToSurfaceVector(bounds.getNorthWest()), - angularCoordinatesToSurfaceVector(bounds.getNorthEast()), - angularCoordinatesToSurfaceVector(bounds.getSouthWest()), - angularCoordinatesToSurfaceVector(bounds.getSouthEast()), - // Also test edge midpoints - angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)), - angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)), - angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)), - angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth)) - ]; - const vecToCenter = angularCoordinatesToSurfaceVector(result.center); - - // Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space. - let smallestNeededScale = Number.POSITIVE_INFINITY; - for (const vec of testVectors) { - if (xLeft < 0) - smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft)); - if (xRight > 0) - smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight)); - if (yTop > 0) - smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop)); - if (yBottom < 0) - smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom)); - } - - if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) { - cameraBoundsWarning(); - return undefined; - } - - // Compute target zoom from the obtained scale. - result.zoom = clonedTr.zoom + scaleZoom(smallestNeededScale); - return result; + return this.currentHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr); } /** * Handles the zoom and center change during camera jumpTo. */ handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void { - if (!this.useGlobeControls) { - this._mercatorCameraHelper.handleJumpToCenterZoom(tr, options); - return; - } - - // Special zoom & center handling for globe: - // Globe constrained center isn't dependent on zoom level - const startingLat = tr.center.lat; - const constrainedCenter = tr.getConstrained(options.center ? LngLat.convert(options.center) : tr.center, tr.zoom).center; - tr.setCenter(constrainedCenter.wrap()); - - // Make sure to compute correct target zoom level if no zoom is specified - const targetZoom = (typeof options.zoom !== 'undefined') ? +options.zoom : (tr.zoom + getZoomAdjustment(startingLat, constrainedCenter.lat)); - if (tr.zoom !== targetZoom) { - tr.setZoom(targetZoom); - } + this.currentHelper.handleJumpToCenterZoom(tr, options); } handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult { - if (!this.useGlobeControls) { - return this._mercatorCameraHelper.handleEaseTo(tr, options); - } - - const startZoom = tr.zoom; - const startCenter = tr.center; - const startPadding = tr.padding; - const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing}; - const endRoll = options.roll === undefined ? tr.roll : options.roll; - const endPitch = options.pitch === undefined ? tr.pitch : options.pitch; - const endBearing = options.bearing === undefined ? tr.bearing : options.bearing; - const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing}; - - const optionsZoom = typeof options.zoom !== 'undefined'; - - const doPadding = !tr.isPaddingEqual(options.padding); - - let isZooming = false; - - // Globe needs special handling for how zoom should be animated. - // 1) if zoom is set, ease to the given mercator zoom - // 2) if neither is set, assume constant apparent zoom (constant planet size) is to be kept - const preConstrainCenter = options.center ? - LngLat.convert(options.center) : - startCenter; - const constrainedCenter = tr.getConstrained( - preConstrainCenter, - startZoom // zoom can be whatever at this stage, it should not affect anything if globe is enabled - ).center; - normalizeCenter(tr, constrainedCenter); - - const clonedTr = tr.clone(); - clonedTr.setCenter(constrainedCenter); - - clonedTr.setZoom(optionsZoom ? - +options.zoom : - startZoom + getZoomAdjustment(startCenter.lat, preConstrainCenter.lat)); - clonedTr.setBearing(options.bearing); - const clampedPoint = new Point( - clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), - clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) - ); - clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); - // Find final animation targets - const endCenterWithShift = (options.offset && options.offsetAsPoint.mag()) > 0 ? clonedTr.center : constrainedCenter; - const endZoomWithShift = optionsZoom ? - +options.zoom : - startZoom + getZoomAdjustment(startCenter.lat, endCenterWithShift.lat); - - // Planet radius for a given zoom level differs according to latitude - // Convert zooms to what they would be at equator for the given planet radius - const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); - const normalizedEndZoom = endZoomWithShift + getZoomAdjustment(endCenterWithShift.lat, 0); - const deltaLng = differenceOfAnglesDegrees(startCenter.lng, endCenterWithShift.lng); - const deltaLat = differenceOfAnglesDegrees(startCenter.lat, endCenterWithShift.lat); - - const finalScale = zoomScale(normalizedEndZoom - normalizedStartZoom); - isZooming = (endZoomWithShift !== startZoom); - - const easeFunc = (k: number) => { - if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) { - updateRotation({ - startEulerAngles, - endEulerAngles, - tr, - k, - useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs); - } - - if (doPadding) { - tr.interpolatePadding(startPadding, options.padding,k); - } - - if (options.around) { - warnOnce('Easing around a point is not supported under globe projection.'); - tr.setLocationAtPoint(options.around, options.aroundPoint); - } else { - const base = normalizedEndZoom > normalizedStartZoom ? - Math.min(2, finalScale) : - Math.max(0.5, finalScale); - const speedup = Math.pow(base, 1 - k); - const factor = k * speedup; - - // Spherical lerp might be used here instead, but that was tested and it leads to very weird paths when the interpolated arc gets near the poles. - // Instead we interpolate LngLat almost directly, but taking into account that - // one degree of longitude gets progressively smaller relative to latitude towards the poles. - const newCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, factor); - tr.setCenter(newCenter.wrap()); - } - - if (isZooming) { - const normalizedInterpolatedZoom = interpolates.number(normalizedStartZoom, normalizedEndZoom, k); - const interpolatedZoom = normalizedInterpolatedZoom + getZoomAdjustment(0, tr.center.lat); - tr.setZoom(interpolatedZoom); - } - }; - - return { - easeFunc, - isZooming, - elevationCenter: endCenterWithShift, - }; + return this.currentHelper.handleEaseTo(tr, options); } handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult { - if (!this.useGlobeControls) { - return this._mercatorCameraHelper.handleFlyTo(tr, options); - } - const optionsZoom = typeof options.zoom !== 'undefined'; - - const startCenter = tr.center; - const startZoom = tr.zoom; - const doPadding = !tr.isPaddingEqual(options.padding); - - // Obtain target center and zoom - const constrainedCenter = tr.getConstrained( - LngLat.convert(options.center || options.locationAtOffset), - startZoom - ).center; - const targetZoom = optionsZoom ? +options.zoom : tr.zoom + getZoomAdjustment(tr.center.lat, constrainedCenter.lat); - - // Compute target center that respects offset by creating a temporary transform and calling its `setLocationAtPoint`. - const clonedTr = tr.clone(); - clonedTr.setCenter(constrainedCenter); - if (doPadding) { - clonedTr.setPadding(options.padding as PaddingOptions); - } - clonedTr.setZoom(targetZoom); - clonedTr.setBearing(options.bearing); - const clampedPoint = new Point( - clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), - clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) - ); - clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); - const targetCenter = clonedTr.center; - - normalizeCenter(tr, targetCenter); - - const pixelPathLength = globeDistanceOfLocationsPixels(tr, startCenter, targetCenter); - - const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); - const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0); - const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom); - - const optionsMinZoom = typeof options.minZoom === 'number'; - - let scaleOfMinZoom: number; - - if (optionsMinZoom) { - const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0); - const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom); - const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat); - const minZoom = tr.getConstrained(targetCenter, minZoomPreConstrain).zoom; - const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0); - scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom); - } - - const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng); - const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat); - - const easeFunc = (k: number, scale: number, centerFactor: number, _pointAtOffset: Point) => { - const interpolatedCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, centerFactor); - - const newCenter = k === 1 ? targetCenter : interpolatedCenter; - tr.setCenter(newCenter.wrap()); - - const interpolatedZoom = normalizedStartZoom + scaleZoom(scale); - tr.setZoom(k === 1 ? targetZoom : (interpolatedZoom + getZoomAdjustment(0, newCenter.lat))); - }; - - return { - easeFunc, - scaleOfZoom, - targetCenter, - scaleOfMinZoom, - pixelPathLength, - }; - } - - /** - * Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis. - * @param vector - Position of the queried location on the surface of the unit sphere globe. - * @param toCenter - Position of current transform center on the surface of the unit sphere globe. - * This is needed because zooming the globe not only changes its scale, - * but also moves the camera closer or further away along this vector (pitch is disregarded). - * @param projection - The globe projection matrix. - * @param targetDimension - The dimension in which the scaled vector must match the target value in clip space. - * @param targetValue - The target clip space value in the specified dimension to which the queried vector must project. - * @returns How much to scale the globe. - */ - private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null { - // We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y). - const k = targetValue; - const columnXorY = targetDimension === 'x' ? - [projection[0], projection[4], projection[8], projection[12]] : // X - [projection[1], projection[5], projection[9], projection[13]]; // Y - const columnZ = [projection[3], projection[7], projection[11], projection[15]]; - - const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2]; - const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2]; - const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2]; - const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2]; - - // The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t". - // Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C". - - const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ); - - if ( - toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ || - columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ - ) { - // The computed result is invalid. - return null; - } - return t; - } - - /** - * Returns `newValue` if it is: - * - * - not null AND - * - not negative AND - * - smaller than `newValue`, - * - * ...otherwise returns `oldValue`. - */ - private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number { - if (newValue !== null && newValue >= 0 && newValue < oldValue) { - return newValue; - } else { - return oldValue; - } + return this.currentHelper.handleFlyTo(tr, options); } -} +} \ No newline at end of file diff --git a/src/geo/projection/globe_covering_tiles_details_provider.ts b/src/geo/projection/globe_covering_tiles_details_provider.ts index 6f7d27e524..6359e7a6f0 100644 --- a/src/geo/projection/globe_covering_tiles_details_provider.ts +++ b/src/geo/projection/globe_covering_tiles_details_provider.ts @@ -1,12 +1,12 @@ -import {type vec3} from 'gl-matrix'; -import {type IReadonlyTransform} from '../transform_interface'; -import {type MercatorCoordinate} from '../mercator_coordinate'; import {EXTENT} from '../../data/extent'; import {projectTileCoordinatesToSphere} from './globe_utils'; -import {type CoveringTilesOptions, coveringZoomLevel} from './covering_tiles'; -import {type CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; import {Aabb} from '../../util/primitives/aabb'; import {AabbCache} from '../../util/primitives/aabb_cache'; +import {coveringZoomLevel, type CoveringTilesOptions} from './covering_tiles'; +import type {vec3} from 'gl-matrix'; +import type {IReadonlyTransform} from '../transform_interface'; +import type {MercatorCoordinate} from '../mercator_coordinate'; +import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; /** * Computes distance of a point to a tile in an arbitrary axis. @@ -43,10 +43,9 @@ export class GlobeCoveringTilesDetailsProvider implements CoveringTilesDetailsPr /** * Prepares the internal AABB cache for the next frame. - * @returns */ - newFrame() { - this._aabbCache.newFrame(); + recalculateCache() { + this._aabbCache.recalculateCache(); } /** diff --git a/src/geo/projection/globe_projection.ts b/src/geo/projection/globe_projection.ts new file mode 100644 index 0000000000..e3968797f8 --- /dev/null +++ b/src/geo/projection/globe_projection.ts @@ -0,0 +1,139 @@ +import {ProjectionDefinition, type ProjectionDefinitionSpecification, type ProjectionSpecification, type StylePropertySpecification, latest as styleSpec} from '@maplibre/maplibre-gl-style-spec'; +import {DataConstantProperty, type PossiblyEvaluated, Properties, Transitionable, type Transitioning, type TransitionParameters} from '../../style/properties'; +import {Evented} from '../../util/evented'; +import {EvaluationParameters} from '../../style/evaluation_parameters'; +import {MercatorProjection} from './mercator_projection'; +import {VerticalPerspectiveProjection} from './vertical_perspective_projection'; +import {type Projection, type ProjectionGPUContext, type TileMeshUsage} from './projection'; +import {type PreparedShader} from '../../shaders/shaders'; +import {type SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import {type Context} from '../../gl/context'; +import {type CanonicalTileID} from '../../source/tile_id'; +import {type Mesh} from '../../render/mesh'; + +type ProjectionProps = { + type: DataConstantProperty; +} + +type ProjectionPossiblyEvaluated = { + type: ProjectionDefinitionSpecification; +} + +const properties: Properties = new Properties({ + 'type': new DataConstantProperty(styleSpec.projection.type as StylePropertySpecification) +}); + +export class GlobeProjection extends Evented implements Projection { + properties: PossiblyEvaluated; + + _transitionable: Transitionable; + _transitioning: Transitioning; + _mercatorProjection: MercatorProjection; + _verticalPerspectiveProjection: VerticalPerspectiveProjection; + + constructor(projection?: ProjectionSpecification) { + super(); + this._transitionable = new Transitionable(properties); + this.setProjection(projection); + this._transitioning = this._transitionable.untransitioned(); + this.recalculate(new EvaluationParameters(0)); + this._mercatorProjection = new MercatorProjection(); + this._verticalPerspectiveProjection = new VerticalPerspectiveProjection(); + } + + public get transitionState(): number { + const currentProjectionSpecValue = this.properties.get('type'); + if (typeof currentProjectionSpecValue === 'string' && currentProjectionSpecValue === 'mercator') { + return 0; + } + if (typeof currentProjectionSpecValue === 'string' && currentProjectionSpecValue === 'vertical-perspective') { + return 1; + } + if (currentProjectionSpecValue instanceof ProjectionDefinition) { + if (currentProjectionSpecValue.from === 'vertical-perspective' && currentProjectionSpecValue.to === 'mercator') { + return 1 - currentProjectionSpecValue.transition; + } + if (currentProjectionSpecValue.from === 'mercator' && currentProjectionSpecValue.to === 'vertical-perspective') { + return currentProjectionSpecValue.transition; + } + }; + return 1; + } + + get useGlobeRendering(): boolean { + return this.transitionState > 0; + } + + get latitudeErrorCorrectionRadians(): number { return this._verticalPerspectiveProjection.latitudeErrorCorrectionRadians; } + + private get currentProjection(): Projection { + return this.useGlobeRendering ? this._verticalPerspectiveProjection : this._mercatorProjection; + } + + get name(): ProjectionSpecification['type'] { + return 'globe'; + } + + get useSubdivision(): boolean { + return this.currentProjection.useSubdivision; + } + + get shaderVariantName(): string { + return this.currentProjection.shaderVariantName; + } + + get shaderDefine(): string { + return this.currentProjection.shaderDefine; + } + + get shaderPreludeCode(): PreparedShader { + return this.currentProjection.shaderPreludeCode; + } + + get vertexShaderPreludeCode(): string { + return this.currentProjection.vertexShaderPreludeCode; + } + + get subdivisionGranularity(): SubdivisionGranularitySetting { + return this.currentProjection.subdivisionGranularity; + } + + get useGlobeControls(): boolean { + return this.transitionState > 0; + } + + public destroy(): void { + this._mercatorProjection.destroy(); + this._verticalPerspectiveProjection.destroy(); + } + + public updateGPUdependent(context: ProjectionGPUContext): void { + this._mercatorProjection.updateGPUdependent(context); + this._verticalPerspectiveProjection.updateGPUdependent(context); + } + + public getMeshFromTileID(context: Context, _tileID: CanonicalTileID, _hasBorder: boolean, _allowPoles: boolean, _usage: TileMeshUsage): Mesh { + return this.currentProjection.getMeshFromTileID(context, _tileID, _hasBorder, _allowPoles, _usage); + } + + setProjection(projection?: ProjectionSpecification) { + this._transitionable.setValue('type', projection?.type || 'mercator'); + } + + updateTransitions(parameters: TransitionParameters) { + this._transitioning = this._transitionable.transitioned(parameters, this._transitioning); + } + + hasTransition(): boolean { + return this._transitioning.hasTransition() || this.currentProjection.hasTransition(); + } + + recalculate(parameters: EvaluationParameters) { + this.properties = this._transitioning.possiblyEvaluate(parameters); + } + + setErrorQueryLatitudeDegrees(value: number) { + this._verticalPerspectiveProjection.setErrorQueryLatitudeDegrees(value); + this._mercatorProjection.setErrorQueryLatitudeDegrees(value); + } +} \ No newline at end of file diff --git a/src/geo/projection/globe_transform.test.ts b/src/geo/projection/globe_transform.test.ts index 0c619e1ce6..0bf5342b25 100644 --- a/src/geo/projection/globe_transform.test.ts +++ b/src/geo/projection/globe_transform.test.ts @@ -1,15 +1,15 @@ import {describe, expect, test} from 'vitest'; -import {globeConstants, type GlobeProjection} from './globe'; import {EXTENT} from '../../data/extent'; import Point from '@mapbox/point-geometry'; import {LngLat} from '../lng_lat'; import {GlobeTransform} from './globe_transform'; import {CanonicalTileID, OverscaledTileID, UnwrappedTileID} from '../../source/tile_id'; import {angularCoordinatesRadiansToVector, mercatorCoordinatesToAngularCoordinatesRadians, sphereSurfacePointToCoordinates} from './globe_utils'; -import {expectToBeCloseToArray, getGlobeProjectionMock, sleep} from '../../util/test/util'; +import {expectToBeCloseToArray} from '../../util/test/util'; import {MercatorCoordinate} from '../mercator_coordinate'; import {tileCoordinatesToLocation} from './mercator_utils'; import {MercatorTransform} from './mercator_transform'; +import {globeConstants} from './vertical_perspective_projection'; function testPlaneAgainstLngLat(lngDegrees: number, latDegrees: number, plane: Array) { const lat = latDegrees / 180.0 * Math.PI; @@ -27,21 +27,19 @@ function planeDistance(point: Array, plane: Array) { return point[0] * plane[0] + point[1] * plane[1] + point[2] * plane[2] + plane[3]; } -function createGlobeTransform(globeProjection: GlobeProjection) { - const globeTransform = new GlobeTransform(globeProjection, true); +function createGlobeTransform() { + const globeTransform = new GlobeTransform(); globeTransform.resize(640, 480); globeTransform.setFov(45); return globeTransform; } describe('GlobeTransform', () => { - const globeProjectionMock = getGlobeProjectionMock(); // Force faster animations so we can use shorter sleeps when testing them - globeConstants.globeTransitionTimeSeconds = 0.1; globeConstants.errorTransitionTimeSeconds = 0.1; describe('getProjectionData', () => { - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); test('mercator tile extents are set', () => { const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)}); expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); @@ -59,7 +57,7 @@ describe('GlobeTransform', () => { }); describe('clipping plane', () => { - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); describe('general plane properties', () => { const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(0, 0, 0, 0, 0)}); @@ -130,7 +128,7 @@ describe('GlobeTransform', () => { test('camera position', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); expectToBeCloseToArray(globeTransform.cameraPosition as Array, [0, 0, 8.110445867263898], precisionDigits); globeTransform.resize(512, 512); @@ -174,7 +172,7 @@ describe('GlobeTransform', () => { describe('project location to coordinates', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); test('basic test', () => { globeTransform.setCenter(new LngLat(0, 0)); @@ -208,7 +206,7 @@ describe('GlobeTransform', () => { describe('unproject', () => { test('unproject screen center', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); let unprojected = globeTransform.screenPointToLocation(screenCenter); expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits); expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits); @@ -226,7 +224,7 @@ describe('GlobeTransform', () => { test('unproject point to the side', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); let coords: LngLat; let projected: Point; let unprojected: LngLat; @@ -256,7 +254,7 @@ describe('GlobeTransform', () => { // This particular case turned out to be problematic, hence this test. const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); // Transform settings from the render test projection/globe/fill-planet-pole // See the expected result for how the globe should look with this transform. globeTransform.resize(512, 512); @@ -287,7 +285,7 @@ describe('GlobeTransform', () => { test('unproject outside of sphere', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); // Try unprojection a point somewhere above the western horizon globeTransform.setPitch(60); globeTransform.setBearing(-90); @@ -299,7 +297,7 @@ describe('GlobeTransform', () => { describe('setLocationAtPoint', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); globeTransform.setZoom(1); let coords: LngLat; let point: Point; @@ -398,7 +396,7 @@ describe('GlobeTransform', () => { }); describe('isPointOnMapSurface', () => { - const globeTransform = new GlobeTransform(globeProjectionMock); + const globeTransform = new GlobeTransform(); globeTransform.resize(640, 480); globeTransform.setZoom(1); @@ -438,7 +436,7 @@ describe('GlobeTransform', () => { test('pointCoordinate', () => { const precisionDigits = 10; - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); let coords: LngLat; let coordsMercator: MercatorCoordinate; let projected: Point; @@ -459,46 +457,10 @@ describe('GlobeTransform', () => { expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits); }); - describe('globeViewAllowed', () => { - test('starts enabled', async () => { - const globeTransform = createGlobeTransform(globeProjectionMock); - - expect(globeTransform.getGlobeViewAllowed()).toBe(true); - expect(globeTransform.isGlobeRendering).toBe(true); - }); - - test('animates to false', async () => { - const globeTransform = createGlobeTransform(globeProjectionMock); - globeTransform.newFrameUpdate(); - globeTransform.setGlobeViewAllowed(false); - - await sleep(10); - globeTransform.newFrameUpdate(); - expect(globeTransform.getGlobeViewAllowed()).toBe(false); - expect(globeTransform.isGlobeRendering).toBe(true); - - await sleep(150); - globeTransform.newFrameUpdate(); - expect(globeTransform.getGlobeViewAllowed()).toBe(false); - expect(globeTransform.isGlobeRendering).toBe(false); - }); - - test('can skip animation if requested', async () => { - const globeTransform = createGlobeTransform(globeProjectionMock); - globeTransform.newFrameUpdate(); - globeTransform.setGlobeViewAllowed(false, false); - - await sleep(10); - globeTransform.newFrameUpdate(); - expect(globeTransform.getGlobeViewAllowed()).toBe(false); - expect(globeTransform.isGlobeRendering).toBe(false); - }); - }); - describe('getBounds', () => { const precisionDigits = 10; - const globeTransform = new GlobeTransform(globeProjectionMock); + const globeTransform = new GlobeTransform(); globeTransform.resize(640, 480); test('basic', () => { @@ -544,7 +506,7 @@ describe('GlobeTransform', () => { describe('projectTileCoordinates', () => { const precisionDigits = 10; - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(512, 512); transform.setCenter(new LngLat(10.0, 50.0)); transform.setZoom(-1); @@ -582,7 +544,7 @@ describe('GlobeTransform', () => { }); describe('isLocationOccluded', () => { - const transform = new GlobeTransform(globeProjectionMock); + const transform = new GlobeTransform(); transform.resize(512, 512); transform.setCenter(new LngLat(0.0, 0.0)); transform.setZoom(-1); @@ -612,38 +574,16 @@ describe('GlobeTransform', () => { }); }); - test('transform and projection instance are synchronized properly', async () => { - const projectionMock = getGlobeProjectionMock(); - const globeTransform = createGlobeTransform(projectionMock); - // projectionMock.useGlobeRendering and globeTransform.isGlobeRendering must have the same value - expect(projectionMock.useGlobeRendering).toBe(true); - expect(globeTransform.isGlobeRendering).toBe(projectionMock.useGlobeRendering); - globeTransform.setGlobeViewAllowed(false); - globeTransform.newFrameUpdate(); - expect(projectionMock.useGlobeRendering).toBe(false); - expect(globeTransform.isGlobeRendering).toBe(projectionMock.useGlobeRendering); - - await sleep(150); - globeTransform.setGlobeViewAllowed(true); - globeTransform.newFrameUpdate(); - expect(projectionMock.useGlobeRendering).toBe(false); - expect(globeTransform.isGlobeRendering).toBe(projectionMock.useGlobeRendering); - await sleep(10); - globeTransform.newFrameUpdate(); - expect(projectionMock.useGlobeRendering).toBe(true); - expect(globeTransform.isGlobeRendering).toBe(projectionMock.useGlobeRendering); - }); - describe('render world copies', () => { test('change projection and make sure render world copies is kept', () => { - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); globeTransform.setRenderWorldCopies(true); expect(globeTransform.renderWorldCopies).toBeTruthy(); }); test('change transform and make sure render world copies is kept', () => { - const globeTransform = createGlobeTransform(globeProjectionMock); + const globeTransform = createGlobeTransform(); globeTransform.setRenderWorldCopies(true); const mercator = new MercatorTransform(0, 1, 2, 3, false); mercator.apply(globeTransform); diff --git a/src/geo/projection/globe_transform.ts b/src/geo/projection/globe_transform.ts index 527a297051..8ab95ca030 100644 --- a/src/geo/projection/globe_transform.ts +++ b/src/geo/projection/globe_transform.ts @@ -1,47 +1,25 @@ -import {type mat2, mat4, vec3, vec4} from 'gl-matrix'; -import {MAX_VALID_LATITUDE, TransformHelper} from '../transform_helper'; +import type {mat2, mat4, vec3, vec4} from 'gl-matrix'; +import {TransformHelper} from '../transform_helper'; import {MercatorTransform} from './mercator_transform'; -import {LngLat, type LngLatLike, earthRadius} from '../lng_lat'; -import {angleToRotateBetweenVectors2D, clamp, createIdentityMat4f32, createIdentityMat4f64, createMat4f32, createMat4f64, createVec3f64, createVec4f64, differenceOfAnglesDegrees, distanceOfAnglesRadians, easeCubicInOut, lerp, pointPlaneSignedDistance, warnOnce} from '../../util/util'; -import {UnwrappedTileID, OverscaledTileID, type CanonicalTileID} from '../../source/tile_id'; -import Point from '@mapbox/point-geometry'; -import {browser} from '../../util/browser'; -import {type Terrain} from '../../render/terrain'; -import {type GlobeProjection, globeConstants} from './globe'; -import {MercatorCoordinate} from '../mercator_coordinate'; -import {type PointProjection} from '../../symbol/projection'; -import {LngLatBounds} from '../lng_lat_bounds'; -import {type IReadonlyTransform, type ITransform, type TransformUpdateResult} from '../transform_interface'; -import {type PaddingOptions} from '../edge_insets'; -import {tileCoordinatesToMercatorCoordinates} from './mercator_utils'; -import {angularCoordinatesToSurfaceVector, getGlobeRadiusPixels, getZoomAdjustment, mercatorCoordinatesToAngularCoordinatesRadians, projectTileCoordinatesToSphere, sphereSurfacePointToCoordinates} from './globe_utils'; -import {EXTENT} from '../../data/extent'; +import {VerticalPerspectiveTransform} from './vertical_perspective_transform'; +import {type LngLat, type LngLatLike,} from '../lng_lat'; +import {lerp} from '../../util/util'; +import type {OverscaledTileID, UnwrappedTileID, CanonicalTileID} from '../../source/tile_id'; + +import type Point from '@mapbox/point-geometry'; +import type {MercatorCoordinate} from '../mercator_coordinate'; +import type {LngLatBounds} from '../lng_lat_bounds'; +import type {Frustum} from '../../util/primitives/frustum'; +import type {Terrain} from '../../render/terrain'; +import type {PointProjection} from '../../symbol/projection'; +import type {IReadonlyTransform, ITransform} from '../transform_interface'; +import type {PaddingOptions} from '../edge_insets'; import type {ProjectionData, ProjectionDataParams} from './projection_data'; -import {GlobeCoveringTilesDetailsProvider} from './globe_covering_tiles_details_provider'; -import {Frustum} from '../../util/primitives/frustum'; -import {type CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; +import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; /** - * Describes the intersection of ray and sphere. - * When null, no intersection occurred. - * When both "t" values are the same, the ray just touched the sphere's surface. - * When both value are different, a full intersection occurred. + * Globe transform is a transform that moves between vertical perspective and mercator projections. */ -type RaySphereIntersection = { - /** - * The ray parameter for intersection that is "less" along the ray direction. - * Note that this value can be negative, meaning that this intersection occurred before the ray's origin. - * The intersection point can be computed as `origin + direction * tMin`. - */ - tMin: number; - /** - * The ray parameter for intersection that is "more" along the ray direction. - * Note that this value can be negative, meaning that this intersection occurred before the ray's origin. - * The intersection point can be computed as `origin + direction * tMax`. - */ - tMax: number; -} | null; - export class GlobeTransform implements ITransform { private _helper: TransformHelper; @@ -130,6 +108,12 @@ export class GlobeTransform implements ITransform { setMaxBounds(bounds?: LngLatBounds): void { this._helper.setMaxBounds(bounds); } + overrideNearFarZ(nearZ: number, farZ: number): void { + this._helper.overrideNearFarZ(nearZ, farZ); + } + clearNearFarZOverride(): void { + this._helper.clearNearFarZOverride(); + } getCameraQueryGeometry(queryGeometry: Point[]): Point[] { return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry); } @@ -215,52 +199,22 @@ export class GlobeTransform implements ITransform { get renderWorldCopies(): boolean { return this._helper.renderWorldCopies; } - + get cameraToCenterDistance(): number { + return this._helper.cameraToCenterDistance; + } + public get nearZ(): number { + return this._helper.nearZ; + } + public get farZ(): number { + return this._helper.farZ; + } + public get autoCalculateNearFarZ(): boolean { + return this._helper.autoCalculateNearFarZ; + } // // Implementation of globe transform // - private _cachedClippingPlane: vec4 = createVec4f64(); - private _cachedFrustum: Frustum; - - // Transition handling - private _lastGlobeStateEnabled: boolean = true; - - /** - * Stores when {@link newFrameUpdate} was last called. - * Serves as a unified clock for globe (instead of each function using a slightly different value from `browser.now()`). - */ - private _lastUpdateTimeSeconds = browser.now() / 1000.0; - /** - * Stores when switch from globe to mercator or back last occurred, for animation purposes. - * This switch can be caused either by the map passing the threshold zoom level, - * or by {@link setGlobeViewAllowed} being called. - */ - private _lastGlobeChangeTimeSeconds: number = browser.now() / 1000 - 10; // Ten seconds before transform creation - - private _skipNextAnimation: boolean = true; - - private _projectionMatrix: mat4 = createIdentityMat4f64(); - private _globeViewProjMatrix32f: mat4 = createIdentityMat4f32(); // Must be 32 bit floats, otherwise WebGL calls in Chrome get very slow. - private _globeViewProjMatrixNoCorrection: mat4 = createIdentityMat4f64(); - private _globeViewProjMatrixNoCorrectionInverted: mat4 = createIdentityMat4f64(); - private _globeProjMatrixInverted: mat4 = createIdentityMat4f64(); - - private _cameraPosition: vec3 = createVec3f64(); - - /** - * Whether globe projection is allowed to be used. - * Set with {@link setGlobeViewAllowed}. - * Can be used to dynamically disable globe projection without changing the map's projection, - * which would cause a map reload. - */ - private _globeProjectionAllowed = true; - - /** - * Note: projection instance should only be accessed in the {@link newFrameUpdate} function. - * to ensure the transform's state isn't unintentionally changed. - */ - private _projectionInstance: GlobeProjection; private _globeLatitudeErrorCorrectionRadians: number = 0; /** @@ -272,36 +226,40 @@ export class GlobeTransform implements ITransform { return this._globeness > 0; } + setTransitionState(globeness: number, errorCorrectionValue: number): void { + this._globeness = globeness; + this._globeLatitudeErrorCorrectionRadians = errorCorrectionValue; + this._calcMatrices(); + this._verticalPerspectiveTransform.getCoveringTilesDetailsProvider().recalculateCache(); + this._mercatorTransform.getCoveringTilesDetailsProvider().recalculateCache(); + } + + private get currentTransform(): ITransform { + return this.isGlobeRendering ? this._verticalPerspectiveTransform : this._mercatorTransform; + } + /** * Globe projection can smoothly interpolate between globe view and mercator. This variable controls this interpolation. * Value 0 is mercator, value 1 is globe, anything between is an interpolation between the two projections. */ private _globeness: number = 1.0; private _mercatorTransform: MercatorTransform; + private _verticalPerspectiveTransform: VerticalPerspectiveTransform; - private _nearZ: number; - private _farZ: number; - - private _coveringTilesDetailsProvider: GlobeCoveringTilesDetailsProvider; - private _adaptive: boolean; - - public constructor(globeProjection: GlobeProjection, globeProjectionEnabled: boolean = true, adaptive:boolean = true) { - this._adaptive = adaptive; - + public constructor() { this._helper = new TransformHelper({ calcMatrices: () => { this._calcMatrices(); }, getConstrained: (center, zoom) => { return this.getConstrained(center, zoom); } }); - this._globeProjectionAllowed = globeProjectionEnabled; - this._globeness = globeProjectionEnabled ? 1 : 0; // When transform is cloned for use in symbols, `_updateAnimation` function which usually sets this value never gets called. - this._projectionInstance = globeProjection; + this._globeness = 1; // When transform is cloned for use in symbols, `_updateAnimation` function which usually sets this value never gets called. this._mercatorTransform = new MercatorTransform(); - this._coveringTilesDetailsProvider = new GlobeCoveringTilesDetailsProvider(); + this._verticalPerspectiveTransform = new VerticalPerspectiveTransform(); } clone(): ITransform { - const clone = new GlobeTransform(null, this._globeProjectionAllowed); - clone._applyGlobeTransform(this); + const clone = new GlobeTransform(); + clone._globeness = this._globeness; + clone._globeLatitudeErrorCorrectionRadians = this._globeLatitudeErrorCorrectionRadians; clone.apply(this); return clone; } @@ -309,406 +267,99 @@ export class GlobeTransform implements ITransform { public apply(that: IReadonlyTransform): void { this._helper.apply(that); this._mercatorTransform.apply(this); + this._verticalPerspectiveTransform.apply(this, this._globeLatitudeErrorCorrectionRadians); } - private _applyGlobeTransform(that: GlobeTransform): void { - this._globeness = that._globeness; - this._globeLatitudeErrorCorrectionRadians = that._globeLatitudeErrorCorrectionRadians; - } - - public get projectionMatrix(): mat4 { return this.isGlobeRendering ? this._projectionMatrix : this._mercatorTransform.projectionMatrix; } + public get projectionMatrix(): mat4 { return this.currentTransform.projectionMatrix } - public get modelViewProjectionMatrix(): mat4 { return this.isGlobeRendering ? this._globeViewProjMatrixNoCorrection : this._mercatorTransform.modelViewProjectionMatrix; } + public get modelViewProjectionMatrix(): mat4 { return this.currentTransform.modelViewProjectionMatrix } - public get inverseProjectionMatrix(): mat4 { return this.isGlobeRendering ? this._globeProjMatrixInverted : this._mercatorTransform.inverseProjectionMatrix; } + public get inverseProjectionMatrix(): mat4 { return this.currentTransform.inverseProjectionMatrix; } - public get cameraPosition(): vec3 { - // Return a copy - don't let outside code mutate our precomputed camera position. - const copy = createVec3f64(); // Ensure the resulting vector is float64s - copy[0] = this._cameraPosition[0]; - copy[1] = this._cameraPosition[1]; - copy[2] = this._cameraPosition[2]; - return copy; - } - - get cameraToCenterDistance(): number { - // Globe uses the same cameraToCenterDistance as mercator. - return this._mercatorTransform.cameraToCenterDistance; - } - - public get nearZ(): number { return this._nearZ; } - public get farZ(): number { return this._farZ; } - - /** - * Returns whether globe view is allowed. - * When allowed, globe fill function as normal, displaying a 3D planet, - * but transitioning to mercator at high zoom levels. - * Otherwise, mercator will be used at all zoom levels instead. - * Set with {@link setGlobeViewAllowed}. - */ - public getGlobeViewAllowed(): boolean { - return this._globeProjectionAllowed; - } - - /** - * Sets whether globe view is allowed. When allowed, globe fill function as normal, displaying a 3D planet, - * but transitioning to mercator at high zoom levels. - * Otherwise, mercator will be used at all zoom levels instead. - * When globe is caused to transition to mercator by this function, the transition will be animated. - * @param allow - Sets whether glove view is allowed. - * @param animateTransition - Controls whether the transition between globe view and mercator (if triggered by this call) should be animated. True by default. - */ - public setGlobeViewAllowed(allow: boolean, animateTransition: boolean = true) { - if (allow === this._globeProjectionAllowed) { - return; - } - - if (!animateTransition) { - this._skipNextAnimation = true; - } - this._globeProjectionAllowed = allow; - this._lastGlobeChangeTimeSeconds = this._lastUpdateTimeSeconds; - } - - /** - * Should be called at the beginning of every frame to synchronize the transform with the underlying projection. - */ - newFrameUpdate(): TransformUpdateResult { - this._lastUpdateTimeSeconds = browser.now() / 1000.0; - const oldGlobeRendering = this.isGlobeRendering; - - this._globeness = (!this._adaptive && this._globeProjectionAllowed) ? 1 : this._computeGlobenessAnimation(); - // Everything below this comment must happen AFTER globeness update - this._updateErrorCorrectionValue(); - this._calcMatrices(); - this._coveringTilesDetailsProvider.newFrame(); - - if (oldGlobeRendering === this.isGlobeRendering) { - return { - forcePlacementUpdate: false, - }; - } else { - return { - forcePlacementUpdate: true, - fireProjectionEvent: { - type: 'projectiontransition', - newProjection: this.isGlobeRendering ? 'globe' : 'globe-mercator', - }, - forceSourceUpdate: true, - }; - } - } - - /** - * This function should never be called on a cloned transform, thus ensuring that - * the state of a cloned transform is never changed after creation. - */ - private _updateErrorCorrectionValue(): void { - if (!this._projectionInstance) { - return; - } - this._projectionInstance.useGlobeRendering = this.isGlobeRendering; - this._projectionInstance.errorQueryLatitudeDegrees = this.center.lat; - this._globeLatitudeErrorCorrectionRadians = this._projectionInstance.latitudeErrorCorrectionRadians; - } - - /** - * Compute new globeness, if needed. - */ - private _computeGlobenessAnimation(): number { - // Update globe transition animation - const globeState = this._globeProjectionAllowed && this.zoom < globeConstants.maxGlobeZoom; - const currentTimeSeconds = this._lastUpdateTimeSeconds; - if (globeState !== this._lastGlobeStateEnabled) { - this._lastGlobeChangeTimeSeconds = currentTimeSeconds; - this._lastGlobeStateEnabled = globeState; - } - - const oldGlobeness = this._globeness; - - // Transition parameter, where 0 is the start and 1 is end. - const globeTransition = Math.min(Math.max((currentTimeSeconds - this._lastGlobeChangeTimeSeconds) / globeConstants.globeTransitionTimeSeconds, 0.0), 1.0); - let newGlobeness = globeState ? globeTransition : (1.0 - globeTransition); - - if (this._skipNextAnimation) { - newGlobeness = globeState ? 1.0 : 0.0; - this._lastGlobeChangeTimeSeconds = currentTimeSeconds - globeConstants.globeTransitionTimeSeconds * 2.0; - this._skipNextAnimation = false; - } - - newGlobeness = easeCubicInOut(newGlobeness); // Smooth animation - - if (oldGlobeness !== newGlobeness) { - this.setCenter(new LngLat( - this._mercatorTransform.center.lng + differenceOfAnglesDegrees(this._mercatorTransform.center.lng, this.center.lng) * newGlobeness, - lerp(this._mercatorTransform.center.lat, this.center.lat, newGlobeness) - )); - this.setZoom(lerp(this._mercatorTransform.zoom, this.zoom, newGlobeness)); - } - - return newGlobeness; - } - - isRenderingDirty(): boolean { - // Globe transition - return (this._lastUpdateTimeSeconds - this._lastGlobeChangeTimeSeconds) < globeConstants.globeTransitionTimeSeconds; - } + public get cameraPosition(): vec3 { return this.currentTransform.cameraPosition; } getProjectionData(params: ProjectionDataParams): ProjectionData { - const {overscaledTileID, aligned, applyTerrainMatrix, applyGlobeMatrix} = params; - const data = this._mercatorTransform.getProjectionData({overscaledTileID, aligned, applyTerrainMatrix}); - - // Set 'projectionMatrix' to actual globe transform - if (this.isGlobeRendering) { - data.mainMatrix = this._globeViewProjMatrix32f; - } - - data.clippingPlane = this._cachedClippingPlane as [number, number, number, number]; - data.projectionTransition = applyGlobeMatrix ? this._globeness : 0; - - return data; - } + const mercatorProjectionData = this._mercatorTransform.getProjectionData(params); + const verticalPerspectiveProjectionData = this._verticalPerspectiveTransform.getProjectionData(params); - private _computeClippingPlane(globeRadiusPixels: number): vec4 { - // We want to compute a plane equation that, when applied to the unit sphere generated - // in the vertex shader, places all visible parts of the sphere into the positive half-space - // and all the non-visible parts in the negative half-space. - // We can then use that to accurately clip all non-visible geometry. - - // cam....------------A - // .... | - // .... | - // ....B - // ggggggggg - // gggggg | .gggggg - // ggg | ...ggg ^ - // gg | | - // g | y - // g | | - // g C #---x---> - // - // Notes: - // - note the coordinate axes - // - "g" marks the globe edge - // - the dotted line is the camera center "ray" - we are looking in this direction - // - "cam" is camera origin - // - "C" is globe center - // - "B" is the point on "top" of the globe - camera is looking at B - "B" is the intersection between the camera center ray and the globe - // - this._pitchInRadians is the angle at B between points cam,B,A - // - this.cameraToCenterDistance is the distance from camera to "B" - // - globe radius is (0.5 * this.worldSize) - // - "T" is any point where a tangent line from "cam" touches the globe surface - // - elevation is assumed to be zero - globe rendering must be separate from terrain rendering anyway - - const pitch = this.pitchInRadians; - // scale things so that the globe radius is 1 - const distanceCameraToB = this.cameraToCenterDistance / globeRadiusPixels; - const radius = 1; - - // Distance from camera to "A" - the point at the same elevation as camera, right above center point on globe - const distanceCameraToA = Math.sin(pitch) * distanceCameraToB; - // Distance from "A" to "C" - const distanceAtoC = (Math.cos(pitch) * distanceCameraToB + radius); - // Distance from camera to "C" - the globe center - const distanceCameraToC = Math.sqrt(distanceCameraToA * distanceCameraToA + distanceAtoC * distanceAtoC); - // cam - C - T angle cosine (at C) - const camCTcosine = radius / distanceCameraToC; - // Distance from globe center to the plane defined by all possible "T" points - const tangentPlaneDistanceToC = camCTcosine * radius; - - let vectorCtoCamX = -distanceCameraToA; - let vectorCtoCamY = distanceAtoC; - // Normalize the vector - const vectorCtoCamLength = Math.sqrt(vectorCtoCamX * vectorCtoCamX + vectorCtoCamY * vectorCtoCamY); - vectorCtoCamX /= vectorCtoCamLength; - vectorCtoCamY /= vectorCtoCamLength; - - // Note the swizzled components - const planeVector: vec3 = [0, vectorCtoCamX, vectorCtoCamY]; - // Apply transforms - lat, lng and angle (NOT pitch - already accounted for, as it affects the tangent plane) - vec3.rotateZ(planeVector, planeVector, [0, 0, 0], -this.bearingInRadians); - vec3.rotateX(planeVector, planeVector, [0, 0, 0], -1 * this.center.lat * Math.PI / 180.0); - vec3.rotateY(planeVector, planeVector, [0, 0, 0], this.center.lng * Math.PI / 180.0); - // Scale the plane vector up - // we don't want the actually visible parts of the sphere to end up beyond distance 1 from the plane - otherwise they would be clipped by the near plane. - const scale = 0.25; - vec3.scale(planeVector, planeVector, scale); - return [...planeVector, -tangentPlaneDistanceToC * scale]; + return { + mainMatrix: this.isGlobeRendering ? verticalPerspectiveProjectionData.mainMatrix : mercatorProjectionData.mainMatrix, + clippingPlane: verticalPerspectiveProjectionData.clippingPlane, + tileMercatorCoords: verticalPerspectiveProjectionData.tileMercatorCoords, + projectionTransition: params.applyGlobeMatrix ? this._globeness : 0, + fallbackMatrix: mercatorProjectionData.fallbackMatrix, + }; } public isLocationOccluded(location: LngLat): boolean { - return !this.isSurfacePointVisible(angularCoordinatesToSurfaceVector(location)); + return this.currentTransform.isLocationOccluded(location); } public transformLightDirection(dir: vec3): vec3 { - const sphereX = this._helper._center.lng * Math.PI / 180.0; - const sphereY = this._helper._center.lat * Math.PI / 180.0; - - const len = Math.cos(sphereY); - const spherePos: vec3 = [ - Math.sin(sphereX) * len, - Math.sin(sphereY), - Math.cos(sphereX) * len - ]; - - const axisRight: vec3 = [spherePos[2], 0.0, -spherePos[0]]; // Equivalent to cross(vec3(0.0, 1.0, 0.0), vec) - const axisDown: vec3 = [0, 0, 0]; - vec3.cross(axisDown, axisRight, spherePos); - vec3.normalize(axisRight, axisRight); - vec3.normalize(axisDown, axisDown); - - const transformed: vec3 = [ - axisRight[0] * dir[0] + axisDown[0] * dir[1] + spherePos[0] * dir[2], - axisRight[1] * dir[0] + axisDown[1] * dir[1] + spherePos[1] * dir[2], - axisRight[2] * dir[0] + axisDown[2] * dir[1] + spherePos[2] * dir[2] - ]; - - const normalized: vec3 = [0, 0, 0]; - vec3.normalize(normalized, transformed); - return normalized; - } - - private getAnimatedLatitude() { - return lerp(this._mercatorTransform.center.lat, this._helper._center.lat, this._globeness); + return this.currentTransform.transformLightDirection(dir); } public getPixelScale(): number { - return lerp(this._mercatorTransform.getPixelScale(), 1.0 / Math.cos(this.getAnimatedLatitude() * Math.PI / 180), this._globeness); + return lerp(this._mercatorTransform.getPixelScale(), this._verticalPerspectiveTransform.getPixelScale(), this._globeness); } public getCircleRadiusCorrection(): number { - return lerp(this._mercatorTransform.getCircleRadiusCorrection(), Math.cos(this.getAnimatedLatitude() * Math.PI / 180), this._globeness); + return lerp(this._mercatorTransform.getCircleRadiusCorrection(), this._verticalPerspectiveTransform.getCircleRadiusCorrection(), this._globeness); } public getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number { const mercatorCorrection = this._mercatorTransform.getPitchedTextCorrection(textAnchorX, textAnchorY, tileID); - if (!this.isGlobeRendering) { - return mercatorCorrection; - } - const mercator = tileCoordinatesToMercatorCoordinates(textAnchorX, textAnchorY, tileID.canonical); - const angular = mercatorCoordinatesToAngularCoordinatesRadians(mercator.x, mercator.y); - return lerp(mercatorCorrection, this.getCircleRadiusCorrection() / Math.cos(angular[1]), this._globeness); + const verticalCorrection = this._verticalPerspectiveTransform.getPitchedTextCorrection(textAnchorX, textAnchorY, tileID); + return lerp(mercatorCorrection, verticalCorrection, this._globeness); } public projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection { - if (!this.isGlobeRendering) { - return this._mercatorTransform.projectTileCoordinates(x, y, unwrappedTileID, getElevation); - } - - const canonical = unwrappedTileID.canonical; - const spherePos = projectTileCoordinatesToSphere(x, y, canonical.x, canonical.y, canonical.z); - const elevation = getElevation ? getElevation(x, y) : 0.0; - const vectorMultiplier = 1.0 + elevation / earthRadius; - const pos: vec4 = [spherePos[0] * vectorMultiplier, spherePos[1] * vectorMultiplier, spherePos[2] * vectorMultiplier, 1]; - vec4.transformMat4(pos, pos, this._globeViewProjMatrixNoCorrection); - - // Also check whether the point projects to the backfacing side of the sphere. - const plane = this._cachedClippingPlane; - // dot(position on sphere, occlusion plane equation) - const dotResult = plane[0] * spherePos[0] + plane[1] * spherePos[1] + plane[2] * spherePos[2] + plane[3]; - const isOccluded = dotResult < 0.0; - - return { - point: new Point(pos[0] / pos[3], pos[1] / pos[3]), - signedDistanceFromCamera: pos[3], - isOccluded - }; + return this.currentTransform.projectTileCoordinates(x, y, unwrappedTileID, getElevation); } private _calcMatrices(): void { if (!this._helper._width || !this._helper._height) { return; } + // VerticalPerspective reads our near/farZ values and autoCalculateNearFarZ: + // - if autoCalculateNearFarZ is true then it computes globe Z values + // - if autoCalculateNearFarZ is false then it inherits our Z values + // In either case, its Z values are consistent with out settings and we want to copy its Z values to our helper. + this._verticalPerspectiveTransform.apply(this, this._globeLatitudeErrorCorrectionRadians); + this._helper._nearZ = this._verticalPerspectiveTransform.nearZ; + this._helper._farZ = this._verticalPerspectiveTransform.farZ; - if (this._mercatorTransform) { - this._mercatorTransform.apply(this, true); - } - - const globeRadiusPixels = getGlobeRadiusPixels(this.worldSize, this.center.lat); - - // Construct a completely separate matrix for globe view - const globeMatrix = createMat4f64(); - const globeMatrixUncorrected = createMat4f64(); - this._nearZ = 0.5; - this._farZ = this.cameraToCenterDistance + globeRadiusPixels * 2.0; // just set the far plane far enough - we will calculate our own z in the vertex shader anyway - mat4.perspective(globeMatrix, this.fovInRadians, this.width / this.height, this._nearZ, this._farZ); - - // Apply center of perspective offset - const offset = this.centerOffset; - globeMatrix[8] = -offset.x * 2 / this._helper._width; - globeMatrix[9] = offset.y * 2 / this._helper._height; - this._projectionMatrix = mat4.clone(globeMatrix); - - this._globeProjMatrixInverted = createMat4f64(); - mat4.invert(this._globeProjMatrixInverted, globeMatrix); - mat4.translate(globeMatrix, globeMatrix, [0, 0, -this.cameraToCenterDistance]); - mat4.rotateZ(globeMatrix, globeMatrix, this.rollInRadians); - mat4.rotateX(globeMatrix, globeMatrix, -this.pitchInRadians); - mat4.rotateZ(globeMatrix, globeMatrix, this.bearingInRadians); - mat4.translate(globeMatrix, globeMatrix, [0.0, 0, -globeRadiusPixels]); - // Rotate the sphere to center it on viewed coordinates - - const scaleVec = createVec3f64(); - scaleVec[0] = globeRadiusPixels; - scaleVec[1] = globeRadiusPixels; - scaleVec[2] = globeRadiusPixels; - - // Keep a atan-correction-free matrix for transformations done on the CPU with accurate math - mat4.rotateX(globeMatrixUncorrected, globeMatrix, this.center.lat * Math.PI / 180.0); - mat4.rotateY(globeMatrixUncorrected, globeMatrixUncorrected, -this.center.lng * Math.PI / 180.0); - mat4.scale(globeMatrixUncorrected, globeMatrixUncorrected, scaleVec); // Scale the unit sphere to a sphere with diameter of 1 - this._globeViewProjMatrixNoCorrection = globeMatrixUncorrected; - - mat4.rotateX(globeMatrix, globeMatrix, this.center.lat * Math.PI / 180.0 - this._globeLatitudeErrorCorrectionRadians); - mat4.rotateY(globeMatrix, globeMatrix, -this.center.lng * Math.PI / 180.0); - mat4.scale(globeMatrix, globeMatrix, scaleVec); // Scale the unit sphere to a sphere with diameter of 1 - this._globeViewProjMatrix32f = new Float32Array(globeMatrix); - - this._globeViewProjMatrixNoCorrectionInverted = createMat4f64(); - mat4.invert(this._globeViewProjMatrixNoCorrectionInverted, globeMatrixUncorrected); - - const zero = createVec3f64(); - this._cameraPosition = createVec3f64(); - this._cameraPosition[2] = this.cameraToCenterDistance / globeRadiusPixels; - vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, -this.rollInRadians); - vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, this.pitchInRadians); - vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, -this.bearingInRadians); - vec3.add(this._cameraPosition, this._cameraPosition, [0, 0, 1]); - vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, -this.center.lat * Math.PI / 180.0); - vec3.rotateY(this._cameraPosition, this._cameraPosition, zero, this.center.lng * Math.PI / 180.0); - - this._cachedClippingPlane = this._computeClippingPlane(globeRadiusPixels); - - const matrix = mat4.clone(this._globeViewProjMatrixNoCorrectionInverted); - mat4.scale(matrix, matrix, [1, 1, -1]); - this._cachedFrustum = Frustum.fromInvProjectionMatrix(matrix); + // When transitioning between globe and mercator, we need to synchronize the depth values in both transforms. + // For this reason we first update vertical perspective and then sync our Z values to its result. + // Now if globe rendering, we always want to force mercator transform to adapt our Z values. + // If not, it will either compute its own (autoCalculateNearFarZ=false) or adapt our (autoCalculateNearFarZ=true). + // In either case we want to (again) sync our Z values, this time with + this._mercatorTransform.apply(this, true, this.isGlobeRendering); + this._helper._nearZ = this._mercatorTransform.nearZ; + this._helper._farZ = this._mercatorTransform.farZ; } - calculateFogMatrix(_unwrappedTileID: UnwrappedTileID): mat4 { - warnOnce('calculateFogMatrix is not supported on globe projection.'); - const m = createMat4f64(); - mat4.identity(m); - return m; + calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 { + return this.currentTransform.calculateFogMatrix(unwrappedTileID); } getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): UnwrappedTileID[] { - // Globe has no wrap. - return [new UnwrappedTileID(0, tileID)]; + return this.currentTransform.getVisibleUnwrappedCoordinates(tileID); } getCameraFrustum(): Frustum { - return this.isGlobeRendering ? this._cachedFrustum : this._mercatorTransform.getCameraFrustum(); + return this.currentTransform.getCameraFrustum(); } getClippingPlane(): vec4 | null { - return this.isGlobeRendering ? this._cachedClippingPlane : this._mercatorTransform.getClippingPlane(); + return this.currentTransform.getClippingPlane(); } getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider { - return this.isGlobeRendering ? this._coveringTilesDetailsProvider : this._mercatorTransform.getCoveringTilesDetailsProvider(); + return this.currentTransform.getCoveringTilesDetailsProvider(); } recalculateZoomAndCenter(terrain?: Terrain): void { this._mercatorTransform.recalculateZoomAndCenter(terrain); - this.apply(this._mercatorTransform); + this._verticalPerspectiveTransform.recalculateZoomAndCenter(terrain); } maxPitchScaleFactor(): number { @@ -717,127 +368,36 @@ export class GlobeTransform implements ITransform { } getCameraPoint(): Point { - return this._mercatorTransform.getCameraPoint(); + return this._helper.getCameraPoint(); } getCameraAltitude(): number { - return this._mercatorTransform.getCameraAltitude(); + return this._helper.getCameraAltitude(); } getCameraLngLat(): LngLat { - return this._mercatorTransform.getCameraLngLat(); + return this._helper.getCameraLngLat(); } lngLatToCameraDepth(lngLat: LngLat, elevation: number): number { - if (!this.isGlobeRendering) { - return this._mercatorTransform.lngLatToCameraDepth(lngLat, elevation); - } - if (!this._globeViewProjMatrixNoCorrection) { - return 1.0; // _calcMatrices hasn't run yet - } - const vec = angularCoordinatesToSurfaceVector(lngLat); - vec3.scale(vec, vec, (1.0 + elevation / earthRadius)); - const result = createVec4f64(); - vec4.transformMat4(result, [vec[0], vec[1], vec[2], 1], this._globeViewProjMatrixNoCorrection); - return result[2] / result[3]; + return this.currentTransform.lngLatToCameraDepth(lngLat, elevation); } - precacheTiles(coords: OverscaledTileID[]): void { - this._mercatorTransform.precacheTiles(coords); + populateCache(coords: OverscaledTileID[]): void { + this._mercatorTransform.populateCache(coords); + this._verticalPerspectiveTransform.populateCache(coords); } getBounds(): LngLatBounds { - if (!this.isGlobeRendering) { - return this._mercatorTransform.getBounds(); - } - - const xMid = this.width * 0.5; - const yMid = this.height * 0.5; - - // LngLat extremes will probably tend to be in screen corners or in middle of screen edges. - // These test points should result in a pretty good approximation. - const testPoints = [ - new Point(0, 0), - new Point(xMid, 0), - new Point(this.width, 0), - new Point(this.width, yMid), - new Point(this.width, this.height), - new Point(xMid, this.height), - new Point(0, this.height), - new Point(0, yMid), - ]; - - const projectedPoints = []; - for (const p of testPoints) { - projectedPoints.push(this.unprojectScreenPoint(p)); - } - - // We can't construct a simple min/max aabb, since points might lie on either side of the antimeridian. - // We will instead compute the furthest points relative to map center. - // We also take advantage of the fact that `unprojectScreenPoint` will snap pixels - // outside the planet to the closest point on the planet's horizon. - let mostEast = 0, mostWest = 0, mostNorth = 0, mostSouth = 0; // We will store these values signed. - const center = this.center; - for (const p of projectedPoints) { - const dLng = differenceOfAnglesDegrees(center.lng, p.lng); - const dLat = differenceOfAnglesDegrees(center.lat, p.lat); - if (dLng < mostWest) { - mostWest = dLng; - } - if (dLng > mostEast) { - mostEast = dLng; - } - if (dLat < mostSouth) { - mostSouth = dLat; - } - if (dLat > mostNorth) { - mostNorth = dLat; - } - } - - const boundsArray: [number, number, number, number] = [ - center.lng + mostWest, // west - center.lat + mostSouth, // south - center.lng + mostEast, // east - center.lat + mostNorth // north - ]; - - // Sometimes the poles might end up not being on the horizon, - // thus not being detected as the northernmost/southernmost points. - // We fix that here. - if (this.isSurfacePointOnScreen([0, 1, 0])) { - // North pole is visible - // This also means that the entire longitude range must be visible - boundsArray[3] = 90; - boundsArray[0] = -180; - boundsArray[2] = 180; - } - if (this.isSurfacePointOnScreen([0, -1, 0])) { - // South pole is visible - boundsArray[1] = -90; - boundsArray[0] = -180; - boundsArray[2] = 180; - } - - return new LngLatBounds(boundsArray); + return this.currentTransform.getBounds(); } getConstrained(lngLat: LngLat, zoom: number): { center: LngLat; zoom: number } { - // Globe: TODO: respect _lngRange, _latRange - // It is possible to implement exact constrain for globe, but I don't think it is worth the effort. - const constrainedLat = clamp(lngLat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE); - const constrainedZoom = clamp(+zoom, this.minZoom + getZoomAdjustment(0, constrainedLat), this.maxZoom); - return { - center: new LngLat( - lngLat.lng, - constrainedLat - ), - zoom: constrainedZoom - }; + return this.currentTransform.getConstrained(lngLat, zoom); } calculateCenterFromCameraLngLatAlt(lngLat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} { - return this._mercatorTransform.calculateCenterFromCameraLngLatAlt(lngLat, alt, bearing, pitch); + return this._helper.calculateCenterFromCameraLngLatAlt(lngLat, alt, bearing, pitch); } /** @@ -850,362 +410,51 @@ export class GlobeTransform implements ITransform { this.apply(this._mercatorTransform); return; } - // This returns some fake coordinates for pixels that do not lie on the planet. - // Whatever uses this `setLocationAtPoint` function will need to account for that. - const pointLngLat = this.unprojectScreenPoint(point); - const vecToPixelCurrent = angularCoordinatesToSurfaceVector(pointLngLat); - const vecToTarget = angularCoordinatesToSurfaceVector(lnglat); - - const zero = createVec3f64(); - vec3.zero(zero); - - const rotatedPixelVector = createVec3f64(); - vec3.rotateY(rotatedPixelVector, vecToPixelCurrent, zero, -this.center.lng * Math.PI / 180.0); - vec3.rotateX(rotatedPixelVector, rotatedPixelVector, zero, this.center.lat * Math.PI / 180.0); - - // We are looking for the lng,lat that will rotate `vecToTarget` - // so that it is equal to `rotatedPixelVector`. - - // The second rotation around X axis cannot change the X component, - // so we first must find the longitude such that rotating `vecToTarget` with it - // will place it so its X component is equal to X component of `rotatedPixelVector`. - // There will exist zero, one or two longitudes that satisfy this. - - // x | - // / | - // / | the line is the target X - rotatedPixelVector.x - // / | the x is vecToTarget projected to x,z plane - // . | the dot is origin - // - // We need to rotate vecToTarget so that it intersects the line. - // If vecToTarget is shorter than the distance to the line from origin, it is impossible. - - // Otherwise, we compute the intersection of the line with a ring with radius equal to - // length of vecToTarget projected to XZ plane. - - const vecToTargetXZLengthSquared = vecToTarget[0] * vecToTarget[0] + vecToTarget[2] * vecToTarget[2]; - const targetXSquared = rotatedPixelVector[0] * rotatedPixelVector[0]; - if (vecToTargetXZLengthSquared < targetXSquared) { - // Zero solutions - setLocationAtPoint is impossible. - return; - } - - // The intersection's Z coordinates - const intersectionA = Math.sqrt(vecToTargetXZLengthSquared - targetXSquared); - const intersectionB = -intersectionA; // the second solution - - const lngA = angleToRotateBetweenVectors2D(vecToTarget[0], vecToTarget[2], rotatedPixelVector[0], intersectionA); - const lngB = angleToRotateBetweenVectors2D(vecToTarget[0], vecToTarget[2], rotatedPixelVector[0], intersectionB); - - const vecToTargetLngA = createVec3f64(); - vec3.rotateY(vecToTargetLngA, vecToTarget, zero, -lngA); - const latA = angleToRotateBetweenVectors2D(vecToTargetLngA[1], vecToTargetLngA[2], rotatedPixelVector[1], rotatedPixelVector[2]); - const vecToTargetLngB = createVec3f64(); - vec3.rotateY(vecToTargetLngB, vecToTarget, zero, -lngB); - const latB = angleToRotateBetweenVectors2D(vecToTargetLngB[1], vecToTargetLngB[2], rotatedPixelVector[1], rotatedPixelVector[2]); - // Is at least one of the needed latitudes valid? - - const limit = Math.PI * 0.5; - - const isValidA = latA >= -limit && latA <= limit; - const isValidB = latB >= -limit && latB <= limit; - - let validLng: number; - let validLat: number; - if (isValidA && isValidB) { - // Pick the solution that is closer to current map center. - const centerLngRadians = this.center.lng * Math.PI / 180.0; - const centerLatRadians = this.center.lat * Math.PI / 180.0; - const lngDistA = distanceOfAnglesRadians(lngA, centerLngRadians); - const latDistA = distanceOfAnglesRadians(latA, centerLatRadians); - const lngDistB = distanceOfAnglesRadians(lngB, centerLngRadians); - const latDistB = distanceOfAnglesRadians(latB, centerLatRadians); - - if ((lngDistA + latDistA) < (lngDistB + latDistB)) { - validLng = lngA; - validLat = latA; - } else { - validLng = lngB; - validLat = latB; - } - } else if (isValidA) { - validLng = lngA; - validLat = latA; - } else if (isValidB) { - validLng = lngB; - validLat = latB; - } else { - // No solution. - return; - } - - const newLng = validLng / Math.PI * 180; - const newLat = validLat / Math.PI * 180; - const oldLat = this.center.lat; - this.setCenter(new LngLat(newLng, clamp(newLat, -90, 90))); - this.setZoom(this.zoom + getZoomAdjustment(oldLat, this.center.lat)); + this._verticalPerspectiveTransform.setLocationAtPoint(lnglat, point); + this.apply(this._verticalPerspectiveTransform); + return; } locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point { - if (!this.isGlobeRendering) { - return this._mercatorTransform.locationToScreenPoint(lnglat, terrain); - } - - const pos = angularCoordinatesToSurfaceVector(lnglat); - - if (terrain) { - const elevation = terrain.getElevationForLngLatZoom(lnglat, this._helper._tileZoom); - vec3.scale(pos, pos, 1.0 + elevation / earthRadius); - } - - return this._projectSurfacePointToScreen(pos); - } - - /** - * Projects a given vector on the surface of a unit sphere (or possible above the surface) - * and returns its coordinates on screen in pixels. - */ - private _projectSurfacePointToScreen(pos: vec3): Point { - const projected = createVec4f64(); - vec4.transformMat4(projected, [...pos, 1] as vec4, this._globeViewProjMatrixNoCorrection); - projected[0] /= projected[3]; - projected[1] /= projected[3]; - return new Point( - (projected[0] * 0.5 + 0.5) * this.width, - (-projected[1] * 0.5 + 0.5) * this.height - ); + return this.currentTransform.locationToScreenPoint(lnglat, terrain); } screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate { - if (!this.isGlobeRendering || terrain) { - // Mercator has terrain handling implemented properly and since terrain - // simply draws tile coordinates into a special framebuffer, this works well even for globe. - return this._mercatorTransform.screenPointToMercatorCoordinate(p, terrain); - } - return MercatorCoordinate.fromLngLat(this.unprojectScreenPoint(p)); + return this.currentTransform.screenPointToMercatorCoordinate(p, terrain); } screenPointToLocation(p: Point, terrain?: Terrain): LngLat { - if (!this.isGlobeRendering || terrain) { - // Mercator has terrain handling implemented properly and since terrain - // simply draws tile coordinates into a special framebuffer, this works well even for globe. - return this._mercatorTransform.screenPointToLocation(p, terrain); - } - return this.unprojectScreenPoint(p); + return this.currentTransform.screenPointToLocation(p, terrain); } isPointOnMapSurface(p: Point, terrain?: Terrain): boolean { - if (!this.isGlobeRendering) { - return this._mercatorTransform.isPointOnMapSurface(p, terrain); - } - - const rayOrigin = this._cameraPosition; - const rayDirection = this.getRayDirectionFromPixel(p); - - const intersection = this.rayPlanetIntersection(rayOrigin, rayDirection); - - return !!intersection; + return this.currentTransform.isPointOnMapSurface(p, terrain); } /** * Computes normalized direction of a ray from the camera to the given screen pixel. */ getRayDirectionFromPixel(p: Point): vec3 { - const pos = createVec4f64(); - pos[0] = (p.x / this.width) * 2.0 - 1.0; - pos[1] = ((p.y / this.height) * 2.0 - 1.0) * -1.0; - pos[2] = 1; - pos[3] = 1; - vec4.transformMat4(pos, pos, this._globeViewProjMatrixNoCorrectionInverted); - pos[0] /= pos[3]; - pos[1] /= pos[3]; - pos[2] /= pos[3]; - const ray = createVec3f64(); - ray[0] = pos[0] - this._cameraPosition[0]; - ray[1] = pos[1] - this._cameraPosition[1]; - ray[2] = pos[2] - this._cameraPosition[2]; - const rayNormalized: vec3 = createVec3f64(); - vec3.normalize(rayNormalized, ray); - return rayNormalized; - } - - /** - * For a given point on the unit sphere of the planet, returns whether it is visible from - * camera's position (not taking into account camera rotation at all). - */ - private isSurfacePointVisible(p: vec3): boolean { - if (!this.isGlobeRendering) { - return true; - } - const plane = this._cachedClippingPlane; - // dot(position on sphere, occlusion plane equation) - const dotResult = plane[0] * p[0] + plane[1] * p[1] + plane[2] * p[2] + plane[3]; - return dotResult >= 0.0; - } - - /** - * Returns whether surface point is visible on screen. - * It must both project to a pixel in screen bounds and not be occluded by the planet. - */ - private isSurfacePointOnScreen(vec: vec3): boolean { - if (!this.isSurfacePointVisible(vec)) { - return false; - } - - const projected = createVec4f64(); - vec4.transformMat4(projected, [...vec, 1] as vec4, this._globeViewProjMatrixNoCorrection); - projected[0] /= projected[3]; - projected[1] /= projected[3]; - projected[2] /= projected[3]; - return projected[0] > -1 && projected[0] < 1 && - projected[1] > -1 && projected[1] < 1 && - projected[2] > -1 && projected[2] < 1; - } - - /** - * Returns the two intersection points of the ray and the planet's sphere, - * or null if no intersection occurs. - * The intersections are encoded as the parameter for parametric ray equation, - * with `tMin` being the first intersection and `tMax` being the second. - * Eg. the nearer intersection point can then be computed as `origin + direction * tMin`. - * @param origin - The ray origin. - * @param direction - The normalized ray direction. - */ - private rayPlanetIntersection(origin: vec3, direction: vec3): RaySphereIntersection { - const originDotDirection = vec3.dot(origin, direction); - const planetRadiusSquared = 1.0; // planet is a unit sphere, so its radius squared is 1 - - // Ray-sphere intersection involves a quadratic equation. - // However solving it in the traditional schoolbook way leads to floating point precision issues. - // Here we instead use the approach suggested in the book Ray Tracing Gems, chapter 7. - // https://www.realtimerendering.com/raytracinggems/rtg/index.html - const inner = createVec3f64(); - const scaledDir = createVec3f64(); - vec3.scale(scaledDir, direction, originDotDirection); - vec3.sub(inner, origin, scaledDir); - const discriminant = planetRadiusSquared - vec3.dot(inner, inner); - - if (discriminant < 0) { - return null; - } - - const c = vec3.dot(origin, origin) - planetRadiusSquared; - const q = -originDotDirection + (originDotDirection < 0 ? 1 : -1) * Math.sqrt(discriminant); - const t0 = c / q; - const t1 = q; - // Assume the ray origin is never inside the sphere - const tMin = Math.min(t0, t1); - const tMax = Math.max(t0, t1); - return { - tMin, - tMax - }; - } - - /** - * @internal - * Returns a {@link LngLat} representing geographical coordinates that correspond to the specified pixel coordinates. - * Note: if the point does not lie on the globe, returns a location on the visible globe horizon (edge) that is - * as close to the point as possible. - * @param p - Screen point in pixels to unproject. - * @param terrain - Optional terrain. - */ - private unprojectScreenPoint(p: Point): LngLat { - // Here we compute the intersection of the ray towards the pixel at `p` and the planet sphere. - // As always, we assume that the planet is centered at 0,0,0 and has radius 1. - // Ray origin is `_cameraPosition` and direction is `rayNormalized`. - const rayOrigin = this._cameraPosition; - const rayDirection = this.getRayDirectionFromPixel(p); - const intersection = this.rayPlanetIntersection(rayOrigin, rayDirection); - - if (intersection) { - // Ray intersects the sphere -> compute intersection LngLat. - // Assume the ray origin is never inside the sphere - just use tMin - const intersectionPoint = createVec3f64(); - vec3.add(intersectionPoint, rayOrigin, [ - rayDirection[0] * intersection.tMin, - rayDirection[1] * intersection.tMin, - rayDirection[2] * intersection.tMin - ]); - const sphereSurface = createVec3f64(); - vec3.normalize(sphereSurface, intersectionPoint); - return sphereSurfacePointToCoordinates(sphereSurface); - } - - // Ray does not intersect the sphere -> find the closest point on the horizon to the ray. - // Intersect the ray with the clipping plane, since we know that the intersection of the clipping plane and the sphere is the horizon. - const directionDotPlaneXyz = this._cachedClippingPlane[0] * rayDirection[0] + this._cachedClippingPlane[1] * rayDirection[1] + this._cachedClippingPlane[2] * rayDirection[2]; - const originToPlaneDistance = pointPlaneSignedDistance(this._cachedClippingPlane, rayOrigin); - const distanceToIntersection = -originToPlaneDistance / directionDotPlaneXyz; - - const maxRayLength = 2.0; // One globe diameter - const planeIntersection = createVec3f64(); - - if (distanceToIntersection > 0) { - vec3.add(planeIntersection, rayOrigin, [ - rayDirection[0] * distanceToIntersection, - rayDirection[1] * distanceToIntersection, - rayDirection[2] * distanceToIntersection - ]); - } else { - // When the ray takes too long to hit the plane (>maxRayLength), or if the plane intersection is behind the camera, handle things differently. - // Take a point along the ray at distance maxRayLength, project it to clipping plane, then continue as normal to find the horizon point. - const distantPoint = createVec3f64(); - vec3.add(distantPoint, rayOrigin, [ - rayDirection[0] * maxRayLength, - rayDirection[1] * maxRayLength, - rayDirection[2] * maxRayLength - ]); - const distanceFromPlane = pointPlaneSignedDistance(this._cachedClippingPlane, distantPoint); - vec3.sub(planeIntersection, distantPoint, [ - this._cachedClippingPlane[0] * distanceFromPlane, - this._cachedClippingPlane[1] * distanceFromPlane, - this._cachedClippingPlane[2] * distanceFromPlane - ]); - } - - const closestOnHorizon = createVec3f64(); - vec3.normalize(closestOnHorizon, planeIntersection); - return sphereSurfacePointToCoordinates(closestOnHorizon); + return this._verticalPerspectiveTransform.getRayDirectionFromPixel(p); } getMatrixForModel(location: LngLatLike, altitude?: number): mat4 { - if (!this.isGlobeRendering) { - return this._mercatorTransform.getMatrixForModel(location, altitude); - } - const lnglat = LngLat.convert(location); - const scale = 1.0 / earthRadius; - - const m = createIdentityMat4f64(); - mat4.rotateY(m, m, lnglat.lng / 180.0 * Math.PI); - mat4.rotateX(m, m, -lnglat.lat / 180.0 * Math.PI); - mat4.translate(m, m, [0, 0, 1 + altitude / earthRadius]); - mat4.rotateX(m, m, Math.PI * 0.5); - mat4.scale(m, m, [scale, scale, scale]); - return m; + return this.currentTransform.getMatrixForModel(location, altitude); } getProjectionDataForCustomLayer(applyGlobeMatrix: boolean = true): ProjectionData { - const projectionData = this.getProjectionData({overscaledTileID: new OverscaledTileID(0, 0, 0, 0, 0), applyGlobeMatrix}); - projectionData.tileMercatorCoords = [0, 0, 1, 1]; + const mercatorData = this._mercatorTransform.getProjectionDataForCustomLayer(applyGlobeMatrix); - // Even though we requested projection data for the mercator base tile which covers the entire mercator range, - // the shader projection machinery still expects inputs to be in tile units range [0..EXTENT]. - // Since custom layers are expected to supply mercator coordinates [0..1], we need to rescale - // the fallback projection matrix by EXTENT. - // Note that the regular projection matrices do not need to be modified, since the rescaling happens by setting - // the `u_projection_tile_mercator_coords` uniform correctly. - const fallbackMatrixScaled = createMat4f32(); - mat4.scale(fallbackMatrixScaled, projectionData.fallbackMatrix, [EXTENT, EXTENT, 1]); + if (!this.isGlobeRendering) { + return mercatorData; + } - projectionData.fallbackMatrix = fallbackMatrixScaled; - return projectionData; + const globeData = this._verticalPerspectiveTransform.getProjectionDataForCustomLayer(applyGlobeMatrix); + globeData.fallbackMatrix = mercatorData.mainMatrix; + return globeData; } getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 { - if (!this.isGlobeRendering) { - return this._mercatorTransform.getFastPathSimpleProjectionMatrix(tileID); - } - return undefined; + return this.currentTransform.getFastPathSimpleProjectionMatrix(tileID); } } diff --git a/src/geo/projection/globe_utils.ts b/src/geo/projection/globe_utils.ts index 085cc2e4ea..5823b4586c 100644 --- a/src/geo/projection/globe_utils.ts +++ b/src/geo/projection/globe_utils.ts @@ -1,9 +1,8 @@ import {vec3} from 'gl-matrix'; -import {clamp, lerp, mod, remapSaturate, wrap} from '../../util/util'; +import {clamp, lerp, MAX_VALID_LATITUDE, mod, remapSaturate, scaleZoom, wrap} from '../../util/util'; import {LngLat} from '../lng_lat'; -import {MAX_VALID_LATITUDE, scaleZoom} from '../transform_helper'; -import type Point from '@mapbox/point-geometry'; import {EXTENT} from '../../data/extent'; +import type Point from '@mapbox/point-geometry'; export function getGlobeCircumferencePixels(transform: {worldSize: number; center: {lat: number}}): number { const radius = getGlobeRadiusPixels(transform.worldSize, transform.center.lat); diff --git a/src/geo/projection/mercator_camera_helper.ts b/src/geo/projection/mercator_camera_helper.ts index edf396751f..7b506284e5 100644 --- a/src/geo/projection/mercator_camera_helper.ts +++ b/src/geo/projection/mercator_camera_helper.ts @@ -1,15 +1,16 @@ -import Point from '@mapbox/point-geometry'; +import type Point from '@mapbox/point-geometry'; import {LngLat, type LngLatLike} from '../lng_lat'; -import {type IReadonlyTransform, type ITransform} from '../transform_interface'; -import {cameraBoundsWarning, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs} from './camera_helper'; -import {type CameraForBoundsOptions} from '../../ui/camera'; -import {type PaddingOptions} from '../edge_insets'; -import {type LngLatBounds} from '../lng_lat_bounds'; -import {normalizeCenter, scaleZoom, zoomScale} from '../transform_helper'; -import {degreesToRadians, rollPitchBearingEqual} from '../../util/util'; +import {cameraForBoxAndBearing, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs} from './camera_helper'; +import {normalizeCenter} from '../transform_helper'; +import {rollPitchBearingEqual, scaleZoom, zoomScale} from '../../util/util'; import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; +import type {IReadonlyTransform, ITransform} from '../transform_interface'; +import type {CameraForBoundsOptions} from '../../ui/camera'; +import type {PaddingOptions} from '../edge_insets'; +import type {LngLatBounds} from '../lng_lat_bounds'; + /** * @internal */ @@ -44,70 +45,7 @@ export class MercatorCameraHelper implements ICameraHelper { } cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult { - const edgePadding = tr.padding; - - // Consider all corners of the rotated bounding box derived from the given points - // when find the camera position that fits the given points. - - const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest()); - const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast()); - const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast()); - const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest()); - - const bearingRadians = degreesToRadians(-bearing); - - const nwRotatedWorld = nwWorld.rotate(bearingRadians); - const neRotatedWorld = neWorld.rotate(bearingRadians); - const seRotatedWorld = seWorld.rotate(bearingRadians); - const swRotatedWorld = swWorld.rotate(bearingRadians); - - const upperRight = new Point( - Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), - Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) - ); - - const lowerLeft = new Point( - Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), - Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) - ); - - // Calculate zoom: consider the original bbox and padding. - const size = upperRight.sub(lowerLeft); - - const availableWidth = (tr.width - (edgePadding.left + edgePadding.right + padding.left + padding.right)); - const availableHeight = (tr.height - (edgePadding.top + edgePadding.bottom + padding.top + padding.bottom)); - const scaleX = availableWidth / size.x; - const scaleY = availableHeight / size.y; - - if (scaleY < 0 || scaleX < 0) { - cameraBoundsWarning(); - return undefined; - } - - const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); - - // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. - const offset = Point.convert(options.offset); - const paddingOffsetX = (padding.left - padding.right) / 2; - const paddingOffsetY = (padding.top - padding.bottom) / 2; - const paddingOffset = new Point(paddingOffsetX, paddingOffsetY); - const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing)); - const offsetAtInitialZoom = offset.add(rotatedPaddingOffset); - const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom)); - - const center = unprojectFromWorldCoordinates( - tr.worldSize, - // either world diagonal can be used (NW-SE or NE-SW) - nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom) - ); - - const result = { - center, - zoom, - bearing - }; - - return result; + return cameraForBoxAndBearing(options, padding, bounds, bearing, tr); } handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void { diff --git a/src/geo/projection/mercator_covering_tiles_details_provider.ts b/src/geo/projection/mercator_covering_tiles_details_provider.ts index feed063e4d..df8d6b038e 100644 --- a/src/geo/projection/mercator_covering_tiles_details_provider.ts +++ b/src/geo/projection/mercator_covering_tiles_details_provider.ts @@ -48,4 +48,8 @@ export class MercatorCoveringTilesDetailsProvider implements CoveringTilesDetail allowWorldCopies(): boolean { return true; } + + recalculateCache(): void { + // Do nothing + } } \ No newline at end of file diff --git a/src/geo/projection/mercator.ts b/src/geo/projection/mercator_projection.ts similarity index 90% rename from src/geo/projection/mercator.ts rename to src/geo/projection/mercator_projection.ts index 9ae1a390a7..6293039c26 100644 --- a/src/geo/projection/mercator.ts +++ b/src/geo/projection/mercator_projection.ts @@ -48,13 +48,16 @@ export class MercatorProjection implements Projection { return false; } - public destroy(): void { - // Do nothing. + get transitionState(): number { + return 0; } - public isRenderingDirty(): boolean { - // Mercator projection does no animations of its own, so rendering is never dirty from its perspective. - return false; + get latitudeErrorCorrectionRadians(): number { + return 0; + } + + public destroy(): void { + // Do nothing. } public updateGPUdependent(_: ProjectionGPUContext): void { @@ -84,4 +87,16 @@ export class MercatorProjection implements Projection { this._cachedMesh = new Mesh(tileExtentBuffer, quadTriangleIndexBuffer, tileExtentSegments); return this._cachedMesh; } + + public recalculate(): void { + // Do nothing. + } + + public hasTransition(): boolean { + return false; + } + + setErrorQueryLatitudeDegrees(_value: number) { + // Do nothing. + } } diff --git a/src/geo/projection/mercator_transform.ts b/src/geo/projection/mercator_transform.ts index 5ec9db2d97..5b58e794b6 100644 --- a/src/geo/projection/mercator_transform.ts +++ b/src/geo/projection/mercator_transform.ts @@ -1,22 +1,23 @@ import {LngLat, type LngLatLike} from '../lng_lat'; -import {altitudeFromMercatorZ, MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate'; +import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate'; import Point from '@mapbox/point-geometry'; -import {wrap, clamp, createIdentityMat4f64, createMat4f64, degreesToRadians} from '../../util/util'; +import {wrap, clamp, createIdentityMat4f64, createMat4f64, degreesToRadians, createIdentityMat4f32, zoomScale, scaleZoom} from '../../util/util'; import {type mat2, mat4, vec3, vec4} from 'gl-matrix'; import {UnwrappedTileID, OverscaledTileID, type CanonicalTileID, calculateTileKey} from '../../source/tile_id'; -import {type Terrain} from '../../render/terrain'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; import {type PointProjection, xyTransformMat4} from '../../symbol/projection'; import {LngLatBounds} from '../lng_lat_bounds'; -import {type IReadonlyTransform, type ITransform, type TransformUpdateResult} from '../transform_interface'; -import {type PaddingOptions} from '../edge_insets'; -import {mercatorCoordinateToLocation, getBasicProjectionData, getMercatorHorizon, locationToMercatorCoordinate, projectToWorldCoordinates, unprojectFromWorldCoordinates, calculateTileMatrix, maxMercatorHorizonAngle, cameraMercatorCoordinateFromCenterAndRotation} from './mercator_utils'; +import {getMercatorHorizon, projectToWorldCoordinates, unprojectFromWorldCoordinates, calculateTileMatrix, maxMercatorHorizonAngle, cameraMercatorCoordinateFromCenterAndRotation} from './mercator_utils'; import {EXTENT} from '../../data/extent'; -import type {ProjectionData, ProjectionDataParams} from './projection_data'; -import {scaleZoom, TransformHelper, zoomScale} from '../transform_helper'; +import {TransformHelper} from '../transform_helper'; import {MercatorCoveringTilesDetailsProvider} from './mercator_covering_tiles_details_provider'; import {Frustum} from '../../util/primitives/frustum'; -import {type CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; + +import type {Terrain} from '../../render/terrain'; +import type {IReadonlyTransform, ITransform} from '../transform_interface'; +import type {PaddingOptions} from '../edge_insets'; +import type {ProjectionData, ProjectionDataParams} from './projection_data'; +import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; export class MercatorTransform implements ITransform { private _helper: TransformHelper; @@ -106,6 +107,12 @@ export class MercatorTransform implements ITransform { setMaxBounds(bounds?: LngLatBounds): void { this._helper.setMaxBounds(bounds); } + overrideNearFarZ(nearZ: number, farZ: number): void { + this._helper.overrideNearFarZ(nearZ, farZ); + } + clearNearFarZOverride(): void { + this._helper.clearNearFarZOverride(); + } getCameraQueryGeometry(queryGeometry: Point[]): Point[] { return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry); } @@ -191,12 +198,25 @@ export class MercatorTransform implements ITransform { get renderWorldCopies(): boolean { return this._helper.renderWorldCopies; } - + get cameraToCenterDistance(): number { + return this._helper.cameraToCenterDistance; + } + public get nearZ(): number { + return this._helper.nearZ; + } + public get farZ(): number { + return this._helper.farZ; + } + public get autoCalculateNearFarZ(): boolean { + return this._helper.autoCalculateNearFarZ; + } + setTransitionState(_value: number, _error: number): void { + // Do nothing + } // // Implementation of mercator transform // - private _cameraToCenterDistance: number; private _cameraPosition: vec3; private _mercatorMatrix: mat4; @@ -214,9 +234,6 @@ export class MercatorTransform implements ITransform { private _alignedPosMatrixCache: Map = new Map(); private _fogMatrixCacheF32: Map = new Map(); - private _nearZ; - private _farZ; - private _coveringTilesDetailsProvider; constructor(minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) { @@ -233,18 +250,14 @@ export class MercatorTransform implements ITransform { return clone; } - public apply(that: IReadonlyTransform, constrain?: boolean): void { - this._helper.apply(that, constrain); + public apply(that: IReadonlyTransform, constrain?: boolean, forceOverrideZ?: boolean): void { + this._helper.apply(that, constrain, forceOverrideZ); } - public get cameraToCenterDistance(): number { return this._cameraToCenterDistance; } public get cameraPosition(): vec3 { return this._cameraPosition; } public get projectionMatrix(): mat4 { return this._projectionMatrix; } public get modelViewProjectionMatrix(): mat4 { return this._viewProjMatrix; } public get inverseProjectionMatrix(): mat4 { return this._invProjMatrix; } - public get nearZ(): number { return this._nearZ; } - public get farZ(): number { return this._farZ; } - public get mercatorMatrix(): mat4 { return this._mercatorMatrix; } // Not part of ITransform interface getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array { @@ -284,34 +297,18 @@ export class MercatorTransform implements ITransform { // find position the camera is looking on const center = this.screenPointToLocation(this.centerPoint, terrain); const elevation = terrain ? terrain.getElevationForLngLatZoom(center, this._helper._tileZoom) : 0; - const deltaElevation = this.elevation - elevation; - if (!deltaElevation) return; - - // Find the current camera position - const originalPixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; - const cameraToCenterDistanceMeters = this._cameraToCenterDistance / originalPixelPerMeter; - const origCenterMercator = MercatorCoordinate.fromLngLat(this.center, this.elevation); - const cameraMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters); - - // update elevation to the new terrain intercept elevation and recalculate the center point - this._helper._elevation = elevation; - const centerInfo = this.calculateCenterFromCameraLngLatAlt(cameraMercator.toLngLat(), altitudeFromMercatorZ(cameraMercator.z, origCenterMercator.y), this.bearing, this.pitch); - - // update matrices - this._helper._elevation = centerInfo.elevation; - this._helper._center = centerInfo.center; - this.setZoom(centerInfo.zoom); + this._helper.recalculateZoomAndCenter(elevation); } setLocationAtPoint(lnglat: LngLat, point: Point) { const z = mercatorZfromAltitude(this.elevation, this.center.lat); const a = this.screenPointToMercatorCoordinateAtZ(point, z); const b = this.screenPointToMercatorCoordinateAtZ(this.centerPoint, z); - const loc = locationToMercatorCoordinate(lnglat); + const loc = MercatorCoordinate.fromLngLat(lnglat); const newCenter = new MercatorCoordinate( loc.x - (a.x - b.x), loc.y - (a.y - b.y)); - this.setCenter(mercatorCoordinateToLocation(newCenter)); + this.setCenter(newCenter?.toLngLat()); if (this._helper._renderWorldCopies) { this.setCenter(this.center.wrap()); } @@ -319,12 +316,12 @@ export class MercatorTransform implements ITransform { locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point { return terrain ? - this.coordinatePoint(locationToMercatorCoordinate(lnglat), terrain.getElevationForLngLatZoom(lnglat, this._helper._tileZoom), this._pixelMatrix3D) : - this.coordinatePoint(locationToMercatorCoordinate(lnglat)); + this.coordinatePoint(MercatorCoordinate.fromLngLat(lnglat), terrain.getElevationForLngLatZoom(lnglat, this._helper._tileZoom), this._pixelMatrix3D) : + this.coordinatePoint(MercatorCoordinate.fromLngLat(lnglat)); } screenPointToLocation(p: Point, terrain?: Terrain): LngLat { - return mercatorCoordinateToLocation(this.screenPointToMercatorCoordinate(p, terrain)); + return this.screenPointToMercatorCoordinate(p, terrain)?.toLngLat(); } screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate { @@ -539,66 +536,13 @@ export class MercatorTransform implements ITransform { } calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} { - const cameraBearing = bearing !== undefined ? bearing : this.bearing; - const cameraPitch = pitch = pitch !== undefined ? pitch : this.pitch; - - const camMercator = MercatorCoordinate.fromLngLat(lnglat, alt); - const dzNormalized = -Math.cos(degreesToRadians(cameraPitch)); - const dhNormalized = Math.sin(degreesToRadians(cameraPitch)); - const dxNormalized = dhNormalized * Math.sin(degreesToRadians(cameraBearing)); - const dyNormalized = -dhNormalized * Math.cos(degreesToRadians(cameraBearing)); - - let elevation = this.elevation; - const altitudeAGL = alt - elevation; - let distanceToCenterMeters; - if (dzNormalized * altitudeAGL >= 0.0 || Math.abs(dzNormalized) < 0.1) { - distanceToCenterMeters = 10000; - elevation = alt + distanceToCenterMeters * dzNormalized; - } else { - distanceToCenterMeters = -altitudeAGL / dzNormalized; - } - - // The mercator transform scale changes with latitude. At high latitudes, there are more "Merc units" per meter - // than at the equator. We treat the center point as our fundamental quantity. This means we want to convert - // elevation to Mercator Z using the scale factor at the center point (not the camera point). Since the center point is - // initially unknown, we compute it using the scale factor at the camera point. This gives us a better estimate of the - // center point scale factor, which we use to recompute the center point. We repeat until the error is very small. - // This typically takes about 5 iterations. - let metersPerMercUnit = altitudeFromMercatorZ(1, camMercator.y); - let centerMercator: MercatorCoordinate; - let dMercator: number; - let iter = 0; - const maxIter = 10; - do { - iter += 1; - if (iter > maxIter) { - break; - } - dMercator = distanceToCenterMeters / metersPerMercUnit; - const dx = dxNormalized * dMercator; - const dy = dyNormalized * dMercator; - centerMercator = new MercatorCoordinate(camMercator.x + dx, camMercator.y + dy); - metersPerMercUnit = 1 / centerMercator.meterInMercatorCoordinateUnits(); - } while (Math.abs(distanceToCenterMeters - dMercator * metersPerMercUnit) > 1.0e-12); - - const center = centerMercator.toLngLat(); - const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / dMercator / this.tileSize); - return {center, elevation, zoom}; + return this._helper.calculateCenterFromCameraLngLatAlt(lnglat, alt, bearing, pitch); } - _calcMatrices(): void { - if (!this._helper._height) return; - - const halfFov = this.fovInRadians / 2; - const offset = this.centerOffset; - const point = projectToWorldCoordinates(this.worldSize, this.center); - const x = point.x, y = point.y; - this._cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this._helper._height; - this._helper._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; - - // Calculate the camera to sea-level distance in pixel in respect of terrain - const limitedPitchRadians = degreesToRadians(Math.min(this.pitch, maxMercatorHorizonAngle)); - const cameraToSeaLevelDistance = Math.max(this._cameraToCenterDistance / 2, this._cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(limitedPitchRadians)); + _calculateNearFarZIfNeeded(cameraToSeaLevelDistance: number, limitedPitchRadians: number, offset: Point): void { + if (!this._helper.autoCalculateNearFarZ) { + return; + } // In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation const minRenderDistanceBelowCameraInMeters = 100; const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile, this.getCameraAltitude() - minRenderDistanceBelowCameraInMeters); @@ -616,7 +560,7 @@ export class MercatorTransform implements ITransform { // Find the distance from the center point to the horizon const horizon = getMercatorHorizon(this); - const horizonAngle = Math.atan(horizon / this._cameraToCenterDistance); + const horizonAngle = Math.atan(horizon / this._helper.cameraToCenterDistance); const minFovCenterToHorizonRadians = degreesToRadians(90 - maxMercatorHorizonAngle); const fovCenterToHorizon = horizonAngle > minFovCenterToHorizonRadians ? 2 * horizonAngle * (0.5 + offset.y / (horizon * 2)) : minFovCenterToHorizonRadians; const topHalfSurfaceDistanceHorizon = Math.sin(fovCenterToHorizon) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01)); @@ -624,7 +568,8 @@ export class MercatorTransform implements ITransform { // Calculate z distance of the farthest fragment that should be rendered. // Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance` const topHalfMinDistance = Math.min(topHalfSurfaceDistance, topHalfSurfaceDistanceHorizon); - this._farZ = (Math.cos(Math.PI / 2 - limitedPitchRadians) * topHalfMinDistance + lowestPlane) * 1.01; + + this._helper._farZ = (Math.cos(Math.PI / 2 - limitedPitchRadians) * topHalfMinDistance + lowestPlane) * 1.01; // The larger the value of nearZ is // - the more depth precision is available for features (good) @@ -633,12 +578,27 @@ export class MercatorTransform implements ITransform { // Other values work for mapbox-gl-js but deck.gl was encountering precision issues // when rendering custom layers. This value was experimentally chosen and // seems to solve z-fighting issues in deck.gl while not clipping buildings too close to the camera. - this._nearZ = this._helper._height / 50; + this._helper._nearZ = this._helper._height / 50; + } + + _calcMatrices(): void { + if (!this._helper._height) return; + + const offset = this.centerOffset; + const point = projectToWorldCoordinates(this.worldSize, this.center); + const x = point.x, y = point.y; + this._helper._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; + + // Calculate the camera to sea-level distance in pixel in respect of terrain + const limitedPitchRadians = degreesToRadians(Math.min(this.pitch, maxMercatorHorizonAngle)); + const cameraToSeaLevelDistance = Math.max(this._helper.cameraToCenterDistance / 2, this._helper.cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(limitedPitchRadians)); + + this._calculateNearFarZIfNeeded(cameraToSeaLevelDistance, limitedPitchRadians, offset); // matrix for conversion from location to clip space(-1 .. 1) let m: mat4; m = new Float64Array(16) as any; - mat4.perspective(m, this.fovInRadians, this._helper._width / this._helper._height, this._nearZ, this._farZ); + mat4.perspective(m, this.fovInRadians, this._helper._width / this._helper._height, this._helper._nearZ, this._helper._farZ); this._invProjMatrix = new Float64Array(16) as any as mat4; mat4.invert(this._invProjMatrix, m); @@ -648,7 +608,7 @@ export class MercatorTransform implements ITransform { this._projectionMatrix = mat4.clone(m); mat4.scale(m, m, [1, -1, 1]); - mat4.translate(m, m, [0, 0, -this._cameraToCenterDistance]); + mat4.translate(m, m, [0, 0, -this._helper.cameraToCenterDistance]); mat4.rotateZ(m, m, -this.rollInRadians); mat4.rotateX(m, m, this.pitchInRadians); mat4.rotateZ(m, m, -this.bearingInRadians); @@ -680,7 +640,7 @@ export class MercatorTransform implements ITransform { // create a fog matrix, same es proj-matrix but with near clipping-plane in mapcenter // needed to calculate a correct z-value for fog calculation, because projMatrix z value is not this._fogMatrix = new Float64Array(16) as any; - mat4.perspective(this._fogMatrix, this.fovInRadians, this.width / this.height, cameraToSeaLevelDistance, this._farZ); + mat4.perspective(this._fogMatrix, this.fovInRadians, this.width / this.height, cameraToSeaLevelDistance, this._helper._farZ); this._fogMatrix[8] = -offset.x * 2 / this.width; this._fogMatrix[9] = offset.y * 2 / this.height; mat4.scale(this._fogMatrix, this._fogMatrix, [1, -1, 1]); @@ -730,43 +690,51 @@ export class MercatorTransform implements ITransform { const coord = this.screenPointToMercatorCoordinate(new Point(0, 0)); const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as vec4; const topPoint = vec4.transformMat4(p, p, this._pixelMatrix); - return topPoint[3] / this._cameraToCenterDistance; + return topPoint[3] / this._helper.cameraToCenterDistance; } getCameraPoint(): Point { - const pitch = this.pitchInRadians; - const offset = Math.tan(pitch) * (this._cameraToCenterDistance || 1); - return this.centerPoint.add(new Point(offset*Math.sin(this.rollInRadians), offset*Math.cos(this.rollInRadians))); + return this._helper.getCameraPoint(); } getCameraAltitude(): number { - const altitude = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / this._helper._pixelPerMeter; - return altitude + this.elevation; + return this._helper.getCameraAltitude(); } getCameraLngLat(): LngLat { - const cameraToCenterDistancePixels = 0.5 / Math.tan(this.fovInRadians / 2) * this.height; const pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; - const cameraToCenterDistanceMeters = cameraToCenterDistancePixels / pixelPerMeter; + const cameraToCenterDistanceMeters = this._helper.cameraToCenterDistance / pixelPerMeter; const camMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters); return camMercator.toLngLat(); } lngLatToCameraDepth(lngLat: LngLat, elevation: number) { - const coord = locationToMercatorCoordinate(lngLat); + const coord = MercatorCoordinate.fromLngLat(lngLat); const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; vec4.transformMat4(p, p, this._viewProjMatrix); return (p[2] / p[3]); } - isRenderingDirty(): boolean { - return false; - } - getProjectionData(params: ProjectionDataParams): ProjectionData { const {overscaledTileID, aligned, applyTerrainMatrix} = params; - const matrix = overscaledTileID ? this.calculatePosMatrix(overscaledTileID, aligned, true) : null; - return getBasicProjectionData(overscaledTileID, matrix, applyTerrainMatrix); + const mercatorTileCoordinates = this._helper.getMercatorTileCoordinates(overscaledTileID); + const tilePosMatrix = overscaledTileID ? this.calculatePosMatrix(overscaledTileID, aligned, true) : null; + + let mainMatrix: mat4; + if (overscaledTileID && overscaledTileID.terrainRttPosMatrix32f && applyTerrainMatrix) { + mainMatrix = overscaledTileID.terrainRttPosMatrix32f; + } else if (tilePosMatrix) { + mainMatrix = tilePosMatrix; // This matrix should be float32 + } else { + mainMatrix = createIdentityMat4f32(); + } + return { + mainMatrix, // Might be set to a custom matrix by different projections. + tileMercatorCoords: mercatorTileCoordinates, + clippingPlane: [0, 0, 0, 0], + projectionTransition: 0.0, // Range 0..1, where 0 is mercator, 1 is another projection, mostly globe. + fallbackMatrix: mainMatrix, + }; } isLocationOccluded(_: LngLat): boolean { @@ -785,10 +753,6 @@ export class MercatorTransform implements ITransform { return 1.0; } - newFrameUpdate(): TransformUpdateResult { - return {}; - } - transformLightDirection(dir: vec3): vec3 { return vec3.clone(dir); } @@ -815,7 +779,7 @@ export class MercatorTransform implements ITransform { }; } - precacheTiles(coords: Array): void { + populateCache(coords: Array): void { for (const coord of coords) { // Return value is thrown away, but this function will still // place the pos matrix into the transform's internal cache. @@ -855,13 +819,11 @@ export class MercatorTransform implements ITransform { const scale: vec3 = [EXTENT, EXTENT, this.worldSize / this._helper.pixelsPerMeter]; // We pass full-precision 64bit float matrices to custom layers to prevent precision loss in case the user wants to do further transformations. - const fallbackMatrixScaled = createMat4f64(); - mat4.scale(fallbackMatrixScaled, tileMatrix, scale); - + // Otherwise we get very visible precision-artifacts and twitching for objects that are bulding-scale. const projectionMatrixScaled = createMat4f64(); mat4.scale(projectionMatrixScaled, tileMatrix, scale); - projectionData.fallbackMatrix = fallbackMatrixScaled; + projectionData.fallbackMatrix = projectionMatrixScaled; projectionData.mainMatrix = projectionMatrixScaled; return projectionData; } diff --git a/src/geo/projection/mercator_utils.test.ts b/src/geo/projection/mercator_utils.test.ts index 61dfe9e8b6..5452854bcd 100644 --- a/src/geo/projection/mercator_utils.test.ts +++ b/src/geo/projection/mercator_utils.test.ts @@ -1,14 +1,11 @@ import {describe, expect, test} from 'vitest'; import Point from '@mapbox/point-geometry'; import {LngLat} from '../lng_lat'; -import {getBasicProjectionData, getMercatorHorizon, locationToMercatorCoordinate, projectToWorldCoordinates, tileCoordinatesToLocation, tileCoordinatesToMercatorCoordinates} from './mercator_utils'; +import {getMercatorHorizon, projectToWorldCoordinates, tileCoordinatesToLocation, tileCoordinatesToMercatorCoordinates} from './mercator_utils'; import {MercatorTransform} from './mercator_transform'; -import {MAX_VALID_LATITUDE} from '../transform_helper'; -import {mat4} from 'gl-matrix'; -import {CanonicalTileID, OverscaledTileID} from '../../source/tile_id'; +import {CanonicalTileID} from '../../source/tile_id'; import {EXTENT} from '../../data/extent'; -import {expectToBeCloseToArray} from '../../util/test/util'; -import type {ProjectionData} from './projection_data'; +import {createIdentityMat4f32, MAX_VALID_LATITUDE} from '../../util/util'; describe('mercator utils', () => { test('projectToWorldCoordinates basic', () => { @@ -24,10 +21,6 @@ describe('mercator utils', () => { expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, 90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, MAX_VALID_LATITUDE))); }); - test('locationCoordinate', () => { - expect(locationToMercatorCoordinate(new LngLat(0, 0))).toEqual({x: 0.5, y: 0.5, z: 0}); - }); - test('getMercatorHorizon', () => { const transform = new MercatorTransform(0, 22, 0, 85, true); transform.resize(500, 500); @@ -54,26 +47,11 @@ describe('mercator utils', () => { expect(horizon).toBeCloseTo(-75.52102888757743, 10); }); - - describe('getBasicProjectionData', () => { - test('posMatrix is set', () => { - const mat = mat4.create(); - mat[0] = 1234; - const projectionData = getBasicProjectionData(new OverscaledTileID(0, 0, 0, 0, 0), mat); - expect(projectionData.fallbackMatrix).toEqual(mat); - }); - - test('mercator tile extents are set', () => { - let projectionData: ProjectionData; - - projectionData = getBasicProjectionData(new OverscaledTileID(0, 0, 0, 0, 0)); - expectToBeCloseToArray(projectionData.tileMercatorCoords, [0, 0, 1 / EXTENT, 1 / EXTENT]); - - projectionData = getBasicProjectionData(new OverscaledTileID(1, 0, 1, 0, 0)); - expectToBeCloseToArray(projectionData.tileMercatorCoords, [0, 0, 0.5 / EXTENT, 0.5 / EXTENT]); - - projectionData = getBasicProjectionData(new OverscaledTileID(1, 0, 1, 1, 0)); - expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + describe('getProjectionData', () => { + test('return identity matrix when not passing overscaledTileID', () => { + const transform = new MercatorTransform(0, 22, 0, 180, true); + const projectionData = transform.getProjectionData({overscaledTileID: null}); + expect(projectionData.fallbackMatrix).toEqual(createIdentityMat4f32()); }); }); diff --git a/src/geo/projection/mercator_utils.ts b/src/geo/projection/mercator_utils.ts index b2ffdf45c5..0ae6f4369e 100644 --- a/src/geo/projection/mercator_utils.ts +++ b/src/geo/projection/mercator_utils.ts @@ -1,12 +1,10 @@ import {mat4} from 'gl-matrix'; import {EXTENT} from '../../data/extent'; -import {type OverscaledTileID} from '../../source/tile_id'; -import {clamp, createIdentityMat4f32, degreesToRadians} from '../../util/util'; -import {MAX_VALID_LATITUDE, type UnwrappedTileIDType, zoomScale} from '../transform_helper'; -import {type LngLat} from '../lng_lat'; +import {clamp, degreesToRadians, MAX_VALID_LATITUDE, zoomScale} from '../../util/util'; import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate'; import Point from '@mapbox/point-geometry'; -import type {ProjectionData} from './projection_data'; +import type {UnwrappedTileIDType} from '../transform_helper'; +import type {LngLat} from '../lng_lat'; /* * The maximum angle to use for the Mercator horizon. This must be less than 90 @@ -41,25 +39,6 @@ export function tileCoordinatesToLocation(inTileX: number, inTileY: number, cano return tileCoordinatesToMercatorCoordinates(inTileX, inTileY, canonicalTileID).toLngLat(); } -/** - * Given a geographical lnglat, return an unrounded - * coordinate that represents it at low zoom level. - * @param lnglat - the location - * @returns The mercator coordinate - */ -export function locationToMercatorCoordinate(lnglat: LngLat): MercatorCoordinate { - return MercatorCoordinate.fromLngLat(lnglat); -} - -/** - * Given a Coordinate, return its geographical position. - * @param coord - mercator coordinates - * @returns lng and lat - */ -export function mercatorCoordinateToLocation(coord: MercatorCoordinate): LngLat { - return coord && coord.toLngLat(); -} - /** * Convert from LngLat to world coordinates (Mercator coordinates scaled by world size). * @param worldSize - Mercator world size computed from zoom level and tile size. @@ -94,41 +73,6 @@ export function getMercatorHorizon(transform: {pitch: number; cameraToCenterDist Math.tan(degreesToRadians(maxMercatorHorizonAngle - transform.pitch))); } -export function getBasicProjectionData(overscaledTileID: OverscaledTileID, tilePosMatrix?: mat4, applyTerrainMatrix: boolean = true): ProjectionData { - let tileOffsetSize: [number, number, number, number]; - - if (overscaledTileID) { - const scale = (overscaledTileID.canonical.z >= 0) ? (1 << overscaledTileID.canonical.z) : Math.pow(2.0, overscaledTileID.canonical.z); - tileOffsetSize = [ - overscaledTileID.canonical.x / scale, - overscaledTileID.canonical.y / scale, - 1.0 / scale / EXTENT, - 1.0 / scale / EXTENT - ]; - } else { - tileOffsetSize = [0, 0, 1, 1]; - } - - let mainMatrix: mat4; - if (overscaledTileID && overscaledTileID.terrainRttPosMatrix32f && applyTerrainMatrix) { - mainMatrix = overscaledTileID.terrainRttPosMatrix32f; - } else if (tilePosMatrix) { - mainMatrix = tilePosMatrix; // This matrix should be float32 - } else { - mainMatrix = createIdentityMat4f32(); - } - - const data: ProjectionData = { - mainMatrix, // Might be set to a custom matrix by different projections. - tileMercatorCoords: tileOffsetSize, - clippingPlane: [0, 0, 0, 0], - projectionTransition: 0.0, // Range 0..1, where 0 is mercator, 1 is another projection, mostly globe. - fallbackMatrix: mainMatrix, - }; - - return data; -} - export function calculateTileMatrix(unwrappedTileID: UnwrappedTileIDType, worldSize: number): mat4 { const canonical = unwrappedTileID.canonical; const scale = worldSize / zoomScale(canonical.z); diff --git a/src/geo/projection/projection.ts b/src/geo/projection/projection.ts index 4d3d035ee5..59d7c20bca 100644 --- a/src/geo/projection/projection.ts +++ b/src/geo/projection/projection.ts @@ -4,7 +4,8 @@ import type {Context} from '../../gl/context'; import type {Mesh} from '../../render/mesh'; import type {Program} from '../../render/program'; import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; -import {type ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {EvaluationParameters} from '../../style/evaluation_parameters'; /** * Custom projections are handled both by a class which implements this `Projection` interface, @@ -91,16 +92,24 @@ export interface Projection { /** * @internal - * Cleans up any resources the projection created, especially GPU buffers. + * A number representing the current transition state of the projection. + * The return value should be a number between 0 and 1, + * where 0 means the projection is fully in the initial state, + * and 1 means the projection is fully in the final state. */ - destroy(): void; + get transitionState(): number; + + /** + * @internal + * Gets the error correction latitude in radians. + */ + get latitudeErrorCorrectionRadians(): number; /** * @internal - * True when an animation handled by the projection is in progress, - * requiring MapLibre to keep rendering new frames. + * Cleans up any resources the projection created, especially GPU buffers. */ - isRenderingDirty(): boolean; + destroy(): void; /** * @internal @@ -118,4 +127,23 @@ export interface Projection { * @param usage - Specify the usage of the tile mesh, as different usages might use different levels of subdivision. */ getMeshFromTileID(context: Context, tileID: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh; + + /** + * @internal + * Recalculates the projection state based on the current evaluation parameters. + * @param params - Evaluation parameters. + */ + recalculate(params: EvaluationParameters): void; + + /** + * @internal + * Returns true if the projection is currently transitioning between two states. + */ + hasTransition(): boolean; + + /** + * @internal + * Sets the error query latidude in degrees + */ + setErrorQueryLatitudeDegrees(value: number); } diff --git a/src/geo/projection/projection_data.ts b/src/geo/projection/projection_data.ts index 215af751c8..43d6d33e27 100644 --- a/src/geo/projection/projection_data.ts +++ b/src/geo/projection/projection_data.ts @@ -46,6 +46,10 @@ export type ProjectionData = { fallbackMatrix: mat4; } +/** + * Parameters object for the transform's `getProjectionData` function. + * Contains the requested tile ID and more. + */ export type ProjectionDataParams = { /** * The ID of the current tile diff --git a/src/geo/projection/projection_factory.ts b/src/geo/projection/projection_factory.ts index 217a1ff603..2944b82c0d 100644 --- a/src/geo/projection/projection_factory.ts +++ b/src/geo/projection/projection_factory.ts @@ -1,20 +1,32 @@ -import {type ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec'; import {warnOnce} from '../../util/util'; -import {type Projection} from './projection'; -import {type ITransform} from '../transform_interface'; -import {type ICameraHelper} from './camera_helper'; -import {MercatorProjection} from './mercator'; +import {MercatorProjection} from './mercator_projection'; import {MercatorTransform} from './mercator_transform'; import {MercatorCameraHelper} from './mercator_camera_helper'; -import {GlobeProjection} from './globe'; +import {GlobeProjection} from './globe_projection'; import {GlobeTransform} from './globe_transform'; import {GlobeCameraHelper} from './globe_camera_helper'; +import {VerticalPerspectiveCameraHelper} from './vertical_perspective_camera_helper'; +import {VerticalPerspectiveTransform} from './vertical_perspective_transform'; +import {VerticalPerspectiveProjection} from './vertical_perspective_projection'; + +import type {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {Projection} from './projection'; +import type {ITransform} from '../transform_interface'; +import type {ICameraHelper} from './camera_helper'; export function createProjectionFromName(name: ProjectionSpecification['type']): { projection: Projection; transform: ITransform; cameraHelper: ICameraHelper; } { + if (Array.isArray(name)) { + const globeProjection = new GlobeProjection({type: name}); + return { + projection: globeProjection, + transform: new GlobeTransform(), + cameraHelper: new GlobeCameraHelper(globeProjection), + }; + } switch (name) { case 'mercator': { @@ -26,20 +38,27 @@ export function createProjectionFromName(name: ProjectionSpecification['type']): } case 'globe': { - const proj = new GlobeProjection(); + const globeProjection = new GlobeProjection({type: [ + 'interpolate', + ['linear'], + ['zoom'], + 11, + 'vertical-perspective', + 12, + 'mercator' + ]}); return { - projection: proj, - transform: new GlobeTransform(proj, true), - cameraHelper: new GlobeCameraHelper(proj), + projection: globeProjection, + transform: new GlobeTransform(), + cameraHelper: new GlobeCameraHelper(globeProjection), }; } case 'vertical-perspective': { - const proj = new GlobeProjection(); return { - projection: proj, - transform: new GlobeTransform(proj, true, false), - cameraHelper: new GlobeCameraHelper(proj), + projection: new VerticalPerspectiveProjection(), + transform: new VerticalPerspectiveTransform(), + cameraHelper: new VerticalPerspectiveCameraHelper(), }; } default: diff --git a/src/geo/projection/vertical_perspective_camera_helper.ts b/src/geo/projection/vertical_perspective_camera_helper.ts new file mode 100644 index 0000000000..65c9295fe1 --- /dev/null +++ b/src/geo/projection/vertical_perspective_camera_helper.ts @@ -0,0 +1,454 @@ +import Point from '@mapbox/point-geometry'; +import {cameraBoundsWarning, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs, cameraForBoxAndBearing} from './camera_helper'; +import {LngLat, type LngLatLike} from '../lng_lat'; +import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils'; +import {clamp, createVec3f64, differenceOfAnglesDegrees, MAX_VALID_LATITUDE, remapSaturate, rollPitchBearingEqual, scaleZoom, warnOnce, zoomScale} from '../../util/util'; +import {type mat4, vec3} from 'gl-matrix'; +import {normalizeCenter} from '../transform_helper'; +import {interpolates} from '@maplibre/maplibre-gl-style-spec'; + +import type {IReadonlyTransform, ITransform} from '../transform_interface'; +import type {CameraForBoundsOptions} from '../../ui/camera'; +import type {LngLatBounds} from '../lng_lat_bounds'; +import type {PaddingOptions} from '../edge_insets'; + +/** + * @internal + */ +export class VerticalPerspectiveCameraHelper implements ICameraHelper { + + get useGlobeControls(): boolean { return true; } + + handlePanInertia(pan: Point, transform: IReadonlyTransform): { + easingCenter: LngLat; + easingOffset: Point; + } { + const panCenter = computeGlobePanCenter(pan, transform); + if (Math.abs(panCenter.lng - transform.center.lng) > 180) { + // If easeTo target would be over 180° distant, the animation would move + // in the opposite direction that what the user intended. + // Thus we clamp the movement to 179.5°. + panCenter.lng = transform.center.lng + 179.5 * Math.sign(panCenter.lng - transform.center.lng); + } + return { + easingCenter: panCenter, + easingOffset: new Point(0, 0), + }; + } + + handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { + const zoomPixel = deltas.around; + const zoomLoc = tr.screenPointToLocation(zoomPixel); + + if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta); + if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta); + if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta); + const oldZoomPreZoomDelta = tr.zoom; + if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta); + const actualZoomDelta = tr.zoom - oldZoomPreZoomDelta; + + if (actualZoomDelta === 0) { + return; + } + + // Problem: `setLocationAtPoint` for globe works when it is called a single time, but is a little glitchy in practice when used repeatedly for zooming. + // - `setLocationAtPoint` repeatedly called at a location behind a pole will eventually glitch out + // - `setLocationAtPoint` at location the longitude of which is more than 90° different from current center will eventually glitch out + // But otherwise works fine at higher zooms, or when the target is somewhat near the current map center. + // Solution: use a heuristic zooming in the problematic cases and interpolate to `setLocationAtPoint` when possible. + + // Magic numbers that control: + // - when zoom movement slowing starts for cursor not on globe (avoid unnatural map movements) + // - when we interpolate from exact zooming to heuristic zooming based on longitude difference of target location to current center + // - when we interpolate from exact zooming to heuristic zooming based on globe being too small on screen + // - when zoom movement slowing starts for globe being too small on viewport (avoids unnatural/unwanted map movements when map is zoomed out a lot) + const raySurfaceDistanceForSlowingStart = 0.3; // Zoom movement slowing will start when the planet surface to ray distance is greater than this number (globe radius is 1, so 0.3 is ~2000km form the surface). + const slowingMultiplier = 0.5; // The lower this value, the slower will the "zoom movement slowing" occur. + const interpolateToHeuristicStartLng = 45; // When zoom location longitude is this many degrees away from map center, we start interpolating from exact zooming to heuristic zooming. + const interpolateToHeuristicEndLng = 85; // Longitude difference at which interpolation to heuristic zooming ends. + const interpolateToHeuristicExponent = 0.25; // Makes interpolation smoother. + const interpolateToHeuristicStartRadius = 0.75; // When globe is this many times larger than the smaller viewport dimension, we start interpolating from exact zooming to heuristic zooming. + const interpolateToHeuristicEndRadius = 0.35; // Globe size at which interpolation to heuristic zooming ends. + const slowingRadiusStart = 0.9; // If globe is this many times larger than the smaller viewport dimension, start inhibiting map movement while zooming + const slowingRadiusStop = 0.5; + const slowingRadiusSlowFactor = 0.25; // How much is movement slowed when globe is too small + + const dLngRaw = differenceOfAnglesDegrees(tr.center.lng, zoomLoc.lng); + const dLng = dLngRaw / (Math.abs(dLngRaw / 180) + 1.0); // This gradually reduces the amount of longitude change if the zoom location is very far, eg. on the other side of the pole (possible when looking at a pole). + const dLat = differenceOfAnglesDegrees(tr.center.lat, zoomLoc.lat); + + // Slow zoom movement down if the mouse ray is far from the planet. + const rayDirection = tr.getRayDirectionFromPixel(zoomPixel); + const rayOrigin = tr.cameraPosition; + const distanceToClosestPoint = vec3.dot(rayOrigin, rayDirection) * -1; // Globe center relative to ray origin is equal to -rayOrigin and rayDirection is normalized, thus we want to compute dot(-rayOrigin, rayDirection). + const closestPoint = createVec3f64(); + vec3.add(closestPoint, rayOrigin, [ + rayDirection[0] * distanceToClosestPoint, + rayDirection[1] * distanceToClosestPoint, + rayDirection[2] * distanceToClosestPoint + ]); + const distanceFromSurface = vec3.length(closestPoint) - 1; + const distanceFactor = Math.exp(-Math.max(distanceFromSurface - raySurfaceDistanceForSlowingStart, 0) * slowingMultiplier); + + // Slow zoom movement down if the globe is too small on viewport + const radius = getGlobeRadiusPixels(tr.worldSize, tr.center.lat) / Math.min(tr.width, tr.height); // Radius relative to larger viewport dimension + const radiusFactor = remapSaturate(radius, slowingRadiusStart, slowingRadiusStop, 1.0, slowingRadiusSlowFactor); + + // Compute how much to move towards the zoom location + const factor = (1.0 - zoomScale(-actualZoomDelta)) * Math.min(distanceFactor, radiusFactor); + + const oldCenterLat = tr.center.lat; + const oldZoom = tr.zoom; + const heuristicCenter = new LngLat( + tr.center.lng + dLng * factor, + clamp(tr.center.lat + dLat * factor, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE) + ); + + // Now compute the map center exact zoom + tr.setLocationAtPoint(zoomLoc, zoomPixel); + const exactCenter = tr.center; + + // Interpolate between exact zooming and heuristic zooming depending on the longitude difference between current center and zoom location. + const interpolationFactorLongitude = remapSaturate(Math.abs(dLngRaw), interpolateToHeuristicStartLng, interpolateToHeuristicEndLng, 0, 1); + const interpolationFactorRadius = remapSaturate(radius, interpolateToHeuristicStartRadius, interpolateToHeuristicEndRadius, 0, 1); + const heuristicFactor = Math.pow(Math.max(interpolationFactorLongitude, interpolationFactorRadius), interpolateToHeuristicExponent); + + const lngExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lng, heuristicCenter.lng); + const latExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lat, heuristicCenter.lat); + + tr.setCenter(new LngLat( + exactCenter.lng + lngExactToHeuristic * heuristicFactor, + exactCenter.lat + latExactToHeuristic * heuristicFactor + ).wrap()); + tr.setZoom(oldZoom + getZoomAdjustment(oldCenterLat, tr.center.lat)); + } + + handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, _preZoomAroundLoc: LngLat): void { + if (!deltas.panDelta) { + return; + } + + // These are actually very similar to mercator controls, and should converge to them at high zooms. + // We avoid using the "grab a place and move it around" approach from mercator here, + // since it is not a very pleasant way to pan a globe. + const oldLat = tr.center.lat; + const oldZoom = tr.zoom; + tr.setCenter(computeGlobePanCenter(deltas.panDelta, tr).wrap()); + // Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time + tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat)); + } + + cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult { + const result = cameraForBoxAndBearing(options, padding, bounds, bearing, tr); + // If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds. + + // Get clip space bounds including padding + const xLeft = (padding.left) / tr.width * 2.0 - 1.0; + const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0; + const yTop = (padding.top) / tr.height * -2.0 + 1.0; + const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0; + + // Get camera bounds + const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0; + const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest(); + const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast(); + + const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north... + const latSouth = Math.min(bounds.getNorth(), bounds.getSouth()); + + // Additional vectors will be tested for the rectangle midpoints + const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5; + const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5; + + // Obtain a globe projection matrix that does not include pitch (unsupported) + const clonedTr = tr.clone(); + clonedTr.setCenter(result.center); + clonedTr.setBearing(result.bearing); + clonedTr.setPitch(0); + clonedTr.setRoll(0); + clonedTr.setZoom(result.zoom); + const matrix = clonedTr.modelViewProjectionMatrix; + + // Vectors to test - the bounds' corners and edge midpoints + const testVectors = [ + angularCoordinatesToSurfaceVector(bounds.getNorthWest()), + angularCoordinatesToSurfaceVector(bounds.getNorthEast()), + angularCoordinatesToSurfaceVector(bounds.getSouthWest()), + angularCoordinatesToSurfaceVector(bounds.getSouthEast()), + // Also test edge midpoints + angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)), + angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)), + angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)), + angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth)) + ]; + const vecToCenter = angularCoordinatesToSurfaceVector(result.center); + + // Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space. + let smallestNeededScale = Number.POSITIVE_INFINITY; + for (const vec of testVectors) { + if (xLeft < 0) + smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft)); + if (xRight > 0) + smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight)); + if (yTop > 0) + smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop)); + if (yBottom < 0) + smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom)); + } + + if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) { + cameraBoundsWarning(); + return undefined; + } + + // Compute target zoom from the obtained scale. + result.zoom = clonedTr.zoom + scaleZoom(smallestNeededScale); + return result; + } + + /** + * Handles the zoom and center change during camera jumpTo. + */ + handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void { + // Special zoom & center handling for globe: + // Globe constrained center isn't dependent on zoom level + const startingLat = tr.center.lat; + const constrainedCenter = tr.getConstrained(options.center ? LngLat.convert(options.center) : tr.center, tr.zoom).center; + tr.setCenter(constrainedCenter.wrap()); + + // Make sure to compute correct target zoom level if no zoom is specified + const targetZoom = (typeof options.zoom !== 'undefined') ? +options.zoom : (tr.zoom + getZoomAdjustment(startingLat, constrainedCenter.lat)); + if (tr.zoom !== targetZoom) { + tr.setZoom(targetZoom); + } + } + + handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult { + const startZoom = tr.zoom; + const startCenter = tr.center; + const startPadding = tr.padding; + const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing}; + const endRoll = options.roll === undefined ? tr.roll : options.roll; + const endPitch = options.pitch === undefined ? tr.pitch : options.pitch; + const endBearing = options.bearing === undefined ? tr.bearing : options.bearing; + const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing}; + + const optionsZoom = typeof options.zoom !== 'undefined'; + + const doPadding = !tr.isPaddingEqual(options.padding); + + let isZooming = false; + + // Globe needs special handling for how zoom should be animated. + // 1) if zoom is set, ease to the given mercator zoom + // 2) if neither is set, assume constant apparent zoom (constant planet size) is to be kept + const preConstrainCenter = options.center ? + LngLat.convert(options.center) : + startCenter; + const constrainedCenter = tr.getConstrained( + preConstrainCenter, + startZoom // zoom can be whatever at this stage, it should not affect anything if globe is enabled + ).center; + normalizeCenter(tr, constrainedCenter); + + const clonedTr = tr.clone(); + clonedTr.setCenter(constrainedCenter); + + clonedTr.setZoom(optionsZoom ? + +options.zoom : + startZoom + getZoomAdjustment(startCenter.lat, preConstrainCenter.lat)); + clonedTr.setBearing(options.bearing); + const clampedPoint = new Point( + clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), + clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) + ); + clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); + // Find final animation targets + const endCenterWithShift = (options.offset && options.offsetAsPoint.mag()) > 0 ? clonedTr.center : constrainedCenter; + const endZoomWithShift = optionsZoom ? + +options.zoom : + startZoom + getZoomAdjustment(startCenter.lat, endCenterWithShift.lat); + + // Planet radius for a given zoom level differs according to latitude + // Convert zooms to what they would be at equator for the given planet radius + const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); + const normalizedEndZoom = endZoomWithShift + getZoomAdjustment(endCenterWithShift.lat, 0); + const deltaLng = differenceOfAnglesDegrees(startCenter.lng, endCenterWithShift.lng); + const deltaLat = differenceOfAnglesDegrees(startCenter.lat, endCenterWithShift.lat); + + const finalScale = zoomScale(normalizedEndZoom - normalizedStartZoom); + isZooming = (endZoomWithShift !== startZoom); + + const easeFunc = (k: number) => { + if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) { + updateRotation({ + startEulerAngles, + endEulerAngles, + tr, + k, + useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs); + } + + if (doPadding) { + tr.interpolatePadding(startPadding, options.padding,k); + } + + if (options.around) { + warnOnce('Easing around a point is not supported under globe projection.'); + tr.setLocationAtPoint(options.around, options.aroundPoint); + } else { + const base = normalizedEndZoom > normalizedStartZoom ? + Math.min(2, finalScale) : + Math.max(0.5, finalScale); + const speedup = Math.pow(base, 1 - k); + const factor = k * speedup; + + // Spherical lerp might be used here instead, but that was tested and it leads to very weird paths when the interpolated arc gets near the poles. + // Instead we interpolate LngLat almost directly, but taking into account that + // one degree of longitude gets progressively smaller relative to latitude towards the poles. + const newCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, factor); + tr.setCenter(newCenter.wrap()); + } + + if (isZooming) { + const normalizedInterpolatedZoom = interpolates.number(normalizedStartZoom, normalizedEndZoom, k); + const interpolatedZoom = normalizedInterpolatedZoom + getZoomAdjustment(0, tr.center.lat); + tr.setZoom(interpolatedZoom); + } + }; + + return { + easeFunc, + isZooming, + elevationCenter: endCenterWithShift, + }; + } + + handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult { + const optionsZoom = typeof options.zoom !== 'undefined'; + + const startCenter = tr.center; + const startZoom = tr.zoom; + const doPadding = !tr.isPaddingEqual(options.padding); + + // Obtain target center and zoom + const constrainedCenter = tr.getConstrained( + LngLat.convert(options.center || options.locationAtOffset), + startZoom + ).center; + const targetZoom = optionsZoom ? +options.zoom : tr.zoom + getZoomAdjustment(tr.center.lat, constrainedCenter.lat); + + // Compute target center that respects offset by creating a temporary transform and calling its `setLocationAtPoint`. + const clonedTr = tr.clone(); + clonedTr.setCenter(constrainedCenter); + if (doPadding) { + clonedTr.setPadding(options.padding as PaddingOptions); + } + clonedTr.setZoom(targetZoom); + clonedTr.setBearing(options.bearing); + const clampedPoint = new Point( + clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), + clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) + ); + clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); + const targetCenter = clonedTr.center; + + normalizeCenter(tr, targetCenter); + + const pixelPathLength = globeDistanceOfLocationsPixels(tr, startCenter, targetCenter); + + const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); + const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0); + const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom); + + const optionsMinZoom = typeof options.minZoom === 'number'; + + let scaleOfMinZoom: number; + + if (optionsMinZoom) { + const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0); + const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom); + const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat); + const minZoom = tr.getConstrained(targetCenter, minZoomPreConstrain).zoom; + const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0); + scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom); + } + + const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng); + const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat); + + const easeFunc = (k: number, scale: number, centerFactor: number, _pointAtOffset: Point) => { + const interpolatedCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, centerFactor); + + const newCenter = k === 1 ? targetCenter : interpolatedCenter; + tr.setCenter(newCenter.wrap()); + + const interpolatedZoom = normalizedStartZoom + scaleZoom(scale); + tr.setZoom(k === 1 ? targetZoom : (interpolatedZoom + getZoomAdjustment(0, newCenter.lat))); + }; + + return { + easeFunc, + scaleOfZoom, + targetCenter, + scaleOfMinZoom, + pixelPathLength, + }; + } + + /** + * Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis. + * @param vector - Position of the queried location on the surface of the unit sphere globe. + * @param toCenter - Position of current transform center on the surface of the unit sphere globe. + * This is needed because zooming the globe not only changes its scale, + * but also moves the camera closer or further away along this vector (pitch is disregarded). + * @param projection - The globe projection matrix. + * @param targetDimension - The dimension in which the scaled vector must match the target value in clip space. + * @param targetValue - The target clip space value in the specified dimension to which the queried vector must project. + * @returns How much to scale the globe. + */ + private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null { + // We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y). + const k = targetValue; + const columnXorY = targetDimension === 'x' ? + [projection[0], projection[4], projection[8], projection[12]] : // X + [projection[1], projection[5], projection[9], projection[13]]; // Y + const columnZ = [projection[3], projection[7], projection[11], projection[15]]; + + const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2]; + const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2]; + const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2]; + const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2]; + + // The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t". + // Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C". + + const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ); + + if ( + toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ || + columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ + ) { + // The computed result is invalid. + return null; + } + return t; + } + + /** + * Returns `newValue` if it is: + * + * - not null AND + * - not negative AND + * - smaller than `newValue`, + * + * ...otherwise returns `oldValue`. + */ + private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number { + if (newValue !== null && newValue >= 0 && newValue < oldValue) { + return newValue; + } else { + return oldValue; + } + } +} \ No newline at end of file diff --git a/src/geo/projection/globe.ts b/src/geo/projection/vertical_perspective_projection.ts similarity index 78% rename from src/geo/projection/globe.ts rename to src/geo/projection/vertical_perspective_projection.ts index 7d728dad32..fbe6fca32d 100644 --- a/src/geo/projection/globe.ts +++ b/src/geo/projection/vertical_perspective_projection.ts @@ -7,13 +7,14 @@ import {mercatorYfromLat} from '../mercator_coordinate'; import {SubdivisionGranularityExpression, SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection'; import {type PreparedShader, shaders} from '../../shaders/shaders'; -import {MercatorProjection} from './mercator'; import {ProjectionErrorMeasurement} from './globe_projection_error_measurement'; import {createTileMeshWithBuffers, type CreateTileMeshOptions} from '../../util/create_tile_mesh'; +import {type EvaluationParameters} from '../../style/evaluation_parameters'; + +export const VerticalPerspectiveShaderDefine = '#define GLOBE'; +export const VerticalPerspectiveShaderVariantKey = 'globe'; export const globeConstants = { - globeTransitionTimeSeconds: 0.5, - maxGlobeZoom: 12.0, errorTransitionTimeSeconds: 0.5 }; @@ -32,17 +33,9 @@ const granularitySettingsGlobe: SubdivisionGranularitySetting = new SubdivisionG circle: 3 }); -export class GlobeProjection implements Projection { - private _mercator: MercatorProjection; - +export class VerticalPerspectiveProjection implements Projection { private _tileMeshCache: {[_: string]: Mesh} = {}; - /** - * Stores whether globe rendering should be used. - * The value is injected from GlobeTransform. - */ - private _useGlobeRendering: boolean = true; - // GPU atan() error correction private _errorMeasurement: ProjectionErrorMeasurement; private _errorQueryLatitudeDegrees: number; @@ -51,40 +44,28 @@ export class GlobeProjection implements Projection { private _errorCorrectionPreviousValue: number = 0.0; private _errorMeasurementLastChangeTime: number = -1000.0; - get name(): 'globe' { - return 'globe'; + get name(): 'vertical-perspective' { + return 'vertical-perspective'; } - /** - * This property is true when globe rendering and globe shader variants should be in use. - * This is false when globe is disabled, or when globe is enabled, but mercator rendering is used due to zoom level (and no transition is happening). - */ - get useGlobeRendering(): boolean { - return this._useGlobeRendering; - } - - /** - * @internal - * Intended for internal use, only called from GlobeTransform. - */ - set useGlobeRendering(value: boolean) { - this._useGlobeRendering = value; + get transitionState(): number { + return 1; } get useSubdivision(): boolean { - return this.useGlobeRendering; + return true; } get shaderVariantName(): string { - return this.useGlobeRendering ? 'globe' : this._mercator.shaderVariantName; + return VerticalPerspectiveShaderVariantKey; } get shaderDefine(): string { - return this.useGlobeRendering ? '#define GLOBE' : this._mercator.shaderDefine; + return VerticalPerspectiveShaderDefine; } get shaderPreludeCode(): PreparedShader { - return this.useGlobeRendering ? shaders.projectionGlobe : this._mercator.shaderPreludeCode; + return shaders.projectionGlobe; } get vertexShaderPreludeCode(): string { @@ -96,17 +77,7 @@ export class GlobeProjection implements Projection { } get useGlobeControls(): boolean { - return this._useGlobeRendering; - } - - get errorQueryLatitudeDegrees(): number { return this._errorQueryLatitudeDegrees; } - - /** - * @internal - * Intended for internal use, only called from GlobeTransform. - */ - set errorQueryLatitudeDegrees(value: number) { - this._errorQueryLatitudeDegrees = value; + return true; } /** @@ -118,28 +89,13 @@ export class GlobeProjection implements Projection { */ get latitudeErrorCorrectionRadians(): number { return this._errorCorrectionUsable; } - constructor() { - this._mercator = new MercatorProjection(); - } - public destroy() { if (this._errorMeasurement) { this._errorMeasurement.destroy(); } } - public isRenderingDirty(): boolean { - const now = browser.now(); - let dirty = false; - // Error correction transition - dirty = dirty || (now - this._errorMeasurementLastChangeTime) / 1000.0 < (globeConstants.errorTransitionTimeSeconds + 0.2); - // Error correction query in flight - dirty = dirty || (this._errorMeasurement && this._errorMeasurement.awaitingQuery); - return dirty; - } - public updateGPUdependent(renderContext: ProjectionGPUContext): void { - this._mercator.updateGPUdependent(renderContext); if (!this._errorMeasurement) { this._errorMeasurement = new ProjectionErrorMeasurement(renderContext); } @@ -190,4 +146,22 @@ export class GlobeProjection implements Projection { this._tileMeshCache[key] = mesh; return mesh; } -} + + recalculate(_params: EvaluationParameters): void { + // Do nothing. + } + + hasTransition(): boolean { + const now = browser.now(); + let dirty = false; + // Error correction transition + dirty = dirty || (now - this._errorMeasurementLastChangeTime) / 1000.0 < (globeConstants.errorTransitionTimeSeconds + 0.2); + // Error correction query in flight + dirty = dirty || (this._errorMeasurement && this._errorMeasurement.awaitingQuery); + return dirty; + } + + setErrorQueryLatitudeDegrees(value: number) { + this._errorQueryLatitudeDegrees = value; + } +} \ No newline at end of file diff --git a/src/geo/projection/vertical_perspective_transform.ts b/src/geo/projection/vertical_perspective_transform.ts new file mode 100644 index 0000000000..adad6c28cd --- /dev/null +++ b/src/geo/projection/vertical_perspective_transform.ts @@ -0,0 +1,991 @@ +import {type mat2, mat4, vec3, vec4} from 'gl-matrix'; +import {TransformHelper} from '../transform_helper'; +import {LngLat, type LngLatLike, earthRadius} from '../lng_lat'; +import {angleToRotateBetweenVectors2D, clamp, createIdentityMat4f32, createIdentityMat4f64, createMat4f64, createVec3f64, createVec4f64, differenceOfAnglesDegrees, distanceOfAnglesRadians, MAX_VALID_LATITUDE, pointPlaneSignedDistance, warnOnce} from '../../util/util'; +import {OverscaledTileID, UnwrappedTileID, type CanonicalTileID} from '../../source/tile_id'; +import Point from '@mapbox/point-geometry'; +import {MercatorCoordinate} from '../mercator_coordinate'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {tileCoordinatesToMercatorCoordinates} from './mercator_utils'; +import {angularCoordinatesToSurfaceVector, getGlobeRadiusPixels, getZoomAdjustment, mercatorCoordinatesToAngularCoordinatesRadians, projectTileCoordinatesToSphere, sphereSurfacePointToCoordinates} from './globe_utils'; +import {GlobeCoveringTilesDetailsProvider} from './globe_covering_tiles_details_provider'; +import {Frustum} from '../../util/primitives/frustum'; + +import type {Terrain} from '../../render/terrain'; +import type {PointProjection} from '../../symbol/projection'; +import type {IReadonlyTransform, ITransform} from '../transform_interface'; +import type {PaddingOptions} from '../edge_insets'; +import type {ProjectionData, ProjectionDataParams} from './projection_data'; +import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider'; + +/** + * Describes the intersection of ray and sphere. + * When null, no intersection occurred. + * When both "t" values are the same, the ray just touched the sphere's surface. + * When both value are different, a full intersection occurred. + */ +type RaySphereIntersection = { + /** + * The ray parameter for intersection that is "less" along the ray direction. + * Note that this value can be negative, meaning that this intersection occurred before the ray's origin. + * The intersection point can be computed as `origin + direction * tMin`. + */ + tMin: number; + /** + * The ray parameter for intersection that is "more" along the ray direction. + * Note that this value can be negative, meaning that this intersection occurred before the ray's origin. + * The intersection point can be computed as `origin + direction * tMax`. + */ + tMax: number; +} | null; + +export class VerticalPerspectiveTransform implements ITransform { + private _helper: TransformHelper; + + // + // Implementation of transform getters and setters + // + + get pixelsToClipSpaceMatrix(): mat4 { + return this._helper.pixelsToClipSpaceMatrix; + } + get clipSpaceToPixelsMatrix(): mat4 { + return this._helper.clipSpaceToPixelsMatrix; + } + get pixelsToGLUnits(): [number, number] { + return this._helper.pixelsToGLUnits; + } + get centerOffset(): Point { + return this._helper.centerOffset; + } + get size(): Point { + return this._helper.size; + } + get rotationMatrix(): mat2 { + return this._helper.rotationMatrix; + } + get centerPoint(): Point { + return this._helper.centerPoint; + } + get pixelsPerMeter(): number { + return this._helper.pixelsPerMeter; + } + setMinZoom(zoom: number): void { + this._helper.setMinZoom(zoom); + } + setMaxZoom(zoom: number): void { + this._helper.setMaxZoom(zoom); + } + setMinPitch(pitch: number): void { + this._helper.setMinPitch(pitch); + } + setMaxPitch(pitch: number): void { + this._helper.setMaxPitch(pitch); + } + setRenderWorldCopies(renderWorldCopies: boolean): void { + this._helper.setRenderWorldCopies(renderWorldCopies); + } + setBearing(bearing: number): void { + this._helper.setBearing(bearing); + } + setPitch(pitch: number): void { + this._helper.setPitch(pitch); + } + setRoll(roll: number): void { + this._helper.setRoll(roll); + } + setFov(fov: number): void { + this._helper.setFov(fov); + } + setZoom(zoom: number): void { + this._helper.setZoom(zoom); + } + setCenter(center: LngLat): void { + this._helper.setCenter(center); + } + setElevation(elevation: number): void { + this._helper.setElevation(elevation); + } + setMinElevationForCurrentTile(elevation: number): void { + this._helper.setMinElevationForCurrentTile(elevation); + } + setPadding(padding: PaddingOptions): void { + this._helper.setPadding(padding); + } + interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void { + return this._helper.interpolatePadding(start, target, t); + } + isPaddingEqual(padding: PaddingOptions): boolean { + return this._helper.isPaddingEqual(padding); + } + resize(width: number, height: number): void { + this._helper.resize(width, height); + } + getMaxBounds(): LngLatBounds { + return this._helper.getMaxBounds(); + } + setMaxBounds(bounds?: LngLatBounds): void { + this._helper.setMaxBounds(bounds); + } + overrideNearFarZ(nearZ: number, farZ: number): void { + this._helper.overrideNearFarZ(nearZ, farZ); + } + clearNearFarZOverride(): void { + this._helper.clearNearFarZOverride(); + } + getCameraQueryGeometry(queryGeometry: Point[]): Point[] { + return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry); + } + + get tileSize(): number { + return this._helper.tileSize; + } + get tileZoom(): number { + return this._helper.tileZoom; + } + get scale(): number { + return this._helper.scale; + } + get worldSize(): number { + return this._helper.worldSize; + } + get width(): number { + return this._helper.width; + } + get height(): number { + return this._helper.height; + } + get lngRange(): [number, number] { + return this._helper.lngRange; + } + get latRange(): [number, number] { + return this._helper.latRange; + } + get minZoom(): number { + return this._helper.minZoom; + } + get maxZoom(): number { + return this._helper.maxZoom; + } + get zoom(): number { + return this._helper.zoom; + } + get center(): LngLat { + return this._helper.center; + } + get minPitch(): number { + return this._helper.minPitch; + } + get maxPitch(): number { + return this._helper.maxPitch; + } + get pitch(): number { + return this._helper.pitch; + } + get pitchInRadians(): number { + return this._helper.pitchInRadians; + } + get roll(): number { + return this._helper.roll; + } + get rollInRadians(): number { + return this._helper.rollInRadians; + } + get bearing(): number { + return this._helper.bearing; + } + get bearingInRadians(): number { + return this._helper.bearingInRadians; + } + get fov(): number { + return this._helper.fov; + } + get fovInRadians(): number { + return this._helper.fovInRadians; + } + get elevation(): number { + return this._helper.elevation; + } + get minElevationForCurrentTile(): number { + return this._helper.minElevationForCurrentTile; + } + get padding(): PaddingOptions { + return this._helper.padding; + } + get unmodified(): boolean { + return this._helper.unmodified; + } + get renderWorldCopies(): boolean { + return this._helper.renderWorldCopies; + } + public get nearZ(): number { + return this._helper.nearZ; + } + public get farZ(): number { + return this._helper.farZ; + } + public get autoCalculateNearFarZ(): boolean { + return this._helper.autoCalculateNearFarZ; + } + setTransitionState(_value: number): void { + // Do nothing + } + // + // Implementation of globe transform + // + + private _cachedClippingPlane: vec4 = createVec4f64(); + private _cachedFrustum: Frustum; + private _projectionMatrix: mat4 = createIdentityMat4f64(); + private _globeViewProjMatrix32f: mat4 = createIdentityMat4f32(); // Must be 32 bit floats, otherwise WebGL calls in Chrome get very slow. + private _globeViewProjMatrixNoCorrection: mat4 = createIdentityMat4f64(); + private _globeViewProjMatrixNoCorrectionInverted: mat4 = createIdentityMat4f64(); + private _globeProjMatrixInverted: mat4 = createIdentityMat4f64(); + + private _cameraPosition: vec3 = createVec3f64(); + private _globeLatitudeErrorCorrectionRadians: number = 0; + /** + * Globe projection can smoothly interpolate between globe view and mercator. This variable controls this interpolation. + * Value 0 is mercator, value 1 is globe, anything between is an interpolation between the two projections. + */ + + private _coveringTilesDetailsProvider: GlobeCoveringTilesDetailsProvider; + + public constructor() { + + this._helper = new TransformHelper({ + calcMatrices: () => { this._calcMatrices(); }, + getConstrained: (center, zoom) => { return this.getConstrained(center, zoom); } + }); + this._coveringTilesDetailsProvider = new GlobeCoveringTilesDetailsProvider(); + } + + clone(): ITransform { + const clone = new VerticalPerspectiveTransform(); + clone.apply(this); + return clone; + } + + public apply(that: IReadonlyTransform, globeLatitudeErrorCorrectionRadians?: number): void { + this._globeLatitudeErrorCorrectionRadians = globeLatitudeErrorCorrectionRadians || 0; + this._helper.apply(that); + } + + public get projectionMatrix(): mat4 { return this._projectionMatrix; } + + public get modelViewProjectionMatrix(): mat4 { return this._globeViewProjMatrixNoCorrection; } + + public get inverseProjectionMatrix(): mat4 { return this._globeProjMatrixInverted; } + + public get cameraPosition(): vec3 { + // Return a copy - don't let outside code mutate our precomputed camera position. + const copy = createVec3f64(); // Ensure the resulting vector is float64s + copy[0] = this._cameraPosition[0]; + copy[1] = this._cameraPosition[1]; + copy[2] = this._cameraPosition[2]; + return copy; + } + + get cameraToCenterDistance(): number { + // Globe uses the same cameraToCenterDistance as mercator. + return this._helper.cameraToCenterDistance; + } + + getProjectionData(params: ProjectionDataParams): ProjectionData { + const {overscaledTileID, applyGlobeMatrix} = params; + const mercatorTileCoordinates = this._helper.getMercatorTileCoordinates(overscaledTileID); + return { + mainMatrix: this._globeViewProjMatrix32f, + tileMercatorCoords: mercatorTileCoordinates, + clippingPlane: this._cachedClippingPlane as [number, number, number, number], + projectionTransition: applyGlobeMatrix ? 1 : 0, + fallbackMatrix: this._globeViewProjMatrix32f, + } + } + + private _computeClippingPlane(globeRadiusPixels: number): vec4 { + // We want to compute a plane equation that, when applied to the unit sphere generated + // in the vertex shader, places all visible parts of the sphere into the positive half-space + // and all the non-visible parts in the negative half-space. + // We can then use that to accurately clip all non-visible geometry. + + // cam....------------A + // .... | + // .... | + // ....B + // ggggggggg + // gggggg | .gggggg + // ggg | ...ggg ^ + // gg | | + // g | y + // g | | + // g C #---x---> + // + // Notes: + // - note the coordinate axes + // - "g" marks the globe edge + // - the dotted line is the camera center "ray" - we are looking in this direction + // - "cam" is camera origin + // - "C" is globe center + // - "B" is the point on "top" of the globe - camera is looking at B - "B" is the intersection between the camera center ray and the globe + // - this._pitchInRadians is the angle at B between points cam,B,A + // - this.cameraToCenterDistance is the distance from camera to "B" + // - globe radius is (0.5 * this.worldSize) + // - "T" is any point where a tangent line from "cam" touches the globe surface + // - elevation is assumed to be zero - globe rendering must be separate from terrain rendering anyway + + const pitch = this.pitchInRadians; + // scale things so that the globe radius is 1 + const distanceCameraToB = this.cameraToCenterDistance / globeRadiusPixels; + const radius = 1; + + // Distance from camera to "A" - the point at the same elevation as camera, right above center point on globe + const distanceCameraToA = Math.sin(pitch) * distanceCameraToB; + // Distance from "A" to "C" + const distanceAtoC = (Math.cos(pitch) * distanceCameraToB + radius); + // Distance from camera to "C" - the globe center + const distanceCameraToC = Math.sqrt(distanceCameraToA * distanceCameraToA + distanceAtoC * distanceAtoC); + // cam - C - T angle cosine (at C) + const camCTcosine = radius / distanceCameraToC; + // Distance from globe center to the plane defined by all possible "T" points + const tangentPlaneDistanceToC = camCTcosine * radius; + + let vectorCtoCamX = -distanceCameraToA; + let vectorCtoCamY = distanceAtoC; + // Normalize the vector + const vectorCtoCamLength = Math.sqrt(vectorCtoCamX * vectorCtoCamX + vectorCtoCamY * vectorCtoCamY); + vectorCtoCamX /= vectorCtoCamLength; + vectorCtoCamY /= vectorCtoCamLength; + + // Note the swizzled components + const planeVector: vec3 = [0, vectorCtoCamX, vectorCtoCamY]; + // Apply transforms - lat, lng and angle (NOT pitch - already accounted for, as it affects the tangent plane) + vec3.rotateZ(planeVector, planeVector, [0, 0, 0], -this.bearingInRadians); + vec3.rotateX(planeVector, planeVector, [0, 0, 0], -1 * this.center.lat * Math.PI / 180.0); + vec3.rotateY(planeVector, planeVector, [0, 0, 0], this.center.lng * Math.PI / 180.0); + // Scale the plane vector up + // we don't want the actually visible parts of the sphere to end up beyond distance 1 from the plane - otherwise they would be clipped by the near plane. + const scale = 0.25; + vec3.scale(planeVector, planeVector, scale); + return [...planeVector, -tangentPlaneDistanceToC * scale]; + } + + public isLocationOccluded(location: LngLat): boolean { + return !this.isSurfacePointVisible(angularCoordinatesToSurfaceVector(location)); + } + + public transformLightDirection(dir: vec3): vec3 { + const sphereX = this._helper._center.lng * Math.PI / 180.0; + const sphereY = this._helper._center.lat * Math.PI / 180.0; + + const len = Math.cos(sphereY); + const spherePos: vec3 = [ + Math.sin(sphereX) * len, + Math.sin(sphereY), + Math.cos(sphereX) * len + ]; + + const axisRight: vec3 = [spherePos[2], 0.0, -spherePos[0]]; // Equivalent to cross(vec3(0.0, 1.0, 0.0), vec) + const axisDown: vec3 = [0, 0, 0]; + vec3.cross(axisDown, axisRight, spherePos); + vec3.normalize(axisRight, axisRight); + vec3.normalize(axisDown, axisDown); + + const transformed: vec3 = [ + axisRight[0] * dir[0] + axisDown[0] * dir[1] + spherePos[0] * dir[2], + axisRight[1] * dir[0] + axisDown[1] * dir[1] + spherePos[1] * dir[2], + axisRight[2] * dir[0] + axisDown[2] * dir[1] + spherePos[2] * dir[2] + ]; + + const normalized: vec3 = [0, 0, 0]; + vec3.normalize(normalized, transformed); + return normalized; + } + + public getPixelScale(): number { + return 1.0 / Math.cos(this._helper._center.lat * Math.PI / 180); + } + + public getCircleRadiusCorrection(): number { + return Math.cos(this._helper._center.lat * Math.PI / 180); + } + + public getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number { + const mercator = tileCoordinatesToMercatorCoordinates(textAnchorX, textAnchorY, tileID.canonical); + const angular = mercatorCoordinatesToAngularCoordinatesRadians(mercator.x, mercator.y); + return this.getCircleRadiusCorrection() / Math.cos(angular[1]); + } + + public projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection { + const canonical = unwrappedTileID.canonical; + const spherePos = projectTileCoordinatesToSphere(x, y, canonical.x, canonical.y, canonical.z); + const elevation = getElevation ? getElevation(x, y) : 0.0; + const vectorMultiplier = 1.0 + elevation / earthRadius; + const pos: vec4 = [spherePos[0] * vectorMultiplier, spherePos[1] * vectorMultiplier, spherePos[2] * vectorMultiplier, 1]; + vec4.transformMat4(pos, pos, this._globeViewProjMatrixNoCorrection); + + // Also check whether the point projects to the backfacing side of the sphere. + const plane = this._cachedClippingPlane; + // dot(position on sphere, occlusion plane equation) + const dotResult = plane[0] * spherePos[0] + plane[1] * spherePos[1] + plane[2] * spherePos[2] + plane[3]; + const isOccluded = dotResult < 0.0; + + return { + point: new Point(pos[0] / pos[3], pos[1] / pos[3]), + signedDistanceFromCamera: pos[3], + isOccluded + }; + } + + private _calcMatrices(): void { + if (!this._helper._width || !this._helper._height) { + return; + } + + const globeRadiusPixels = getGlobeRadiusPixels(this.worldSize, this.center.lat); + + // Construct a completely separate matrix for globe view + const globeMatrix = createMat4f64(); + const globeMatrixUncorrected = createMat4f64(); + if (this._helper.autoCalculateNearFarZ) { + this._helper._nearZ = 0.5; + this._helper._farZ = this.cameraToCenterDistance + globeRadiusPixels * 2.0; // just set the far plane far enough - we will calculate our own z in the vertex shader anyway + } + mat4.perspective(globeMatrix, this.fovInRadians, this.width / this.height, this._helper._nearZ, this._helper._farZ); + + // Apply center of perspective offset + const offset = this.centerOffset; + globeMatrix[8] = -offset.x * 2 / this._helper._width; + globeMatrix[9] = offset.y * 2 / this._helper._height; + this._projectionMatrix = mat4.clone(globeMatrix); + + this._globeProjMatrixInverted = createMat4f64(); + mat4.invert(this._globeProjMatrixInverted, globeMatrix); + mat4.translate(globeMatrix, globeMatrix, [0, 0, -this.cameraToCenterDistance]); + mat4.rotateZ(globeMatrix, globeMatrix, this.rollInRadians); + mat4.rotateX(globeMatrix, globeMatrix, -this.pitchInRadians); + mat4.rotateZ(globeMatrix, globeMatrix, this.bearingInRadians); + mat4.translate(globeMatrix, globeMatrix, [0.0, 0, -globeRadiusPixels]); + // Rotate the sphere to center it on viewed coordinates + + const scaleVec = createVec3f64(); + scaleVec[0] = globeRadiusPixels; + scaleVec[1] = globeRadiusPixels; + scaleVec[2] = globeRadiusPixels; + + // Keep a atan-correction-free matrix for transformations done on the CPU with accurate math + mat4.rotateX(globeMatrixUncorrected, globeMatrix, this.center.lat * Math.PI / 180.0); + mat4.rotateY(globeMatrixUncorrected, globeMatrixUncorrected, -this.center.lng * Math.PI / 180.0); + mat4.scale(globeMatrixUncorrected, globeMatrixUncorrected, scaleVec); // Scale the unit sphere to a sphere with diameter of 1 + this._globeViewProjMatrixNoCorrection = globeMatrixUncorrected; + + mat4.rotateX(globeMatrix, globeMatrix, this.center.lat * Math.PI / 180.0 - this._globeLatitudeErrorCorrectionRadians); + mat4.rotateY(globeMatrix, globeMatrix, -this.center.lng * Math.PI / 180.0); + mat4.scale(globeMatrix, globeMatrix, scaleVec); // Scale the unit sphere to a sphere with diameter of 1 + this._globeViewProjMatrix32f = new Float32Array(globeMatrix); + + this._globeViewProjMatrixNoCorrectionInverted = createMat4f64(); + mat4.invert(this._globeViewProjMatrixNoCorrectionInverted, globeMatrixUncorrected); + + const zero = createVec3f64(); + this._cameraPosition = createVec3f64(); + this._cameraPosition[2] = this.cameraToCenterDistance / globeRadiusPixels; + vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, -this.rollInRadians); + vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, this.pitchInRadians); + vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, -this.bearingInRadians); + vec3.add(this._cameraPosition, this._cameraPosition, [0, 0, 1]); + vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, -this.center.lat * Math.PI / 180.0); + vec3.rotateY(this._cameraPosition, this._cameraPosition, zero, this.center.lng * Math.PI / 180.0); + + this._cachedClippingPlane = this._computeClippingPlane(globeRadiusPixels); + + const matrix = mat4.clone(this._globeViewProjMatrixNoCorrectionInverted); + mat4.scale(matrix, matrix, [1, 1, -1]); + this._cachedFrustum = Frustum.fromInvProjectionMatrix(matrix); + } + + calculateFogMatrix(_unwrappedTileID: UnwrappedTileID): mat4 { + warnOnce('calculateFogMatrix is not supported on globe projection.'); + const m = createMat4f64(); + mat4.identity(m); + return m; + } + + getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): UnwrappedTileID[] { + // Globe has no wrap. + return [new UnwrappedTileID(0, tileID)]; + } + + getCameraFrustum(): Frustum { + return this._cachedFrustum; + } + getClippingPlane(): vec4 | null { + return this._cachedClippingPlane; + } + getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider { + return this._coveringTilesDetailsProvider; + } + + recalculateZoomAndCenter(terrain?: Terrain): void { + if (terrain) { + warnOnce('terrain is not fully supported on vertical perspective projection.'); + } + this._helper.recalculateZoomAndCenter(0); + } + + maxPitchScaleFactor(): number { + // In mercaltor it uses the pixelMatrix, but this is not available here... + return 1; + } + + getCameraPoint(): Point { + return this._helper.getCameraPoint(); + } + + getCameraAltitude(): number { + return this._helper.getCameraAltitude(); + } + + getCameraLngLat(): LngLat { + return this._helper.getCameraLngLat(); + } + + lngLatToCameraDepth(lngLat: LngLat, elevation: number): number { + if (!this._globeViewProjMatrixNoCorrection) { + return 1.0; // _calcMatrices hasn't run yet + } + const vec = angularCoordinatesToSurfaceVector(lngLat); + vec3.scale(vec, vec, (1.0 + elevation / earthRadius)); + const result = createVec4f64(); + vec4.transformMat4(result, [vec[0], vec[1], vec[2], 1], this._globeViewProjMatrixNoCorrection); + return result[2] / result[3]; + } + + populateCache(_coords: OverscaledTileID[]): void { + // Do nothing + } + + getBounds(): LngLatBounds { + const xMid = this.width * 0.5; + const yMid = this.height * 0.5; + + // LngLat extremes will probably tend to be in screen corners or in middle of screen edges. + // These test points should result in a pretty good approximation. + const testPoints = [ + new Point(0, 0), + new Point(xMid, 0), + new Point(this.width, 0), + new Point(this.width, yMid), + new Point(this.width, this.height), + new Point(xMid, this.height), + new Point(0, this.height), + new Point(0, yMid), + ]; + + const projectedPoints = []; + for (const p of testPoints) { + projectedPoints.push(this.unprojectScreenPoint(p)); + } + + // We can't construct a simple min/max aabb, since points might lie on either side of the antimeridian. + // We will instead compute the furthest points relative to map center. + // We also take advantage of the fact that `unprojectScreenPoint` will snap pixels + // outside the planet to the closest point on the planet's horizon. + let mostEast = 0, mostWest = 0, mostNorth = 0, mostSouth = 0; // We will store these values signed. + const center = this.center; + for (const p of projectedPoints) { + const dLng = differenceOfAnglesDegrees(center.lng, p.lng); + const dLat = differenceOfAnglesDegrees(center.lat, p.lat); + if (dLng < mostWest) { + mostWest = dLng; + } + if (dLng > mostEast) { + mostEast = dLng; + } + if (dLat < mostSouth) { + mostSouth = dLat; + } + if (dLat > mostNorth) { + mostNorth = dLat; + } + } + + const boundsArray: [number, number, number, number] = [ + center.lng + mostWest, // west + center.lat + mostSouth, // south + center.lng + mostEast, // east + center.lat + mostNorth // north + ]; + + // Sometimes the poles might end up not being on the horizon, + // thus not being detected as the northernmost/southernmost points. + // We fix that here. + if (this.isSurfacePointOnScreen([0, 1, 0])) { + // North pole is visible + // This also means that the entire longitude range must be visible + boundsArray[3] = 90; + boundsArray[0] = -180; + boundsArray[2] = 180; + } + if (this.isSurfacePointOnScreen([0, -1, 0])) { + // South pole is visible + boundsArray[1] = -90; + boundsArray[0] = -180; + boundsArray[2] = 180; + } + + return new LngLatBounds(boundsArray); + } + + getConstrained(lngLat: LngLat, zoom: number): { center: LngLat; zoom: number } { + // Globe: TODO: respect _lngRange, _latRange + // It is possible to implement exact constrain for globe, but I don't think it is worth the effort. + const constrainedLat = clamp(lngLat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE); + const constrainedZoom = clamp(+zoom, this.minZoom + getZoomAdjustment(0, constrainedLat), this.maxZoom); + return { + center: new LngLat( + lngLat.lng, + constrainedLat + ), + zoom: constrainedZoom + }; + } + + calculateCenterFromCameraLngLatAlt(lngLat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} { + return this._helper.calculateCenterFromCameraLngLatAlt(lngLat, alt, bearing, pitch); + } + + /** + * Note: automatically adjusts zoom to keep planet size consistent + * (same size before and after a {@link setLocationAtPoint} call). + */ + setLocationAtPoint(lnglat: LngLat, point: Point): void { + // This returns some fake coordinates for pixels that do not lie on the planet. + // Whatever uses this `setLocationAtPoint` function will need to account for that. + const pointLngLat = this.unprojectScreenPoint(point); + const vecToPixelCurrent = angularCoordinatesToSurfaceVector(pointLngLat); + const vecToTarget = angularCoordinatesToSurfaceVector(lnglat); + + const zero = createVec3f64(); + vec3.zero(zero); + + const rotatedPixelVector = createVec3f64(); + vec3.rotateY(rotatedPixelVector, vecToPixelCurrent, zero, -this.center.lng * Math.PI / 180.0); + vec3.rotateX(rotatedPixelVector, rotatedPixelVector, zero, this.center.lat * Math.PI / 180.0); + + // We are looking for the lng,lat that will rotate `vecToTarget` + // so that it is equal to `rotatedPixelVector`. + + // The second rotation around X axis cannot change the X component, + // so we first must find the longitude such that rotating `vecToTarget` with it + // will place it so its X component is equal to X component of `rotatedPixelVector`. + // There will exist zero, one or two longitudes that satisfy this. + + // x | + // / | + // / | the line is the target X - rotatedPixelVector.x + // / | the x is vecToTarget projected to x,z plane + // . | the dot is origin + // + // We need to rotate vecToTarget so that it intersects the line. + // If vecToTarget is shorter than the distance to the line from origin, it is impossible. + + // Otherwise, we compute the intersection of the line with a ring with radius equal to + // length of vecToTarget projected to XZ plane. + + const vecToTargetXZLengthSquared = vecToTarget[0] * vecToTarget[0] + vecToTarget[2] * vecToTarget[2]; + const targetXSquared = rotatedPixelVector[0] * rotatedPixelVector[0]; + if (vecToTargetXZLengthSquared < targetXSquared) { + // Zero solutions - setLocationAtPoint is impossible. + return; + } + + // The intersection's Z coordinates + const intersectionA = Math.sqrt(vecToTargetXZLengthSquared - targetXSquared); + const intersectionB = -intersectionA; // the second solution + + const lngA = angleToRotateBetweenVectors2D(vecToTarget[0], vecToTarget[2], rotatedPixelVector[0], intersectionA); + const lngB = angleToRotateBetweenVectors2D(vecToTarget[0], vecToTarget[2], rotatedPixelVector[0], intersectionB); + + const vecToTargetLngA = createVec3f64(); + vec3.rotateY(vecToTargetLngA, vecToTarget, zero, -lngA); + const latA = angleToRotateBetweenVectors2D(vecToTargetLngA[1], vecToTargetLngA[2], rotatedPixelVector[1], rotatedPixelVector[2]); + const vecToTargetLngB = createVec3f64(); + vec3.rotateY(vecToTargetLngB, vecToTarget, zero, -lngB); + const latB = angleToRotateBetweenVectors2D(vecToTargetLngB[1], vecToTargetLngB[2], rotatedPixelVector[1], rotatedPixelVector[2]); + // Is at least one of the needed latitudes valid? + + const limit = Math.PI * 0.5; + + const isValidA = latA >= -limit && latA <= limit; + const isValidB = latB >= -limit && latB <= limit; + + let validLng: number; + let validLat: number; + if (isValidA && isValidB) { + // Pick the solution that is closer to current map center. + const centerLngRadians = this.center.lng * Math.PI / 180.0; + const centerLatRadians = this.center.lat * Math.PI / 180.0; + const lngDistA = distanceOfAnglesRadians(lngA, centerLngRadians); + const latDistA = distanceOfAnglesRadians(latA, centerLatRadians); + const lngDistB = distanceOfAnglesRadians(lngB, centerLngRadians); + const latDistB = distanceOfAnglesRadians(latB, centerLatRadians); + + if ((lngDistA + latDistA) < (lngDistB + latDistB)) { + validLng = lngA; + validLat = latA; + } else { + validLng = lngB; + validLat = latB; + } + } else if (isValidA) { + validLng = lngA; + validLat = latA; + } else if (isValidB) { + validLng = lngB; + validLat = latB; + } else { + // No solution. + return; + } + + const newLng = validLng / Math.PI * 180; + const newLat = validLat / Math.PI * 180; + const oldLat = this.center.lat; + this.setCenter(new LngLat(newLng, clamp(newLat, -90, 90))); + this.setZoom(this.zoom + getZoomAdjustment(oldLat, this.center.lat)); + } + + locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point { + const pos = angularCoordinatesToSurfaceVector(lnglat); + + if (terrain) { + const elevation = terrain.getElevationForLngLatZoom(lnglat, this._helper._tileZoom); + vec3.scale(pos, pos, 1.0 + elevation / earthRadius); + } + + return this._projectSurfacePointToScreen(pos); + } + + /** + * Projects a given vector on the surface of a unit sphere (or possible above the surface) + * and returns its coordinates on screen in pixels. + */ + private _projectSurfacePointToScreen(pos: vec3): Point { + const projected = createVec4f64(); + vec4.transformMat4(projected, [...pos, 1] as vec4, this._globeViewProjMatrixNoCorrection); + projected[0] /= projected[3]; + projected[1] /= projected[3]; + return new Point( + (projected[0] * 0.5 + 0.5) * this.width, + (-projected[1] * 0.5 + 0.5) * this.height + ); + } + + screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate { + if (terrain) { + // Mercator has terrain handling implemented properly and since terrain + // simply draws tile coordinates into a special framebuffer, this works well even for globe. + const coordinate = terrain.pointCoordinate(p); + if (coordinate) { + return coordinate; + } + } + return MercatorCoordinate.fromLngLat(this.unprojectScreenPoint(p)); + } + + screenPointToLocation(p: Point, terrain?: Terrain): LngLat { + return this.screenPointToMercatorCoordinate(p, terrain)?.toLngLat(); + } + + isPointOnMapSurface(p: Point, _terrain?: Terrain): boolean { + const rayOrigin = this._cameraPosition; + const rayDirection = this.getRayDirectionFromPixel(p); + + const intersection = this.rayPlanetIntersection(rayOrigin, rayDirection); + + return !!intersection; + } + + /** + * Computes normalized direction of a ray from the camera to the given screen pixel. + */ + getRayDirectionFromPixel(p: Point): vec3 { + const pos = createVec4f64(); + pos[0] = (p.x / this.width) * 2.0 - 1.0; + pos[1] = ((p.y / this.height) * 2.0 - 1.0) * -1.0; + pos[2] = 1; + pos[3] = 1; + vec4.transformMat4(pos, pos, this._globeViewProjMatrixNoCorrectionInverted); + pos[0] /= pos[3]; + pos[1] /= pos[3]; + pos[2] /= pos[3]; + const ray = createVec3f64(); + ray[0] = pos[0] - this._cameraPosition[0]; + ray[1] = pos[1] - this._cameraPosition[1]; + ray[2] = pos[2] - this._cameraPosition[2]; + const rayNormalized: vec3 = createVec3f64(); + vec3.normalize(rayNormalized, ray); + return rayNormalized; + } + + /** + * For a given point on the unit sphere of the planet, returns whether it is visible from + * camera's position (not taking into account camera rotation at all). + */ + private isSurfacePointVisible(p: vec3): boolean { + const plane = this._cachedClippingPlane; + // dot(position on sphere, occlusion plane equation) + const dotResult = plane[0] * p[0] + plane[1] * p[1] + plane[2] * p[2] + plane[3]; + return dotResult >= 0.0; + } + + /** + * Returns whether surface point is visible on screen. + * It must both project to a pixel in screen bounds and not be occluded by the planet. + */ + private isSurfacePointOnScreen(vec: vec3): boolean { + if (!this.isSurfacePointVisible(vec)) { + return false; + } + + const projected = createVec4f64(); + vec4.transformMat4(projected, [...vec, 1] as vec4, this._globeViewProjMatrixNoCorrection); + projected[0] /= projected[3]; + projected[1] /= projected[3]; + projected[2] /= projected[3]; + return projected[0] > -1 && projected[0] < 1 && + projected[1] > -1 && projected[1] < 1 && + projected[2] > -1 && projected[2] < 1; + } + + /** + * Returns the two intersection points of the ray and the planet's sphere, + * or null if no intersection occurs. + * The intersections are encoded as the parameter for parametric ray equation, + * with `tMin` being the first intersection and `tMax` being the second. + * Eg. the nearer intersection point can then be computed as `origin + direction * tMin`. + * @param origin - The ray origin. + * @param direction - The normalized ray direction. + */ + private rayPlanetIntersection(origin: vec3, direction: vec3): RaySphereIntersection { + const originDotDirection = vec3.dot(origin, direction); + const planetRadiusSquared = 1.0; // planet is a unit sphere, so its radius squared is 1 + + // Ray-sphere intersection involves a quadratic equation. + // However solving it in the traditional schoolbook way leads to floating point precision issues. + // Here we instead use the approach suggested in the book Ray Tracing Gems, chapter 7. + // https://www.realtimerendering.com/raytracinggems/rtg/index.html + const inner = createVec3f64(); + const scaledDir = createVec3f64(); + vec3.scale(scaledDir, direction, originDotDirection); + vec3.sub(inner, origin, scaledDir); + const discriminant = planetRadiusSquared - vec3.dot(inner, inner); + + if (discriminant < 0) { + return null; + } + + const c = vec3.dot(origin, origin) - planetRadiusSquared; + const q = -originDotDirection + (originDotDirection < 0 ? 1 : -1) * Math.sqrt(discriminant); + const t0 = c / q; + const t1 = q; + // Assume the ray origin is never inside the sphere + const tMin = Math.min(t0, t1); + const tMax = Math.max(t0, t1); + return { + tMin, + tMax + }; + } + + /** + * @internal + * Returns a {@link LngLat} representing geographical coordinates that correspond to the specified pixel coordinates. + * Note: if the point does not lie on the globe, returns a location on the visible globe horizon (edge) that is + * as close to the point as possible. + * @param p - Screen point in pixels to unproject. + * @param terrain - Optional terrain. + */ + private unprojectScreenPoint(p: Point): LngLat { + // Here we compute the intersection of the ray towards the pixel at `p` and the planet sphere. + // As always, we assume that the planet is centered at 0,0,0 and has radius 1. + // Ray origin is `_cameraPosition` and direction is `rayNormalized`. + const rayOrigin = this._cameraPosition; + const rayDirection = this.getRayDirectionFromPixel(p); + const intersection = this.rayPlanetIntersection(rayOrigin, rayDirection); + + if (intersection) { + // Ray intersects the sphere -> compute intersection LngLat. + // Assume the ray origin is never inside the sphere - just use tMin + const intersectionPoint = createVec3f64(); + vec3.add(intersectionPoint, rayOrigin, [ + rayDirection[0] * intersection.tMin, + rayDirection[1] * intersection.tMin, + rayDirection[2] * intersection.tMin + ]); + const sphereSurface = createVec3f64(); + vec3.normalize(sphereSurface, intersectionPoint); + return sphereSurfacePointToCoordinates(sphereSurface); + } + + // Ray does not intersect the sphere -> find the closest point on the horizon to the ray. + // Intersect the ray with the clipping plane, since we know that the intersection of the clipping plane and the sphere is the horizon. + const directionDotPlaneXyz = this._cachedClippingPlane[0] * rayDirection[0] + this._cachedClippingPlane[1] * rayDirection[1] + this._cachedClippingPlane[2] * rayDirection[2]; + const originToPlaneDistance = pointPlaneSignedDistance(this._cachedClippingPlane, rayOrigin); + const distanceToIntersection = -originToPlaneDistance / directionDotPlaneXyz; + + const maxRayLength = 2.0; // One globe diameter + const planeIntersection = createVec3f64(); + + if (distanceToIntersection > 0) { + vec3.add(planeIntersection, rayOrigin, [ + rayDirection[0] * distanceToIntersection, + rayDirection[1] * distanceToIntersection, + rayDirection[2] * distanceToIntersection + ]); + } else { + // When the ray takes too long to hit the plane (>maxRayLength), or if the plane intersection is behind the camera, handle things differently. + // Take a point along the ray at distance maxRayLength, project it to clipping plane, then continue as normal to find the horizon point. + const distantPoint = createVec3f64(); + vec3.add(distantPoint, rayOrigin, [ + rayDirection[0] * maxRayLength, + rayDirection[1] * maxRayLength, + rayDirection[2] * maxRayLength + ]); + const distanceFromPlane = pointPlaneSignedDistance(this._cachedClippingPlane, distantPoint); + vec3.sub(planeIntersection, distantPoint, [ + this._cachedClippingPlane[0] * distanceFromPlane, + this._cachedClippingPlane[1] * distanceFromPlane, + this._cachedClippingPlane[2] * distanceFromPlane + ]); + } + + const closestOnHorizon = createVec3f64(); + vec3.normalize(closestOnHorizon, planeIntersection); + return sphereSurfacePointToCoordinates(closestOnHorizon); + } + + getMatrixForModel(location: LngLatLike, altitude?: number): mat4 { + const lnglat = LngLat.convert(location); + const scale = 1.0 / earthRadius; + + const m = createIdentityMat4f64(); + mat4.rotateY(m, m, lnglat.lng / 180.0 * Math.PI); + mat4.rotateX(m, m, -lnglat.lat / 180.0 * Math.PI); + mat4.translate(m, m, [0, 0, 1 + altitude / earthRadius]); + mat4.rotateX(m, m, Math.PI * 0.5); + mat4.scale(m, m, [scale, scale, scale]); + return m; + } + + getProjectionDataForCustomLayer(applyGlobeMatrix: boolean = true): ProjectionData { + const globeData = this.getProjectionData({overscaledTileID: new OverscaledTileID(0, 0, 0, 0, 0), applyGlobeMatrix}); + globeData.tileMercatorCoords = [0, 0, 1, 1]; + return globeData; + } + + getFastPathSimpleProjectionMatrix(_tileID: OverscaledTileID): mat4 { + return undefined; + } +} diff --git a/src/geo/transform_helper.test.ts b/src/geo/transform_helper.test.ts index 3383709bc5..53d62265ad 100644 --- a/src/geo/transform_helper.test.ts +++ b/src/geo/transform_helper.test.ts @@ -1,15 +1,18 @@ import {describe, expect, test} from 'vitest'; import {LngLat} from './lng_lat'; import {LngLatBounds} from './lng_lat_bounds'; -import {scaleZoom, TransformHelper, zoomScale} from './transform_helper'; +import {TransformHelper} from './transform_helper'; +import {OverscaledTileID} from '../source/tile_id'; +import {expectToBeCloseToArray} from '../util/test/util'; +import {EXTENT} from '../data/extent'; + +const emptyCallbacks = { + calcMatrices: () => {}, + getConstrained: (center, zoom) => { return {center, zoom}; }, +}; describe('TransformHelper', () => { test('apply', () => { - const emptyCallbacks = { - calcMatrices: () => {}, - getConstrained: (center, zoom) => { return {center, zoom}; }, - }; - const original = new TransformHelper(emptyCallbacks); original.setBearing(12); original.setCenter(new LngLat(3, 4)); @@ -62,10 +65,18 @@ describe('TransformHelper', () => { expect(cloned.renderWorldCopies).toEqual(original.renderWorldCopies); }); - test('scaleZoom+zoomScale', () => { - expect(scaleZoom(0)).toBe(-Infinity); - expect(scaleZoom(10)).toBe(3.3219280948873626); - expect(zoomScale(3.3219280948873626)).toBeCloseTo(10, 10); - expect(scaleZoom(zoomScale(5))).toBe(5); + describe('getMercatorTilesCoordinates', () => { + test('mercator tile extents are set', () => { + const helper = new TransformHelper(emptyCallbacks); + + let tileMercatorCoords = helper.getMercatorTileCoordinates(new OverscaledTileID(0, 0, 0, 0, 0)); + expectToBeCloseToArray(tileMercatorCoords, [0, 0, 1 / EXTENT, 1 / EXTENT]); + + tileMercatorCoords = helper.getMercatorTileCoordinates(new OverscaledTileID(1, 0, 1, 0, 0)); + expectToBeCloseToArray(tileMercatorCoords, [0, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + + tileMercatorCoords = helper.getMercatorTileCoordinates(new OverscaledTileID(1, 0, 1, 1, 0)); + expectToBeCloseToArray(tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + }); }); }); diff --git a/src/geo/transform_helper.ts b/src/geo/transform_helper.ts index b0e0cb5076..95ceecf521 100644 --- a/src/geo/transform_helper.ts +++ b/src/geo/transform_helper.ts @@ -1,14 +1,16 @@ -import {LngLat} from './lng_lat'; +import {LngLat, type LngLatLike} from './lng_lat'; import {LngLatBounds} from './lng_lat_bounds'; import Point from '@mapbox/point-geometry'; -import {wrap, clamp, degreesToRadians, radiansToDegrees} from '../util/util'; +import {wrap, clamp, degreesToRadians, radiansToDegrees, zoomScale, MAX_VALID_LATITUDE, scaleZoom} from '../util/util'; import {mat4, mat2} from 'gl-matrix'; import {EdgeInsets} from './edge_insets'; -import type {PaddingOptions} from './edge_insets'; -import {type IReadonlyTransform, type ITransformGetters} from './transform_interface'; - -export const MAX_VALID_LATITUDE = 85.051129; +import {altitudeFromMercatorZ, MercatorCoordinate, mercatorZfromAltitude} from './mercator_coordinate'; +import {cameraMercatorCoordinateFromCenterAndRotation} from './projection/mercator_utils'; +import {EXTENT} from '../data/extent'; +import type {PaddingOptions} from './edge_insets'; +import type {IReadonlyTransform, ITransformGetters} from './transform_interface'; +import type {OverscaledTileID} from '../source/tile_id'; /** * If a path crossing the antimeridian would be shorter, extend the final coordinate so that * interpolating between the two endpoints will cross it. @@ -22,16 +24,6 @@ export function normalizeCenter(tr: IReadonlyTransform, center: LngLat): void { delta < -180 ? 360 : 0; } -/** - * Computes scaling from zoom level. - */ -export function zoomScale(zoom: number) { return Math.pow(2, zoom); } - -/** - * Computes zoom level from scaling. - */ -export function scaleZoom(scale: number) { return Math.log(scale) / Math.LN2; } - export type UnwrappedTileIDType = { /** * Tile wrap: 0 for the "main" world, @@ -124,6 +116,11 @@ export class TransformHelper implements ITransformGetters { _pixelsToGLUnits: [number, number]; _pixelsToClipSpaceMatrix: mat4; _clipSpaceToPixelsMatrix: mat4; + _cameraToCenterDistance: number; + + _nearZ: number; + _farZ: number; + _autoCalculateNearFarZ: boolean; constructor(callbacks: TransformHelperCallbacks, minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) { this._callbacks = callbacks; @@ -152,9 +149,10 @@ export class TransformHelper implements ITransformGetters { this._unmodified = true; this._edgeInsets = new EdgeInsets(); this._minElevationForCurrentTile = 0; + this._autoCalculateNearFarZ = true; } - public apply(thatI: ITransformGetters, constrain?: boolean): void { + public apply(thatI: ITransformGetters, constrain?: boolean, forceOverrideZ?: boolean): void { this._latRange = thatI.latRange; this._lngRange = thatI.lngRange; this._width = thatI.width; @@ -176,6 +174,10 @@ export class TransformHelper implements ITransformGetters { this._minPitch = thatI.minPitch; this._maxPitch = thatI.maxPitch; this._renderWorldCopies = thatI.renderWorldCopies; + this._cameraToCenterDistance = thatI.cameraToCenterDistance; + this._nearZ = thatI.nearZ; + this._farZ = thatI.farZ; + this._autoCalculateNearFarZ = !forceOverrideZ && thatI.autoCalculateNearFarZ; if (constrain) { this._constrain(); } @@ -383,6 +385,22 @@ export class TransformHelper implements ITransformGetters { get unmodified(): boolean { return this._unmodified; } + get cameraToCenterDistance(): number { return this._cameraToCenterDistance; } + + get nearZ(): number { return this._nearZ; } + get farZ(): number { return this._farZ; } + get autoCalculateNearFarZ(): boolean { return this._autoCalculateNearFarZ; } + overrideNearFarZ(nearZ: number, farZ: number): void { + this._autoCalculateNearFarZ = false; + this._nearZ = nearZ; + this._farZ = farZ; + this._calcMatrices(); + } + clearNearFarZOverride(): void { + this._autoCalculateNearFarZ = true; + this._calcMatrices(); + } + /** * Returns if the padding params match * @@ -512,7 +530,107 @@ export class TransformHelper implements ITransformGetters { mat4.translate(m, m, [-1, -1, 0]); mat4.scale(m, m, [2 / this._width, 2 / this._height, 1]); this._pixelsToClipSpaceMatrix = m; + const halfFov = this.fovInRadians / 2; + this._cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this._height; } this._callbacks.calcMatrices(); } + + calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} { + const cameraBearing = bearing !== undefined ? bearing : this.bearing; + const cameraPitch = pitch = pitch !== undefined ? pitch : this.pitch; + + const camMercator = MercatorCoordinate.fromLngLat(lnglat, alt); + const dzNormalized = -Math.cos(degreesToRadians(cameraPitch)); + const dhNormalized = Math.sin(degreesToRadians(cameraPitch)); + const dxNormalized = dhNormalized * Math.sin(degreesToRadians(cameraBearing)); + const dyNormalized = -dhNormalized * Math.cos(degreesToRadians(cameraBearing)); + + let elevation = this.elevation; + const altitudeAGL = alt - elevation; + let distanceToCenterMeters; + if (dzNormalized * altitudeAGL >= 0.0 || Math.abs(dzNormalized) < 0.1) { + distanceToCenterMeters = 10000; + elevation = alt + distanceToCenterMeters * dzNormalized; + } else { + distanceToCenterMeters = -altitudeAGL / dzNormalized; + } + + // The mercator transform scale changes with latitude. At high latitudes, there are more "Merc units" per meter + // than at the equator. We treat the center point as our fundamental quantity. This means we want to convert + // elevation to Mercator Z using the scale factor at the center point (not the camera point). Since the center point is + // initially unknown, we compute it using the scale factor at the camera point. This gives us a better estimate of the + // center point scale factor, which we use to recompute the center point. We repeat until the error is very small. + // This typically takes about 5 iterations. + let metersPerMercUnit = altitudeFromMercatorZ(1, camMercator.y); + let centerMercator: MercatorCoordinate; + let dMercator: number; + let iter = 0; + const maxIter = 10; + do { + iter += 1; + if (iter > maxIter) { + break; + } + dMercator = distanceToCenterMeters / metersPerMercUnit; + const dx = dxNormalized * dMercator; + const dy = dyNormalized * dMercator; + centerMercator = new MercatorCoordinate(camMercator.x + dx, camMercator.y + dy); + metersPerMercUnit = 1 / centerMercator.meterInMercatorCoordinateUnits(); + } while (Math.abs(distanceToCenterMeters - dMercator * metersPerMercUnit) > 1.0e-12); + + const center = centerMercator.toLngLat(); + const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / dMercator / this.tileSize); + return {center, elevation, zoom}; + } + + recalculateZoomAndCenter(elevation: number): void { + if (this.elevation - elevation === 0) return; + + // Find the current camera position + const originalPixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; + const cameraToCenterDistanceMeters = this.cameraToCenterDistance / originalPixelPerMeter; + const origCenterMercator = MercatorCoordinate.fromLngLat(this.center, this.elevation); + const cameraMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters); + + // update elevation to the new terrain intercept elevation and recalculate the center point + this._elevation = elevation; + const centerInfo = this.calculateCenterFromCameraLngLatAlt(cameraMercator.toLngLat(), altitudeFromMercatorZ(cameraMercator.z, origCenterMercator.y), this.bearing, this.pitch); + + // update matrices + this._elevation = centerInfo.elevation; + this._center = centerInfo.center; + this.setZoom(centerInfo.zoom); + } + + getCameraPoint(): Point { + const pitch = this.pitchInRadians; + const offset = Math.tan(pitch) * (this.cameraToCenterDistance || 1); + return this.centerPoint.add(new Point(offset * Math.sin(this.rollInRadians), offset * Math.cos(this.rollInRadians))); + } + + getCameraAltitude(): number { + const altitude = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / this._pixelPerMeter; + return altitude + this.elevation; + } + + getCameraLngLat(): LngLat { + const pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; + const cameraToCenterDistanceMeters = this.cameraToCenterDistance / pixelPerMeter; + const camMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters); + return camMercator.toLngLat(); + } + + getMercatorTileCoordinates(overscaledTileID: OverscaledTileID): [number, number, number, number] { + if (!overscaledTileID) { + return [0, 0, 1, 1]; + } + const scale = (overscaledTileID.canonical.z >= 0) ? (1 << overscaledTileID.canonical.z) : Math.pow(2.0, overscaledTileID.canonical.z); + return [ + overscaledTileID.canonical.x / scale, + overscaledTileID.canonical.y / scale, + 1.0 / scale / EXTENT, + 1.0 / scale / EXTENT + ]; + } } diff --git a/src/geo/transform_interface.ts b/src/geo/transform_interface.ts index 44fe7597d3..6b990fd1c8 100644 --- a/src/geo/transform_interface.ts +++ b/src/geo/transform_interface.ts @@ -1,22 +1,15 @@ -import {type LngLat, type LngLatLike} from './lng_lat'; -import {type LngLatBounds} from './lng_lat_bounds'; -import {type MercatorCoordinate} from './mercator_coordinate'; +import type {LngLat, LngLatLike} from './lng_lat'; +import type {LngLatBounds} from './lng_lat_bounds'; +import type {MercatorCoordinate} from './mercator_coordinate'; import type Point from '@mapbox/point-geometry'; -import {type mat4, type mat2, type vec3, type vec4} from 'gl-matrix'; -import {type UnwrappedTileID, type OverscaledTileID, type CanonicalTileID} from '../source/tile_id'; +import type {mat4, mat2, vec3, vec4} from 'gl-matrix'; +import type {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../source/tile_id'; import type {PaddingOptions} from './edge_insets'; -import {type Terrain} from '../render/terrain'; -import {type PointProjection} from '../symbol/projection'; -import {type MapProjectionEvent} from '../ui/events'; +import type {Terrain} from '../render/terrain'; +import type {PointProjection} from '../symbol/projection'; import type {ProjectionData, ProjectionDataParams} from './projection/projection_data'; -import {type CoveringTilesDetailsProvider} from './projection/covering_tiles_details_provider'; -import {type Frustum} from '../util/primitives/frustum'; - -export type TransformUpdateResult = { - forcePlacementUpdate?: boolean; - fireProjectionEvent?: MapProjectionEvent; - forceSourceUpdate?: boolean; -}; +import type {CoveringTilesDetailsProvider} from './projection/covering_tiles_details_provider'; +import type {Frustum} from '../util/primitives/frustum'; export interface ITransformGetters { get tileSize(): number; @@ -82,6 +75,14 @@ export interface ITransformGetters { get unmodified(): boolean; get renderWorldCopies(): boolean; + /** + * The distance from the camera to the center of the map in pixels space. + */ + get cameraToCenterDistance(): number; + + get nearZ(): number; + get farZ(): number; + get autoCalculateNearFarZ(): boolean; } /** @@ -147,6 +148,17 @@ interface ITransformMutators { setElevation(elevation: number): void; setMinElevationForCurrentTile(elevation: number): void; setPadding(padding: PaddingOptions): void; + /** + * Sets the overriding values to use for near and far Z instead of what the transform would normally compute. + * If set to undefined, the transform will compute its ideal values. + * Calling this will set `autoCalculateNearFarZ` to false. + */ + overrideNearFarZ(nearZ: number, farZ: number): void; + + /** + * Resets near and far Z plane override. Sets `autoCalculateNearFarZ` to true. + */ + clearNearFarZOverride(): void; /** * Sets the transform's width and height and recomputes internal matrices. @@ -182,13 +194,6 @@ interface ITransformMutators { */ setMaxBounds(bounds?: LngLatBounds | null): void; - /** - * @internal - * Signals to the transform that a new frame is starting. - * The transform might update some of its internal variables and animations based on this. - */ - newFrameUpdate(): TransformUpdateResult; - /** * @internal * Called before rendering to allow the transform implementation @@ -196,7 +201,15 @@ interface ITransformMutators { * Used in mercator transform to precompute tile matrices (posMatrix). * @param coords - Array of tile IDs that will be rendered. */ - precacheTiles(coords: Array): void; + populateCache(coords: Array): void; + + /** + * @internal + * Sets the transform's transition state from one projection to another. + * @param value - The transition state value. + * @param error - The error value. + */ + setTransitionState(value: number, error: number): void; } /** @@ -242,9 +255,6 @@ export interface IReadonlyTransform extends ITransformGetters { */ get cameraPosition(): vec3; - get nearZ(): number; - get farZ(): number; - /** * Returns if the padding params match * @@ -400,13 +410,6 @@ export interface IReadonlyTransform extends ITransformGetters { */ calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4; - /** - * @internal - * True when an animation handled by the transform is in progress, - * requiring MapLibre to keep rendering new frames. - */ - isRenderingDirty(): boolean; - /** * @internal * Generates a `ProjectionData` instance to be used while rendering the supplied tile. diff --git a/src/render/draw_custom.test.ts b/src/render/draw_custom.test.ts index 79fffa0574..1e16e2ddad 100644 --- a/src/render/draw_custom.test.ts +++ b/src/render/draw_custom.test.ts @@ -7,7 +7,7 @@ import type {Map} from '../ui/map'; import {drawCustom} from './draw_custom'; import {CustomStyleLayer} from '../style/style_layer/custom_style_layer'; import {MercatorTransform} from '../geo/projection/mercator_transform'; -import {MercatorProjection} from '../geo/projection/mercator'; +import {MercatorProjection} from '../geo/projection/mercator_projection'; vi.mock('./painter'); vi.mock('./program'); diff --git a/src/render/draw_symbol.test.ts b/src/render/draw_symbol.test.ts index 0d66fd44c4..c847fc5f98 100644 --- a/src/render/draw_symbol.test.ts +++ b/src/render/draw_symbol.test.ts @@ -15,7 +15,7 @@ import {type IReadonlyTransform} from '../geo/transform_interface'; import type {EvaluationParameters} from '../style/evaluation_parameters'; import type {SymbolLayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import {type Style} from '../style/style'; -import {MercatorProjection} from '../geo/projection/mercator'; +import {MercatorProjection} from '../geo/projection/mercator_projection'; import type {ProjectionData} from '../geo/projection/projection_data'; vi.mock('./painter'); diff --git a/src/render/painter.ts b/src/render/painter.ts index 300501ab82..01f6cca55a 100644 --- a/src/render/painter.ts +++ b/src/render/painter.ts @@ -33,7 +33,7 @@ import {drawDepth, drawCoords} from './draw_terrain'; import {type OverscaledTileID} from '../source/tile_id'; import {drawSky, drawAtmosphere} from './draw_sky'; import {Mesh} from './mesh'; -import {MercatorShaderDefine, MercatorShaderVariantKey} from '../geo/projection/mercator'; +import {MercatorShaderDefine, MercatorShaderVariantKey} from '../geo/projection/mercator_projection'; import type {IReadonlyTransform} from '../geo/transform_interface'; import type {Style} from '../style/style'; @@ -492,7 +492,7 @@ export class Painter { const coordsAscending: {[_: string]: Array} = {}; const coordsDescending: {[_: string]: Array} = {}; const coordsDescendingSymbol: {[_: string]: Array} = {}; - const renderOptions: RenderOptions = {isRenderingToTexture: false, isRenderingGlobe: style.projection.name === 'globe'}; + const renderOptions: RenderOptions = {isRenderingToTexture: false, isRenderingGlobe: style.projection.transitionState > 0}; for (const id in sourceCaches) { const sourceCache = sourceCaches[id]; diff --git a/src/shaders/_projection_globe.vertex.glsl b/src/shaders/_projection_globe.vertex.glsl index 010d3569e1..48ed141d34 100644 --- a/src/shaders/_projection_globe.vertex.glsl +++ b/src/shaders/_projection_globe.vertex.glsl @@ -86,30 +86,38 @@ vec4 interpolateProjection(vec2 posInTile, vec3 spherePos, float elevation) { // Z is overwritten by glDepthRange anyway - use a custom z value to clip geometry on the invisible side of the sphere. globePosition.z = globeComputeClippingZ(elevatedPos) * globePosition.w; - if (u_projection_transition < 0.999) { - vec4 flatPosition = u_projection_fallback_matrix * vec4(posInTile, elevation, 1.0); - // Only interpolate to globe's Z for the last 50% of the animation. - // (globe Z hides anything on the backfacing side of the planet) - const float z_globeness_threshold = 0.2; - vec4 result = globePosition; - result.z = mix(0.0, globePosition.z, clamp((u_projection_transition - z_globeness_threshold) / (1.0 - z_globeness_threshold), 0.0, 1.0)); - result.xyw = mix(flatPosition.xyw, globePosition.xyw, u_projection_transition); - // Gradually hide poles during transition - if ((posInTile.y < -32767.5) || (posInTile.y > 32766.5)) { - result = globePosition; - const float poles_hidden_anim_percentage = 0.02; // Only draw poles in the last 2% of the animation. - result.z = mix(globePosition.z, 100.0, pow(max((1.0 - u_projection_transition) / poles_hidden_anim_percentage, 0.0), 8.0)); - } - return result; + if (u_projection_transition > 0.999) { + // Simple case - no transition, only globe projection + return globePosition; } - return globePosition; + // Blend between globe and mercator projections. + vec4 flatPosition = u_projection_fallback_matrix * vec4(posInTile, elevation, 1.0); + // Only interpolate to globe's Z for the last 50% of the animation. + // (globe Z hides anything on the backfacing side of the planet) + const float z_globeness_threshold = 0.2; + vec4 result = globePosition; + result.z = mix(0.0, globePosition.z, clamp((u_projection_transition - z_globeness_threshold) / (1.0 - z_globeness_threshold), 0.0, 1.0)); + result.xyw = mix(flatPosition.xyw, globePosition.xyw, u_projection_transition); + // Gradually hide poles during transition + if ((posInTile.y < -32767.5) || (posInTile.y > 32766.5)) { + result = globePosition; + const float poles_hidden_anim_percentage = 0.02; // Only draw poles in the last 2% of the animation. + result.z = mix(globePosition.z, 100.0, pow(max((1.0 - u_projection_transition) / poles_hidden_anim_percentage, 0.0), 8.0)); + } + return result; } // Unlike interpolateProjection, this variant of the function preserves the Z value of the final vector. vec4 interpolateProjectionFor3D(vec2 posInTile, vec3 spherePos, float elevation) { vec3 elevatedPos = spherePos * (1.0 + elevation / GLOBE_RADIUS); vec4 globePosition = u_projection_matrix * vec4(elevatedPos, 1.0); + + if (u_projection_transition > 0.999) { + return globePosition; + } + + // Blend between globe and mercator projections. vec4 fallbackPosition = u_projection_fallback_matrix * vec4(posInTile, elevation, 1.0); return mix(fallbackPosition, globePosition, u_projection_transition); } diff --git a/src/source/source_cache.ts b/src/source/source_cache.ts index 8f9177c9cf..d0832bf93f 100644 --- a/src/source/source_cache.ts +++ b/src/source/source_cache.ts @@ -1024,7 +1024,7 @@ export class SourceCache extends Evented { getVisibleCoordinates(symbolLayer?: boolean): Array { const coords = this.getRenderableIds(symbolLayer).map((id) => this._tiles[id].tileID); if (this.transform) { - this.transform.precacheTiles(coords); + this.transform.populateCache(coords); } return coords; } diff --git a/src/style/style.ts b/src/style/style.ts index 3bf8fa1218..d65dd647ed 100644 --- a/src/style/style.ts +++ b/src/style/style.ts @@ -545,11 +545,15 @@ export class Style extends Evented { } hasTransitions() { - if (this.light && this.light.hasTransition()) { + if (this.light?.hasTransition()) { return true; } - if (this.sky && this.sky.hasTransition()) { + if (this.sky?.hasTransition()) { + return true; + } + + if (this.projection?.hasTransition()) { return true; } @@ -657,6 +661,7 @@ export class Style extends Evented { this.light.recalculate(parameters); this.sky.recalculate(parameters); + this.projection.recalculate(parameters); this.z = parameters.zoom; if (changed) { diff --git a/src/ui/camera.test.ts b/src/ui/camera.test.ts index 6bf894517c..372ee1b8a9 100644 --- a/src/ui/camera.test.ts +++ b/src/ui/camera.test.ts @@ -5,16 +5,17 @@ import {browser} from '../util/browser'; import {fixedLngLat, fixedNum} from '../../test/unit/lib/fixed'; import {setMatchMedia} from '../util/test/util'; import {mercatorZfromAltitude} from '../geo/mercator_coordinate'; -import {type Terrain} from '../render/terrain'; import {LngLat, type LngLatLike} from '../geo/lng_lat'; import {LngLatBounds} from '../geo/lng_lat_bounds'; import {MercatorTransform} from '../geo/projection/mercator_transform'; import {GlobeTransform} from '../geo/projection/globe_transform'; import {getZoomAdjustment} from '../geo/projection/globe_utils'; import {GlobeCameraHelper} from '../geo/projection/globe_camera_helper'; -import {type GlobeProjection} from '../geo/projection/globe'; import {MercatorCameraHelper} from '../geo/projection/mercator_camera_helper'; +import type {GlobeProjection} from '../geo/projection/globe_projection'; +import type {Terrain} from '../render/terrain'; + beforeEach(() => { setMatchMedia(); Object.defineProperty(browser, 'prefersReducedMotion', {value: false}); @@ -42,7 +43,7 @@ function attachSimulateFrame(camera) { function createCamera(options?): Camera & { simulateFrame: () => void } { options = options || {}; - const transform = options.globe ? new GlobeTransform({} as any, true) : new MercatorTransform(); + const transform = options.globe ? new GlobeTransform() : new MercatorTransform(); transform.setMinZoom(0); transform.setMaxZoom(20); transform.setMinPitch(0); diff --git a/src/ui/camera.ts b/src/ui/camera.ts index b77e5492c7..999169c72f 100644 --- a/src/ui/camera.ts +++ b/src/ui/camera.ts @@ -1,21 +1,20 @@ -import {extend, wrap, defaultEasing, pick} from '../util/util'; +import {extend, wrap, defaultEasing, pick, scaleZoom} from '../util/util'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; import {browser} from '../util/browser'; import {LngLat} from '../geo/lng_lat'; import {LngLatBounds} from '../geo/lng_lat_bounds'; import Point from '@mapbox/point-geometry'; import {Event, Evented} from '../util/evented'; -import {type Terrain} from '../render/terrain'; import {MercatorCoordinate} from '../geo/mercator_coordinate'; +import type {Terrain} from '../render/terrain'; import type {ITransform} from '../geo/transform_interface'; import type {LngLatLike} from '../geo/lng_lat'; import type {LngLatBoundsLike} from '../geo/lng_lat_bounds'; import type {TaskID} from '../util/task_queue'; import type {PaddingOptions} from '../geo/edge_insets'; import type {HandlerManager} from './handler_manager'; -import {scaleZoom} from '../geo/transform_helper'; -import {type ICameraHelper} from '../geo/projection/camera_helper'; +import type {ICameraHelper} from '../geo/projection/camera_helper'; /** * A [Point](https://github.com/mapbox/point-geometry) or an array of two numbers representing `x` and `y` screen coordinates in pixels. diff --git a/src/ui/control/navigation_control.ts b/src/ui/control/navigation_control.ts index ed0d89d5ca..4e0d3de159 100644 --- a/src/ui/control/navigation_control.ts +++ b/src/ui/control/navigation_control.ts @@ -214,7 +214,6 @@ class MouseRotateWrapper { move(e: MouseEvent | TouchEvent, point: Point) { const map = this.map; const {bearingDelta, pitchDelta} = this._rotatePitchHanlder.dragMove(e, point) || {}; - console.log(bearingDelta, pitchDelta, point); if (bearingDelta) map.setBearing(map.getBearing() + bearingDelta); if (pitchDelta) map.setPitch(map.getPitch() + pitchDelta); } diff --git a/src/ui/handler/scroll_zoom.ts b/src/ui/handler/scroll_zoom.ts index 6b89f7d3e2..3c19acfc4e 100644 --- a/src/ui/handler/scroll_zoom.ts +++ b/src/ui/handler/scroll_zoom.ts @@ -1,6 +1,6 @@ import {DOM} from '../../util/dom'; -import {defaultEasing, bezier} from '../../util/util'; +import {defaultEasing, bezier, zoomScale, scaleZoom} from '../../util/util'; import {browser} from '../../util/browser'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; import {LngLat} from '../../geo/lng_lat'; @@ -9,8 +9,7 @@ import {TransformProvider} from './transform-provider'; import type {Map} from '../map'; import type Point from '@mapbox/point-geometry'; import type {AroundCenterOptions} from './two_fingers_touch'; -import {type Handler} from '../handler_manager'; -import {scaleZoom, zoomScale} from '../../geo/transform_helper'; +import type {Handler} from '../handler_manager'; // deltaY value for mouse scroll wheel identification const wheelZoomDelta = 4.000244140625; diff --git a/src/ui/map.ts b/src/ui/map.ts index b8d51d2e20..3e6cb63d31 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -3168,6 +3168,8 @@ export class Map extends Camera { _render(paintStartTimeStamp: number) { const fadeDuration = this._idleTriggered ? this._fadeDuration : 0; + const isGlobeRendering = this.style.projection.transitionState > 0; + // A custom layer may have used the context asynchronously. Mark the state as dirty. this.painter.context.setDirty(); this.painter.setBaseState(); @@ -3204,12 +3206,14 @@ export class Map extends Camera { this.style.update(parameters); } - const transformUpdateResult = this.transform.newFrameUpdate(); + const globeRenderingChaged = this.style.projection.transitionState > 0 !== isGlobeRendering; + this.style.projection.setErrorQueryLatitudeDegrees(this.transform.center.lat); + this.transform.setTransitionState(this.style.projection.transitionState, this.style.projection.latitudeErrorCorrectionRadians); // If we are in _render for any reason other than an in-progress paint // transition, update source caches to check for and load any tiles we // need for the current transform - if (this.style && (this._sourcesDirty || transformUpdateResult.forceSourceUpdate)) { + if (this.style && (this._sourcesDirty || globeRenderingChaged)) { this._sourcesDirty = false; this.style._updateSources(this.transform); } @@ -3228,11 +3232,7 @@ export class Map extends Camera { } } - this._placementDirty = this.style && this.style._updatePlacement(this.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions, transformUpdateResult.forcePlacementUpdate); - - if (transformUpdateResult.fireProjectionEvent) { - this.fire(new Event('projectiontransition', transformUpdateResult.fireProjectionEvent)); - } + this._placementDirty = this.style && this.style._updatePlacement(this.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions, globeRenderingChaged); // Actually draw this.painter.render(this.style, { @@ -3269,7 +3269,7 @@ export class Map extends Camera { // Even though `_styleDirty` and `_sourcesDirty` are reset in this // method, synchronous events fired during Style#update or // Style#_updateSources could have caused them to be set again. - const somethingDirty = this._sourcesDirty || this._styleDirty || this._placementDirty || this.style.projection.isRenderingDirty() || this.transform.isRenderingDirty(); + const somethingDirty = this._sourcesDirty || this._styleDirty || this._placementDirty; if (somethingDirty || this._repaint) { this.triggerRepaint(); } else if (!this.isMoving() && this.loaded()) { diff --git a/src/ui/map_tests/map_events.test.ts b/src/ui/map_tests/map_events.test.ts index e59a35d3bb..9ce2e70a6a 100644 --- a/src/ui/map_tests/map_events.test.ts +++ b/src/ui/map_tests/map_events.test.ts @@ -6,7 +6,7 @@ import {type MapGeoJSONFeature} from '../../util/vectortile_to_geojson'; import {type MapLayerEventType, type MapLibreEvent} from '../events'; import {Map, type MapOptions} from '../map'; import {Event as EventedEvent, ErrorEvent} from '../../util/evented'; -import {GlobeProjection} from '../../geo/projection/globe'; +import {GlobeProjection} from '../../geo/projection/globe_projection'; type IsAny = 0 extends T & 1 ? T : never; type NotAny = T extends IsAny ? never : T; @@ -1092,11 +1092,9 @@ describe('map events', () => { type: 'mercator', }); - expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenNthCalledWith(1, 'globe'); - expect(spy).toHaveBeenNthCalledWith(2, 'globe-mercator'); - expect(spy).toHaveBeenNthCalledWith(3, 'globe'); - expect(spy).toHaveBeenNthCalledWith(4, 'mercator'); + expect(spy).toHaveBeenNthCalledWith(2, 'mercator'); }); }); }); diff --git a/src/ui/map_tests/map_resize.test.ts b/src/ui/map_tests/map_resize.test.ts index 29f89bf09b..e5ea84b8d1 100644 --- a/src/ui/map_tests/map_resize.test.ts +++ b/src/ui/map_tests/map_resize.test.ts @@ -1,5 +1,5 @@ import {describe, beforeEach, test, expect, vi} from 'vitest'; -import {MercatorProjection} from '../../geo/projection/mercator'; +import {MercatorProjection} from '../../geo/projection/mercator_projection'; import {createMap, beforeMapTest, sleep} from '../../util/test/util'; beforeEach(() => { diff --git a/src/util/primitives/aabb_cache.test.ts b/src/util/primitives/aabb_cache.test.ts index 25b73a4e30..db81489842 100644 --- a/src/util/primitives/aabb_cache.test.ts +++ b/src/util/primitives/aabb_cache.test.ts @@ -9,7 +9,7 @@ describe('aabb cache', () => { y: 0, z: 1, }, null, null, null); - detailsProvider.newFrame(); + detailsProvider.recalculateCache(); const aabb1b = detailsProvider.getTileAABB({ x: 0, y: 0, @@ -36,7 +36,7 @@ describe('aabb cache', () => { y: 1, z: 1, }, null, null, null); - detailsProvider.newFrame(); + detailsProvider.recalculateCache(); // Get 2+3+4 const box2b = detailsProvider.getTileAABB({ x: 1, @@ -53,7 +53,7 @@ describe('aabb cache', () => { y: 1, z: 1, }, null, null, null); - detailsProvider.newFrame(); + detailsProvider.recalculateCache(); // Get 1+3+4 const box1c = detailsProvider.getTileAABB({ x: 0, @@ -102,7 +102,7 @@ describe('aabb cache', () => { y: 1, z: 1, }, null, null, null); - detailsProvider.newFrame(); + detailsProvider.recalculateCache(); // Get 2+3 const box2b = detailsProvider.getTileAABB({ x: 1, @@ -114,7 +114,7 @@ describe('aabb cache', () => { y: 1, z: 1, }, null, null, null); - detailsProvider.newFrame(); + detailsProvider.recalculateCache(); // Get 1+3 const box1c = detailsProvider.getTileAABB({ x: 0, diff --git a/src/util/primitives/aabb_cache.ts b/src/util/primitives/aabb_cache.ts index 7e19c6d72c..276050c901 100644 --- a/src/util/primitives/aabb_cache.ts +++ b/src/util/primitives/aabb_cache.ts @@ -18,7 +18,7 @@ export class AabbCache { * Any tile accesses in the last frame is kept in the cache, other tiles are deleted. * @returns */ - newFrame() { + recalculateCache() { if (!this._hadAnyChanges) { // If no new boxes were added this frame, no need to conserve memory, do not clear caches. return; diff --git a/src/util/test/util.ts b/src/util/test/util.ts index cdd092bcb8..83629b5e27 100644 --- a/src/util/test/util.ts +++ b/src/util/test/util.ts @@ -9,7 +9,6 @@ import {MercatorTransform} from '../../geo/projection/mercator_transform'; import {RequestManager} from '../request_manager'; import {type IReadonlyTransform, type ITransform} from '../../geo/transform_interface'; import {type Style} from '../../style/style'; -import type {GlobeProjection} from '../../geo/projection/globe'; export class StubMap extends Evented { style: Style; @@ -224,14 +223,3 @@ export function expectToBeCloseToArray(actual: Array, expected: Array { @@ -519,3 +519,11 @@ describe('util getAngleDelta', () => { expect(getAngleDelta(lastPoint, currentPoint, center)).toBe(-90); }); }); +describe('util scaleZoom and zoomScale relation', () => { + test('convert and back', () => { + expect(scaleZoom(0)).toBe(-Infinity); + expect(scaleZoom(10)).toBe(3.3219280948873626); + expect(zoomScale(3.3219280948873626)).toBeCloseTo(10, 10); + expect(scaleZoom(zoomScale(5))).toBe(5); + }); +}); diff --git a/src/util/util.ts b/src/util/util.ts index d4361288bb..2ef09de771 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -406,6 +406,16 @@ export function nextPowerOfTwo(value: number): number { return Math.pow(2, Math.ceil(Math.log(value) / Math.LN2)); } +/** + * Computes scaling from zoom level. + */ +export function zoomScale(zoom: number) { return Math.pow(2, zoom); } + +/** + * Computes zoom level from scaling. + */ +export function scaleZoom(scale: number) { return Math.log(scale) / Math.LN2; } + /** * Create an object by mapping all the values of an existing object while * preserving their keys. @@ -1023,3 +1033,5 @@ export const MAX_TILE_ZOOM = 25; * In other words, the lower bound supported for tile zoom. */ export const MIN_TILE_ZOOM = 0; + +export const MAX_VALID_LATITUDE = 85.051129; diff --git a/test/bench/benchmarks/covering_tiles_globe.ts b/test/bench/benchmarks/covering_tiles_globe.ts index f2d1b378f0..8eac90e887 100644 --- a/test/bench/benchmarks/covering_tiles_globe.ts +++ b/test/bench/benchmarks/covering_tiles_globe.ts @@ -1,8 +1,8 @@ import Benchmark from '../lib/benchmark'; -import { GlobeTransform } from '../../../src/geo/projection/globe_transform'; -import { GlobeProjection } from '../../../src/geo/projection/globe'; -import { LngLat } from '../styles'; -import { coveringTiles } from '../../../src/geo/projection/covering_tiles'; +import {GlobeTransform} from '../../../src/geo/projection/globe_transform'; +import {GlobeProjection} from '../../../src/geo/projection/globe_projection'; +import {LngLat} from '../styles'; +import {coveringTiles} from '../../../src/geo/projection/covering_tiles'; export default class CoveringTilesGlobe extends Benchmark { _pitch: number; @@ -13,8 +13,7 @@ export default class CoveringTilesGlobe extends Benchmark { } bench() { - const projection = new GlobeProjection(); - const transform = new GlobeTransform(projection, true); + const transform = new GlobeTransform(); transform.setCenter(new LngLat(0, 0)); transform.setZoom(4); transform.resize(4096, 4096); diff --git a/test/bench/benchmarks/symbol_collision_box.ts b/test/bench/benchmarks/symbol_collision_box.ts index ca6c5ef5a0..1c0a9d807c 100644 --- a/test/bench/benchmarks/symbol_collision_box.ts +++ b/test/bench/benchmarks/symbol_collision_box.ts @@ -8,7 +8,7 @@ import {SingleCollisionBox} from '../../../src/data/bucket/symbol_bucket'; import {EXTENT} from '../../../src/data/extent'; import {MercatorTransform} from '../../../src/geo/projection/mercator_transform'; import {mat4} from 'gl-matrix'; -import {GlobeProjection} from '../../../src/geo/projection/globe'; +import {GlobeProjection} from '../../../src/geo/projection/globe_projection'; import {GlobeTransform} from '../../../src/geo/projection/globe_transform'; type TestSymbol = { @@ -51,9 +51,8 @@ export default class SymbolCollisionBox extends Benchmark { private _createTransform() { if (this._useGlobeProjection) { - const projection = new GlobeProjection(); return { - transform: new GlobeTransform(projection, true), + transform: new GlobeTransform(), calculatePosMatrix: (_tileID: UnwrappedTileID) => { return undefined; }, }; } else { diff --git a/test/build/min.test.ts b/test/build/min.test.ts index a14f6ac23d..55b6568d45 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -38,7 +38,7 @@ describe('test min build', () => { const decreaseQuota = 4096; // feel free to update this value after you've checked that it has changed on purpose :-) - const expectedBytes = 898930; + const expectedBytes = 907610; expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); diff --git a/test/examples/add-3d-model.html b/test/examples/add-3d-model.html index 727fc7625d..0dc3662817 100644 --- a/test/examples/add-3d-model.html +++ b/test/examples/add-3d-model.html @@ -135,7 +135,7 @@ // It will work regardless of current projection. // Also see the example "globe-3d-model.html". // - // const modelMatrix = map.transform.getMatrixForModel(modelOrigin, modelAltitude); + // const modelMatrix = args.getMatrixForModel(modelOrigin, modelAltitude); // const m = new THREE.Matrix4().fromArray(matrix); // const l = new THREE.Matrix4().fromArray(modelMatrix); diff --git a/test/examples/globe-3d-model.html b/test/examples/globe-3d-model.html index a9139d3c20..ef4b9b7c8f 100644 --- a/test/examples/globe-3d-model.html +++ b/test/examples/globe-3d-model.html @@ -129,7 +129,7 @@ // We can use this API to get the correct model matrix. // It will work regardless of current projection. - // See MapLibre source code, files mercator_transform.ts or globe_transform.ts, function "getCustomLayerArgs". + // See MapLibre source code, file "mercator_transform.ts" or "vertical_perspective_transform.ts". const modelMatrix = map.transform.getMatrixForModel(modelOrigin, modelAltitude); const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix); const l = new THREE.Matrix4().fromArray(modelMatrix).scale( diff --git a/test/examples/globe-custom-tiles.html b/test/examples/globe-custom-tiles.html index 4a0f928143..b3e4702a81 100644 --- a/test/examples/globe-custom-tiles.html +++ b/test/examples/globe-custom-tiles.html @@ -222,7 +222,7 @@ const mesh = { vbo, ibo, - indexCount: meshBuffers.indices.length, + indexCount: meshBuffers.indices.byteLength / 2, }; this.meshMap.set(key, mesh); return mesh; @@ -262,7 +262,7 @@ } }; - const projectionData = map.transform.getProjectionData({overscaledTileID: tileID}); + const projectionData = map.transform.getProjectionData({overscaledTileID: tileID, applyGlobeMatrix: true}); gl.uniform4f( locations['u_projection_clipping_plane'], diff --git a/test/integration/render/tests/projection/globe/custom/tent-3d-globe-zoomed/expected.png b/test/integration/render/tests/projection/globe/custom/tent-3d-globe-zoomed/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..34c3885fe3707d07302972472b3339729a28673e GIT binary patch literal 341 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV3hH6aSW+oe0z|QmqCHy@P>&# v7UvvK-23%FuuL!0h~{wK>6VjA1}m&JozKc>`zFl{7+egVu6{1-oD!MH%cmvbT`t{uz++o3P^WJH!Mo0OG}7M4&JAGJ3EcsPHeTKJ)cGyM?fYBHl zAfb@s!*eK1-1Ht4_WiB0446SD8!rVjP)ziJK-F^4@qq0Aj=ze6^ki~$fXzeT`2SwW z-Y$SXLy9Q8-Gu&DbS_eMCdcpFxxbJ)#N;O4iv22#T>hV-gxjIS+o6hovtHaTWb#HT zY>L#FPFDI>d=<~Flz;Cvfxa)_mUrB0^uHh6$Zn_oTl1fXuG=;L`#}=(cFn(OlD7iv z+HaNm-w(#OYwX%@MgQla^LCA!IU7(rdaU;qFje+FBLa*jRuBaL``HHz5-aFxrS1bS zB#4Hsv9=vC#02t5|5fdWC5Oe^H$FeyQ0I^T-#8PS~jMG%n z94UG#3qLaW8E5nyx(%NX#Ct`#hkhV4og>XeEJk~1{EiRggJ4ev_9iXQ1U-jMzD|oY ztj9C$Y|l%P!xD3G7zrt=y>~E^CiPsZa3y1fM-AECWKlf$ zuG?J}nl#+Q0RtiA^L;9eSF%dj*&m;u#@x%F%v*l+ z@8|mEo-lPk!vu&9a1lK;n zW>X8sA)6%)R)i+81#SOz8rLhI{=rKI%o#Bp|4P0V-5I@PT|ZH3%0Abi^_q9G<~4<* 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 MT<%S=sGiUN1GKXhUH||9 literal 11103 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 JM8wede*mIRbGZNj diff --git a/test/integration/render/tests/terrain/fog-sky-blend-globe/style.json b/test/integration/render/tests/terrain/fog-sky-blend-globe/style.json index 4580413245..b83c025389 100644 --- a/test/integration/render/tests/terrain/fog-sky-blend-globe/style.json +++ b/test/integration/render/tests/terrain/fog-sky-blend-globe/style.json @@ -22,7 +22,7 @@ "maxzoom": 12, "tileSize": 256 }, - "osm": { + "numbers": { "type": "raster", "tiles": ["local://tiles/number/{z}.png"], "maxzoom": 17, @@ -41,7 +41,7 @@ { "id": "raster", "type": "raster", - "source": "osm", + "source": "numbers", "paint": { "raster-opacity": 1.0 }