UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

993 lines (880 loc) 42.7 kB
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, clampToSphere, getGlobeRadiusPixels, getZoomAdjustment, horizonPlaneToCenterAndRadius, 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); // Normalize the plane vector const scale = 1 / vec3.length(planeVector); 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, 1, 0, this._cachedClippingPlane, true); } 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 horizonPlane = this._cachedClippingPlane; const directionDotPlaneXyz = horizonPlane[0] * rayDirection[0] + horizonPlane[1] * rayDirection[1] + horizonPlane[2] * rayDirection[2]; const originToPlaneDistance = pointPlaneSignedDistance(horizonPlane, 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 horizonDisk = horizonPlaneToCenterAndRadius(horizonPlane); const closestOnHorizon = clampToSphere(horizonDisk.center, horizonDisk.radius, 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; } }