diff --git a/src/data/load_geometry.ts b/src/data/load_geometry.ts index 199585bee6..415cb1b31a 100644 --- a/src/data/load_geometry.ts +++ b/src/data/load_geometry.ts @@ -26,7 +26,7 @@ export function loadGeometry(feature: VectorTileFeature): Array> { for (let p = 0; p < ring.length; p++) { const point = ring[p]; // round here because mapbox-gl-native uses integers to represent - // points and we need to do the same to avoid renering differences. + // points and we need to do the same to avoid rendering differences. const x = Math.round(point.x * scale); const y = Math.round(point.y * scale); diff --git a/src/geo/lng_lat.ts b/src/geo/lng_lat.ts index 28e3b4c7e7..a49a38f40f 100644 --- a/src/geo/lng_lat.ts +++ b/src/geo/lng_lat.ts @@ -51,7 +51,14 @@ export type LngLatLike = LngLat | { * @see [Create a timeline animation](https://maplibre.org/maplibre-gl-js/docs/examples/timeline-animation/) */ export class LngLat { + /** + * Longitude, measured in degrees. + */ lng: number; + + /** + * Latitude, measured in degrees. + */ lat: number; /** diff --git a/src/geo/projection/globe.test.ts b/src/geo/projection/globe.test.ts new file mode 100644 index 0000000000..ce0c76c7d1 --- /dev/null +++ b/src/geo/projection/globe.test.ts @@ -0,0 +1,123 @@ +import {mat4} from 'gl-matrix'; +import {GlobeProjection} from './globe'; +import {EXTENT} from '../../data/extent'; +import {Transform} from '../transform'; +import {expectToBeCloseToArray} from './mercator.test'; + +describe('GlobeProjection', () => { + describe('getProjectionData', () => { + const globe = new GlobeProjection(); + + test('fallback matrix is set', () => { + const mat = mat4.create(); + mat[0] = 1234; + const projectionData = globe.getProjectionData({ + x: 0, + y: 0, + z: 0 + }, mat); + expect(projectionData.u_projection_fallback_matrix).toEqual(mat); + }); + test('mercator tile extents are set', () => { + const mat = mat4.create(); + const projectionData = globe.getProjectionData({ + x: 1, + y: 0, + z: 1 + }, mat); + expectToBeCloseToArray(projectionData.u_projection_tile_mercator_coords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + }); + }); + + describe('clipping plane', () => { + const globe = new GlobeProjection(); + + describe('general plane properties', () => { + const mat = mat4.create(); + const transform = createMockTransform({ + pitchDegrees: 0, + }); + globe.updateProjection(transform); + const projectionData = globe.getProjectionData({ + x: 0, + y: 0, + z: 0 + }, mat); + + test('plane vector length', () => { + const len = Math.sqrt( + projectionData.u_projection_clipping_plane[0] * projectionData.u_projection_clipping_plane[0] + + projectionData.u_projection_clipping_plane[1] * projectionData.u_projection_clipping_plane[1] + + projectionData.u_projection_clipping_plane[2] * projectionData.u_projection_clipping_plane[2] + ); + expect(len).toBeCloseTo(0.25); + }); + + test('camera is in positive halfspace', () => { + expect(planeDistance((globe as any)._globeCameraPosition, projectionData.u_projection_clipping_plane)).toBeGreaterThan(0); + }); + + test('coordinates 0E,0N are in positive halfspace', () => { + expect(testPlaneAgainstLngLat(0, 0, projectionData.u_projection_clipping_plane)).toBeGreaterThan(0); + }); + + test('coordinates 40E,0N are in positive halfspace', () => { + expect(testPlaneAgainstLngLat(40, 0, projectionData.u_projection_clipping_plane)).toBeGreaterThan(0); + }); + + test('coordinates 0E,90N are in negative halfspace', () => { + expect(testPlaneAgainstLngLat(0, 90, projectionData.u_projection_clipping_plane)).toBeLessThan(0); + }); + + test('coordinates 90E,0N are in negative halfspace', () => { + expect(testPlaneAgainstLngLat(90, 0, projectionData.u_projection_clipping_plane)).toBeLessThan(0); + }); + + test('coordinates 180E,0N are in negative halfspace', () => { + expect(testPlaneAgainstLngLat(180, 0, projectionData.u_projection_clipping_plane)).toBeLessThan(0); + }); + }); + }); +}); + +function testPlaneAgainstLngLat(lngDegrees: number, latDegrees: number, plane: Array) { + const lat = latDegrees / 180.0 * Math.PI; + const lng = lngDegrees / 180.0 * Math.PI; + const len = Math.cos(lat); + const pointOnSphere = [ + Math.sin(lng) * len, + Math.sin(lat), + Math.cos(lng) * len + ]; + return planeDistance(pointOnSphere, plane); +} + +function planeDistance(point: Array, plane: Array) { + return point[0] * plane[0] + point[1] * plane[1] + point[2] * plane[2] + plane[3]; +} + +function createMockTransform(object: { + center?: { + latDegrees: number; + lngDegrees: number; + }; + pitchDegrees?: number; + angleDegrees?: number; +}): Transform { + const pitchDegrees = object.pitchDegrees ? object.pitchDegrees : 0; + return { + center: { + lat: object.center ? (object.center.latDegrees / 180.0 * Math.PI) : 0, + lng: object.center ? (object.center.lngDegrees / 180.0 * Math.PI) : 0, + }, + worldSize: 10.5 * 512, + _fov: Math.PI / 4.0, + width: 640, + height: 480, + cameraToCenterDistance: 759, + _pitch: pitchDegrees / 180.0 * Math.PI, // in radians + pitch: pitchDegrees, // in degrees + angle: object.angleDegrees ? (object.angleDegrees / 180.0 * Math.PI) : 0, + zoom: 0, + } as Transform; +} diff --git a/src/geo/projection/globe.ts b/src/geo/projection/globe.ts new file mode 100644 index 0000000000..9c99bd6e33 --- /dev/null +++ b/src/geo/projection/globe.ts @@ -0,0 +1,521 @@ +import {mat4, vec3, vec4} from 'gl-matrix'; +import {Context} from '../../gl/context'; +import {CanonicalTileID, UnwrappedTileID} from '../../source/tile_id'; +import {PosArray, TriangleIndexArray} from '../../data/array_types.g'; +import {Mesh} from '../../render/mesh'; +import {EXTENT} from '../../data/extent'; +import {SegmentVector} from '../../data/segment'; +import posAttributes from '../../data/pos_attributes'; +import {Transform} from '../transform'; +import {Tile} from '../../source/tile'; +import {browser} from '../../util/browser'; +import {easeCubicInOut, lerp} from '../../util/util'; +import {mercatorYfromLat} from '../mercator_coordinate'; +import {granularitySettings} from '../../render/subdivision'; +import Point from '@mapbox/point-geometry'; +import {ProjectionData} from '../../render/program/projection_program'; +import {Projection, ProjectionGPUContext} from './projection'; +import {PreparedShader, shaders} from '../../shaders/shaders'; +import {MercatorProjection, translatePosition} from './mercator'; +import {ProjectionErrorMeasurement} from './globe_projection_error_measurement'; + +/** + * The size of border region for stencil masks, in internal tile coordinates. + * Used for globe rendering. + */ +const EXTENT_STENCIL_BORDER = EXTENT / 128; + +const globeTransitionTimeSeconds = 0.5; +const zoomTransitionTimeSeconds = 0.5; +const maxGlobeZoom = 12.0; +const errorTransitionTimeSeconds = 0.5; + +export class GlobeProjection implements Projection { + private _mercator: MercatorProjection; + + private _tileMeshCache: {[_: string]: Mesh} = {}; + private _cachedClippingPlane: [number, number, number, number] = [1, 0, 0, 0]; + + // Transition handling + private _lastGlobeStateEnabled: boolean = true; + private _lastGlobeChangeTime: number = -1000.0; + private _lastLargeZoomStateChange: number = -1000.0; + private _lastLargeZoomState: boolean = false; + + /** + * 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 _skipNextAnimation: boolean = true; + + // GPU atan() error correction + private _errorMeasurement: ProjectionErrorMeasurement; + private _errorQueryLatitudeDegrees: number; + private _errorCorrectionUsable: number = 0.0; + private _errorMeasurementLastValue: number = 0.0; + private _errorCorrectionPreviousValue: number = 0.0; + private _errorMeasurementLastChangeTime: number = -1000.0; + + private _globeProjectionOverride = true; + + private _globeProjMatrix: mat4 = mat4.create(); + private _globeProjMatrixNoCorrection: mat4 = mat4.create(); + + private _globeCameraPosition: vec3 = [0, 0, 0]; + + get name(): string { + return 'globe'; + } + + /** + * 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._globeness > 0.0; + } + + get globeCameraPosition(): [number, number, number] { + return [this._globeCameraPosition[0], this._globeCameraPosition[1], this._globeCameraPosition[2]]; + } + + /** + * This property is true when wrapped tiles need to be rendered. + * This is false when globe rendering is used and no transition is happening. + */ + get drawWrappedTiles(): boolean { + return this._globeness < 1.0; + } + + get useSubdivision(): boolean { + return this.useGlobeRendering; + } + + get useSpecialProjectionForSymbols(): boolean { + return this.useGlobeRendering; + } + + get shaderVariantName(): string { + return this.useGlobeRendering ? 'globe' : this._mercator.shaderVariantName; + } + + get shaderDefine(): string { + return this.useGlobeRendering ? '#define GLOBE' : this._mercator.shaderDefine; + } + + get shaderPreludeCode(): PreparedShader { + return this.useGlobeRendering ? shaders.projectionGlobe : this._mercator.shaderPreludeCode; + } + + get vertexShaderPreludeCode(): string { + return shaders.projectionMercator.vertexSource; + } + + /** + * 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._globeProjectionOverride; + } + + /** + * 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. + * @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 (!animateTransition && allow !== this._globeProjectionOverride) { + this._skipNextAnimation = true; + } + this._globeProjectionOverride = allow; + } + + constructor() { + this._mercator = new MercatorProjection(); + } + + public destroy() { + if (this._errorMeasurement) { + this._errorMeasurement.destroy(); + } + } + + public updateGPUdependent(renderContext: ProjectionGPUContext): void { + if (!this._errorMeasurement) { + this._errorMeasurement = new ProjectionErrorMeasurement(renderContext); + } + const mercatorY = mercatorYfromLat(this._errorQueryLatitudeDegrees); + const expectedResult = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5; + const newValue = this._errorMeasurement.updateErrorLoop(mercatorY, expectedResult); + + const now = browser.now(); + + if (newValue !== this._errorMeasurementLastValue) { + this._errorCorrectionPreviousValue = this._errorCorrectionUsable; // store the interpolated value + this._errorMeasurementLastValue = newValue; + this._errorMeasurementLastChangeTime = now; + } + + const sinceUpdateSeconds = (now - this._errorMeasurementLastChangeTime) / 1000.0; + const mix = Math.min(Math.max(sinceUpdateSeconds / errorTransitionTimeSeconds, 0.0), 1.0); + const newCorrection = -this._errorMeasurementLastValue; // Note the negation + this._errorCorrectionUsable = lerp(this._errorCorrectionPreviousValue, newCorrection, easeCubicInOut(mix)); + } + + public updateProjection(transform: Transform): void { + this._errorQueryLatitudeDegrees = transform.center.lat; + this._updateAnimation(transform); + + // We want zoom levels to be consistent between globe and flat views. + // This means that the pixel size of features at the map center point + // should be the same for both globe and flat view. + const globeRadiusPixels = transform.worldSize / (2.0 * Math.PI) / Math.cos(transform.center.lat * Math.PI / 180); + + // Construct a completely separate matrix for globe view + const globeMatrix = new Float64Array(16) as any; + const globeMatrixUncorrected = new Float64Array(16) as any; + mat4.perspective(globeMatrix, transform._fov, transform.width / transform.height, 0.5, transform.cameraToCenterDistance + globeRadiusPixels * 2.0); // just set the far plane far enough - we will calculate our own z in the vertex shader anyway + mat4.translate(globeMatrix, globeMatrix, [0, 0, -transform.cameraToCenterDistance]); + mat4.rotateX(globeMatrix, globeMatrix, -transform._pitch); + mat4.rotateZ(globeMatrix, globeMatrix, -transform.angle); + mat4.translate(globeMatrix, globeMatrix, [0.0, 0, -globeRadiusPixels]); + // Rotate the sphere to center it on viewed coordinates + + // Keep a atan-correction-free matrix for transformations done on the CPU with accurate math + mat4.rotateX(globeMatrixUncorrected, globeMatrix, transform.center.lat * Math.PI / 180.0); + mat4.rotateY(globeMatrixUncorrected, globeMatrixUncorrected, -transform.center.lng * Math.PI / 180.0); + mat4.scale(globeMatrixUncorrected, globeMatrixUncorrected, [globeRadiusPixels, globeRadiusPixels, globeRadiusPixels]); // Scale the unit sphere to a sphere with diameter of 1 + this._globeProjMatrixNoCorrection = globeMatrix; + + mat4.rotateX(globeMatrix, globeMatrix, transform.center.lat * Math.PI / 180.0 - this._errorCorrectionUsable); + mat4.rotateY(globeMatrix, globeMatrix, -transform.center.lng * Math.PI / 180.0); + mat4.scale(globeMatrix, globeMatrix, [globeRadiusPixels, globeRadiusPixels, globeRadiusPixels]); // Scale the unit sphere to a sphere with diameter of 1 + this._globeProjMatrix = globeMatrix; + + const invProj = mat4.create(); + mat4.invert(invProj, globeMatrix); + + const cameraPos: vec4 = [0, 0, -1, 1]; + vec4.transformMat4(cameraPos, cameraPos, invProj); + this._globeCameraPosition = [ + cameraPos[0] / cameraPos[3], + cameraPos[1] / cameraPos[3], + cameraPos[2] / cameraPos[3] + ]; + + this._cachedClippingPlane = this._computeClippingPlane(transform, globeRadiusPixels); + } + + public getProjectionData(canonicalTileCoords: {x: number; y: number; z: number}, tilePosMatrix: mat4, useAtanCorrection: boolean = true): ProjectionData { + const data = this._mercator.getProjectionData(canonicalTileCoords, tilePosMatrix); + + // Set 'u_projection_matrix' to actual globe transform + if (this.useGlobeRendering) { + data['u_projection_matrix'] = useAtanCorrection ? this._globeProjMatrix : this._globeProjMatrixNoCorrection; + } + + data['u_projection_clipping_plane'] = [...this._cachedClippingPlane]; + data['u_projection_transition'] = this._globeness; + + return data; + } + + public isRenderingDirty(): boolean { + const now = browser.now(); + let dirty = false; + // Globe transition + dirty = dirty || (now - this._lastGlobeChangeTime) / 1000.0 < (Math.max(globeTransitionTimeSeconds, zoomTransitionTimeSeconds) + 0.2); + // Error correction transition + dirty = dirty || (now - this._errorMeasurementLastChangeTime) / 1000.0 < (errorTransitionTimeSeconds + 0.2); + // Error correction query in flight + dirty = dirty || this._errorMeasurement.awaitingQuery; + return dirty; + } + + private _computeClippingPlane(transform: Transform, globeRadiusPixels: number): [number, number, number, number] { + // 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._pitch 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 = transform.pitch * Math.PI / 180.0; + // scale things so that the globe radius is 1 + const distanceCameraToB = transform.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], transform.angle); + vec3.rotateX(planeVector, planeVector, [0, 0, 0], -1 * transform.center.lat * Math.PI / 180.0); + vec3.rotateY(planeVector, planeVector, [0, 0, 0], transform.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]; + } + + private _projectToSphere(mercatorX: number, mercatorY: number): vec3 { + const sphericalX = mercatorX * Math.PI * 2.0 + Math.PI; + const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5; + + const len = Math.cos(sphericalY); + return [ + Math.sin(sphericalX) * len, + Math.sin(sphericalY), + Math.cos(sphericalX) * len + ]; + } + + private _projectToSphereTile(inTileX: number, inTileY: number, unwrappedTileID: UnwrappedTileID): vec3 { + const scale = 1.0 / (1 << unwrappedTileID.canonical.z); + return this._projectToSphere( + inTileX / EXTENT * scale + unwrappedTileID.canonical.x * scale, + inTileY / EXTENT * scale + unwrappedTileID.canonical.y * scale + ); + } + + public isOccluded(x: number, y: number, unwrappedTileID: UnwrappedTileID): boolean { + const spherePos = this._projectToSphereTile(x, y, unwrappedTileID); + + 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]; + return dotResult < 0.0; + } + + public project(x: number, y: number, unwrappedTileID: UnwrappedTileID) { + const spherePos = this._projectToSphereTile(x, y, unwrappedTileID); + const pos: vec4 = [spherePos[0], spherePos[1], spherePos[2], 1]; + vec4.transformMat4(pos, pos, this._globeProjMatrixNoCorrection); + + // 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 + }; + } + + public transformLightDirection(transform: Transform, dir: vec3): vec3 { + const sphereX = transform.center.lng * Math.PI / 180.0; + const sphereY = transform.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(transform: Transform): number { + const globePixelScale = 1.0 / Math.cos(transform.center.lat * Math.PI / 180); + const flatPixelScale = 1.0; + if (this.useGlobeRendering) { + return lerp(flatPixelScale, globePixelScale, this._globeness); + } + return flatPixelScale; + } + + private _updateAnimation(transform: Transform) { + // Update globe transition animation + const globeState = this._globeProjectionOverride; + const currentTime = browser.now(); + if (globeState !== this._lastGlobeStateEnabled) { + this._lastGlobeChangeTime = currentTime; + this._lastGlobeStateEnabled = globeState; + } + // Transition parameter, where 0 is the start and 1 is end. + const globeTransition = Math.min(Math.max((currentTime - this._lastGlobeChangeTime) / 1000.0 / globeTransitionTimeSeconds, 0.0), 1.0); + this._globeness = globeState ? globeTransition : (1.0 - globeTransition); + + if (this._skipNextAnimation) { + this._globeness = globeState ? 1.0 : 0.0; + this._lastGlobeChangeTime = currentTime - globeTransitionTimeSeconds * 1000.0 * 2.0; + this._skipNextAnimation = false; + } + + // Update globe zoom transition + const currentZoomState = transform.zoom >= maxGlobeZoom; + if (currentZoomState !== this._lastLargeZoomState) { + this._lastLargeZoomState = currentZoomState; + this._lastLargeZoomStateChange = currentTime; + } + const zoomTransition = Math.min(Math.max((currentTime - this._lastLargeZoomStateChange) / 1000.0 / zoomTransitionTimeSeconds, 0.0), 1.0); + const zoomGlobenessBound = currentZoomState ? (1.0 - zoomTransition) : zoomTransition; + this._globeness = Math.min(this._globeness, zoomGlobenessBound); + this._globeness = easeCubicInOut(this._globeness); // Smooth animation + } + + private _getMeshKey(granularity: number, border: boolean, north: boolean, south: boolean): string { + return `${granularity.toString(36)}_${border ? 'b' : ''}${north ? 'n' : ''}${south ? 's' : ''}`; + } + + public getMeshFromTileID(context: Context, canonical: CanonicalTileID, hasBorder: boolean): Mesh { + const granularity = granularitySettings.fill.getGranularityForZoomLevel(canonical.z); + const north = (canonical.y === 0); + const south = (canonical.y === (1 << canonical.z) - 1); + return this.getMesh(context, granularity, hasBorder, north, south); + } + + public getMesh(context: Context, granularity: number, hasBorder: boolean, hasNorthEdge: boolean, hasSouthEdge: boolean): Mesh { + const key = this._getMeshKey(granularity, hasBorder, hasNorthEdge, hasSouthEdge); + + if (key in this._tileMeshCache) { + return this._tileMeshCache[key]; + } + + const mesh = this._createQuadMesh(context, granularity, hasBorder, hasNorthEdge, hasSouthEdge); + this._tileMeshCache[key] = mesh; + return mesh; + } + + public translatePosition(transform: Transform, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport'): [number, number] { + // In the future, some better translation for globe and other weird projections should be implemented here, + // especially for the translateAnchor==='viewport' case. + return translatePosition(transform, tile, translate, translateAnchor); + } + + /** + * Creates a quad mesh covering positions in range 0..EXTENT, for tile clipping. + * @param context - MapLibre's rendering context object. + * @param granularity - Mesh triangulation granularity: 1 for just a single quad, 3 for 3x3 quads. + * @returns + */ + private _createQuadMesh(context: Context, granularity: number, border: boolean, north: boolean, south: boolean): Mesh { + const vertexArray = new PosArray(); + const indexArray = new TriangleIndexArray(); + + // We only want to generate the north/south border if the tile + // does NOT border the north/south edge of the mercator range. + + const quadsPerAxisX = granularity + (border ? 2 : 0); // two extra quads for border + const quadsPerAxisY = granularity + ((north || border) ? 1 : 0) + (south || border ? 1 : 0); + const verticesPerAxisX = quadsPerAxisX + 1; // one more vertex than quads + //const verticesPerAxisY = quadsPerAxisY + 1; // one more vertex than quads + const offsetX = border ? -1 : 0; + const offsetY = (border || north) ? -1 : 0; + const endX = granularity + (border ? 1 : 0); + const endY = granularity + ((border || south) ? 1 : 0); + + const northY = -32768; + const southY = 32767; + + for (let y = offsetY; y <= endY; y++) { + for (let x = offsetX; x <= endX; x++) { + let vx = x / granularity * EXTENT; + if (x === -1) { + vx = -EXTENT_STENCIL_BORDER; + } + if (x === granularity + 1) { + vx = EXTENT + EXTENT_STENCIL_BORDER; + } + let vy = y / granularity * EXTENT; + if (y === -1) { + vy = north ? northY : (-EXTENT_STENCIL_BORDER); + } + if (y === granularity + 1) { + vy = south ? southY : EXTENT + EXTENT_STENCIL_BORDER; + } + vertexArray.emplaceBack(vx, vy); + } + } + + for (let y = 0; y < quadsPerAxisY; y++) { + for (let x = 0; x < quadsPerAxisX; x++) { + const v0 = x + y * verticesPerAxisX; + const v1 = (x + 1) + y * verticesPerAxisX; + const v2 = x + (y + 1) * verticesPerAxisX; + const v3 = (x + 1) + (y + 1) * verticesPerAxisX; + // v0----v1 + // | / | + // | / | + // v2----v3 + indexArray.emplaceBack(v0, v2, v1); + indexArray.emplaceBack(v1, v2, v3); + } + } + + const mesh = new Mesh( + context.createVertexBuffer(vertexArray, posAttributes.members), + context.createIndexBuffer(indexArray), + SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length) + ); + + return mesh; + } +} diff --git a/src/geo/projection/globe_projection_error_measurement.ts b/src/geo/projection/globe_projection_error_measurement.ts new file mode 100644 index 0000000000..ec5ab547e0 --- /dev/null +++ b/src/geo/projection/globe_projection_error_measurement.ts @@ -0,0 +1,243 @@ +import {Color} from '@maplibre/maplibre-gl-style-spec'; +import {ColorMode} from '../../gl/color_mode'; +import {CullFaceMode} from '../../gl/cull_face_mode'; +import {DepthMode} from '../../gl/depth_mode'; +import {StencilMode} from '../../gl/stencil_mode'; +import {warnOnce} from '../../util/util'; +import {projectionErrorMeasurementUniformValues} from '../../render/program/projection_error_measurement_program'; +import {Mesh} from '../../render/mesh'; +import {SegmentVector} from '../../data/segment'; +import {PosArray, TriangleIndexArray} from '../../data/array_types.g'; +import posAttributes from '../../data/pos_attributes'; +import {Framebuffer} from '../../gl/framebuffer'; +import {isWebGL2} from '../../gl/webgl2'; +import {ProjectionGPUContext} from './projection'; + +/** + * For vector globe the vertex shader projects mercator coordinates to angular coordinates on a sphere. + * This projection requires some inverse trigonometry `atan(exp(...))`, which is inaccurate on some GPUs (mainly on AMD and Nvidia). + * The inaccuracy is severe enough to require a workaround. The uncorrected map is shifted north-south by up to several hundred meters in some latitudes. + * Since the inaccuracy is hardware-dependant and may change in the future, we need to measure the error at runtime. + * + * Our approach relies on several assumptions: + * + * - the error is only present in the "latitude" component (longitude doesn't need any inverse trigonometry) + * - the error is continuous and changes slowly with latitude + * - at zoom levels where the error is noticeable, the error is more-or-less the same across the entire visible map area (and thus can be described with a single number) + * + * Solution: + * + * Every few frames, launch a GPU shader that measures the error for the current map center latitude, and writes it to a 1x1 texture. + * Read back that texture, and offset the globe projection matrix according to the error (interpolating smoothly from old error to new error if needed). + * The texture readback is done asynchronously using Pixel Pack Buffers (WebGL2) when possible, and has a few frames of latency, but that should not be a problem. + * + * General operation of this class each frame is: + * + * - render the error shader into a fbo, read that pixel into a PBO, place a fence + * - wait a few frames to allow the GPU (and driver) to actually execute the shader + * - wait for the fence to be signalled (guaranteeing the shader to actually be executed) + * - read back the PBO's contents + * - wait a few more frames + * - repeat + */ +export class ProjectionErrorMeasurement { + // We wait at least this many frames after measuring until we read back the value. + // After this period, we might wait more frames until a fence is signalled to make sure the rendering is completed. + private readonly _readbackWaitFrames = 4; + // We wait this many frames after *reading back* a measurement until we trigger measure again. + // We could in theory render the measurement pixel immediately, but we wait to make sure + // no pipeline stall happens. + private readonly _measureWaitFrames = 6; + private readonly _texWidth = 1; + private readonly _texHeight = 1; + private readonly _texFormat: number; + private readonly _texType: number; + + private _fullscreenTriangle: Mesh; + private _fbo: Framebuffer; + private _resultBuffer: Uint8Array; + private _pbo: WebGLBuffer; + private _cachedRenderContext: ProjectionGPUContext; + + private _measuredError: number = 0; // Result of last measurement + private _updateCount: number = 0; + private _lastReadbackFrame: number = -1000; + + get awaitingQuery(): boolean { + return !!this._readbackQueue; + } + + // There is never more than one readback waiting + private _readbackQueue: { + frameNumberIssued: number; // Frame number when the data was first computed + sync: WebGLSync; + } = null; + + public constructor(renderContext: ProjectionGPUContext) { + this._cachedRenderContext = renderContext; + + const context = renderContext.context; + const gl = context.gl; + + this._texFormat = gl.RGBA; + this._texType = gl.UNSIGNED_BYTE; + + const vertexArray = new PosArray(); + vertexArray.emplaceBack(-1, -1); + vertexArray.emplaceBack(2, -1); + vertexArray.emplaceBack(-1, 2); + const indexArray = new TriangleIndexArray(); + indexArray.emplaceBack(0, 1, 2); + + this._fullscreenTriangle = new Mesh( + context.createVertexBuffer(vertexArray, posAttributes.members), + context.createIndexBuffer(indexArray), + SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length) + ); + + this._resultBuffer = new Uint8Array(4); + + context.activeTexture.set(gl.TEXTURE1); + + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texImage2D(gl.TEXTURE_2D, 0, this._texFormat, this._texWidth, this._texHeight, 0, this._texFormat, this._texType, null); + + this._fbo = context.createFramebuffer(this._texWidth, this._texHeight, false, false); + this._fbo.colorAttachment.set(texture); + + if (isWebGL2(gl)) { + this._pbo = gl.createBuffer(); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo); + gl.bufferData(gl.PIXEL_PACK_BUFFER, 4, gl.STREAM_READ); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + } + } + + public destroy() { + const gl = this._cachedRenderContext.context.gl; + this._fullscreenTriangle.destroy(); + this._fbo.destroy(); + gl.deleteBuffer(this._pbo); + this._fullscreenTriangle = null; + this._fbo = null; + this._pbo = null; + this._resultBuffer = null; + } + + public updateErrorLoop(normalizedMercatorY: number, expectedAngleY: number): number { + const currentFrame = this._updateCount; + + if (this._readbackQueue) { + // Try to read back if enough frames elapsed. Otherwise do nothing, just wait another frame. + if (currentFrame >= this._readbackQueue.frameNumberIssued + this._readbackWaitFrames) { + // Try to read back - it is possible that this method does nothing, then + // the readback queue will not be cleared and we will retry next frame. + this._tryReadback(); + } + } else { + if (currentFrame >= this._lastReadbackFrame + this._measureWaitFrames) { + this._renderErrorTexture(normalizedMercatorY, expectedAngleY); + } + } + + this._updateCount++; + return this._measuredError; + } + + private _bindFramebuffer() { + const context = this._cachedRenderContext.context; + const gl = context.gl; + context.activeTexture.set(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this._fbo.colorAttachment.get()); + context.bindFramebuffer.set(this._fbo.framebuffer); + } + + private _renderErrorTexture(input: number, outputExpected: number): void { + const context = this._cachedRenderContext.context; + const gl = context.gl; + + // Update framebuffer contents + this._bindFramebuffer(); + context.viewport.set([0, 0, this._texWidth, this._texHeight]); + context.clear({color: Color.transparent}); + + const program = this._cachedRenderContext.useProgram('projectionErrorMeasurement'); + + program.draw(context, gl.TRIANGLES, + DepthMode.disabled, StencilMode.disabled, + ColorMode.unblended, CullFaceMode.disabled, + projectionErrorMeasurementUniformValues(input, outputExpected), null, null, + '$clipping', this._fullscreenTriangle.vertexBuffer, this._fullscreenTriangle.indexBuffer, + this._fullscreenTriangle.segments); + + if (this._pbo && isWebGL2(gl)) { + // Read back into PBO + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo); + gl.readBuffer(gl.COLOR_ATTACHMENT0); + gl.readPixels(0, 0, this._texWidth, this._texHeight, this._texFormat, this._texType, 0); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + gl.flush(); + + this._readbackQueue = { + frameNumberIssued: this._updateCount, + sync, + }; + } else { + // Read it back later. + this._readbackQueue = { + frameNumberIssued: this._updateCount, + sync: null, + }; + } + } + + private _tryReadback(): void { + const gl = this._cachedRenderContext.context.gl; + + if (this._pbo && this._readbackQueue && isWebGL2(gl)) { + // WebGL 2 path + const waitResult = gl.clientWaitSync(this._readbackQueue.sync, 0, 0); + + if (waitResult === gl.WAIT_FAILED) { + warnOnce('WebGL2 clientWaitSync failed.'); + this._readbackQueue = null; + this._lastReadbackFrame = this._updateCount; + return; + } + + if (waitResult === gl.TIMEOUT_EXPIRED) { + return; // Wait one more frame + } + + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this._resultBuffer, 0, 4); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + } else { + // WebGL1 compatible + this._bindFramebuffer(); + gl.readPixels(0, 0, this._texWidth, this._texHeight, this._texFormat, this._texType, this._resultBuffer); + } + + // If we made it here, _resultBuffer contains the new measurement + this._readbackQueue = null; + this._measuredError = ProjectionErrorMeasurement._parseRGBA8float(this._resultBuffer); + this._lastReadbackFrame = this._updateCount; + } + + private static _parseRGBA8float(buffer: Uint8Array): number { + let result = 0; + result += buffer[0] / 256.0; + result += buffer[1] / 65536.0; + result += buffer[2] / 16777216.0; + if (buffer[3] < 127.0) { + result = -result; + } + return result / 128.0; + } +} diff --git a/src/geo/projection/mercator.test.ts b/src/geo/projection/mercator.test.ts new file mode 100644 index 0000000000..910ab09e8b --- /dev/null +++ b/src/geo/projection/mercator.test.ts @@ -0,0 +1,62 @@ +import {mat4} from 'gl-matrix'; +import {ProjectionData} from '../../render/program/projection_program'; +import {EXTENT} from '../../data/extent'; +import {MercatorProjection} from './mercator'; + +describe('MercatorProjection', () => { + describe('getProjectionData', () => { + const mercator = new MercatorProjection(); + + test('fallback matrix is set', () => { + const mat = mat4.create(); + mat[0] = 1234; + const projectionData = mercator.getProjectionData({ + x: 0, + y: 0, + z: 0 + }, mat); + expect(projectionData.u_projection_fallback_matrix).toEqual(mat); + }); + test('mercator tile extents are set', () => { + const mat = mat4.create(); + let projectionData: ProjectionData; + + projectionData = mercator.getProjectionData({ + x: 0, + y: 0, + z: 0 + }, mat); + expectToBeCloseToArray(projectionData.u_projection_tile_mercator_coords, [0, 0, 1 / EXTENT, 1 / EXTENT]); + + projectionData = mercator.getProjectionData({ + x: 0, + y: 0, + z: 1 + }, mat); + expectToBeCloseToArray(projectionData.u_projection_tile_mercator_coords, [0, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + + projectionData = mercator.getProjectionData({ + x: 1, + y: 0, + z: 1 + }, mat); + expectToBeCloseToArray(projectionData.u_projection_tile_mercator_coords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + }); + test('mercator tile extents are set for negative zoom', () => { + const mat = mat4.create(); + const projectionData = mercator.getProjectionData({ + x: 0, + y: 0, + z: -2 + }, mat); + expectToBeCloseToArray(projectionData.u_projection_tile_mercator_coords, [0, 0, 1 / EXTENT, 1 / EXTENT]); // same as for zoom=0, as it gets clamped + }); + }); +}); + +export function expectToBeCloseToArray(actual: Array, expected: Array) { + expect(actual).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(actual[i]).toBeCloseTo(expected[i]); + } +} diff --git a/src/geo/projection/mercator.ts b/src/geo/projection/mercator.ts new file mode 100644 index 0000000000..31bbc0e6c6 --- /dev/null +++ b/src/geo/projection/mercator.ts @@ -0,0 +1,197 @@ +import {mat4} from 'gl-matrix'; +import {Transform} from '../transform'; +import {Projection, ProjectionGPUContext} from './projection'; +import {CanonicalTileID, UnwrappedTileID} from '../../source/tile_id'; +import Point from '@mapbox/point-geometry'; +import {Tile} from '../../source/tile'; +import {ProjectionData} from '../../render/program/projection_program'; +import {pixelsToTileUnits} from '../../source/pixels_to_tile_units'; +import {EXTENT} from '../../data/extent'; +import {PreparedShader, shaders} from '../../shaders/shaders'; +import {Context} from '../../gl/context'; +import {Mesh} from '../../render/mesh'; +import {PosArray, TriangleIndexArray} from '../../data/array_types.g'; +import {SegmentVector} from '../../data/segment'; +import posAttributes from '../../data/pos_attributes'; + +export const MercatorShaderDefine = '#define PROJECTION_MERCATOR'; +export const MercatorShaderVariantKey = 'mercator'; + +export class MercatorProjection implements Projection { + private _cachedMesh: Mesh = null; + + get name(): string { + return 'mercator'; + } + + get useSpecialProjectionForSymbols(): boolean { + return false; + } + + get drawWrappedTiles(): boolean { + // Mercator always needs to draw wrapped/duplicated tiles. + return true; + } + + get useSubdivision(): boolean { + // Mercator never uses subdivision. + return false; + } + + get shaderVariantName(): string { + return MercatorShaderVariantKey; + } + + get shaderDefine(): string { + return MercatorShaderDefine; + } + + get shaderPreludeCode(): PreparedShader { + return shaders.projectionMercator; + } + + get vertexShaderPreludeCode(): string { + return shaders.projectionMercator.vertexSource; + } + + public isRenderingDirty(): boolean { + // Mercator projection does no animations of its own, so rendering is never dirty from its perspective. + return false; + } + + destroy(): void { + // Do nothing. + } + + updateGPUdependent(_: ProjectionGPUContext): void { + // Do nothing. + } + + updateProjection(_: Transform): void { + // Do nothing. + } + + getProjectionData(canonicalTileCoords: {x: number; y: number; z: number}, tilePosMatrix: mat4): ProjectionData { + let tileOffsetSize: [number, number, number, number]; + + if (canonicalTileCoords) { + const scale = (canonicalTileCoords.z >= 0) ? (1 << canonicalTileCoords.z) : Math.pow(2.0, canonicalTileCoords.z); + tileOffsetSize = [ + canonicalTileCoords.x / scale, + canonicalTileCoords.y / scale, + 1.0 / scale / EXTENT, + 1.0 / scale / EXTENT + ]; + } else { + tileOffsetSize = [0, 0, 1, 1]; + } + const mainMatrix = tilePosMatrix ? tilePosMatrix : mat4.create(); + + const data: ProjectionData = { + 'u_projection_matrix': mainMatrix, // Might be set to a custom matrix by different projections + 'u_projection_tile_mercator_coords': tileOffsetSize, + 'u_projection_clipping_plane': [0, 0, 0, 0], + 'u_projection_transition': 0.0, + 'u_projection_fallback_matrix': mainMatrix, + }; + + return data; + } + + isOccluded(_: number, __: number, ___: UnwrappedTileID): boolean { + return false; + } + + project(_x: number, _y: number, _unwrappedTileID: UnwrappedTileID): { + point: Point; + signedDistanceFromCamera: number; + isOccluded: boolean; + } { + // This function should only be used when useSpecialProjectionForSymbols is set to true. + throw new Error('Not implemented.'); + } + + getPixelScale(_: Transform): number { + return 1.0; + } + + translatePosition(transform: Transform, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport'): [number, number] { + return translatePosition(transform, tile, translate, translateAnchor); + } + + getMeshFromTileID(context: Context, _: CanonicalTileID, _hasBorder: boolean): Mesh { + if (this._cachedMesh) { + return this._cachedMesh; + } + + // Both poles/canonicalTileID and borders are ignored for mercator meshes on purpose. + + const tileExtentArray = new PosArray(); + tileExtentArray.emplaceBack(0, 0); + tileExtentArray.emplaceBack(EXTENT, 0); + tileExtentArray.emplaceBack(0, EXTENT); + tileExtentArray.emplaceBack(EXTENT, EXTENT); + const tileExtentBuffer = context.createVertexBuffer(tileExtentArray, posAttributes.members); + const tileExtentSegments = SegmentVector.simpleSegment(0, 0, 4, 2); + + const quadTriangleIndices = new TriangleIndexArray(); + quadTriangleIndices.emplaceBack(1, 0, 2); + quadTriangleIndices.emplaceBack(1, 2, 3); + const quadTriangleIndexBuffer = context.createIndexBuffer(quadTriangleIndices); + + this._cachedMesh = new Mesh(tileExtentBuffer, quadTriangleIndexBuffer, tileExtentSegments); + return this._cachedMesh; + } +} + +/** + * Transform a matrix to incorporate the *-translate and *-translate-anchor properties into it. + * @param inViewportPixelUnitsUnits - True when the units accepted by the matrix are in viewport pixels instead of tile units. + * @returns matrix + */ +export function translatePosMatrix( + transform: Transform, + tile: Tile, + matrix: mat4, + translate: [number, number], + translateAnchor: 'map' | 'viewport', + inViewportPixelUnitsUnits: boolean = false +): mat4 { + if (!translate[0] && !translate[1]) return matrix; + + const translation = translatePosition(transform, tile, translate, translateAnchor, inViewportPixelUnitsUnits); + const translatedMatrix = new Float32Array(16); + mat4.translate(translatedMatrix, matrix, [translation[0], translation[1], 0]); + return translatedMatrix; +} + +/** + * Returns a translation in tile units that correctly incorporates the view angle and the *-translate and *-translate-anchor properties. + * @param inViewportPixelUnitsUnits - True when the units accepted by the matrix are in viewport pixels instead of tile units. + */ +export function translatePosition( + transform: Transform, + tile: Tile, + translate: [number, number], + translateAnchor: 'map' | 'viewport', + inViewportPixelUnitsUnits: boolean = false +): [number, number] { + if (!translate[0] && !translate[1]) return [0, 0]; + + const angle = inViewportPixelUnitsUnits ? + (translateAnchor === 'map' ? transform.angle : 0) : + (translateAnchor === 'viewport' ? -transform.angle : 0); + + if (angle) { + const sinA = Math.sin(angle); + const cosA = Math.cos(angle); + translate = [ + translate[0] * cosA - translate[1] * sinA, + translate[0] * sinA + translate[1] * cosA + ]; + } + + return [ + inViewportPixelUnitsUnits ? translate[0] : pixelsToTileUnits(tile, translate[0], transform.zoom), + inViewportPixelUnitsUnits ? translate[1] : pixelsToTileUnits(tile, translate[1], transform.zoom)]; +} diff --git a/src/geo/projection/projection.ts b/src/geo/projection/projection.ts new file mode 100644 index 0000000000..fe372c02a4 --- /dev/null +++ b/src/geo/projection/projection.ts @@ -0,0 +1,144 @@ +import {mat4} from 'gl-matrix'; +import {Tile} from '../../source/tile'; +import {CanonicalTileID, UnwrappedTileID} from '../../source/tile_id'; +import {Transform} from '../transform'; +import Point from '@mapbox/point-geometry'; +import {ProjectionData} from '../../render/program/projection_program'; +import {PreparedShader} from '../../shaders/shaders'; +import {Context} from '../../gl/context'; +import {Mesh} from '../../render/mesh'; +import {Program} from '../../render/program'; + +export type ProjectionGPUContext = { + context: Context; + useProgram: (name: string) => Program; +}; + +/** + * An abstract class the specializations of which are used internally by MapLibre to handle different projections. + */ +export interface Projection { + /** + * @internal + * A short, descriptive name of this projection, such as 'mercator' or 'globe'. + */ + get name(): string; + + /** + * @internal + * True if symbols should use the `project` method of the current ProjectionBase class + * instead of the default (and fast) mercator projection path. + */ + get useSpecialProjectionForSymbols(): boolean; + + /** + * @internal + * True if this projection requires wrapped copies of the world to be drawn. + */ + get drawWrappedTiles(): boolean; + + /** + * @internal + * True if this projection needs to render subdivided geometry. + * Optimized rendering paths for non-subdivided geometry might be used throughout MapLibre. + * The value of this property may change during runtime, for example in globe projection depending on zoom. + */ + get useSubdivision(): boolean; + + /** + * Name of the shader projection variant that should be used for this projection. + * Note that this value may change dynamically, for example when globe projection internally transitions to mercator. + * Then globe projection might start reporting the mercator shader variant name to make MapLibre use faster mercator shaders. + */ + get shaderVariantName(): string; + + /** + * A `#define` macro that is injected into every MapLibre shader that uses this projection. + * @example + * `const define = projection.shaderDefine; // '#define GLOBE'` + */ + get shaderDefine(): string; + + /** + * @internal + * A preprocessed prelude code for both vertex and fragment shaders. + */ + get shaderPreludeCode(): PreparedShader; + + /** + * Vertex shader code that is injected into every MapLibre vertex shader that uses this projection. + */ + get vertexShaderPreludeCode(): string; + + /** + * @internal + * True when an animation handled by the projection is in progress, + * requiring MapLibre to keep rendering new frames. + */ + isRenderingDirty(): boolean; + + /** + * @internal + * Cleans up any resources the projection created, especially GPU buffers. + */ + destroy(): void; + + /** + * @internal + * Runs any GPU-side tasks this projection required. Called at the beginning of every frame. + */ + updateGPUdependent(renderContext: ProjectionGPUContext): void; + + /** + * @internal + * Updates the projection for current transform, such as recomputing internal matrices. + * May change the value of `isRenderingDirty`. + */ + updateProjection(transform: Transform): void; + + /** + * @internal + * Generates a `ProjectionData` instance to be used while rendering the supplied tile. + */ + getProjectionData(canonicalTileCoords: {x: number; y: number; z: number}, tilePosMatrix: mat4): ProjectionData; + + /** + * @internal + * Returns whether the supplied location is occluded in this projection. + * For example during globe rendering a location on the backfacing side of the globe is occluded. + * @param x - Tile space coordinate in range 0..EXTENT. + * @param y - Tile space coordinate in range 0..EXTENT. + * @param unwrappedTileID - TileID of the tile the supplied coordinates belong to. + */ + isOccluded(x: number, y: number, unwrappedTileID: UnwrappedTileID): boolean; + + /** + * @internal + * Projects a point in tile coordinates. Used in symbol rendering. + */ + project(x: number, y: number, unwrappedTileID: UnwrappedTileID): { + point: Point; + signedDistanceFromCamera: number; + isOccluded: boolean; + }; + + /** + * @internal + */ + getPixelScale(transform: Transform): number; + + /** + * @internal + * Returns a translation in tile units that correctly incorporates the view angle and the *-translate and *-translate-anchor properties. + */ + translatePosition(transform: Transform, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport'): [number, number]; + + /** + * @internal + * Returns a subdivided mesh for a given canonical tile ID, covering 0..EXTENT range. + * @param context - WebGL context. + * @param canonical - The tile coordinates for which to return a mesh. Meshes for tiles that border the top/bottom mercator edge might include extra geometry for the north/south pole. + * @param hasBorder - When true, the mesh will also include a small border beyond the 0..EXTENT range. + */ + getMeshFromTileID(context: Context, canonical: CanonicalTileID, hasBorder: boolean): Mesh; +} diff --git a/src/geo/projection/projection_factory.ts b/src/geo/projection/projection_factory.ts new file mode 100644 index 0000000000..e2f9bfd850 --- /dev/null +++ b/src/geo/projection/projection_factory.ts @@ -0,0 +1,24 @@ +import {warnOnce} from '../../util/util'; +import {GlobeProjection} from './globe'; +import {MercatorProjection} from './mercator'; +import {Projection} from './projection'; + +/** + * Name of MapLibre's map projection. Can be: + * + * - `mercator` - A classic Web Mercator 2D map + * - 'globe' - A 3D spherical view of the planet when zoomed out, transitioning seamlessly to Web Mercator at high zoom levels. + */ +export type ProjectionName = 'mercator' | 'globe'; + +export function createProjectionFromName(name: ProjectionName): Projection { + switch (name) { + case 'mercator': + return new MercatorProjection(); + case 'globe': + return new GlobeProjection(); + default: + warnOnce(`Unknown projection name: ${name}. Falling back to mercator projection.`); + return new MercatorProjection(); + } +} diff --git a/src/geo/transform.ts b/src/geo/transform.ts index cfa1120cd5..a49eddeb64 100644 --- a/src/geo/transform.ts +++ b/src/geo/transform.ts @@ -27,9 +27,19 @@ export class Transform { scale: number; width: number; height: number; + + /** + * This transform's bearing in radians. + */ angle: number; rotationMatrix: mat2; pixelsToGLUnits: [number, number]; + + /** + * Distance from camera origin to view plane, in pixels. + * Calculated using vertical fov and viewport height. + * Center is considered to be in the middle of the viewport. + */ cameraToCenterDistance: number; mercatorMatrix: mat4; projMatrix: mat4; @@ -41,7 +51,12 @@ export class Transform { glCoordMatrix: mat4; labelPlaneMatrix: mat4; minElevationForCurrentTile: number; + + /** + * Vertical field of view in radians. + */ _fov: number; + _pitch: number; _zoom: number; _unmodified: boolean; diff --git a/src/gl/cull_face_mode.ts b/src/gl/cull_face_mode.ts index 4bd679a2bf..d4691c7e02 100644 --- a/src/gl/cull_face_mode.ts +++ b/src/gl/cull_face_mode.ts @@ -15,6 +15,11 @@ export class CullFaceMode { } static disabled: Readonly; + + /** + * The standard GL cull mode. Culls backfacing triangles when counterclockwise vertex order is used. + * Use for 3D geometry such as terrain. + */ static backCCW: Readonly; } diff --git a/src/render/draw_background.ts b/src/render/draw_background.ts index 16ba882cae..2a1adda455 100644 --- a/src/render/draw_background.ts +++ b/src/render/draw_background.ts @@ -47,7 +47,7 @@ export function drawBackground(painter: Painter, sourceCache: SourceCache, layer const terrainData = painter.style.map.terrain && painter.style.map.terrain.getTerrainData(tileID); program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, painter.tileExtentBuffer, + uniformValues, terrainData, null, layer.id, painter.tileExtentBuffer, painter.quadTriangleIndexBuffer, painter.tileExtentSegments); } } diff --git a/src/render/draw_circle.ts b/src/render/draw_circle.ts index be08b0dd4d..fc093018b2 100644 --- a/src/render/draw_circle.ts +++ b/src/render/draw_circle.ts @@ -106,7 +106,7 @@ export function drawCircles(painter: Painter, sourceCache: SourceCache, layer: C const segments = segmentsState.segments; program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, + uniformValues, terrainData, null, layer.id, layoutVertexBuffer, indexBuffer, segments, layer.paint, painter.transform.zoom, programConfiguration); } diff --git a/src/render/draw_collision_debug.ts b/src/render/draw_collision_debug.ts index 876c5b22de..62e3f25035 100644 --- a/src/render/draw_collision_debug.ts +++ b/src/render/draw_collision_debug.ts @@ -75,7 +75,7 @@ export function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, l posMatrix, painter.transform, tile), - painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord), + painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord), null, layer.id, buffers.layoutVertexBuffer, buffers.indexBuffer, buffers.segments, null, painter.transform.zoom, null, null, buffers.collisionVertexBuffer); @@ -134,6 +134,7 @@ export function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, l CullFaceMode.disabled, uniforms, painter.style.map.terrain && painter.style.map.terrain.getTerrainData(batch.coord), + null, layer.id, vertexBuffer, indexBuffer, diff --git a/src/render/draw_debug.ts b/src/render/draw_debug.ts index 5b72587b9b..4a598d6db9 100644 --- a/src/render/draw_debug.ts +++ b/src/render/draw_debug.ts @@ -93,10 +93,10 @@ function drawDebugTile(painter: Painter, sourceCache: SourceCache, coord: Oversc drawTextToOverlay(painter, tileLabel); program.draw(context, gl.TRIANGLES, depthMode, stencilMode, ColorMode.alphaBlended, CullFaceMode.disabled, - debugUniformValues(posMatrix, Color.transparent, scaleRatio), null, id, + debugUniformValues(posMatrix, Color.transparent, scaleRatio), null, null, id, painter.debugBuffer, painter.quadTriangleIndexBuffer, painter.debugSegments); program.draw(context, gl.LINE_STRIP, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - debugUniformValues(posMatrix, Color.red), terrainData, id, + debugUniformValues(posMatrix, Color.red), terrainData, null, id, painter.debugBuffer, painter.tileBorderIndexBuffer, painter.debugSegments); } diff --git a/src/render/draw_fill.test.ts b/src/render/draw_fill.test.ts index e0a02762ef..3dee2a8563 100644 --- a/src/render/draw_fill.test.ts +++ b/src/render/draw_fill.test.ts @@ -25,10 +25,10 @@ jest.mock('../symbol/projection'); describe('drawFill', () => { test('should call programConfiguration.setConstantPatternPositions for transitioning fill-pattern', () => { - const painterMock: Painter = constructMockPainer(); + const painterMock: Painter = constructMockPainter(); const layer: FillStyleLayer = constructMockLayer(); - const programMock = new Program(null as any, null as any, null as any, null as any, null as any, null as any); + const programMock = new Program(null as any, null as any, null as any, null as any, null as any, null as any, null as any, null as any); (painterMock.useProgram as jest.Mock).mockReturnValue(programMock); const mockTile = constructMockTile(layer); @@ -73,7 +73,7 @@ describe('drawFill', () => { return layer; } - function constructMockPainer(): Painter { + function constructMockPainter(): Painter { const painterMock = new Painter(null as any, null as any); painterMock.context = { gl: {}, diff --git a/src/render/draw_fill.ts b/src/render/draw_fill.ts index c850dc9882..04a9beb194 100644 --- a/src/render/draw_fill.ts +++ b/src/render/draw_fill.ts @@ -121,7 +121,7 @@ function drawFillTiles( } program.draw(painter.context, drawMode, depthMode, - painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, + painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, null, layer.id, bucket.layoutVertexBuffer, indexBuffer, segments, layer.paint, painter.transform.zoom, programConfiguration); } diff --git a/src/render/draw_fill_extrusion.ts b/src/render/draw_fill_extrusion.ts index 6ce366ddb1..ba9dbef1ad 100644 --- a/src/render/draw_fill_extrusion.ts +++ b/src/render/draw_fill_extrusion.ts @@ -90,7 +90,7 @@ function drawExtrusionTiles( fillExtrusionUniformValues(matrix, painter, shouldUseVerticalGradient, opacity); program.draw(context, context.gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.backCCW, - uniformValues, terrainData, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, + uniformValues, terrainData, null, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments, layer.paint, painter.transform.zoom, programConfiguration, painter.style.map.terrain && bucket.centroidVertexBuffer); } diff --git a/src/render/draw_heatmap.ts b/src/render/draw_heatmap.ts index bf2e6a6887..1e909b40d0 100644 --- a/src/render/draw_heatmap.ts +++ b/src/render/draw_heatmap.ts @@ -53,7 +53,7 @@ export function drawHeatmap(painter: Painter, sourceCache: SourceCache, layer: H const {zoom} = painter.transform; program.draw(context, gl.TRIANGLES, DepthMode.disabled, stencilMode, colorMode, CullFaceMode.disabled, - heatmapUniformValues(coord.posMatrix, tile, zoom, layer.paint.get('heatmap-intensity')), null, + heatmapUniformValues(coord.posMatrix, tile, zoom, layer.paint.get('heatmap-intensity')), null, null, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments, layer.paint, painter.transform.zoom, programConfiguration); @@ -127,7 +127,7 @@ function renderTextureToMap(painter: Painter, layer: HeatmapStyleLayer) { painter.useProgram('heatmapTexture').draw(context, gl.TRIANGLES, DepthMode.disabled, StencilMode.disabled, painter.colorModeForRenderPass(), CullFaceMode.disabled, - heatmapTextureUniformValues(painter, layer, 0, 1), null, + heatmapTextureUniformValues(painter, layer, 0, 1), null, null, layer.id, painter.viewportBuffer, painter.quadTriangleIndexBuffer, painter.viewportSegments, layer.paint, painter.transform.zoom); } diff --git a/src/render/draw_hillshade.ts b/src/render/draw_hillshade.ts index 39f464125b..dbd294b539 100644 --- a/src/render/draw_hillshade.ts +++ b/src/render/draw_hillshade.ts @@ -58,7 +58,7 @@ function renderHillshade( const terrainCoord = terrainData ? coord : null; program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - hillshadeUniformValues(painter, tile, layer, terrainCoord), terrainData, layer.id, painter.rasterBoundsBuffer, + hillshadeUniformValues(painter, tile, layer, terrainCoord), terrainData, null, layer.id, painter.rasterBoundsBuffer, painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); } @@ -111,7 +111,7 @@ function prepareHillshade( painter.useProgram('hillshadePrepare').draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, hillshadeUniformPrepareValues(tile.tileID, dem), - null, layer.id, painter.rasterBoundsBuffer, + null, null, layer.id, painter.rasterBoundsBuffer, painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); tile.needsHillshadePrepare = false; diff --git a/src/render/draw_line.ts b/src/render/draw_line.ts index 285160645b..ba75f45816 100644 --- a/src/render/draw_line.ts +++ b/src/render/draw_line.ts @@ -115,7 +115,7 @@ export function drawLine(painter: Painter, sourceCache: SourceCache, layer: Line } program.draw(context, gl.TRIANGLES, depthMode, - painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, + painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, null, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments, layer.paint, painter.transform.zoom, programConfiguration, bucket.layoutVertexBuffer2); diff --git a/src/render/draw_raster.ts b/src/render/draw_raster.ts index 2bb283310a..cd4334470d 100644 --- a/src/render/draw_raster.ts +++ b/src/render/draw_raster.ts @@ -11,25 +11,71 @@ import type {Painter} from './painter'; import type {SourceCache} from '../source/source_cache'; import type {RasterStyleLayer} from '../style/style_layer/raster_style_layer'; import type {OverscaledTileID} from '../source/tile_id'; +import Point from '@mapbox/point-geometry'; +import {EXTENT} from '../data/extent'; + +const cornerCoords = [ + new Point(0, 0), + new Point(EXTENT, 0), + new Point(EXTENT, EXTENT), + new Point(0, EXTENT), +]; export function drawRaster(painter: Painter, sourceCache: SourceCache, layer: RasterStyleLayer, tileIDs: Array) { if (painter.renderPass !== 'translucent') return; if (layer.paint.get('raster-opacity') === 0) return; if (!tileIDs.length) return; - const context = painter.context; - const gl = context.gl; const source = sourceCache.getSource(); - const program = painter.useProgram('raster'); - - const colorMode = painter.colorModeForRenderPass(); - const [stencilModes, coords] = source instanceof ImageSource ? [{}, tileIDs] : - painter.stencilConfigForOverlap(tileIDs); + const projection = painter.style.map.projection; + const useSubdivision = projection.useSubdivision; + + // When rendering globe (or any other subdivided projection), two passes are needed. + // Subdivided tiles with different granularities might have tiny gaps between them. + // To combat this, tile meshes for globe have a slight border region. + // However tiles borders will overlap, and a part of a tile often + // gets hidden by its neighbour's border, which displays an ugly stretched texture. + // To both hide the border stretch and avoid tiny gaps, tiles are first drawn without borders (with gaps), + // and then any missing pixels (gaps, not marked in stencil) get overdrawn with tile borders. + // This approach also avoids pixel shader overdraw, as any pixel is drawn at most once. + + // Stencil mask and two-pass is not used for ImageSource sources regardless of projection. + if (source instanceof ImageSource) { + // Image source - not stencil is used + drawTiles(painter, sourceCache, layer, tileIDs, null, false, source.tileCoords); + } else if (useSubdivision) { + // Two-pass rendering + const [stencilBorderless, stencilBorders, coords] = painter.stencilConfigForOverlapTwoPass(tileIDs); + drawTiles(painter, sourceCache, layer, coords, stencilBorderless, false, cornerCoords); // draw without borders + drawTiles(painter, sourceCache, layer, coords, stencilBorders, true, cornerCoords); // draw with borders + } else { + // Simple rendering + const [stencil, coords] = painter.stencilConfigForOverlap(tileIDs); + drawTiles(painter, sourceCache, layer, coords, stencil, false, cornerCoords); + } +} +function drawTiles( + painter: Painter, + sourceCache: SourceCache, + layer: RasterStyleLayer, + coords: Array, + stencilModes: {[_: number]: Readonly} | null, + useBorder: boolean, + corners: Array) { const minTileZ = coords[coords.length - 1].overscaledZ; + const context = painter.context; + const gl = context.gl; + const program = painter.useProgram('raster'); + + const projection = painter.style.map.projection; + + const colorMode = painter.colorModeForRenderPass(); const align = !painter.options.moving; + + // Draw all tiles for (const coord of coords) { // Set the lower zoom level to sublayer 0, and higher zoom levels to higher sublayers // Use gl.LESS to prevent double drawing in areas where tiles overlap. @@ -56,25 +102,24 @@ export function drawRaster(painter: Painter, sourceCache: SourceCache, layer: Ra parentTile.texture.bind(textureFilter, gl.CLAMP_TO_EDGE, gl.LINEAR_MIPMAP_NEAREST); parentScaleBy = Math.pow(2, parentTile.tileID.overscaledZ - tile.tileID.overscaledZ); parentTL = [tile.tileID.canonical.x * parentScaleBy % 1, tile.tileID.canonical.y * parentScaleBy % 1]; - } else { tile.texture.bind(textureFilter, gl.CLAMP_TO_EDGE, gl.LINEAR_MIPMAP_NEAREST); } const terrainData = painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord); + const terrainCoord = terrainData ? coord : null; const posMatrix = terrainCoord ? terrainCoord.posMatrix : painter.transform.calculatePosMatrix(coord.toUnwrapped(), align); - const uniformValues = rasterUniformValues(posMatrix, parentTL || [0, 0], parentScaleBy || 1, fade, layer); + const projectionData = projection.getProjectionData(coord.canonical, posMatrix); + const uniformValues = rasterUniformValues(parentTL || [0, 0], parentScaleBy || 1, fade, layer, corners); - if (source instanceof ImageSource) { - program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, source.boundsBuffer, - painter.quadTriangleIndexBuffer, source.boundsSegments); - } else { - program.draw(context, gl.TRIANGLES, depthMode, stencilModes[coord.overscaledZ], colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, painter.rasterBoundsBuffer, - painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); - } + const mesh = projection.getMeshFromTileID(context, coord.canonical, useBorder); + + const stencilMode = stencilModes ? stencilModes[coord.overscaledZ] : StencilMode.disabled; + + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, + uniformValues, terrainData, projectionData, layer.id, mesh.vertexBuffer, + mesh.indexBuffer, mesh.segments); } } diff --git a/src/render/draw_symbol.test.ts b/src/render/draw_symbol.test.ts index a7f8514243..ab6b6ccb91 100644 --- a/src/render/draw_symbol.test.ts +++ b/src/render/draw_symbol.test.ts @@ -62,7 +62,7 @@ describe('drawSymbol', () => { const tileId = new OverscaledTileID(1, 0, 1, 0, 0); tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + const programMock = new Program(null, null, null, null, null, null, null, null); (painterMock.useProgram as jest.Mock).mockReturnValue(programMock); const bucketMock = new SymbolBucket(null); bucketMock.icon = { @@ -124,7 +124,7 @@ describe('drawSymbol', () => { const tileId = new OverscaledTileID(1, 0, 1, 0, 0); tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + const programMock = new Program(null, null, null, null, null, null, null, null); (painterMock.useProgram as jest.Mock).mockReturnValue(programMock); const bucketMock = new SymbolBucket(null); bucketMock.icon = { @@ -189,7 +189,7 @@ describe('drawSymbol', () => { const tileId = new OverscaledTileID(1, 0, 1, 0, 0); tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + const programMock = new Program(null, null, null, null, null, null, null, null); (painterMock.useProgram as jest.Mock).mockReturnValue(programMock); const bucketMock = new SymbolBucket(null); bucketMock.icon = { diff --git a/src/render/draw_symbol.ts b/src/render/draw_symbol.ts index ec912679fb..5b580ccae8 100644 --- a/src/render/draw_symbol.ts +++ b/src/render/draw_symbol.ts @@ -440,7 +440,7 @@ function drawSymbolElements( const context = painter.context; const gl = context.gl; program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, buffers.layoutVertexBuffer, + uniformValues, terrainData, null, layer.id, buffers.layoutVertexBuffer, buffers.indexBuffer, segments, layer.paint, painter.transform.zoom, buffers.programConfigurations.get(layer.id), buffers.dynamicLayoutVertexBuffer, buffers.opacityVertexBuffer); diff --git a/src/render/draw_terrain.ts b/src/render/draw_terrain.ts index 688c578146..fe929f4139 100644 --- a/src/render/draw_terrain.ts +++ b/src/render/draw_terrain.ts @@ -28,7 +28,7 @@ function drawDepth(painter: Painter, terrain: Terrain) { const terrainData = terrain.getTerrainData(tile.tileID); const posMatrix = painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped()); const uniformValues = terrainDepthUniformValues(posMatrix, terrain.getMeshFrameDelta(painter.transform.zoom)); - program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, null, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); } context.bindFramebuffer.set(null); context.viewport.set([0, 0, painter.width, painter.height]); @@ -60,7 +60,7 @@ function drawCoords(painter: Painter, terrain: Terrain) { gl.bindTexture(gl.TEXTURE_2D, coords.texture); const posMatrix = painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped()); const uniformValues = terrainCoordsUniformValues(posMatrix, 255 - terrain.coordsIndex.length, terrain.getMeshFrameDelta(painter.transform.zoom)); - program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, null, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); terrain.coordsIndex.push(tile.tileID.key); } context.bindFramebuffer.set(null); @@ -85,7 +85,7 @@ function drawTerrain(painter: Painter, terrain: Terrain, tiles: Array) { gl.bindTexture(gl.TEXTURE_2D, texture.texture); const posMatrix = painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped()); const uniformValues = terrainUniformValues(posMatrix, terrain.getMeshFrameDelta(painter.transform.zoom)); - program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, null, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); } } diff --git a/src/render/mesh.ts b/src/render/mesh.ts new file mode 100644 index 0000000000..0d2986ee45 --- /dev/null +++ b/src/render/mesh.ts @@ -0,0 +1,25 @@ +import {SegmentVector} from '../data/segment'; +import {VertexBuffer} from '../gl/vertex_buffer'; +import {IndexBuffer} from '../gl/index_buffer'; + +export class Mesh { + vertexBuffer: VertexBuffer; + indexBuffer: IndexBuffer; + segments: SegmentVector; + + constructor(vertexBuffer: VertexBuffer, indexBuffer: IndexBuffer, segments: SegmentVector) { + this.vertexBuffer = vertexBuffer; + this.indexBuffer = indexBuffer; + this.segments = segments; + } + + destroy(): void { + this.vertexBuffer.destroy(); + this.indexBuffer.destroy(); + this.segments.destroy(); + + this.vertexBuffer = null; + this.indexBuffer = null; + this.segments = null; + } +} diff --git a/src/render/painter.ts b/src/render/painter.ts index 89184b2933..f0c1c3918d 100644 --- a/src/render/painter.ts +++ b/src/render/painter.ts @@ -1,8 +1,7 @@ import {browser} from '../util/browser'; -import {mat4, vec3} from 'gl-matrix'; +import {mat4} from 'gl-matrix'; import {SourceCache} from '../source/source_cache'; import {EXTENT} from '../data/extent'; -import {pixelsToTileUnits} from '../source/pixels_to_tile_units'; import {SegmentVector} from '../data/segment'; import {RasterBoundsArray, PosArray, TriangleIndexArray, LineStripIndexArray} from '../data/array_types.g'; import rasterBoundsAttributes from '../data/raster_bounds_attributes'; @@ -18,7 +17,6 @@ import {StencilMode} from '../gl/stencil_mode'; import {ColorMode} from '../gl/color_mode'; import {CullFaceMode} from '../gl/cull_face_mode'; import {Texture} from './texture'; -import {clippingMaskUniformValues} from './program/clipping_mask_program'; import {Color} from '@maplibre/maplibre-gl-style-spec'; import {drawSymbols} from './draw_symbol'; import {drawCircles} from './draw_circle'; @@ -35,7 +33,6 @@ import {drawDepth, drawCoords} from './draw_terrain'; import {OverscaledTileID} from '../source/tile_id'; import type {Transform} from '../geo/transform'; -import type {Tile} from '../source/tile'; import type {Style} from '../style/style'; import type {StyleLayer} from '../style/style_layer'; import type {CrossFaded} from '../style/properties'; @@ -47,6 +44,10 @@ import type {IndexBuffer} from '../gl/index_buffer'; import type {DepthRangeType, DepthMaskType, DepthFuncType} from '../gl/types'; import type {ResolvedImage} from '@maplibre/maplibre-gl-style-spec'; import {RenderToTexture} from './render_to_texture'; +import {Mesh} from './mesh'; +import {translatePosMatrix as mercatorTranslatePosMatrix, MercatorShaderDefine, MercatorShaderVariantKey} from '../geo/projection/mercator'; +import {Tile} from '../source/tile'; +import {ProjectionData} from './program/projection_program'; export type RenderPass = 'offscreen' | 'opaque' | 'translucent'; @@ -79,10 +80,14 @@ export class Painter { pixelRatio: number; tileExtentBuffer: VertexBuffer; tileExtentSegments: SegmentVector; + tileExtentMesh: Mesh; + debugBuffer: VertexBuffer; debugSegments: SegmentVector; rasterBoundsBuffer: VertexBuffer; rasterBoundsSegments: SegmentVector; + rasterBoundsBufferPosOnly: VertexBuffer; + rasterBoundsSegmentsPosOnly: SegmentVector; viewportBuffer: VertexBuffer; viewportSegments: SegmentVector; quadTriangleIndexBuffer: IndexBuffer; @@ -172,6 +177,14 @@ export class Painter { this.rasterBoundsBuffer = context.createVertexBuffer(rasterBoundsArray, rasterBoundsAttributes.members); this.rasterBoundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); + const rasterBoundsArrayPosOnly = new PosArray(); + rasterBoundsArrayPosOnly.emplaceBack(0, 0); + rasterBoundsArrayPosOnly.emplaceBack(EXTENT, 0); + rasterBoundsArrayPosOnly.emplaceBack(0, EXTENT); + rasterBoundsArrayPosOnly.emplaceBack(EXTENT, EXTENT); + this.rasterBoundsBufferPosOnly = context.createVertexBuffer(rasterBoundsArrayPosOnly, posAttributes.members); + this.rasterBoundsSegmentsPosOnly = SegmentVector.simpleSegment(0, 0, 4, 5); + const viewportArray = new PosArray(); viewportArray.emplaceBack(0, 0); viewportArray.emplaceBack(1, 0); @@ -189,12 +202,14 @@ export class Painter { this.tileBorderIndexBuffer = context.createIndexBuffer(tileLineStripIndices); const quadTriangleIndices = new TriangleIndexArray(); - quadTriangleIndices.emplaceBack(0, 1, 2); - quadTriangleIndices.emplaceBack(2, 1, 3); + quadTriangleIndices.emplaceBack(1, 0, 2); + quadTriangleIndices.emplaceBack(1, 2, 3); this.quadTriangleIndexBuffer = context.createIndexBuffer(quadTriangleIndices); const gl = this.context.gl; this.stencilClearMode = new StencilMode({func: gl.ALWAYS, mask: 0}, 0x0, 0xFF, gl.ZERO, gl.ZERO, gl.ZERO); + + this.tileExtentMesh = new Mesh(this.tileExtentBuffer, this.quadTriangleIndexBuffer, this.tileExtentSegments); } /* @@ -217,9 +232,18 @@ export class Painter { mat4.ortho(matrix, 0, this.width, this.height, 0, 0, 1); mat4.scale(matrix, matrix, [gl.drawingBufferWidth, gl.drawingBufferHeight, 0]); - this.useProgram('clippingMask').draw(context, gl.TRIANGLES, + const projectionData: ProjectionData = { + 'u_projection_matrix': matrix, + 'u_projection_tile_mercator_coords': [0, 0, 1, 1], + 'u_projection_clipping_plane': [0, 0, 0, 0], + 'u_projection_transition': 0.0, + 'u_projection_fallback_matrix': matrix, + }; + + // Note: we force a simple mercator projection for the shader, since we want to draw a fullscreen quad. + this.useProgram('clippingMask', null, true).draw(context, gl.TRIANGLES, DepthMode.disabled, this.stencilClearMode, ColorMode.disabled, CullFaceMode.disabled, - clippingMaskUniformValues(matrix), null, + null, null, projectionData, '$clipping', this.viewportBuffer, this.quadTriangleIndexBuffer, this.viewportSegments); } @@ -244,16 +268,23 @@ export class Painter { this._tileClippingMaskIDs = {}; + const projection = this.style.map.projection; + + // tiles are usually supplied in ascending order of z, then y, then x for (const tileID of tileIDs) { const id = this._tileClippingMaskIDs[tileID.key] = this.nextStencilID++; const terrainData = this.style.map.terrain && this.style.map.terrain.getTerrainData(tileID); + const mesh = projection.getMeshFromTileID(this.context, tileID.canonical, true); + + const projectionData = projection.getProjectionData(tileID.canonical, tileID.posMatrix); + program.draw(context, gl.TRIANGLES, DepthMode.disabled, // Tests will always pass, and ref value will be written to stencil buffer. new StencilMode({func: gl.ALWAYS, mask: 0}, id, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE), - ColorMode.disabled, CullFaceMode.disabled, clippingMaskUniformValues(tileID.posMatrix), - terrainData, '$clipping', this.tileExtentBuffer, - this.quadTriangleIndexBuffer, this.tileExtentSegments); + ColorMode.disabled, CullFaceMode.backCCW, null, + terrainData, projectionData, '$clipping', mesh.vertexBuffer, + mesh.indexBuffer, mesh.segments); } } @@ -306,6 +337,41 @@ export class Painter { return [{[minTileZ]: StencilMode.disabled}, coords]; } + stencilConfigForOverlapTwoPass(tileIDs: Array): [ + { [_: number]: Readonly }, // borderless tiles - high priority & high stencil values + { [_: number]: Readonly }, // tiles with border - low priority + Array + ] { + const gl = this.context.gl; + const coords = tileIDs.sort((a, b) => b.overscaledZ - a.overscaledZ); + const minTileZ = coords[coords.length - 1].overscaledZ; + const stencilValues = coords[0].overscaledZ - minTileZ + 1; + + this.clearStencil(); + + if (stencilValues > 1) { + const zToStencilModeHigh = {}; + const zToStencilModeLow = {}; + for (let i = 0; i < stencilValues; i++) { + zToStencilModeHigh[i + minTileZ] = new StencilMode({func: gl.GREATER, mask: 0xFF}, stencilValues + 1 + i, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE); + zToStencilModeLow[i + minTileZ] = new StencilMode({func: gl.GREATER, mask: 0xFF}, 1 + i, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE); + } + this.nextStencilID = stencilValues * 2 + 1; + return [ + zToStencilModeHigh, + zToStencilModeLow, + coords + ]; + } else { + this.nextStencilID = 3; + return [ + {[minTileZ]: new StencilMode({func: gl.GREATER, mask: 0xFF}, 2, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE)}, + {[minTileZ]: new StencilMode({func: gl.GREATER, mask: 0xFF}, 1, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE)}, + coords + ]; + } + } + colorModeForRenderPass(): Readonly { const gl = this.context.gl; if (this._showOverdrawInspector) { @@ -362,7 +428,7 @@ export class Painter { sourceCache.prepare(this.context); } - coordsAscending[id] = sourceCache.getVisibleCoordinates(); + coordsAscending[id] = sourceCache.getVisibleCoordinates(false); coordsDescending[id] = coordsAscending[id].slice().reverse(); coordsDescendingSymbol[id] = sourceCache.getVisibleCoordinates(true).reverse(); } @@ -382,8 +448,8 @@ export class Painter { this.opaquePassCutoff = 0; // update coords/depth-framebuffer on camera movement, or tile reloading - const newTiles = this.style.map.terrain.sourceCache.tilesAfterTime(this.terrainFacilitator.renderTime); - if (this.terrainFacilitator.dirty || !mat4.equals(this.terrainFacilitator.matrix, this.transform.projMatrix) || newTiles.length) { + const hasNewTiles = this.style.map.terrain.sourceCache.anyTilesAfterTime(this.terrainFacilitator.renderTime); + if (this.terrainFacilitator.dirty || !mat4.equals(this.terrainFacilitator.matrix, this.transform.projMatrix) || hasNewTiles) { mat4.copy(this.terrainFacilitator.matrix, this.transform.projMatrix); this.terrainFacilitator.renderTime = Date.now(); this.terrainFacilitator.dirty = false; @@ -408,7 +474,14 @@ export class Painter { this.renderLayer(this, sourceCaches[layer.source], layer, coords); } + // Execute offscreen GPU tasks of the projection manager + this.style.map.projection.updateGPUdependent({ + context: this.context, + useProgram: (name: string) => this.useProgram(name) + }); + // Rebind the main framebuffer now that all offscreen layers have been rendered: + this.context.viewport.set([0, 0, this.width, this.height]); this.context.bindFramebuffer.set(null); // Clear buffers in preparation for drawing to the main framebuffer @@ -508,35 +581,18 @@ export class Painter { } /** - * Transform a matrix to incorporate the *-translate and *-translate-anchor properties into it. - * @param inViewportPixelUnitsUnits - True when the units accepted by the matrix are in viewport pixels instead of tile units. - * @returns matrix + * Temporary function - translate & translate-anchor handling will be moved to projection classes, + * since it is inherently projection dependent. Most translations will not be handled by the + * projection matrix (like the one this function produces), but by specialized code in the vertex shader. */ - translatePosMatrix(matrix: mat4, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport', inViewportPixelUnitsUnits?: boolean): mat4 { - if (!translate[0] && !translate[1]) return matrix; - - const angle = inViewportPixelUnitsUnits ? - (translateAnchor === 'map' ? this.transform.angle : 0) : - (translateAnchor === 'viewport' ? -this.transform.angle : 0); - - if (angle) { - const sinA = Math.sin(angle); - const cosA = Math.cos(angle); - translate = [ - translate[0] * cosA - translate[1] * sinA, - translate[0] * sinA + translate[1] * cosA - ]; - } - - const translation = [ - inViewportPixelUnitsUnits ? translate[0] : pixelsToTileUnits(tile, translate[0], this.transform.zoom), - inViewportPixelUnitsUnits ? translate[1] : pixelsToTileUnits(tile, translate[1], this.transform.zoom), - 0 - ] as vec3; - - const translatedMatrix = new Float32Array(16); - mat4.translate(translatedMatrix, matrix, translation); - return translatedMatrix; + translatePosMatrix( + matrix: mat4, + tile: Tile, + translate: [number, number], + translateAnchor: 'map' | 'viewport', + inViewportPixelUnitsUnits: boolean = false + ): mat4 { + return mercatorTranslatePosMatrix(this.transform, tile, matrix, translate, translateAnchor, inViewportPixelUnitsUnits); } saveTileTexture(texture: Texture) { @@ -566,12 +622,31 @@ export class Painter { return !imagePosA || !imagePosB; } - useProgram(name: string, programConfiguration?: ProgramConfiguration | null): Program { + /** + * Finds the required shader and its variant (base/terrain/globe, etc.) and binds it, compiling a new shader if required. + * @param name - Name of the desired shader. + * @param programConfiguration - Configuration of shader's inputs. + * @param defines - Additional macros to be injected at the beginning of the shader. Expected format is `['#define XYZ']`, etc. + * @param forceSimpleProjection - Whether to force the use of a shader variant with simple mercator projection vertex shader. + * False by default. Use true when drawing with a simple projection matrix is desired, eg. when drawing a fullscreen quad. + * @returns + */ + useProgram(name: string, programConfiguration?: ProgramConfiguration | null, forceSimpleProjection: boolean = false): Program { this.cache = this.cache || {}; - const key = name + - (programConfiguration ? programConfiguration.cacheKey : '') + - (this._showOverdrawInspector ? '/overdraw' : '') + - (this.style.map.terrain ? '/terrain' : ''); + const useTerrain = !!this.style.map.terrain; + + const projection = this.style.map.projection; + + const projectionPrelude = forceSimpleProjection ? shaders.projectionMercator : projection.shaderPreludeCode; + const projectionDefine = forceSimpleProjection ? MercatorShaderDefine : projection.shaderDefine; + const projectionKey = `/${forceSimpleProjection ? MercatorShaderVariantKey : projection.shaderVariantName}`; + + const configurationKey = (programConfiguration ? programConfiguration.cacheKey : ''); + const overdrawKey = (this._showOverdrawInspector ? '/overdraw' : ''); + const terrainKey = (useTerrain ? '/terrain' : ''); + + const key = name + configurationKey + projectionKey + overdrawKey + terrainKey; + if (!this.cache[key]) { this.cache[key] = new Program( this.context, @@ -579,7 +654,9 @@ export class Painter { programConfiguration, programUniforms[name], this._showOverdrawInspector, - this.style.map.terrain + useTerrain, + projectionPrelude, + projectionDefine ); } return this.cache[key]; diff --git a/src/render/program.ts b/src/render/program.ts index bac18b2093..b47ba2acdb 100644 --- a/src/render/program.ts +++ b/src/render/program.ts @@ -1,4 +1,4 @@ -import {shaders} from '../shaders/shaders'; +import {PreparedShader, shaders} from '../shaders/shaders'; import {ProgramConfiguration} from '../data/program_configuration'; import {VertexArrayObject} from './vertex_array_object'; import {Context} from '../gl/context'; @@ -14,7 +14,7 @@ import type {UniformBindings, UniformValues, UniformLocations} from './uniform_b import type {BinderUniform} from '../data/program_configuration'; import {terrainPreludeUniforms, TerrainPreludeUniformsType} from './program/terrain_program'; import type {TerrainData} from '../render/terrain'; -import {Terrain} from '../render/terrain'; +import {ProjectionData, ProjectionPreludeUniformsType, projectionUniforms} from './program/projection_program'; export type DrawMode = WebGLRenderingContextBase['LINES'] | WebGLRenderingContextBase['TRIANGLES'] | WebGL2RenderingContext['LINE_STRIP']; @@ -39,20 +39,18 @@ export class Program { numAttributes: number; fixedUniforms: Us; terrainUniforms: TerrainPreludeUniformsType; + projectionUniforms: ProjectionPreludeUniformsType; binderUniforms: Array; failedToCreate: boolean; constructor(context: Context, - source: { - fragmentSource: string; - vertexSource: string; - staticAttributes: Array; - staticUniforms: Array; - }, + source: PreparedShader, configuration: ProgramConfiguration, fixedUniforms: (b: Context, a: UniformLocations) => Us, showOverdrawInspector: boolean, - terrain: Terrain) { + hasTerrain: boolean, + projectionPrelude: PreparedShader, + projectionDefine: string) { const gl = context.gl; this.program = gl.createProgram(); @@ -62,10 +60,11 @@ export class Program { const allAttrInfo = staticAttrInfo.concat(dynamicAttrInfo); const preludeUniformsInfo = shaders.prelude.staticUniforms ? getTokenizedAttributesAndUniforms(shaders.prelude.staticUniforms) : []; + const projectionPreludeUniformsInfo = projectionPrelude.staticUniforms ? getTokenizedAttributesAndUniforms(projectionPrelude.staticUniforms) : []; const staticUniformsInfo = source.staticUniforms ? getTokenizedAttributesAndUniforms(source.staticUniforms) : []; const dynamicUniformsInfo = configuration ? configuration.getBinderUniforms() : []; // remove duplicate uniforms - const uniformList = preludeUniformsInfo.concat(staticUniformsInfo).concat(dynamicUniformsInfo); + const uniformList = preludeUniformsInfo.concat(projectionPreludeUniformsInfo).concat(staticUniformsInfo).concat(dynamicUniformsInfo); const allUniformsInfo = []; for (const uniform of uniformList) { if (allUniformsInfo.indexOf(uniform) < 0) allUniformsInfo.push(uniform); @@ -75,12 +74,15 @@ export class Program { if (showOverdrawInspector) { defines.push('#define OVERDRAW_INSPECTOR;'); } - if (terrain) { + if (hasTerrain) { defines.push('#define TERRAIN3D;'); } + if (projectionDefine) { + defines.push(projectionDefine); + } - const fragmentSource = defines.concat(shaders.prelude.fragmentSource, source.fragmentSource).join('\n'); - const vertexSource = defines.concat(shaders.prelude.vertexSource, source.vertexSource).join('\n'); + const fragmentSource = defines.concat(shaders.prelude.fragmentSource, projectionPrelude.fragmentSource, source.fragmentSource).join('\n'); + const vertexSource = defines.concat(shaders.prelude.vertexSource, projectionPrelude.vertexSource, source.vertexSource).join('\n'); const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); if (gl.isContextLost()) { @@ -143,6 +145,7 @@ export class Program { this.fixedUniforms = fixedUniforms(context, uniformLocations); this.terrainUniforms = terrainPreludeUniforms(context, uniformLocations); + this.projectionUniforms = projectionUniforms(context, uniformLocations); this.binderUniforms = configuration ? configuration.getUniforms(context, uniformLocations) : []; } @@ -154,6 +157,7 @@ export class Program { cullFaceMode: Readonly, uniformValues: UniformValues, terrain: TerrainData, + projectionData: ProjectionData, layerID: string, layoutVertexBuffer: VertexBuffer, indexBuffer: IndexBuffer, @@ -186,8 +190,16 @@ export class Program { } } - for (const name in this.fixedUniforms) { - this.fixedUniforms[name].set(uniformValues[name]); + if (projectionData) { + for (const name in this.projectionUniforms) { + this.projectionUniforms[name].set(projectionData[name]); + } + } + + if (uniformValues) { + for (const name in this.fixedUniforms) { + this.fixedUniforms[name].set(uniformValues[name]); + } } if (configuration) { diff --git a/src/render/program/clipping_mask_program.ts b/src/render/program/clipping_mask_program.ts deleted file mode 100644 index 1881982129..0000000000 --- a/src/render/program/clipping_mask_program.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {UniformMatrix4f} from '../uniform_binding'; - -import type {Context} from '../../gl/context'; -import type {UniformValues, UniformLocations} from '../uniform_binding'; -import {mat4} from 'gl-matrix'; - -export type ClippingMaskUniformsType = { - 'u_matrix': UniformMatrix4f; -}; - -const clippingMaskUniforms = (context: Context, locations: UniformLocations): ClippingMaskUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix) -}); - -const clippingMaskUniformValues = (matrix: mat4): UniformValues => ({ - 'u_matrix': matrix -}); - -export {clippingMaskUniforms, clippingMaskUniformValues}; diff --git a/src/render/program/program_uniforms.ts b/src/render/program/program_uniforms.ts index f1723ccac7..1ee3e76780 100644 --- a/src/render/program/program_uniforms.ts +++ b/src/render/program/program_uniforms.ts @@ -1,9 +1,8 @@ import {fillExtrusionUniforms, fillExtrusionPatternUniforms} from './fill_extrusion_program'; -import {fillUniforms, fillPatternUniforms, fillOutlineUniforms, fillOutlinePatternUniforms} from './fill_program'; +import {fillPatternUniforms, fillOutlineUniforms, fillOutlinePatternUniforms, fillUniforms} from './fill_program'; import {circleUniforms} from './circle_program'; import {collisionUniforms, collisionCircleUniforms} from './collision_program'; import {debugUniforms} from './debug_program'; -import {clippingMaskUniforms} from './clipping_mask_program'; import {heatmapUniforms, heatmapTextureUniforms} from './heatmap_program'; import {hillshadeUniforms, hillshadePrepareUniforms} from './hillshade_program'; import {lineUniforms, lineGradientUniforms, linePatternUniforms, lineSDFUniforms} from './line_program'; @@ -11,6 +10,9 @@ import {rasterUniforms} from './raster_program'; import {symbolIconUniforms, symbolSDFUniforms, symbolTextAndIconUniforms} from './symbol_program'; import {backgroundUniforms, backgroundPatternUniforms} from './background_program'; import {terrainUniforms, terrainDepthUniforms, terrainCoordsUniforms} from './terrain_program'; +import {projectionErrorMeasurementUniforms} from './projection_error_measurement_program'; + +const emptyUniforms = (_: any, __: any): any => {}; export const programUniforms = { fillExtrusion: fillExtrusionUniforms, @@ -23,7 +25,7 @@ export const programUniforms = { collisionBox: collisionUniforms, collisionCircle: collisionCircleUniforms, debug: debugUniforms, - clippingMask: clippingMaskUniforms, + clippingMask: emptyUniforms, heatmap: heatmapUniforms, heatmapTexture: heatmapTextureUniforms, hillshade: hillshadeUniforms, @@ -40,5 +42,6 @@ export const programUniforms = { backgroundPattern: backgroundPatternUniforms, terrain: terrainUniforms, terrainDepth: terrainDepthUniforms, - terrainCoords: terrainCoordsUniforms + terrainCoords: terrainCoordsUniforms, + projectionErrorMeasurement: projectionErrorMeasurementUniforms, }; diff --git a/src/render/program/projection_error_measurement_program.ts b/src/render/program/projection_error_measurement_program.ts new file mode 100644 index 0000000000..c5b1cd4f13 --- /dev/null +++ b/src/render/program/projection_error_measurement_program.ts @@ -0,0 +1,23 @@ +import {Uniform1f} from '../uniform_binding'; +import type {Context} from '../../gl/context'; +import type {UniformValues, UniformLocations} from '../../render/uniform_binding'; + +export type ProjectionErrorMeasurementUniformsType = { + 'u_input': Uniform1f; + 'u_output_expected': Uniform1f; +}; + +const projectionErrorMeasurementUniforms = (context: Context, locations: UniformLocations): ProjectionErrorMeasurementUniformsType => ({ + 'u_input': new Uniform1f(context, locations.u_input), + 'u_output_expected': new Uniform1f(context, locations.u_output_expected), +}); + +const projectionErrorMeasurementUniformValues = ( + input: number, + outputExpected: number +): UniformValues => ({ + 'u_input': input, + 'u_output_expected': outputExpected, +}); + +export {projectionErrorMeasurementUniforms, projectionErrorMeasurementUniformValues}; diff --git a/src/render/program/projection_program.ts b/src/render/program/projection_program.ts new file mode 100644 index 0000000000..27e391037e --- /dev/null +++ b/src/render/program/projection_program.ts @@ -0,0 +1,27 @@ +import {Uniform1f, Uniform4f, UniformLocations, UniformMatrix4f} from '../uniform_binding'; +import {Context} from '../../gl/context'; +import {mat4} from 'gl-matrix'; + +export type ProjectionPreludeUniformsType = { + 'u_projection_matrix': UniformMatrix4f; + 'u_projection_tile_mercator_coords': Uniform4f; + 'u_projection_clipping_plane': Uniform4f; + 'u_projection_transition': Uniform1f; + 'u_projection_fallback_matrix': UniformMatrix4f; +}; + +export const projectionUniforms = (context: Context, locations: UniformLocations): ProjectionPreludeUniformsType => ({ + 'u_projection_matrix': new UniformMatrix4f(context, locations.u_projection_matrix), + 'u_projection_tile_mercator_coords': new Uniform4f(context, locations.u_projection_tile_mercator_coords), + 'u_projection_clipping_plane': new Uniform4f(context, locations.u_projection_clipping_plane), + 'u_projection_transition': new Uniform1f(context, locations.u_projection_transition), + 'u_projection_fallback_matrix': new UniformMatrix4f(context, locations.u_projection_fallback_matrix) +}); + +export type ProjectionData = { + 'u_projection_matrix': mat4; + 'u_projection_tile_mercator_coords': [number, number, number, number]; + 'u_projection_clipping_plane': [number, number, number, number]; + 'u_projection_transition': number; + 'u_projection_fallback_matrix': mat4; +} diff --git a/src/render/program/raster_program.ts b/src/render/program/raster_program.ts index 4ee6a431dc..7fb7f7d2f9 100644 --- a/src/render/program/raster_program.ts +++ b/src/render/program/raster_program.ts @@ -1,12 +1,11 @@ -import {Uniform1i, Uniform1f, Uniform2f, Uniform3f, UniformMatrix4f} from '../uniform_binding'; +import {Uniform1i, Uniform1f, Uniform2f, Uniform3f, Uniform4f} from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; import type {RasterStyleLayer} from '../../style/style_layer/raster_style_layer'; -import {mat4} from 'gl-matrix'; +import Point from '@mapbox/point-geometry'; export type RasterUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_tl_parent': Uniform2f; 'u_scale_parent': Uniform1f; 'u_buffer_scale': Uniform1f; @@ -19,10 +18,11 @@ export type RasterUniformsType = { 'u_saturation_factor': Uniform1f; 'u_contrast_factor': Uniform1f; 'u_spin_weights': Uniform3f; + 'u_coords_top': Uniform4f; + 'u_coords_bottom': Uniform4f; }; const rasterUniforms = (context: Context, locations: UniformLocations): RasterUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_tl_parent': new Uniform2f(context, locations.u_tl_parent), 'u_scale_parent': new Uniform1f(context, locations.u_scale_parent), 'u_buffer_scale': new Uniform1f(context, locations.u_buffer_scale), @@ -34,22 +34,27 @@ const rasterUniforms = (context: Context, locations: UniformLocations): RasterUn 'u_brightness_high': new Uniform1f(context, locations.u_brightness_high), 'u_saturation_factor': new Uniform1f(context, locations.u_saturation_factor), 'u_contrast_factor': new Uniform1f(context, locations.u_contrast_factor), - 'u_spin_weights': new Uniform3f(context, locations.u_spin_weights) + 'u_spin_weights': new Uniform3f(context, locations.u_spin_weights), + 'u_coords_top': new Uniform4f(context, locations.u_coords_top), + 'u_coords_bottom': new Uniform4f(context, locations.u_coords_bottom) }); const rasterUniformValues = ( - matrix: mat4, parentTL: [number, number], parentScaleBy: number, fade: { mix: number; opacity: number; }, - layer: RasterStyleLayer + layer: RasterStyleLayer, + cornerCoords: Array, ): UniformValues => ({ - 'u_matrix': matrix, 'u_tl_parent': parentTL, 'u_scale_parent': parentScaleBy, + // If u_buffer_scale is ever something else than a constant 1, + // the north/south pole handling in the vertex shader might need modification + // so that the texture coordinares for poles always lie beyond the edge of the texture. + // Right now the coordinates are placed right at the texture border. 'u_buffer_scale': 1, 'u_fade_t': fade.mix, 'u_opacity': fade.opacity * layer.paint.get('raster-opacity'), @@ -59,7 +64,9 @@ const rasterUniformValues = ( 'u_brightness_high': layer.paint.get('raster-brightness-max'), 'u_saturation_factor': saturationFactor(layer.paint.get('raster-saturation')), 'u_contrast_factor': contrastFactor(layer.paint.get('raster-contrast')), - 'u_spin_weights': spinWeights(layer.paint.get('raster-hue-rotate')) + 'u_spin_weights': spinWeights(layer.paint.get('raster-hue-rotate')), + 'u_coords_top': [cornerCoords[0].x, cornerCoords[0].y, cornerCoords[1].x, cornerCoords[1].y], + 'u_coords_bottom': [cornerCoords[3].x, cornerCoords[3].y, cornerCoords[2].x, cornerCoords[2].y] }); function spinWeights(angle) { diff --git a/src/render/subdivision.ts b/src/render/subdivision.ts new file mode 100644 index 0000000000..b14a4e5d30 --- /dev/null +++ b/src/render/subdivision.ts @@ -0,0 +1,46 @@ +export class SubdivisionGranularityExpression { + /** + * A tile of zoom level 0 will be subdivided to this granularity level. + * Each subsequent zoom level will have its granularity halved. + */ + private readonly _baseZoomGranularity: number; + + /** + * No tile will have granularity level smaller than this. + */ + private readonly _minGranularity: number; + + constructor(baseZoomGranularity: number, minGranularity: number) { + this._baseZoomGranularity = baseZoomGranularity; + this._minGranularity = minGranularity; + } + + public getGranularityForZoomLevel(zoomLevel: number): number { + const divisor = 1 << zoomLevel; + return Math.max(Math.floor(this._baseZoomGranularity / divisor), this._minGranularity, 0); + } +} + +export class SubdivisionGranularitySetting { + /** + * granularity settings used for fill layer (both polygons and their anti-aliasing outlines). + */ + public readonly fill; + + /** + * granularity used for the line layer. + */ + public readonly line; + + constructor(options: {fill: SubdivisionGranularityExpression; line: SubdivisionGranularityExpression}) { + this.fill = options.fill; + this.line = options.line; + } +} + +export const granularitySettings: SubdivisionGranularitySetting = new SubdivisionGranularitySetting({ + fill: new SubdivisionGranularityExpression(128, 1), + line: new SubdivisionGranularityExpression(512, 1) +}); + +// Lots more code to come once fill, line and fill-extrusion layers get ported. diff --git a/src/render/terrain.ts b/src/render/terrain.ts index 7114b380f6..4fd08b12f0 100644 --- a/src/render/terrain.ts +++ b/src/render/terrain.ts @@ -7,8 +7,6 @@ import {warnOnce} from '../util/util'; import {Pos3dArray, TriangleIndexArray} from '../data/array_types.g'; import pos3dAttributes from '../data/pos3d_attributes'; import {SegmentVector} from '../data/segment'; -import {VertexBuffer} from '../gl/vertex_buffer'; -import {IndexBuffer} from '../gl/index_buffer'; import {Painter} from './painter'; import {Texture} from '../render/texture'; import type {Framebuffer} from '../gl/framebuffer'; @@ -19,6 +17,7 @@ import {SourceCache} from '../source/source_cache'; import {EXTENT} from '../data/extent'; import type {TerrainSpecification} from '@maplibre/maplibre-gl-style-spec'; import {LngLat, earthRadius} from '../geo/lng_lat'; +import {Mesh} from './mesh'; /** * @internal @@ -36,16 +35,6 @@ export type TerrainData = { tile: Tile; } -/** - * @internal - * A terrain mesh object - */ -export type TerrainMesh = { - indexBuffer: IndexBuffer; - vertexBuffer: VertexBuffer; - segments: SegmentVector; -} - /** * @internal * This is the main class which handles most of the 3D Terrain logic. It has the following topics: @@ -112,7 +101,7 @@ export class Terrain { * GL Objects for the terrain-mesh * The mesh is a regular mesh, which has the advantage that it can be reused for all tiles. */ - _mesh: TerrainMesh; + _mesh: Mesh; /** * coords index contains a list of tileID.keys. This index is used to identify * the tile via the alpha-cannel in the coords-texture. @@ -369,7 +358,7 @@ export class Terrain { * create a regular mesh which will be used by all terrain-tiles * @returns the created regular mesh */ - getTerrainMesh(): TerrainMesh { + getTerrainMesh(): Mesh { if (this._mesh) return this._mesh; const context = this.painter.context; const vertexArray = new Pos3dArray(); @@ -403,11 +392,11 @@ export class Terrain { indexArray.emplaceBack(offsetRight + y, offsetRight + y + 3, offsetRight + y + 1); indexArray.emplaceBack(offsetRight + y, offsetRight + y + 2, offsetRight + y + 3); } - this._mesh = { - indexBuffer: context.createIndexBuffer(indexArray), - vertexBuffer: context.createVertexBuffer(vertexArray, pos3dAttributes.members), - segments: SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length) - }; + this._mesh = new Mesh( + context.createVertexBuffer(vertexArray, pos3dAttributes.members), + context.createIndexBuffer(indexArray), + SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length) + ); return this._mesh; } diff --git a/src/shaders/_prelude.vertex.glsl b/src/shaders/_prelude.vertex.glsl index 69b68398f1..c1f5d6c471 100644 --- a/src/shaders/_prelude.vertex.glsl +++ b/src/shaders/_prelude.vertex.glsl @@ -72,6 +72,19 @@ vec2 get_pattern_pos(const vec2 pixel_coord_upper, const vec2 pixel_coord_lower, return (tile_units_to_pixels * pos + offset) / pattern_size; } +// Axis must be a normalized vector +// Angle is in radians +mat3 rotationMatrixFromAxisAngle(vec3 u, float angle) { + float c = cos(angle); + float s = sin(angle); + float c2 = 1.0 - c; + return mat3( + u.x*u.x * c2 + c, u.x*u.y * c2 - u.z*s, u.x*u.z * c2 + u.y*s, + u.y*u.x * c2 + u.z * s, u.y*u.y * c2 + c, u.y*u.z * c2 - u.x*s, + u.z*u.x * c2 - u.y * s, u.z*u.y * c2 + u.x*s, u.z*u.z * c2 + c + ); +} + // logic for terrain 3d #ifdef TERRAIN3D @@ -146,3 +159,8 @@ float get_elevation(vec2 pos) { return 0.0; #endif } + + +const float PI = 3.141592653589793; + +uniform mat4 u_projection_matrix; diff --git a/src/shaders/_projection_globe.vertex.glsl b/src/shaders/_projection_globe.vertex.glsl new file mode 100644 index 0000000000..17c843bfad --- /dev/null +++ b/src/shaders/_projection_globe.vertex.glsl @@ -0,0 +1,139 @@ +#define GLOBE_RADIUS 6371008.8 + +uniform highp vec4 u_projection_tile_mercator_coords; +uniform highp vec4 u_projection_clipping_plane; +uniform highp float u_projection_transition; +uniform mat4 u_projection_fallback_matrix; + +vec3 globeRotateVector(vec3 vec, vec2 angles) { + vec3 axisRight = vec3(vec.z, 0.0, -vec.x); // Equivalent to cross(vec3(0.0, 1.0, 0.0), vec) + vec3 axisUp = cross(axisRight, vec); + axisRight = normalize(axisRight); + axisUp = normalize(axisUp); + vec2 t = tan(angles); + return normalize(vec + axisRight * t.x + axisUp * t.y); +} + +mat3 globeGetRotationMatrix(vec3 spherePos) { + vec3 axisRight = vec3(spherePos.z, 0.0, -spherePos.x); // Equivalent to cross(vec3(0.0, 1.0, 0.0), vec) + vec3 axisDown = cross(axisRight, spherePos); + axisRight = normalize(axisRight); + axisDown = normalize(axisDown); + return mat3( + axisRight, + axisDown, + spherePos + ); +} + +// Consider this private, do not use in other shaders directly! +// Use `projectLineThickness` or `projectCircleRadius` instead. +float circumferenceRatioAtTileY(float tileY) { + float mercator_pos_y = u_projection_tile_mercator_coords.y + u_projection_tile_mercator_coords.w * tileY; + float spherical_y = 2.0 * atan(exp(PI - (mercator_pos_y * PI * 2.0))) - PI * 0.5; + return cos(spherical_y); +} + +float projectLineThickness(float tileY) { + float thickness = 1.0 / circumferenceRatioAtTileY(tileY); + if (u_projection_transition < 0.999) { + return mix(1.0, thickness, u_projection_transition); + } else { + return thickness; + } +} + +float projectCircleRadius(float tileY) { + float thickness = 1.0 / circumferenceRatioAtTileY(tileY); + if (u_projection_transition < 0.999) { + return mix(1.0, thickness, u_projection_transition); + } else { + return thickness; + } +} + +// get position inside the tile in range 0..8192 and project it onto the surface of a unit sphere +vec3 projectToSphere(vec2 posInTile) { + // Compute position in range 0..1 of the base tile of web mercator + vec2 mercator_pos = u_projection_tile_mercator_coords.xy + u_projection_tile_mercator_coords.zw * posInTile; + + // Now compute angular coordinates on the surface of a perfect sphere + vec2 spherical; + spherical.x = mercator_pos.x * PI * 2.0 + PI; + spherical.y = 2.0 * atan(exp(PI - (mercator_pos.y * PI * 2.0))) - PI * 0.5; + + float len = cos(spherical.y); + vec3 pos = vec3( + sin(spherical.x) * len, + sin(spherical.y), + cos(spherical.x) * len + ); + + // North pole + if (posInTile.y < -32767.5) { + pos = vec3(0.0, 1.0, 0.0); + } + // South pole + if (posInTile.y > 32766.5) { + pos = vec3(0.0, -1.0, 0.0); + } + + return pos; +} + +float globeComputeClippingZ(vec3 spherePos) { + return (1.0 - (dot(spherePos, u_projection_clipping_plane.xyz) + u_projection_clipping_plane.w)); +} + +vec4 interpolateProjection(vec2 posInTile, vec3 spherePos, float elevation) { + vec3 elevatedPos = spherePos * (1.0 + elevation / GLOBE_RADIUS); + vec4 globePosition = u_projection_matrix * vec4(elevatedPos, 1.0); + // 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; + } + + return globePosition; +} + +// Computes screenspace projection +// and replaces Z with a custom value that clips geometry +// on the backfacing side of the planet. +vec4 projectTile(vec2 posInTile) { + return interpolateProjection(posInTile, projectToSphere(posInTile), 0.0); +} + +// Uses elevation to compute final screenspace projection +// and replaces Z with a custom value that clips geometry +// on the backfacing side of the planet. +vec4 projectTileWithElevation(vec2 posInTile, float elevation) { + return interpolateProjection(posInTile, projectToSphere(posInTile), elevation); +} + +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); + vec4 fallbackPosition = u_projection_fallback_matrix * vec4(posInTile, elevation, 1.0); + return mix(fallbackPosition, globePosition, u_projection_transition); +} + +// Projects the tile coordinates+elevation while preserving the Z value from the projection matrix. +vec4 projectTileFor3D(vec2 posInTile, float elevation) { + vec3 spherePos = projectToSphere(posInTile); + return interpolateProjectionFor3D(posInTile, spherePos, elevation); +} diff --git a/src/shaders/_projection_mercator.vertex.glsl b/src/shaders/_projection_mercator.vertex.glsl new file mode 100644 index 0000000000..687bca0620 --- /dev/null +++ b/src/shaders/_projection_mercator.vertex.glsl @@ -0,0 +1,24 @@ +float projectLineThickness(float tileY) { + return 1.0; +} + +float projectCircleRadius(float tileY) { + return 1.0; +} + +// Projects a point in tile-local coordinates (usually 0..EXTENT) to screen. +vec4 projectTile(vec2 p) { + // Kill pole vertices and triangles by placing the pole vertex so far in Z that + // the clipping hardware kills the entire triangle. + vec4 result = u_projection_matrix * vec4(p, 0.0, 1.0); + if (p.y < -32767.5 || p.y > 32766.5) { + result.z = -10000000.0; + } + return result; +} + +vec4 projectTileWithElevation(vec2 posInTile, float elevation) { + // This function is only used in symbol vertex shaders and symbols never use pole vertices, + // so no need to detect them. + return u_projection_matrix * vec4(posInTile, elevation, 1.0); +} diff --git a/src/shaders/clipping_mask.vertex.glsl b/src/shaders/clipping_mask.vertex.glsl index 46f9eaa124..b7eb3b2d6c 100644 --- a/src/shaders/clipping_mask.vertex.glsl +++ b/src/shaders/clipping_mask.vertex.glsl @@ -1,7 +1,5 @@ in vec2 a_pos; -uniform mat4 u_matrix; - void main() { - gl_Position = u_matrix * vec4(a_pos, 0, 1); + gl_Position = projectTile(a_pos); } diff --git a/src/shaders/projection_error_measurement.fragment.glsl b/src/shaders/projection_error_measurement.fragment.glsl new file mode 100644 index 0000000000..3fe832700b --- /dev/null +++ b/src/shaders/projection_error_measurement.fragment.glsl @@ -0,0 +1,5 @@ +in vec4 v_output_error_encoded; + +void main() { + fragColor = v_output_error_encoded; +} diff --git a/src/shaders/projection_error_measurement.vertex.glsl b/src/shaders/projection_error_measurement.vertex.glsl new file mode 100644 index 0000000000..4babda479f --- /dev/null +++ b/src/shaders/projection_error_measurement.vertex.glsl @@ -0,0 +1,22 @@ +in vec2 a_pos; + +uniform highp float u_input; +uniform highp float u_output_expected; + +out vec4 v_output_error_encoded; + +void main() { + float real_output = 2.0 * atan(exp(PI - (u_input * PI * 2.0))) - PI * 0.5; + // If we assume that the error visible on the map is never more than 1 km, + // then the angular error is always smaller than 1/6378 * 2PI = ~0.00098513 + float error = real_output - u_output_expected; + float abs_error = abs(error) * 128.0; // Scale error by some large value for extra precision + // abs_error is assumed to be in range 0..1 + v_output_error_encoded.x = min(floor(abs_error * 256.0), 255.0) / 255.0; + abs_error -= v_output_error_encoded.x; + v_output_error_encoded.y = min(floor(abs_error * 65536.0), 255.0) / 255.0; + abs_error -= v_output_error_encoded.x / 255.0; + v_output_error_encoded.z = min(floor(abs_error * 16777216.0), 255.0) / 255.0; + v_output_error_encoded.w = error >= 0.0 ? 1.0 : 0.0; // sign + gl_Position = vec4(a_pos, 0.0, 1.0); +} diff --git a/src/shaders/raster.vertex.glsl b/src/shaders/raster.vertex.glsl index 04166a0c6c..6f02159723 100644 --- a/src/shaders/raster.vertex.glsl +++ b/src/shaders/raster.vertex.glsl @@ -1,21 +1,39 @@ -uniform mat4 u_matrix; uniform vec2 u_tl_parent; uniform float u_scale_parent; uniform float u_buffer_scale; +uniform vec4 u_coords_top; // xy = left, zw = right +uniform vec4 u_coords_bottom; in vec2 a_pos; -in vec2 a_texture_pos; out vec2 v_pos0; out vec2 v_pos1; void main() { - gl_Position = u_matrix * vec4(a_pos, 0, 1); + // Attribute a_pos always forms a (sometimes subdivided) quad in 0..EXTENT, but actual corner coords may be different. + // Interpolate the actual desired coordinates to get the final position. + vec2 fractionalPos = a_pos / 8192.0; + vec2 position = mix(mix(u_coords_top.xy, u_coords_top.zw, fractionalPos.x), mix(u_coords_bottom.xy, u_coords_bottom.zw, fractionalPos.x), fractionalPos.y); + gl_Position = projectTile(position); + // We are using Int16 for texture position coordinates to give us enough precision for // fractional coordinates. We use 8192 to scale the texture coordinates in the buffer // as an arbitrarily high number to preserve adequate precision when rendering. // This is also the same value as the EXTENT we are using for our tile buffer pos coordinates, // so math for modifying either is consistent. - v_pos0 = (((a_texture_pos / 8192.0) - 0.5) / u_buffer_scale ) + 0.5; + v_pos0 = ((fractionalPos - 0.5) / u_buffer_scale ) + 0.5; + + // When globe rendering is enabled, pole vertices need special handling to get nice texture coordinates. + #ifdef GLOBE + // North pole + if (a_pos.y < -32767.5) { + v_pos0.y = 0.0; + } + // South pole + if (a_pos.y > 32766.5) { + v_pos0.y = 1.0; + } + #endif + v_pos1 = (v_pos0 * u_scale_parent) + u_tl_parent; } diff --git a/src/shaders/shaders.ts b/src/shaders/shaders.ts index 383df3c9f7..f7bfc2e4b2 100644 --- a/src/shaders/shaders.ts +++ b/src/shaders/shaders.ts @@ -57,9 +57,22 @@ import terrainDepthFrag from './terrain_depth.fragment.glsl.g'; import terrainCoordsFrag from './terrain_coords.fragment.glsl.g'; import terrainFrag from './terrain.fragment.glsl.g'; import terrainVert from './terrain.vertex.glsl.g'; +import projectionErrorMeasurementVert from './projection_error_measurement.vertex.glsl.g'; +import projectionErrorMeasurementFrag from './projection_error_measurement.fragment.glsl.g'; +import projectionMercatorVert from './_projection_mercator.vertex.glsl.g'; +import projectionGlobeVert from './_projection_globe.vertex.glsl.g'; + +export type PreparedShader = { + fragmentSource: string; + vertexSource: string; + staticAttributes: Array; + staticUniforms: Array; +}; export const shaders = { prelude: compile(preludeFrag, preludeVert), + projectionMercator: compile('', projectionMercatorVert), + projectionGlobe: compile('', projectionGlobeVert), background: compile(backgroundFrag, backgroundVert), backgroundPattern: compile(backgroundPatternFrag, backgroundPatternVert), circle: compile(circleFrag, circleVert), @@ -87,12 +100,13 @@ export const shaders = { symbolTextAndIcon: compile(symbolTextAndIconFrag, symbolTextAndIconVert), terrain: compile(terrainFrag, terrainVert), terrainDepth: compile(terrainDepthFrag, terrainVert), - terrainCoords: compile(terrainCoordsFrag, terrainVert) + terrainCoords: compile(terrainCoordsFrag, terrainVert), + projectionErrorMeasurement: compile(projectionErrorMeasurementFrag, projectionErrorMeasurementVert) }; // Expand #pragmas to #ifdefs. -function compile(fragmentSource, vertexSource) { +function compile(fragmentSource: string, vertexSource: string): PreparedShader { const re = /#pragma mapbox: ([\w]+) ([\w]+) ([\w]+) ([\w]+)/g; const staticAttributes = vertexSource.match(/attribute ([\w]+) ([\w]+)/g); diff --git a/src/shaders/symbol_icon.vertex.glsl b/src/shaders/symbol_icon.vertex.glsl index a12ff351cb..ff1f9500af 100644 --- a/src/shaders/symbol_icon.vertex.glsl +++ b/src/shaders/symbol_icon.vertex.glsl @@ -1,5 +1,3 @@ -const float PI = 3.141592653589793; - in vec4 a_pos_offset; in vec4 a_data; in vec4 a_pixeloffset; diff --git a/src/shaders/symbol_sdf.vertex.glsl b/src/shaders/symbol_sdf.vertex.glsl index 8c041358bb..4461906f4a 100644 --- a/src/shaders/symbol_sdf.vertex.glsl +++ b/src/shaders/symbol_sdf.vertex.glsl @@ -1,5 +1,3 @@ -const float PI = 3.141592653589793; - in vec4 a_pos_offset; in vec4 a_data; in vec4 a_pixeloffset; diff --git a/src/shaders/symbol_text_and_icon.vertex.glsl b/src/shaders/symbol_text_and_icon.vertex.glsl index e9e3bf9eaa..a86253acb7 100644 --- a/src/shaders/symbol_text_and_icon.vertex.glsl +++ b/src/shaders/symbol_text_and_icon.vertex.glsl @@ -1,5 +1,3 @@ -const float PI = 3.141592653589793; - in vec4 a_pos_offset; in vec4 a_data; in vec3 a_projected_pos; diff --git a/src/source/canvas_source.test.ts b/src/source/canvas_source.test.ts index 306ba5f5ae..2b2a3ef204 100644 --- a/src/source/canvas_source.test.ts +++ b/src/source/canvas_source.test.ts @@ -6,8 +6,6 @@ import {extend} from '../util/util'; import type {Dispatcher} from '../util/dispatcher'; import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; -import {VertexBuffer} from '../gl/vertex_buffer'; -import {SegmentVector} from '../data/segment'; function createSource(options?) { const c = options && options.canvas || window.document.createElement('canvas'); @@ -190,8 +188,6 @@ describe('CanvasSource', () => { source.tiles[String(tile.tileID.wrap)] = tile; // assign dummies directly so we don't need to stub the gl things - source.boundsBuffer = {} as VertexBuffer; - source.boundsSegments = {} as SegmentVector; source.texture = { update: () => {} } as any; diff --git a/src/source/canvas_source.ts b/src/source/canvas_source.ts index 911c87416c..a1897e2b9b 100644 --- a/src/source/canvas_source.ts +++ b/src/source/canvas_source.ts @@ -1,7 +1,5 @@ import {ImageSource} from './image_source'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes'; -import {SegmentVector} from '../data/segment'; import {Texture} from '../render/texture'; import {Event, ErrorEvent} from '../util/evented'; import {ValidationError} from '@maplibre/maplibre-gl-style-spec'; @@ -178,14 +176,6 @@ export class CanvasSource extends ImageSource { const context = this.map.painter.context; const gl = context.gl; - if (!this.boundsBuffer) { - this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); - } - - if (!this.boundsSegments) { - this.boundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); - } - if (!this.texture) { this.texture = new Texture(context, this.canvas, gl.RGBA, {premultiply: true}); } else if (resize || this._playing) { diff --git a/src/source/image_source.test.ts b/src/source/image_source.test.ts index cbb9fab461..4e6e7a7b3f 100644 --- a/src/source/image_source.test.ts +++ b/src/source/image_source.test.ts @@ -7,8 +7,6 @@ import {RequestManager} from '../util/request_manager'; import {sleep, stubAjaxGetImage} from '../util/test/util'; import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; -import {VertexBuffer} from '../gl/vertex_buffer'; -import {SegmentVector} from '../data/segment'; import {Texture} from '../render/texture'; import type {ImageSourceSpecification} from '@maplibre/maplibre-gl-style-spec'; @@ -165,8 +163,6 @@ describe('ImageSource', () => { source.tiles[String(tile.tileID.wrap)] = tile; source.image = new ImageBitmap(); // assign dummies directly so we don't need to stub the gl things - source.boundsBuffer = {destroy: () => {}} as VertexBuffer; - source.boundsSegments = {} as SegmentVector; source.texture = {} as Texture; source.prepare(); }); diff --git a/src/source/image_source.ts b/src/source/image_source.ts index ecff4e0adf..8aeacab965 100644 --- a/src/source/image_source.ts +++ b/src/source/image_source.ts @@ -2,10 +2,6 @@ import {CanonicalTileID} from './tile_id'; import {Event, ErrorEvent, Evented} from '../util/evented'; import {ImageRequest} from '../util/image_request'; import {ResourceType} from '../util/request_manager'; -import {EXTENT} from '../data/extent'; -import {RasterBoundsArray} from '../data/array_types.g'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes'; -import {SegmentVector} from '../data/segment'; import {Texture} from '../render/texture'; import {MercatorCoordinate} from '../geo/mercator_coordinate'; @@ -14,11 +10,11 @@ import type {CanvasSourceSpecification} from './canvas_source'; import type {Map} from '../ui/map'; import type {Dispatcher} from '../util/dispatcher'; import type {Tile} from './tile'; -import type {VertexBuffer} from '../gl/vertex_buffer'; import type { ImageSourceSpecification, VideoSourceSpecification } from '@maplibre/maplibre-gl-style-spec'; +import Point from '@mapbox/point-geometry'; /** * Four geographical coordinates, @@ -101,9 +97,7 @@ export class ImageSource extends Evented implements Source { texture: Texture | null; image: HTMLImageElement | ImageBitmap; tileID: CanonicalTileID; - _boundsArray: RasterBoundsArray; - boundsBuffer: VertexBuffer; - boundsSegments: SegmentVector; + tileCoords: Array; _loaded: boolean; _request: AbortController; @@ -226,18 +220,7 @@ export class ImageSource extends Evented implements Source { // Transform the corner coordinates into the coordinate space of our // tile. - const tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round()); - - this._boundsArray = new RasterBoundsArray(); - this._boundsArray.emplaceBack(tileCoords[0].x, tileCoords[0].y, 0, 0); - this._boundsArray.emplaceBack(tileCoords[1].x, tileCoords[1].y, EXTENT, 0); - this._boundsArray.emplaceBack(tileCoords[3].x, tileCoords[3].y, 0, EXTENT); - this._boundsArray.emplaceBack(tileCoords[2].x, tileCoords[2].y, EXTENT, EXTENT); - - if (this.boundsBuffer) { - this.boundsBuffer.destroy(); - delete this.boundsBuffer; - } + this.tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round()); this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); return this; @@ -251,14 +234,6 @@ export class ImageSource extends Evented implements Source { const context = this.map.painter.context; const gl = context.gl; - if (!this.boundsBuffer) { - this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); - } - - if (!this.boundsSegments) { - this.boundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); - } - if (!this.texture) { this.texture = new Texture(context, this.image, gl.RGBA); this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); diff --git a/src/source/terrain_source_cache.ts b/src/source/terrain_source_cache.ts index 8d32bbfd1e..181e6b3020 100644 --- a/src/source/terrain_source_cache.ts +++ b/src/source/terrain_source_cache.ts @@ -6,6 +6,7 @@ import {Evented} from '../util/evented'; import type {Transform} from '../geo/transform'; import type {SourceCache} from '../source/source_cache'; import {Terrain} from '../render/terrain'; +import {browser} from '../util/browser'; /** * @internal @@ -49,6 +50,10 @@ export class TerrainSourceCache extends Evented { * raster-dem tiles will load for performance the actualZoom - deltaZoom zoom-level. */ deltaZoom: number; + /** + * used to determine whether depth & coord framebuffers need updating + */ + _lastTilesetChange: number = browser.now(); constructor(sourceCache: SourceCache) { super(); @@ -93,6 +98,7 @@ export class TerrainSourceCache extends Evented { tileID.posMatrix = new Float64Array(16) as any; mat4.ortho(tileID.posMatrix, 0, EXTENT, 0, EXTENT, 0, 1); this._tiles[tileID.key] = new Tile(tileID, this.tileSize); + this._lastTilesetChange = browser.now(); } } // free unused tiles @@ -193,11 +199,11 @@ export class TerrainSourceCache extends Evented { } /** - * get a list of tiles, loaded after a specific time. This is used to update depth & coords framebuffers. + * gets whether any tiles were loaded after a specific time. This is used to update depth & coords framebuffers. * @param time - the time - * @returns the relevant tiles + * @returns true if any tiles came into view at or after the specified time */ - tilesAfterTime(time = Date.now()): Array { - return Object.values(this._tiles).filter(t => t.timeAdded >= time); + anyTilesAfterTime(time = Date.now()): boolean { + return this._lastTilesetChange >= time; } } diff --git a/src/source/tile_cache.ts b/src/source/tile_cache.ts index b05d9ed273..f2b368a718 100644 --- a/src/source/tile_cache.ts +++ b/src/source/tile_cache.ts @@ -6,6 +6,10 @@ import type {Tile} from './tile'; * A [least-recently-used cache](http://en.wikipedia.org/wiki/Cache_algorithms) * with hash lookup made possible by keeping a list of keys in parallel to * an array of dictionary of values + * + * source_cache offloads currently unused tiles to this cache, and when a tile gets used again, + * it is also removed from this cache. Thus addition is the only operation that counts as "usage" + * for the purposes of LRU behaviour. */ export class TileCache { max: number; diff --git a/src/source/video_source.test.ts b/src/source/video_source.test.ts index d7ef11c150..3474d71580 100644 --- a/src/source/video_source.test.ts +++ b/src/source/video_source.test.ts @@ -7,8 +7,6 @@ import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; import {Evented} from '../util/evented'; import {Transform} from '../geo/transform'; -import {VertexBuffer} from '../gl/vertex_buffer'; -import {SegmentVector} from '../data/segment'; class StubMap extends Evented { transform: Transform; @@ -113,8 +111,6 @@ describe('VideoSource', () => { source.tiles[String(tile.tileID.wrap)] = tile; // assign dummies directly so we don't need to stub the gl things - source.boundsBuffer = {} as VertexBuffer; - source.boundsSegments = {} as SegmentVector; source.texture = { update: () => {}, bind: () => {} diff --git a/src/source/video_source.ts b/src/source/video_source.ts index b14f436a50..463ba26b18 100644 --- a/src/source/video_source.ts +++ b/src/source/video_source.ts @@ -2,8 +2,6 @@ import {getVideo} from '../util/ajax'; import {ResourceType} from '../util/request_manager'; import {ImageSource} from './image_source'; -import rasterBoundsAttributes from '../data/raster_bounds_attributes'; -import {SegmentVector} from '../data/segment'; import {Texture} from '../render/texture'; import {Event, ErrorEvent} from '../util/evented'; import {ValidationError} from '@maplibre/maplibre-gl-style-spec'; @@ -161,14 +159,6 @@ export class VideoSource extends ImageSource { const context = this.map.painter.context; const gl = context.gl; - if (!this.boundsBuffer) { - this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); - } - - if (!this.boundsSegments) { - this.boundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); - } - if (!this.texture) { this.texture = new Texture(context, this.video, gl.RGBA); this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); diff --git a/src/ui/map.ts b/src/ui/map.ts index 47c9b0c481..a39fd31068 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -58,6 +58,8 @@ import type { import type {MapGeoJSONFeature} from '../util/vectortile_to_geojson'; import type {ControlPosition, IControl} from './control/control'; import type {QueryRenderedFeaturesOptions, QuerySourceFeatureOptions} from '../source/query_features'; +import {Projection} from '../geo/projection/projection'; +import {ProjectionName, createProjectionFromName} from '../geo/projection/projection_factory'; const version = packageJSON.version; @@ -318,6 +320,13 @@ export type MapOptions = { * You shouldn't set this above WebGl `MAX_TEXTURE_SIZE`. Defaults to [4096, 4096]. */ maxCanvasSize?: [number, number]; + /** + * Map projection to use. Options are: + * - 'mercator' - The default, a classical flat Web Mercator map. + * - 'globe' - A 3D spherical view of the planet when zoomed out, transitioning seamlessly to Web Mercator at high zoom levels. + * @defaultValue 'mercator' + */ + projection?: ProjectionName; }; export type AddImageOptions = { @@ -387,7 +396,8 @@ const defaultOptions = { crossSourceCollisions: true, validateStyle: true, /**Because GL MAX_TEXTURE_SIZE is usually at least 4096px. */ - maxCanvasSize: [4096, 4096] + maxCanvasSize: [4096, 4096], + projection: 'mercator' } as CompleteMapOptions; /** @@ -425,6 +435,7 @@ export class Map extends Camera { style: Style; painter: Painter; handlers: HandlerManager; + projection: Projection; _container: HTMLElement; _canvasContainer: HTMLElement; @@ -600,6 +611,8 @@ export class Map extends Camera { this.setMaxBounds(options.maxBounds); } + this.projection = createProjectionFromName(options.projection); + this._setupContainer(); this._setupPainter(); @@ -692,7 +705,7 @@ export class Map extends Camera { /** * Adds an {@link IControl} to the map, calling `control.onAdd(this)`. * - * An {@link ErrorEvent} will be fired if the image parameter is invald. + * An {@link ErrorEvent} will be fired if the image parameter is invalid. * * @param control - The {@link IControl} to add. * @param position - position on the map to which the control will be added. @@ -732,7 +745,7 @@ export class Map extends Camera { /** * Removes the control from the map. * - * An {@link ErrorEvent} will be fired if the image parameter is invald. + * An {@link ErrorEvent} will be fired if the image parameter is invalid. * * @param control - The {@link IControl} to remove. * @returns `this` @@ -2130,7 +2143,7 @@ export class Map extends Camera { * [`fill-pattern`](https://maplibre.org/maplibre-style-spec/layers/#paint-fill-fill-pattern), * or [`line-pattern`](https://maplibre.org/maplibre-style-spec/layers/#paint-line-line-pattern). * - * An {@link ErrorEvent} will be fired if the image parameter is invald. + * An {@link ErrorEvent} will be fired if the image parameter is invalid. * * @param id - The ID of the image. * @param image - The image as an `HTMLImageElement`, `ImageData`, `ImageBitmap` or object with `width`, `height`, and `data` @@ -2200,7 +2213,7 @@ export class Map extends Camera { * in the style's original sprite and any images * that have been added at runtime using {@link Map#addImage}. * - * An {@link ErrorEvent} will be fired if the image parameter is invald. + * An {@link ErrorEvent} will be fired if the image parameter is invalid. * * @param id - The ID of the image. * @@ -2382,7 +2395,7 @@ export class Map extends Camera { /** * Removes the layer with the given ID from the map's style. * - * An {@link ErrorEvent} will be fired if the image parameter is invald. + * An {@link ErrorEvent} will be fired if the image parameter is invalid. * * @param id - The ID of the layer to remove * @returns `this` @@ -3084,6 +3097,9 @@ export class Map extends Camera { this.transform.elevation = 0; } + // This projection update should happen *before* placement update + this.projection.updateProjection(this.painter.transform); + this._placementDirty = this.style && this.style._updatePlacement(this.painter.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions); // Actually draw @@ -3121,7 +3137,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; + const somethingDirty = this._sourcesDirty || this._styleDirty || this._placementDirty || this.projection.isRenderingDirty(); if (somethingDirty || this._repaint) { this.triggerRepaint(); } else if (!this.isMoving() && this.loaded()) { @@ -3175,6 +3191,7 @@ export class Map extends Camera { this._frameRequest.abort(); this._frameRequest = null; } + this.projection.destroy(); this._renderTaskQueue.clear(); this.painter.destroy(); this.handlers.destroy(); @@ -3324,4 +3341,14 @@ export class Map extends Camera { getCameraTargetElevation(): number { return this.transform.elevation; } + + /** + * Returns the active `ProjectionBase` object. + * @returns The projection object. + * @example + * ```ts + * let projection = map.getProjection(); + * ``` + */ + getProjection(): Projection { return this.projection; } } diff --git a/src/util/util.ts b/src/util/util.ts index 3a5ac7bcf4..272353922c 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -4,6 +4,16 @@ import {isOffscreenCanvasDistorted} from './offscreen_canvas_distorted'; import type {Size} from './image'; import type {WorkerGlobalScopeInterface} from './web_worker'; +/** + * Linearly interpolate between two values, similar to `mix` function from GLSL. No clamping is done. + * @param a - The first value to interpolate. This value is returned when mix=0. + * @param b - The second value to interpolate. This value is returned when mix=1. + * @param mix - The interpolation factor. Range 0..1 interpolates between `a` and `b`, but values outside this range are also accepted. + */ +export function lerp(a: number, b: number, mix: number): number { + return a * (1.0 - mix) + b * mix; +} + /** * Given a value `t` that varies between 0 and 1, return * an interpolation function that eases between 0 and 1 in a pleasing diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 5b49b0a603..a36dd3b84f 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -36,7 +36,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 = 774450; + const expectedBytes = 795996; expect(actualBytes - expectedBytes).toBeLessThan(increaseQuota); expect(expectedBytes - actualBytes).toBeLessThan(decreaseQuota); diff --git a/test/examples/globe.html b/test/examples/globe.html new file mode 100644 index 0000000000..50a3c3ce33 --- /dev/null +++ b/test/examples/globe.html @@ -0,0 +1,28 @@ + + + + Display a globe with a satellite map + + + + + + + + +
+ + + diff --git a/test/integration/render/run_render_tests.ts b/test/integration/render/run_render_tests.ts index d924eaa602..afc25d2c79 100644 --- a/test/integration/render/run_render_tests.ts +++ b/test/integration/render/run_render_tests.ts @@ -12,6 +12,7 @@ import {CoverageReport} from 'monocart-coverage-reports'; import {localizeURLs} from '../lib/localize-urls'; import type {Map, CanvasSource, PointLike, StyleSpecification} from '../../../dist/maplibre-gl'; import * as maplibreglModule from '../../../dist/maplibre-gl'; +import {ProjectionName} from '../../../src/geo/projection/projection_factory'; const __dirname = dirname(fileURLToPath(import.meta.url)); let maplibregl: typeof maplibreglModule; @@ -56,6 +57,8 @@ type TestData = { actual: string; diff: string; expected: string; + + projection?: ProjectionName; } type RenderOptions = { @@ -595,7 +598,8 @@ async function getImageFromStyle(styleForTest: StyleWithTestData, page: Page): P fadeDuration: options.fadeDuration || 0, localIdeographFontFamily: options.localIdeographFontFamily || false as any, crossSourceCollisions: typeof options.crossSourceCollisions === 'undefined' ? true : options.crossSourceCollisions, - maxCanvasSize: [8192, 8192] + maxCanvasSize: [8192, 8192], + projection: options.projection, }); let idle = false; diff --git a/test/integration/render/tests/projection/globe/raster-planet/expected.png b/test/integration/render/tests/projection/globe/raster-planet/expected.png new file mode 100644 index 0000000000..2dbe131755 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-planet/expected.png differ diff --git a/test/integration/render/tests/projection/globe/raster-planet/style.json b/test/integration/render/tests/projection/globe/raster-planet/style.json new file mode 100644 index 0000000000..505ebc73d2 --- /dev/null +++ b/test/integration/render/tests/projection/globe/raster-planet/style.json @@ -0,0 +1,42 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that globe projection works with the raster layer type.", + "projection": "globe" + } + }, + "center": [ + 15.0, + 0.0 + ], + "zoom": 1, + "sources": { + "source": { + "type": "raster", + "tiles": [ + "local://tiles/{z}-{x}-{y}.satellite.png" + ], + "minzoom": 1, + "maxzoom": 1, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "raster", + "type": "raster", + "source": "source", + "paint": { + "raster-fade-duration": 0 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/raster-pole/expected.png b/test/integration/render/tests/projection/globe/raster-pole/expected.png new file mode 100644 index 0000000000..87e6ec1684 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-pole/expected.png differ diff --git a/test/integration/render/tests/projection/globe/raster-pole/style.json b/test/integration/render/tests/projection/globe/raster-pole/style.json new file mode 100644 index 0000000000..15ad9cbd48 --- /dev/null +++ b/test/integration/render/tests/projection/globe/raster-pole/style.json @@ -0,0 +1,42 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that globe projection of raster layer fills the poles properly.", + "projection": "globe" + } + }, + "center": [ + 15.0, + 80.0 + ], + "zoom": 1, + "sources": { + "source": { + "type": "raster", + "tiles": [ + "local://tiles/{z}-{x}-{y}.satellite.png" + ], + "minzoom": 1, + "maxzoom": 1, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "raster", + "type": "raster", + "source": "source", + "paint": { + "raster-fade-duration": 0 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/zoom-transition/expected.png b/test/integration/render/tests/projection/globe/zoom-transition/expected.png new file mode 100644 index 0000000000..ae51e2f540 Binary files /dev/null and b/test/integration/render/tests/projection/globe/zoom-transition/expected.png differ diff --git a/test/integration/render/tests/projection/globe/zoom-transition/style.json b/test/integration/render/tests/projection/globe/zoom-transition/style.json new file mode 100644 index 0000000000..705fb5998e --- /dev/null +++ b/test/integration/render/tests/projection/globe/zoom-transition/style.json @@ -0,0 +1,41 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that globe projection transitions to mercator at high zoom levels.", + "height": 256, + "projection": "globe", + "operations": [ + [ + "sleep", + 1250 + ] + ] + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 16, + "sources": { + "satellite": { + "type": "raster", + "tiles": [ + "local://tiles/{z}-{x}-{y}.satellite.png" + ], + "maxzoom": 17, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "raster", + "type": "raster", + "source": "satellite", + "paint": { + "raster-fade-duration": 0 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/mercator/raster-planet/expected.png b/test/integration/render/tests/projection/mercator/raster-planet/expected.png new file mode 100644 index 0000000000..7342a46c82 Binary files /dev/null and b/test/integration/render/tests/projection/mercator/raster-planet/expected.png differ diff --git a/test/integration/render/tests/projection/mercator/raster-planet/style.json b/test/integration/render/tests/projection/mercator/raster-planet/style.json new file mode 100644 index 0000000000..2a4c061cff --- /dev/null +++ b/test/integration/render/tests/projection/mercator/raster-planet/style.json @@ -0,0 +1,42 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that setting projection explicitly to mercator actually renders in mercator.", + "projection": "mercator" + } + }, + "center": [ + 15.0, + 0.0 + ], + "zoom": 1, + "sources": { + "source": { + "type": "raster", + "tiles": [ + "local://tiles/{z}-{x}-{y}.satellite.png" + ], + "minzoom": 1, + "maxzoom": 1, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "raster", + "type": "raster", + "source": "source", + "paint": { + "raster-fade-duration": 0 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/perspective/expected.png b/test/integration/render/tests/projection/perspective/expected.png index 9f38cb37ba..6e3ae50ea1 100644 Binary files a/test/integration/render/tests/projection/perspective/expected.png and b/test/integration/render/tests/projection/perspective/expected.png differ