UNPKG

mapbox-gl

Version:
1,183 lines (1,019 loc) 111 kB
// @flow import LngLat from './lng_lat.js'; import LngLatBounds from './lng_lat_bounds.js'; import MercatorCoordinate, {mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude, latFromMercatorY, MAX_MERCATOR_LATITUDE, circumferenceAtLatitude} from './mercator_coordinate.js'; import {getProjection} from './projection/index.js'; import {tileAABB} from '../geo/projection/tile_transform.js'; import Point from '@mapbox/point-geometry'; import {wrap, clamp, pick, radToDeg, degToRad, getAABBPointSquareDist, furthestTileCorner, warnOnce, deepEqual} from '../util/util.js'; import {number as interpolate} from '../style-spec/util/interpolate.js'; import EXTENT from '../style-spec/data/extent.js'; import {vec4, mat4, mat2, vec3, quat} from 'gl-matrix'; import {Frustum, FrustumCorners, Ray} from '../util/primitives.js'; import EdgeInsets from './edge_insets.js'; import {FreeCamera, FreeCameraOptions, orientationFromFrame} from '../ui/free_camera.js'; import assert from 'assert'; import getProjectionAdjustments, {getProjectionAdjustmentInverted, getScaleAdjustment, getProjectionInterpolationT} from './projection/adjustments.js'; import {getPixelsToTileUnitsMatrix} from '../source/pixels_to_tile_units.js'; import {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../source/tile_id.js'; import { calculateGlobeMatrix, polesInViewport, aabbForTileOnGlobe, GLOBE_ZOOM_THRESHOLD_MIN, GLOBE_ZOOM_THRESHOLD_MAX, GLOBE_SCALE_MATCH_LATITUDE } from '../geo/projection/globe_util.js'; import {projectClamped} from '../symbol/projection.js'; import type Projection from '../geo/projection/projection.js'; import type {Elevation} from '../terrain/elevation.js'; import type {PaddingOptions} from './edge_insets.js'; import type Tile from '../source/tile.js'; import type {ProjectionSpecification} from '../style-spec/types.js'; import type {FeatureDistanceData} from '../style-spec/feature_filter/index.js'; import type {Mat4, Vec3, Vec4, Quat} from 'gl-matrix'; import type {Aabb} from '../util/primitives'; const NUM_WORLD_COPIES = 3; export const DEFAULT_MIN_ZOOM = 0; export const DEFAULT_MAX_ZOOM = 25.5; export const MIN_LOD_PITCH = 60.0; type RayIntersectionResult = { p0: Vec4, p1: Vec4, t: number}; type ElevationReference = "sea" | "ground"; type RootTile = { aabb: Aabb, fullyVisible: boolean, maxZ: number, minZ: number, shouldSplit?: boolean, tileID?: OverscaledTileID, wrap: number, x: number, y: number, zoom: number, }; const OrthographicPitchTranstionValue = 15; const lerp = (x: number, y: number, t: number) => { return (1 - t) * x + t * y; }; const easeIn = (x: number) => { return x * x * x * x * x; }; const lerpMatrix = (out: Float64Array, a: Float64Array, b: Float64Array, value: number) => { for (let i = 0; i < 16; i++) { out[i] = lerp(a[i], b[i], value); } return out; }; /** * A single transform, generally used for a single tile to be * scaled, rotated, and zoomed. * @private */ class Transform { tileSize: number; tileZoom: number; maxBounds: ?LngLatBounds; // 2^zoom (worldSize = tileSize * scale) scale: number; // Map viewport size (not including the pixel ratio) width: number; height: number; // Bearing, radians, in [-pi, pi] angle: number; // 2D rotation matrix in the horizontal plane, as a function of bearing rotationMatrix: Float32Array; // Zoom, modulo 1 zoomFraction: number; // The scale factor component of the conversion from pixels ([0, w] x [h, 0]) to GL // NDC ([1, -1] x [1, -1]) (note flipped y) pixelsToGLUnits: [number, number]; // Distance from camera to the center, in screen pixel units, independent of zoom cameraToCenterDistance: number; // Projection from mercator coordinates ([0, 0] nw, [1, 1] se) to GL clip coordinates mercatorMatrix: Array<number>; // Translate points in mercator coordinates to be centered about the camera, with units chosen // for screen-height-independent scaling of fog. Not affected by orientation of camera. mercatorFogMatrix: Float32Array; // Projection from world coordinates (mercator scaled by worldSize) to clip coordinates projMatrix: Array<number> | Float32Array | Float64Array; invProjMatrix: Float64Array; // Projection matrix with expanded farZ on globe projection expandedFarZProjMatrix: Array<number> | Float32Array | Float64Array; // Same as projMatrix, pixel-aligned to avoid fractional pixels for raster tiles alignedProjMatrix: Float64Array; // From world coordinates to screen pixel coordinates (projMatrix premultiplied by labelPlaneMatrix) pixelMatrix: Float64Array; pixelMatrixInverse: Float64Array; worldToFogMatrix: Float64Array; skyboxMatrix: Float32Array; starsProjMatrix: Float32Array; // Transform from screen coordinates to GL NDC, [0, w] x [h, 0] --> [-1, 1] x [-1, 1] // Roughly speaking, applies pixelsToGLUnits scaling with a translation glCoordMatrix: Float32Array; // Inverse of glCoordMatrix, from NDC to screen coordinates, [-1, 1] x [-1, 1] --> [0, w] x [h, 0] labelPlaneMatrix: Float32Array; // globe coordinate transformation matrix globeMatrix: Float64Array; globeCenterInViewSpace: [number, number, number]; globeRadius: number; inverseAdjustmentMatrix: Array<number>; mercatorFromTransition: boolean; minLng: number; maxLng: number; minLat: number; maxLat: number; worldMinX: number; worldMaxX: number; worldMinY: number; worldMaxY: number; cameraFrustum: Frustum; frustumCorners: FrustumCorners; freezeTileCoverage: boolean; cameraElevationReference: ElevationReference; fogCullDistSq: ?number; _averageElevation: number; projectionOptions: ProjectionSpecification; projection: Projection; _elevation: ?Elevation; _fov: number; _pitch: number; _zoom: number; _seaLevelZoom: ?number; _unmodified: boolean; _renderWorldCopies: boolean; _minZoom: number; _maxZoom: number; _minPitch: number; _maxPitch: number; _center: LngLat; _edgeInsets: EdgeInsets; _constraining: boolean; _projMatrixCache: {[_: number]: Float32Array}; _alignedProjMatrixCache: {[_: number]: Float32Array}; _pixelsToTileUnitsCache: {[_: number]: Float32Array}; _expandedProjMatrixCache: {[_: number]: Float32Array}; _fogTileMatrixCache: {[_: number]: Float32Array}; _distanceTileDataCache: {[_: number]: FeatureDistanceData}; _camera: FreeCamera; _centerAltitude: number; _centerAltitudeValidForExaggeration: ?number; _horizonShift: number; _pixelsPerMercatorPixel: number; _nearZ: number; _farZ: number; _mercatorScaleRatio: number; _isCameraConstrained: boolean; _orthographicProjectionAtLowPitch: boolean; constructor(minZoom: ?number, maxZoom: ?number, minPitch: ?number, maxPitch: ?number, renderWorldCopies: boolean | void, projection?: ?ProjectionSpecification, bounds: ?LngLatBounds) { this.tileSize = 512; // constant this._renderWorldCopies = renderWorldCopies === undefined ? true : renderWorldCopies; this._minZoom = minZoom || DEFAULT_MIN_ZOOM; this._maxZoom = maxZoom || 22; this._minPitch = (minPitch === undefined || minPitch === null) ? 0 : minPitch; this._maxPitch = (maxPitch === undefined || maxPitch === null) ? 60 : maxPitch; this.setProjection(projection); this.setMaxBounds(bounds); this.width = 0; this.height = 0; this._center = new LngLat(0, 0); this.zoom = 0; this.angle = 0; this._fov = 0.6435011087932844; this._pitch = 0; this._nearZ = 0; this._farZ = 0; this._unmodified = true; this._edgeInsets = new EdgeInsets(); this._projMatrixCache = {}; this._alignedProjMatrixCache = {}; this._fogTileMatrixCache = {}; this._expandedProjMatrixCache = {}; this._distanceTileDataCache = {}; this._camera = new FreeCamera(); this._centerAltitude = 0; this._averageElevation = 0; this.cameraElevationReference = "ground"; this._pixelsPerMercatorPixel = 1.0; this.globeRadius = 0; this.globeCenterInViewSpace = [0, 0, 0]; // Move the horizon closer to the center. 0 would not shift the horizon. 1 would put the horizon at the center. this._horizonShift = 0.1; this._orthographicProjectionAtLowPitch = false; } clone(): Transform { const clone = new Transform(this._minZoom, this._maxZoom, this._minPitch, this.maxPitch, this._renderWorldCopies, this.getProjection()); clone._elevation = this._elevation; clone._centerAltitude = this._centerAltitude; clone._centerAltitudeValidForExaggeration = this._centerAltitudeValidForExaggeration; clone.tileSize = this.tileSize; clone.mercatorFromTransition = this.mercatorFromTransition; clone.width = this.width; clone.height = this.height; clone.cameraElevationReference = this.cameraElevationReference; clone._center = this._center; clone._setZoom(this.zoom); clone._seaLevelZoom = this._seaLevelZoom; clone.angle = this.angle; clone._fov = this._fov; clone._pitch = this._pitch; clone._nearZ = this._nearZ; clone._farZ = this._farZ; clone._averageElevation = this._averageElevation; clone._orthographicProjectionAtLowPitch = this._orthographicProjectionAtLowPitch; clone._unmodified = this._unmodified; clone._edgeInsets = this._edgeInsets.clone(); clone._camera = this._camera.clone(); clone._calcMatrices(); clone.freezeTileCoverage = this.freezeTileCoverage; clone.frustumCorners = this.frustumCorners; return clone; } get isOrthographic(): boolean { return this.projection.name !== 'globe' && this._orthographicProjectionAtLowPitch && this.pitch < OrthographicPitchTranstionValue; } get elevation(): ?Elevation { return this._elevation; } set elevation(elevation: ?Elevation) { if (this._elevation === elevation) return; this._elevation = elevation; this._updateCameraOnTerrain(); this._calcMatrices(); } get depthOcclusionForSymbolsAndCircles(): boolean { return this.projection.name !== 'globe' && !this.isOrthographic; } updateElevation(constrainCameraOverTerrain: boolean, adaptCameraAltitude: boolean = false) { const centerAltitudeChanged = this._elevation && this._elevation.exaggeration() !== this._centerAltitudeValidForExaggeration; if (this._seaLevelZoom == null || centerAltitudeChanged) { this._updateCameraOnTerrain(); } if (constrainCameraOverTerrain || centerAltitudeChanged) { this._constrainCamera(adaptCameraAltitude); } this._calcMatrices(); } getProjection(): ProjectionSpecification { return (pick(this.projection, ['name', 'center', 'parallels']): ProjectionSpecification); } // Returns whether the projection changes setProjection(projection?: ?ProjectionSpecification): boolean { this.projectionOptions = projection || {name: 'mercator'}; const oldProjection = this.projection ? this.getProjection() : undefined; this.projection = getProjection(this.projectionOptions); const newProjection = this.getProjection(); const projectionHasChanged = !deepEqual(oldProjection, newProjection); if (projectionHasChanged) { this._calcMatrices(); } this.mercatorFromTransition = false; return projectionHasChanged; } // Returns whether the projection need to be reevaluated setOrthographicProjectionAtLowPitch(enabled: boolean): boolean { if (this._orthographicProjectionAtLowPitch === enabled) { return false; } this._orthographicProjectionAtLowPitch = enabled; this._calcMatrices(); return true; } setMercatorFromTransition(): boolean { const oldProjection = this.projection.name; this.mercatorFromTransition = true; this.projectionOptions = {name: 'mercator'}; this.projection = getProjection({name: 'mercator'}); const projectionHasChanged = oldProjection !== this.projection.name; if (projectionHasChanged) { this._calcMatrices(); } return projectionHasChanged; } get minZoom(): number { return this._minZoom; } set minZoom(zoom: number) { if (this._minZoom === zoom) return; this._minZoom = zoom; this.zoom = Math.max(this.zoom, zoom); } get maxZoom(): number { return this._maxZoom; } set maxZoom(zoom: number) { if (this._maxZoom === zoom) return; this._maxZoom = zoom; this.zoom = Math.min(this.zoom, zoom); } get minPitch(): number { return this._minPitch; } set minPitch(pitch: number) { if (this._minPitch === pitch) return; this._minPitch = pitch; this.pitch = Math.max(this.pitch, pitch); } get maxPitch(): number { return this._maxPitch; } set maxPitch(pitch: number) { if (this._maxPitch === pitch) return; this._maxPitch = pitch; this.pitch = Math.min(this.pitch, pitch); } get renderWorldCopies(): boolean { return this._renderWorldCopies && this.projection.supportsWorldCopies === true; } set renderWorldCopies(renderWorldCopies?: ?boolean) { if (renderWorldCopies === undefined) { renderWorldCopies = true; } else if (renderWorldCopies === null) { renderWorldCopies = false; } this._renderWorldCopies = renderWorldCopies; } get worldSize(): number { return this.tileSize * this.scale; } // This getter returns an incorrect value. // It should eventually be removed and cameraWorldSize be used instead. // See free_camera.getDistanceToElevation for the rationale. get cameraWorldSizeForFog(): number { const distance = Math.max(this._camera.getDistanceToElevation(this._averageElevation), Number.EPSILON); return this._worldSizeFromZoom(this._zoomFromMercatorZ(distance)); } get cameraWorldSize(): number { const distance = Math.max(this._camera.getDistanceToElevation(this._averageElevation, true), Number.EPSILON); return this._worldSizeFromZoom(this._zoomFromMercatorZ(distance)); } // `pixelsPerMeter` is used to describe relation between real world and pixel distances. // In mercator projection it is dependant on latitude value meaning that one meter covers // less pixels at the equator than near polar regions. Globe projection in other hand uses // fixed ratio everywhere. get pixelsPerMeter(): number { return this.projection.pixelsPerMeter(this.center.lat, this.worldSize); } get cameraPixelsPerMeter(): number { return mercatorZfromAltitude(1, this.center.lat) * this.cameraWorldSizeForFog; } get centerOffset(): Point { return this.centerPoint._sub(this.size._div(2)); } get size(): Point { return new Point(this.width, this.height); } get bearing(): number { return wrap(this.rotation, -180, 180); } set bearing(bearing: number) { this.rotation = bearing; } get rotation(): number { return -this.angle / Math.PI * 180; } set rotation(rotation: number) { const b = -rotation * Math.PI / 180; if (this.angle === b) return; this._unmodified = false; this.angle = b; this._calcMatrices(); // 2x2 matrix for rotating points this.rotationMatrix = mat2.create(); mat2.rotate(this.rotationMatrix, this.rotationMatrix, this.angle); } get pitch(): number { return this._pitch / Math.PI * 180; } set pitch(pitch: number) { const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI; if (this._pitch === p) return; this._unmodified = false; this._pitch = p; this._calcMatrices(); } get aspect(): number { return this.width / this.height; } get fov(): number { return this._fov / Math.PI * 180; } get fovX(): number { return this._fov; } get fovY(): number { const focalLength = 1.0 / Math.tan(this.fovX * 0.5); return 2 * Math.atan((1.0 / this.aspect) / focalLength); } set fov(fov: number) { fov = Math.max(0.01, Math.min(60, fov)); if (this._fov === fov) return; this._unmodified = false; this._fov = degToRad(fov); this._calcMatrices(); } get averageElevation(): number { return this._averageElevation; } set averageElevation(averageElevation: number) { this._averageElevation = averageElevation; this._calcFogMatrices(); this._distanceTileDataCache = {}; } get zoom(): number { return this._zoom; } set zoom(zoom: number) { const z = Math.min(Math.max(zoom, this.minZoom), this.maxZoom); if (this._zoom === z) return; this._unmodified = false; this._setZoom(z); this._updateSeaLevelZoom(); this._constrain(); this._calcMatrices(); } _setZoom(z: number) { this._zoom = z; this.scale = this.zoomScale(z); this.tileZoom = Math.floor(z); this.zoomFraction = z - this.tileZoom; } _updateCameraOnTerrain() { const elevationAtCenter = this.elevation ? this.elevation.getAtPoint(this.locationCoordinate(this.center), Number.NEGATIVE_INFINITY) : Number.NEGATIVE_INFINITY; const usePreviousCenter = this.elevation && elevationAtCenter === Number.NEGATIVE_INFINITY && this.elevation.visibleDemTiles.length > 0 && this.elevation.exaggeration() > 0 && this._centerAltitudeValidForExaggeration; if (!this._elevation || (elevationAtCenter === Number.NEGATIVE_INFINITY && !(usePreviousCenter && this._centerAltitude))) { // Elevation data not loaded yet, reset this._centerAltitude = 0; this._seaLevelZoom = null; this._centerAltitudeValidForExaggeration = undefined; return; } const elevation: Elevation = this._elevation; if (usePreviousCenter || (this._centerAltitude && this._centerAltitudeValidForExaggeration && elevation.exaggeration() && this._centerAltitudeValidForExaggeration !== elevation.exaggeration())) { assert(this._centerAltitudeValidForExaggeration); const previousExaggeration = (this._centerAltitudeValidForExaggeration: any); // scale down the centerAltitude this._centerAltitude = this._centerAltitude / previousExaggeration * elevation.exaggeration(); this._centerAltitudeValidForExaggeration = elevation.exaggeration(); } else { this._centerAltitude = elevationAtCenter || 0; this._centerAltitudeValidForExaggeration = elevation.exaggeration(); } this._updateSeaLevelZoom(); } _updateSeaLevelZoom() { if (this._centerAltitudeValidForExaggeration === undefined) { return; } const height = this.cameraToCenterDistance; const terrainElevation = this.pixelsPerMeter * this._centerAltitude; const mercatorZ = (terrainElevation + height) / this.worldSize; // MSL (Mean Sea Level) zoom describes the distance of the camera to the sea level (altitude). // It is used only for manipulating the camera location. The standard zoom (this._zoom) // defines the camera distance to the terrain (height). Its behavior and conceptual // meaning in determining which tiles to stream is same with or without the terrain. this._seaLevelZoom = this._zoomFromMercatorZ(mercatorZ); } sampleAverageElevation(): number { if (!this._elevation) return 0; const elevation: Elevation = this._elevation; const elevationSamplePoints = [ [0.5, 0.2], [0.3, 0.5], [0.5, 0.5], [0.7, 0.5], [0.5, 0.8] ]; const horizon = this.horizonLineFromTop(); let elevationSum = 0.0; let weightSum = 0.0; for (let i = 0; i < elevationSamplePoints.length; i++) { const pt = new Point( elevationSamplePoints[i][0] * this.width, horizon + elevationSamplePoints[i][1] * (this.height - horizon) ); const hit = elevation.pointCoordinate(pt); if (!hit) continue; const distanceToHit = Math.hypot(hit[0] - this._camera.position[0], hit[1] - this._camera.position[1]); const weight = 1 / distanceToHit; elevationSum += hit[3] * weight; weightSum += weight; } if (weightSum === 0) return NaN; return elevationSum / weightSum; } get center(): LngLat { return this._center; } set center(center: LngLat) { if (center.lat === this._center.lat && center.lng === this._center.lng) return; this._unmodified = false; this._center = center; if (this._terrainEnabled()) { if (this.cameraElevationReference === "ground") { this._updateCameraOnTerrain(); } else { this._updateZoomFromElevation(); } } this._constrain(); this._calcMatrices(); } _updateZoomFromElevation() { if (this._seaLevelZoom == null || !this._elevation) return; // Compute zoom level from the height of the camera relative to the terrain const seaLevelZoom: number = this._seaLevelZoom; const elevationAtCenter = this._elevation.getAtPointOrZero(this.locationCoordinate(this.center)); const mercatorElevation = this.pixelsPerMeter / this.worldSize * elevationAtCenter; const altitude = this._mercatorZfromZoom(seaLevelZoom); const minHeight = this._mercatorZfromZoom(this._maxZoom); const height = Math.max(altitude - mercatorElevation, minHeight); this._setZoom(this._zoomFromMercatorZ(height)); } get padding(): PaddingOptions { return this._edgeInsets.toJSON(); } set padding(padding: PaddingOptions) { if (this._edgeInsets.equals(padding)) return; this._unmodified = false; //Update edge-insets inplace this._edgeInsets.interpolate(this._edgeInsets, padding, 1); this._calcMatrices(); } /** * Computes a zoom value relative to a map plane that goes through the provided mercator position. * * @param {MercatorCoordinate} position A position defining the altitude of the the map plane. * @returns {number} The zoom value. */ computeZoomRelativeTo(position: MercatorCoordinate): number { // Find map center position on the target plane by casting a ray from screen center towards the plane. // Direct distance to the target position is used if the target position is above camera position. const centerOnTargetAltitude = this.rayIntersectionCoordinate(this.pointRayIntersection(this.centerPoint, position.toAltitude())); let targetPosition: ?Vec3; if (position.z < this._camera.position[2]) { targetPosition = [centerOnTargetAltitude.x, centerOnTargetAltitude.y, centerOnTargetAltitude.z]; } else { targetPosition = [position.x, position.y, position.z]; } const distToTarget = vec3.length(vec3.sub([], this._camera.position, targetPosition)); return clamp(this._zoomFromMercatorZ(distToTarget), this._minZoom, this._maxZoom); } setFreeCameraOptions(options: FreeCameraOptions) { if (!this.height) return; if (!options.position && !options.orientation) return; // Camera state must be up-to-date before accessing its getters this._updateCameraState(); let changed = false; if (options.orientation && !quat.exactEquals(options.orientation, this._camera.orientation)) { // $FlowFixMe[incompatible-call] - Flow can't infer that orientation is not null changed = this._setCameraOrientation(options.orientation); } if (options.position) { const newPosition = [options.position.x, options.position.y, options.position.z]; if (!vec3.exactEquals(newPosition, this._camera.position)) { this._setCameraPosition(newPosition); changed = true; } } if (changed) { this._updateStateFromCamera(); this.recenterOnTerrain(); } } getFreeCameraOptions(): FreeCameraOptions { this._updateCameraState(); const pos = this._camera.position; const options = new FreeCameraOptions(); options.position = new MercatorCoordinate(pos[0], pos[1], pos[2]); options.orientation = this._camera.orientation; options._elevation = this.elevation; options._renderWorldCopies = this.renderWorldCopies; return options; } _setCameraOrientation(orientation: Quat): boolean { // zero-length quaternions are not valid if (!quat.length(orientation)) return false; quat.normalize(orientation, orientation); // The new orientation must be sanitized by making sure it can be represented // with a pitch and bearing. Roll-component must be removed and the camera can't be upside down const forward = vec3.transformQuat([], [0, 0, -1], orientation); const up = vec3.transformQuat([], [0, -1, 0], orientation); if (up[2] < 0.0) return false; const updatedOrientation = orientationFromFrame(forward, up); if (!updatedOrientation) return false; this._camera.orientation = updatedOrientation; return true; } _setCameraPosition(position: Vec3) { // Altitude must be clamped to respect min and max zoom const minWorldSize = this.zoomScale(this.minZoom) * this.tileSize; const maxWorldSize = this.zoomScale(this.maxZoom) * this.tileSize; const distToCenter = this.cameraToCenterDistance; position[2] = clamp(position[2], distToCenter / maxWorldSize, distToCenter / minWorldSize); this._camera.position = position; } /** * The center of the screen in pixels with the top-left corner being (0,0) * and +y axis pointing downwards. This accounts for padding. * * @readonly * @type {Point} * @memberof Transform */ get centerPoint(): Point { return this._edgeInsets.getCenter(this.width, this.height); } /** * Returns the vertical half-fov, accounting for padding, in radians. * * @readonly * @type {number} * @private */ get fovAboveCenter(): number { return this._fov * (0.5 + this.centerOffset.y / this.height); } /** * Returns true if the padding options are equal. * * @param {PaddingOptions} padding The padding options to compare. * @returns {boolean} True if the padding options are equal. * @memberof Transform */ isPaddingEqual(padding: PaddingOptions): boolean { return this._edgeInsets.equals(padding); } /** * Helper method to update edge-insets inplace. * * @param {PaddingOptions} start The initial padding options. * @param {PaddingOptions} target The target padding options. * @param {number} t The interpolation variable. * @memberof Transform */ interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number) { this._unmodified = false; this._edgeInsets.interpolate(start, target, t); this._constrain(); this._calcMatrices(); } /** * Return the highest zoom level that fully includes all tiles within the transform's boundaries. * @param {Object} options Options. * @param {number} options.tileSize Tile size, expressed in screen pixels. * @param {boolean} options.roundZoom Target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored. * @returns {number} An integer zoom level at which all tiles will be visible. */ coveringZoomLevel(options: {roundZoom?: boolean, tileSize: number}): number { const z = (options.roundZoom ? Math.round : Math.floor)( this.zoom + this.scaleZoom(this.tileSize / options.tileSize) ); // At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist. return Math.max(0, z); } /** * Return any "wrapped" copies of a given tile coordinate that are visible * in the current view. * * @private */ getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array<UnwrappedTileID> { const result = [new UnwrappedTileID(0, tileID)]; if (this.renderWorldCopies) { const utl = this.pointCoordinate(new Point(0, 0)); const utr = this.pointCoordinate(new Point(this.width, 0)); const ubl = this.pointCoordinate(new Point(this.width, this.height)); const ubr = this.pointCoordinate(new Point(0, this.height)); const w0 = Math.floor(Math.min(utl.x, utr.x, ubl.x, ubr.x)); const w1 = Math.floor(Math.max(utl.x, utr.x, ubl.x, ubr.x)); // Add an extra copy of the world on each side to properly render ImageSources and CanvasSources. // Both sources draw outside the tile boundaries of the tile that "contains them" so we need // to add extra copies on both sides in case offscreen tiles need to draw into on-screen ones. const extraWorldCopy = 1; for (let w = w0 - extraWorldCopy; w <= w1 + extraWorldCopy; w++) { if (w === 0) continue; result.push(new UnwrappedTileID(w, tileID)); } } return result; } isLODDisabled(checkPitch: boolean): boolean { // No change of LOD behavior for pitch lower than 60 and when there is no top padding: return only tile ids from the requested zoom level return (!checkPitch || this.pitch <= MIN_LOD_PITCH) && this._edgeInsets.top <= this._edgeInsets.bottom && !this._elevation && !this.projection.isReprojectedInTileSpace; } /** * Extends tile coverage to include potential shadow caster tiles. * @param {Array<OverscaledTileID>} coveringTiles tiles that are potential shadow receivers * @param {Vec3} lightDir direction of the light (unit vector) * @param {number} maxZoom maximum zoom level of shadow caster tiles * @returns {Array<OverscaledTileID>} a set of potential shadow casters */ extendTileCoverForShadows(coveringTiles: Array<OverscaledTileID>, lightDir: Vec3, maxZoom: number): Array<OverscaledTileID> { let out = []; if (lightDir[0] === 0.0 && lightDir[1] === 0.0) { return out; } // Extra tile selection based on the direction of the light: // For each tile we add neighbourgs that might cast shadows over the current tile for (const id of coveringTiles) { const tileId = id.canonical; const overscaledZ = id.overscaledZ; const tileWrap = id.wrap; const tiles = 1 << tileId.z; const xMaxInsideRange = tileId.x + 1 < tiles; const xMinInsideRange = tileId.x > 0; const yMaxInsideRange = tileId.y + 1 < tiles; const yMinInsideRange = tileId.y > 0; const leftWrap = id.wrap - (xMinInsideRange ? 0 : 1); const rightWrap = id.wrap + (xMaxInsideRange ? 0 : 1); const leftTileX = xMinInsideRange ? tileId.x - 1 : tiles - 1; const rightTileX = xMaxInsideRange ? tileId.x + 1 : 0; if (lightDir[0] < 0.0) { out.push(new OverscaledTileID(overscaledZ, rightWrap, tileId.z, rightTileX, tileId.y)); if (lightDir[1] < 0.0 && yMaxInsideRange) { out.push(new OverscaledTileID(overscaledZ, tileWrap, tileId.z, tileId.x, tileId.y + 1)); out.push(new OverscaledTileID(overscaledZ, rightWrap, tileId.z, rightTileX, tileId.y + 1)); } if (lightDir[1] > 0.0 && yMinInsideRange) { out.push(new OverscaledTileID(overscaledZ, tileWrap, tileId.z, tileId.x, tileId.y - 1)); out.push(new OverscaledTileID(overscaledZ, rightWrap, tileId.z, rightTileX, tileId.y - 1)); } } else if (lightDir[0] > 0.0) { out.push(new OverscaledTileID(overscaledZ, leftWrap, tileId.z, leftTileX, tileId.y)); if (lightDir[1] < 0.0 && yMaxInsideRange) { out.push(new OverscaledTileID(overscaledZ, tileWrap, tileId.z, tileId.x, tileId.y + 1)); out.push(new OverscaledTileID(overscaledZ, leftWrap, tileId.z, leftTileX, tileId.y + 1)); } if (lightDir[1] > 0.0 && yMinInsideRange) { out.push(new OverscaledTileID(overscaledZ, tileWrap, tileId.z, tileId.x, tileId.y - 1)); out.push(new OverscaledTileID(overscaledZ, leftWrap, tileId.z, leftTileX, tileId.y - 1)); } } else { if (lightDir[1] < 0.0 && yMaxInsideRange) { out.push(new OverscaledTileID(overscaledZ, tileWrap, tileId.z, tileId.x, tileId.y + 1)); } else if (yMinInsideRange) { out.push(new OverscaledTileID(overscaledZ, tileWrap, tileId.z, tileId.x, tileId.y - 1)); } } } // Remove duplicates from new ids if (out.length > 1) { out.sort((a, b) => { return a.overscaledZ - b.overscaledZ || a.wrap - b.wrap || a.canonical.z - b.canonical.z || a.canonical.x - b.canonical.x || a.canonical.y - b.canonical.y; }); let i = 0; let j = 0; while (j < out.length) { if (!out[j].equals(out[i])) { out[++i] = out[j++]; } else { ++j; } } out.length = i + 1; } // Remove higher zoom new IDs that overlap with other new IDs const nonOverlappingIds = []; for (const id of out) { if (!out.some(ancestorCandidate => id.isChildOf(ancestorCandidate))) { nonOverlappingIds.push(id); } } // Remove new IDs that overlap with old IDs out = nonOverlappingIds.filter(newId => !coveringTiles.some(oldId => { if (newId.overscaledZ < maxZoom && oldId.isChildOf(newId)) { return true; } // Remove identical IDs or children of existing IDs return newId.equals(oldId) || newId.isChildOf(oldId); })); return out; } /** * Return all coordinates that could cover this transform for a covering * zoom level. * @param {Object} options * @param {number} options.tileSize * @param {number} options.minzoom * @param {number} options.maxzoom * @param {boolean} options.roundZoom * @param {boolean} options.reparseOverscaled * @returns {Array<OverscaledTileID>} OverscaledTileIDs * @private */ coveringTiles( options: { tileSize: number, minzoom?: number, maxzoom?: number, roundZoom?: boolean, reparseOverscaled?: boolean, renderWorldCopies?: boolean, isTerrainDEM?: boolean } ): Array<OverscaledTileID> { let z = this.coveringZoomLevel(options); const actualZ = z; const hasExaggeration = this.elevation && this.elevation.exaggeration(); const useElevationData = hasExaggeration && !options.isTerrainDEM; const isMercator = this.projection.name === 'mercator'; if (options.minzoom !== undefined && z < options.minzoom) return []; if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; const centerCoord = this.locationCoordinate(this.center); const centerLatitude = this.center.lat; const numTiles = 1 << z; const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0]; const isGlobe = this.projection.name === 'globe'; const zInMeters = !isGlobe; const cameraFrustum = Frustum.fromInvProjectionMatrix(this.invProjMatrix, this.worldSize, z, zInMeters); const cameraCoord = isGlobe ? this._camera.mercatorPosition : this.pointCoordinate(this.getCameraPoint()); const meterToTile = numTiles * mercatorZfromAltitude(1, this.center.lat); const cameraAltitude = this._camera.position[2] / mercatorZfromAltitude(1, this.center.lat); const cameraPoint = [numTiles * cameraCoord.x, numTiles * cameraCoord.y, cameraAltitude * (zInMeters ? 1 : meterToTile)]; const verticalFrustumIntersect = isGlobe || hasExaggeration; // Let's consider an example for !roundZoom: e.g. tileZoom 16 is used from zoom 16 all the way to zoom 16.99. // This would mean that the minimal distance to split would be based on distance from camera to center of 16.99 zoom. // The same is already incorporated in logic behind roundZoom for raster (so there is no adjustment needed in following line). // 0.02 added to compensate for precision errors, see "coveringTiles for terrain" test in transform.test.js. const zoomSplitDistance = this.cameraToCenterDistance / options.tileSize * (options.roundZoom ? 1 : 0.502); const minZoom = this.isLODDisabled(true) ? z : 0; // When calculating tile cover for terrain, create deep AABB for nodes, to ensure they intersect frustum: for sources, // other than DEM, use minimum of visible DEM tiles and center altitude as upper bound (pitch is always less than 90°). let maxRange; if (this._elevation && options.isTerrainDEM) { maxRange = this._elevation.exaggeration() * 10000; } else if (this._elevation) { const minMaxOpt = this._elevation.getMinMaxForVisibleTiles(); maxRange = minMaxOpt ? minMaxOpt.max : this._centerAltitude; } else { maxRange = this._centerAltitude; } const minRange = options.isTerrainDEM ? -maxRange : this._elevation ? this._elevation.getMinElevationBelowMSL() : 0; const scaleAdjustment = this.projection.isReprojectedInTileSpace ? getScaleAdjustment(this) : 1.0; const relativeScaleAtMercatorCoord = (mc: MercatorCoordinate) => { // Calculate how scale compares between projected coordinates and mercator coordinates. // Returns a length. The units don't matter since the result is only // used in a ratio with other values returned by this function. // Construct a small square in Mercator coordinates. const offset = 1 / 40000; const mcEast = new MercatorCoordinate(mc.x + offset, mc.y, mc.z); const mcSouth = new MercatorCoordinate(mc.x, mc.y + offset, mc.z); // Convert the square to projected coordinates. const ll = mc.toLngLat(); const llEast = mcEast.toLngLat(); const llSouth = mcSouth.toLngLat(); const p = this.locationCoordinate(ll); const pEast = this.locationCoordinate(llEast); const pSouth = this.locationCoordinate(llSouth); // Calculate the size of each edge of the reprojected square const dx = Math.hypot(pEast.x - p.x, pEast.y - p.y); const dy = Math.hypot(pSouth.x - p.x, pSouth.y - p.y); // Calculate the size of a projected square that would have the // same area as the reprojected square. return Math.sqrt(dx * dy) * scaleAdjustment / offset; }; const newRootTile = (wrap: number): RootTile => { const max = maxRange; const min = minRange; return { // With elevation, this._elevation provides z coordinate values. For 2D: // All tiles are on zero elevation plane => z difference is zero aabb: tileAABB(this, numTiles, 0, 0, 0, wrap, min, max, this.projection), zoom: 0, x: 0, y: 0, minZ: min, maxZ: max, wrap, fullyVisible: false }; }; // Do a depth-first traversal to find visible tiles and proper levels of detail const stack = []; let result = []; const maxZoom = z; const overscaledZ = options.reparseOverscaled ? actualZ : z; const square = (a: number) => a * a; const cameraHeightSqr = square((cameraAltitude - this._centerAltitude) * meterToTile); // in tile coordinates. const getAABBFromElevation = (it: RootTile) => { assert(this._elevation); if (!this._elevation || !it.tileID || !isMercator) return; // To silence flow. const minmax = this._elevation.getMinMaxForTile(it.tileID); const aabb = it.aabb; if (minmax) { aabb.min[2] = minmax.min; aabb.max[2] = minmax.max; aabb.center[2] = (aabb.min[2] + aabb.max[2]) / 2; } else { it.shouldSplit = shouldSplit(it); if (!it.shouldSplit) { // At final zoom level, while corresponding DEM tile is not loaded yet, // assume center elevation. This covers ground to horizon and prevents // loading unnecessary tiles until DEM cover is fully loaded. aabb.min[2] = aabb.max[2] = aabb.center[2] = this._centerAltitude; } } }; // Scale distance to split for acute angles. // dzSqr: z component of camera to tile distance, square. // dSqr: 3D distance of camera to tile, square. const distToSplitScale = (dzSqr: number, dSqr: number) => { // When the angle between camera to tile ray and tile plane is smaller // than acuteAngleThreshold, scale the distance to split. Scaling is adaptive: smaller // the angle, the scale gets lower value. Although it seems early to start at 45, // it is not: scaling kicks in around 60 degrees pitch. const acuteAngleThresholdSin = 0.707; // Math.sin(45) const stretchTile = 1.1; // Distances longer than 'dz / acuteAngleThresholdSin' gets scaled // following geometric series sum: every next dz length in distance can be // 'stretchTile times' longer. It is further, the angle is sharper. Total, // adjusted, distance would then be: // = dz / acuteAngleThresholdSin + (dz * stretchTile + dz * stretchTile ^ 2 + ... + dz * stretchTile ^ k), // where k = (d - dz / acuteAngleThresholdSin) / dz = d / dz - 1 / acuteAngleThresholdSin; // = dz / acuteAngleThresholdSin + dz * ((stretchTile ^ (k + 1) - 1) / (stretchTile - 1) - 1) // or put differently, given that k is based on d and dz, tile on distance d could be used on distance scaled by: // 1 / acuteAngleThresholdSin + (stretchTile ^ (k + 1) - 1) / (stretchTile - 1) - 1 if (dSqr * square(acuteAngleThresholdSin) < dzSqr) return 1.0; // Early return, no scale. const r = Math.sqrt(dSqr / dzSqr); const k = r - 1 / acuteAngleThresholdSin; return r / (1 / acuteAngleThresholdSin + (Math.pow(stretchTile, k + 1) - 1) / (stretchTile - 1) - 1); }; const shouldSplit = (it: RootTile) => { if (it.zoom < minZoom) { return true; } else if (it.zoom === maxZoom) { return false; } if (it.shouldSplit != null) { return it.shouldSplit; } const dx = it.aabb.distanceX(cameraPoint); const dy = it.aabb.distanceY(cameraPoint); let dzSqr = cameraHeightSqr; let tileScaleAdjustment = 1; if (isGlobe) { dzSqr = square(it.aabb.distanceZ(cameraPoint)); // Compensate physical sizes of the tiles when determining which zoom level to use. // In practice tiles closer to poles should use more aggressive LOD as their // physical size is already smaller than size of tiles near the equator. const tilesAtZoom = Math.pow(2, it.zoom); const minLat = latFromMercatorY((it.y + 1) / tilesAtZoom); const maxLat = latFromMercatorY((it.y) / tilesAtZoom); const closestLat = Math.min(Math.max(centerLatitude, minLat), maxLat); const relativeTileScale = circumferenceAtLatitude(closestLat) / circumferenceAtLatitude(centerLatitude); // With globe, the rendered scale does not exactly match the mercator scale at low zoom levels. // Account for this difference during LOD of loading so that you load the correct size tiles. // We try to compromise between two conflicting requirements: // - loading tiles at the camera's zoom level (for visual and styling consistency) // - loading correct size tiles (to reduce the number of tiles loaded) // These are arbitrarily balanced: if (closestLat === centerLatitude) { // For tiles that are in the middle of the viewport, prioritize matching the camera // zoom and allow divergence from the true scale. const maxDivergence = 0.3; tileScaleAdjustment = 1 / Math.max(1, this._mercatorScaleRatio - maxDivergence); } else { // For other tiles, use the real scale to reduce tile counts near poles. tileScaleAdjustment = Math.min(1, relativeTileScale / this._mercatorScaleRatio); } // Ensure that all tiles near the center have the same zoom level. // With LOD tile loading, tile zoom levels can change when scale slightly changes. // These differences can be pretty different in globe view. Work around this by // making more tiles match the center tile's zoom level. If the tiles are nearly big enough, // round up. Only apply this adjustment before the transition to mercator rendering has started. if (this.zoom <= GLOBE_ZOOM_THRESHOLD_MIN && it.zoom === maxZoom - 1 && relativeTileScale >= 0.9) { return true; } } else { assert(zInMeters); if (useElevationData) { dzSqr = square(it.aabb.distanceZ(cameraPoint) * meterToTile); } if (this.projection.isReprojectedInTileSpace && actualZ <= 5) { // In other projections, not all tiles are the same size. // Account for the tile size difference by adjusting the distToSplit. // Adjust by the ratio of the area at the tile center to the area at the map center. // Adjustments are only needed at lower zooms where tiles are not similarly sized. const numTiles = Math.pow(2, it.zoom); const relativeScale = relativeScaleAtMercatorCoord(new MercatorCoordinate((it.x + 0.5) / numTiles, (it.y + 0.5) / numTiles)); // Fudge the ratio slightly so that all tiles near the center have the same zoom level. tileScaleAdjustment = relativeScale > 0.85 ? 1 : relativeScale; } } const distanceSqr = dx * dx + dy * dy + dzSqr; const distToSplit = (1 << maxZoom - it.zoom) * zoomSplitDistance * tileScaleAdjustment; const distToSplitSqr = square(distToSplit * distToSplitScale(Math.max(dzSqr, cameraHeightSqr), distanceSqr)); return distanceSqr < distToSplitSqr; }; if (this.renderWorldCopies) { // Render copy of the globe thrice on both sides for (let i = 1; i <= NUM_WORLD_COPIES; i++) { stack.push(newRootTile(-i)); stack.push(newRootTile(i)); } } stack.push(newRootTile(0)); while (stack.length > 0) { const it = stack.pop(); const x = it.x; const y = it.y; let fullyVisible = it.fullyVisible; const isPoleNeighbourAndGlobeProjection = () => { return this.projection.name === 'globe' && (it.y === 0 || it.y === (1 << it.zoom) - 1); }; // Visibility of a tile is not required if any of its ancestor is fully inside the frustum if (!fullyVisible) {